Skip to content

Commit 8466051

Browse files
authored
Merge pull request #3: Ready for release v0.1.1
Ready for release v0.1.1
2 parents 555d753 + 0f05ebd commit 8466051

File tree

18 files changed

+527
-273
lines changed

18 files changed

+527
-273
lines changed

.gitignore

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,12 @@
66
main.py
77
__pycache__
88

9-
setup.py
10-
doc
9+
doc
10+
dist
11+
*.egg-info
12+
*.vscode
13+
*.bat
14+
venv
15+
16+
*tox
17+
tox.ini

MANIFEST.in

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
include requirements.txt
2+
include LICENSE
3+
global-include *.yaml
4+
global-include *.bz2
5+
recursive-include eve_tools/ESI/sso *.py application.json

README.md

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,50 @@
1-
# EVE tools for trading
1+
# EVE Tools for trading
22

33
> A collection of trading tools that are helpful for EVE game plays.
44
55
_Author: Hanbo Guo_
66

7-
_EVE character name: Hanbie Serine_
7+
_EVE character: Hanbie Serine_
88

99
_Contact: hbopublic@163.com_
1010

1111

12+
## What is EVE Tools
13+
14+
EVE Tools is a Python package that simplifies EVE ESI. The goal is to write easier and faster Python scripts that analyze EVE data for ISK making.
15+
1216
## Installations
1317

14-
#### 0. Have a working Python environment.
15-
#### 1. Download everything to local.
16-
#### 2. Run the `requirements.txt` file with pip install.
18+
### 0. Have a working Python environment.
19+
### 1. Install using PyPI
20+
```sh
21+
pip3 install eve_tools
22+
```
23+
-----
24+
or
25+
### 1. Install manually
26+
27+
#### 1.1 Download everything under this repo
28+
29+
#### 1.2 Run `requirements.txt` file with pip install.
1730
```sh
18-
# install after downloading
19-
cd (PATH of the download)
20-
(e.g. macOS: /Users/{NAME}/Downloads/EVE_tools;
21-
Windows: C:\Users\{NAME}\Downloads\EVE_tools)
31+
cd path/to/your/download/EVE_tools-master
2232

2333
pip3 install -r requirements.txt
2434
```
25-
#### 3. Run `example.py` by:
2635

27-
* double clicking it (in Windows)
28-
* or go to a Python IDE to launch it
29-
* or possibly:
36+
#### 1.3 Setup
3037
```sh
31-
python3 examples.py
38+
python setup.py install
3239
```
33-
* or maybe try with
40+
-----
41+
### 2. Try with `example.py`
42+
43+
Download `examples.py`, run it by:
44+
* double clicking it (in Windows)
45+
* or go to a Python IDE to launch it
46+
* or
3447
```sh
3548
python examples.py
3649
```
50+
You will see the result `hauling.csv` generated in the same directory as `examples.py`.

eve_tools/ESI/esi.py

Lines changed: 128 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import asyncio
2+
from dataclasses import dataclass
3+
from datetime import datetime
4+
from email.utils import parsedate
25
import logging
36
import aiohttp
4-
from typing import Optional, Union, List
7+
from typing import Coroutine, Optional, Union, List
58

69
from .token import ESITokens
710
from .metadata import ESIMetadata, ESIRequest
@@ -14,6 +17,32 @@
1417
logger = logging.getLogger(__name__)
1518

1619

20+
@dataclass
21+
class ESIResponse:
22+
"""Response returned by ESI.request() family.
23+
24+
User should never create ESIResponse but gets it from ESI.request() calls.
25+
26+
Attributes: (referencing aiohttp doc)
27+
status: int
28+
HTTP status code of response.
29+
method: str
30+
Request's method.
31+
headers: dict
32+
A case insensitive dictionary with HTTP headers of response.
33+
data: dict | List | int
34+
A json serialized response body, a dictionary or a list or an int.
35+
expires: str | None
36+
A RFC7231 formatted datetime string, if any.
37+
"""
38+
39+
status: int
40+
method: str
41+
headers: dict
42+
data: Optional[Union[dict, List, int]]
43+
expires: Optional[str] = None
44+
45+
1746
class ESI(object):
1847
"""ESI request client for API requests.
1948
@@ -35,6 +64,10 @@ def __init__(self, **kwd):
3564
### Exit flag
3665
self._app_changed = False
3766

67+
### API session
68+
self._api_session = False
69+
self._api_session_record = None
70+
3871
def get(
3972
self,
4073
key: str,
@@ -130,7 +163,8 @@ def recursive_looper(async_loop: List, kwd: dict):
130163

131164
ret = []
132165
for task in tasks:
133-
ret.extend(task.result()) # well, let's forget about memory
166+
# each task.result() is a ESIResponse instance
167+
ret.append(task.result()) # well, let's forget about memory
134168

135169
return ret
136170

@@ -193,7 +227,7 @@ async def request(
193227
kwd: Keywords necessary for sending the request, such as headers, params, and other ESI required inputs.
194228
195229
Returns:
196-
A dictionary containing json serialized data from ESI.
230+
An instance of ESIResponse containing response of the request.
197231
198232
Raises:
199233
NotImplementedError: Request type POST/DELETE/PUT is not supported.
@@ -237,6 +271,83 @@ async def request(
237271

238272
return res
239273

274+
def _api_session_recorder(_coro: Coroutine):
275+
"""Records useful info from ESIResponse that can be fed to other objects."""
276+
277+
async def _api_session_recorder_wrapped(_self, *args, **kwd):
278+
resp: ESIResponse = await _coro(
279+
_self, *args, **kwd
280+
) # this _self should be an instance of ESI
281+
282+
if not _self._api_session:
283+
return resp
284+
285+
expires = resp.expires
286+
287+
if not _self._api_session_record:
288+
_self._api_session_record = expires
289+
290+
# Use the earliest expire
291+
if expires:
292+
expires_dt = datetime(*parsedate(expires)[:6])
293+
record_dt = datetime(*parsedate(_self._api_session_record)[:6])
294+
if expires_dt < record_dt:
295+
_self._api_session_record = expires
296+
return resp
297+
298+
return _api_session_recorder_wrapped
299+
300+
@ESIRequestError(attempts=3)
301+
@_api_session_recorder
302+
async def async_request(self, api_request: ESIRequest, method: str) -> dict:
303+
"""Asynchronous requests to ESI API.
304+
305+
Uses aiohttp to asynchronously request GET to ESI API.
306+
ClientSession is created once for each instance and shared by multiple async_request call of the instance.
307+
Default having maximum 100 open connections (100 async_request pending).
308+
309+
Args:
310+
api_request: ESIRequest
311+
A fully initialized ESIRequest with url, params, headers field filled in, given to aiohttp.ClientSession.get.
312+
method: str
313+
A str for HTTP request method.
314+
315+
Returns:
316+
An instance of ESIResponse containing response of the request. Memory allocation assumed not to be a problem.
317+
"""
318+
if not self._async_session:
319+
self._async_session = aiohttp.ClientSession(
320+
connector=aiohttp.TCPConnector(ssl=False), raise_for_status=True
321+
) # default maximum 100 connections
322+
323+
# no encoding: "4-HWF" stays what it is
324+
if method == "get":
325+
async with self._async_session.get(
326+
api_request.url, params=api_request.params, headers=api_request.headers
327+
) as req:
328+
data = await req.json()
329+
resp = ESIResponse(
330+
req.status,
331+
req.method,
332+
dict(req.headers),
333+
data,
334+
req.headers.get("Expires"),
335+
)
336+
337+
elif method == "head":
338+
async with self._async_session.head(
339+
api_request.url, params=api_request.params, headers=api_request.headers
340+
) as req:
341+
resp = ESIResponse(
342+
req.status,
343+
req.method,
344+
dict(req.headers),
345+
None,
346+
req.headers.get("Expires"),
347+
)
348+
349+
return resp
350+
240351
def add_app_generate_token(
241352
self, clientId: str, scope: str, callbackURL: Optional[str] = None
242353
) -> None:
@@ -280,42 +391,6 @@ def add_app_generate_token(
280391
with ESITokens(new_app) as token:
281392
token.generate()
282393

283-
@ESIRequestError(attempts=3)
284-
async def async_request(self, api_request: ESIRequest, method: str) -> dict:
285-
"""Asynchronous requests to ESI API.
286-
287-
Uses aiohttp to asynchronously request GET to ESI API.
288-
ClientSession is created once for each instance and shared by multiple async_request call of the instance.
289-
Default having maximum 100 open connections (100 async_request pending).
290-
291-
Args:
292-
api_request: ESIRequest
293-
A fully initialized ESIRequest with url, params, headers field filled in, given to aiohttp.ClientSession.get.
294-
method: str
295-
A str for HTTP request method.
296-
297-
Returns:
298-
A dictionary containing the response body or response header. Memory allocation assumed not to be a problem.
299-
"""
300-
if not self._async_session:
301-
self._async_session = aiohttp.ClientSession(
302-
connector=aiohttp.TCPConnector(ssl=False), raise_for_status=True
303-
) # default maximum 100 connections
304-
305-
# no encoding: "4-HWF" stays what it is
306-
if method == "get":
307-
async with self._async_session.get(
308-
api_request.url, params=api_request.params, headers=api_request.headers
309-
) as req:
310-
return (
311-
await req.json()
312-
) # read entire response to memory, which shouldn't be a problem now.
313-
elif method == "head":
314-
async with self._async_session.head(
315-
api_request.url, params=api_request.params, headers=api_request.headers
316-
) as req:
317-
return dict(req.headers)
318-
319394
def _get_auth_headers(
320395
self, tokens: ESITokens, cname: Optional[str] = "any"
321396
) -> dict:
@@ -460,3 +535,17 @@ def __del__(self):
460535
if self._async_session._connector_owner:
461536
self._async_session._connector.close()
462537
self._async_session._connector = None
538+
539+
def _start_api_session(self):
540+
"""Starts recording useful ESIResponse from API calls."""
541+
self._api_session = True
542+
self._api_session_record = None
543+
544+
def _end_api_session(self):
545+
"""Ends recording ESIResponse from API calls."""
546+
self._api_session = False
547+
548+
def _clear_api_record(self):
549+
"""Clears _api_session_record entry of the instance."""
550+
self._api_session_record = None
551+
self._api_session = False

eve_tools/ESI/utils.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,26 @@
55
from typing import Coroutine, Optional
66

77

8+
BAD_REQUEST = 400
9+
NOT_FOUND = 404
10+
ERROR_LIMITED = 420
11+
12+
813
class ESIRequestError:
914
"""A decorator that handles errors in ESI requests.
1015
11-
Currently, this decorator only retries ESI requests upon error.
12-
In the future, different errors will be detected and handled differently.
13-
For example, a 400 error (bad request) should not be repeated, while a 502 (bad gateway) could be repeated.
14-
It should be assumed that this decorator does not change behavior of async_request() method.
16+
This decorator retries ESI request with error code 502 and 503.
17+
Other codes, such as 400, 404, 420, etc., that are not ESI's error, are not repeated.
18+
ESI response headers has an x-error-limit field, which triggers an ERROR_LIMITED error when reaching 0.
1519
1620
Attributes:
1721
attempts: Number of attempts, default 3.
1822
raises: Raises ClientResponseError or not. Default True, raising errors when no attempts left.
1923
If set to False, a None is returned.
2024
"""
2125

26+
status_raise = [BAD_REQUEST, NOT_FOUND, ERROR_LIMITED]
27+
2228
def __init__(
2329
self,
2430
attempts: Optional[int] = 3,
@@ -29,10 +35,13 @@ def __init__(
2935

3036
def wrapper_retry(self, func: Coroutine):
3137
@wraps(func)
32-
async def wrapped_retry(_caller_self, *args, **kwd):
38+
async def wrapped_retry(_esi_self, *args, **kwd):
3339
"""Retry ESI request upon error.
34-
35-
ESI's async_request has signature (self, ESIRequest, method), so a _caller_self arg is added."""
40+
41+
ESI's async_request has signature (self, ESIRequest, method), so a _esi_self arg is added."""
42+
43+
# Can't use something like _caller_self = _args.pop(0),
44+
# because deepcopy can't copy ESI.self.
3645
_args = copy.deepcopy(args)
3746
_kwd = copy.deepcopy(kwd)
3847

@@ -42,12 +51,16 @@ async def wrapped_retry(_caller_self, *args, **kwd):
4251
ret = None
4352
while not success and attempts > 0:
4453
try:
45-
ret = await func(_caller_self, *_args, **_kwd)
54+
ret = await func(_esi_self, *_args, **_kwd)
4655
success = True
4756
except ClientResponseError as exc:
48-
logging.warning("%s | attempts left: %s", exc, self.attempts)
4957
attempts -= 1
50-
if attempts == 0 and self.raises:
58+
resp_code = exc.status
59+
if resp_code in self.status_raise:
60+
attempts = 0
61+
logging.warning("%s | attempts left: %s", exc, attempts)
62+
63+
if self.raises and attempts == 0:
5164
raise
5265
return ret
5366

eve_tools/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
from __future__ import absolute_import
2+
import os
23
import logging
34

45
from .api import *
56
from .ESI import ESIClient, ESITokens, Application
6-
from .data import ESIDB, CacheDB, api_cache, SqliteCache
7+
from .data import *
78
from .config import *
89

910
logging.basicConfig(
10-
filename="esi.log",
11+
filename=os.path.join(SOURCE_DIR, "esi.log"),
1112
format="%(asctime)s %(levelname)s %(module)s.%(funcName)s: %(message)s",
1213
filemode="w",
1314
level=logging.WARNING,
1415
)
1516

16-
__version__ = "0.1.0"
17+
__version__ = "0.1.1"

0 commit comments

Comments
 (0)