apipool-ng 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apipool_ng-1.0.0/LICENSE.txt +21 -0
- apipool_ng-1.0.0/MANIFEST.in +2 -0
- apipool_ng-1.0.0/PKG-INFO +128 -0
- apipool_ng-1.0.0/README.md +87 -0
- apipool_ng-1.0.0/apipool/__init__.py +18 -0
- apipool_ng-1.0.0/apipool/__pycache__/__init__.cpython-314.pyc +0 -0
- apipool_ng-1.0.0/apipool/__pycache__/apikey.cpython-314.pyc +0 -0
- apipool_ng-1.0.0/apipool/__pycache__/manager.cpython-314.pyc +0 -0
- apipool_ng-1.0.0/apipool/__pycache__/stats.cpython-314.pyc +0 -0
- apipool_ng-1.0.0/apipool/apikey.py +83 -0
- apipool_ng-1.0.0/apipool/manager.py +152 -0
- apipool_ng-1.0.0/apipool/stats.py +204 -0
- apipool_ng-1.0.0/apipool/tests/__init__.py +51 -0
- apipool_ng-1.0.0/apipool/tests/__pycache__/__init__.cpython-314.pyc +0 -0
- apipool_ng-1.0.0/apipool_ng.egg-info/PKG-INFO +128 -0
- apipool_ng-1.0.0/apipool_ng.egg-info/SOURCES.txt +24 -0
- apipool_ng-1.0.0/apipool_ng.egg-info/dependency_links.txt +1 -0
- apipool_ng-1.0.0/apipool_ng.egg-info/requires.txt +1 -0
- apipool_ng-1.0.0/apipool_ng.egg-info/top_level.txt +1 -0
- apipool_ng-1.0.0/release-history.rst +31 -0
- apipool_ng-1.0.0/requirements.txt +1 -0
- apipool_ng-1.0.0/setup.cfg +4 -0
- apipool_ng-1.0.0/setup.py +41 -0
- apipool_ng-1.0.0/tests/test_apipool.py +77 -0
- apipool_ng-1.0.0/tests/test_import.py +20 -0
- apipool_ng-1.0.0/tests/test_stats.py +62 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright 2017 Sanhe Hu <https://github.com/MacHu-GWU/apipool-project>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apipool-ng
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Multiple API Key Manager (Next Generation, sqlalchemy_mate free)
|
|
5
|
+
Home-page: https://github.com/apipool-ng/apipool-project
|
|
6
|
+
Author: apipool-ng Contributors
|
|
7
|
+
Author-email:
|
|
8
|
+
Maintainer: apipool-ng Contributors
|
|
9
|
+
Maintainer-email:
|
|
10
|
+
License: MIT
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Natural Language :: English
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: Unix
|
|
18
|
+
Classifier: Programming Language :: Python
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE.txt
|
|
29
|
+
Requires-Dist: sqlalchemy>=1.0.0
|
|
30
|
+
Dynamic: author
|
|
31
|
+
Dynamic: classifier
|
|
32
|
+
Dynamic: description
|
|
33
|
+
Dynamic: description-content-type
|
|
34
|
+
Dynamic: home-page
|
|
35
|
+
Dynamic: license
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
Dynamic: maintainer
|
|
38
|
+
Dynamic: requires-dist
|
|
39
|
+
Dynamic: requires-python
|
|
40
|
+
Dynamic: summary
|
|
41
|
+
|
|
42
|
+
# apipool-ng
|
|
43
|
+
|
|
44
|
+
`apipool-ng` is a next-generation **Multiple API Key Manager**.
|
|
45
|
+
|
|
46
|
+
It allows developers to manage multiple API keys simultaneously. For example,
|
|
47
|
+
if a single API key has 1000/day quota, you can register 10 API keys and let
|
|
48
|
+
`apipool-ng` automatically rotate them.
|
|
49
|
+
|
|
50
|
+
## Features
|
|
51
|
+
|
|
52
|
+
- Automatically rotate API keys across multiple credentials.
|
|
53
|
+
- Built-in usage statistics, searchable by time, status, and apikey.
|
|
54
|
+
Stats collector can be deployed on any relational database (SQLite by default).
|
|
55
|
+
- Clean API, minimal code required to implement complex features.
|
|
56
|
+
- No uncommon dependencies — only requires `sqlalchemy`.
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install apipool-ng
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Quick Start
|
|
65
|
+
|
|
66
|
+
Implement an `ApiKey` subclass, then create an `ApiKeyManager`:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from apipool import ApiKey, ApiKeyManager
|
|
70
|
+
|
|
71
|
+
class MyApiKey(ApiKey):
|
|
72
|
+
def __init__(self, key):
|
|
73
|
+
self.key = key
|
|
74
|
+
|
|
75
|
+
def user_01_get_primary_key(self):
|
|
76
|
+
return self.key
|
|
77
|
+
|
|
78
|
+
def user_02_create_client(self):
|
|
79
|
+
return MyApiClient(api_key=self.key)
|
|
80
|
+
|
|
81
|
+
def user_03_test_usable(self, client):
|
|
82
|
+
return client.test_connection()
|
|
83
|
+
|
|
84
|
+
apikeys = [MyApiKey(k) for k in ["key1", "key2", "key3"]]
|
|
85
|
+
manager = ApiKeyManager(apikey_list=apikeys)
|
|
86
|
+
manager.check_usable()
|
|
87
|
+
|
|
88
|
+
# Use dummyclient like your real client — keys auto-rotate
|
|
89
|
+
result = manager.dummyclient.some_api_method(arg)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## DummyClient
|
|
93
|
+
|
|
94
|
+
Use `manager.dummyclient` just like your original API client.
|
|
95
|
+
Under the hood, it automatically selects a usable key, records usage events,
|
|
96
|
+
and rotates keys on rate-limit errors.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
manager.check_usable()
|
|
100
|
+
|
|
101
|
+
# Keys are automatically rotated
|
|
102
|
+
result = manager.dummyclient.some_method()
|
|
103
|
+
|
|
104
|
+
for _ in range(10):
|
|
105
|
+
manager.dummyclient.some_method()
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## StatsCollector
|
|
109
|
+
|
|
110
|
+
Query usage statistics through `manager.stats`:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from apipool import StatusCollection
|
|
114
|
+
|
|
115
|
+
# Usage count per key in last hour
|
|
116
|
+
manager.stats.usage_count_stats_in_recent_n_seconds(3600)
|
|
117
|
+
# {"key1": 3, "key2": 5, "key3": 2}
|
|
118
|
+
|
|
119
|
+
# Count specific events
|
|
120
|
+
count = manager.stats.usage_count_in_recent_n_seconds(
|
|
121
|
+
n_seconds=3600,
|
|
122
|
+
status_id=StatusCollection.c9_ReachLimit.id,
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT License
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# apipool-ng
|
|
2
|
+
|
|
3
|
+
`apipool-ng` is a next-generation **Multiple API Key Manager**.
|
|
4
|
+
|
|
5
|
+
It allows developers to manage multiple API keys simultaneously. For example,
|
|
6
|
+
if a single API key has 1000/day quota, you can register 10 API keys and let
|
|
7
|
+
`apipool-ng` automatically rotate them.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- Automatically rotate API keys across multiple credentials.
|
|
12
|
+
- Built-in usage statistics, searchable by time, status, and apikey.
|
|
13
|
+
Stats collector can be deployed on any relational database (SQLite by default).
|
|
14
|
+
- Clean API, minimal code required to implement complex features.
|
|
15
|
+
- No uncommon dependencies — only requires `sqlalchemy`.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install apipool-ng
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
Implement an `ApiKey` subclass, then create an `ApiKeyManager`:
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from apipool import ApiKey, ApiKeyManager
|
|
29
|
+
|
|
30
|
+
class MyApiKey(ApiKey):
|
|
31
|
+
def __init__(self, key):
|
|
32
|
+
self.key = key
|
|
33
|
+
|
|
34
|
+
def user_01_get_primary_key(self):
|
|
35
|
+
return self.key
|
|
36
|
+
|
|
37
|
+
def user_02_create_client(self):
|
|
38
|
+
return MyApiClient(api_key=self.key)
|
|
39
|
+
|
|
40
|
+
def user_03_test_usable(self, client):
|
|
41
|
+
return client.test_connection()
|
|
42
|
+
|
|
43
|
+
apikeys = [MyApiKey(k) for k in ["key1", "key2", "key3"]]
|
|
44
|
+
manager = ApiKeyManager(apikey_list=apikeys)
|
|
45
|
+
manager.check_usable()
|
|
46
|
+
|
|
47
|
+
# Use dummyclient like your real client — keys auto-rotate
|
|
48
|
+
result = manager.dummyclient.some_api_method(arg)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## DummyClient
|
|
52
|
+
|
|
53
|
+
Use `manager.dummyclient` just like your original API client.
|
|
54
|
+
Under the hood, it automatically selects a usable key, records usage events,
|
|
55
|
+
and rotates keys on rate-limit errors.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
manager.check_usable()
|
|
59
|
+
|
|
60
|
+
# Keys are automatically rotated
|
|
61
|
+
result = manager.dummyclient.some_method()
|
|
62
|
+
|
|
63
|
+
for _ in range(10):
|
|
64
|
+
manager.dummyclient.some_method()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## StatsCollector
|
|
68
|
+
|
|
69
|
+
Query usage statistics through `manager.stats`:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from apipool import StatusCollection
|
|
73
|
+
|
|
74
|
+
# Usage count per key in last hour
|
|
75
|
+
manager.stats.usage_count_stats_in_recent_n_seconds(3600)
|
|
76
|
+
# {"key1": 3, "key2": 5, "key3": 2}
|
|
77
|
+
|
|
78
|
+
# Count specific events
|
|
79
|
+
count = manager.stats.usage_count_in_recent_n_seconds(
|
|
80
|
+
n_seconds=3600,
|
|
81
|
+
status_id=StatusCollection.c9_ReachLimit.id,
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT License
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
__version__ = "1.0.0"
|
|
5
|
+
__short_description__ = "Multiple API Key Manager (Next Generation, sqlalchemy_mate free)"
|
|
6
|
+
__license__ = "MIT"
|
|
7
|
+
__author__ = "apipool-ng Contributors"
|
|
8
|
+
__author_email__ = ""
|
|
9
|
+
__maintainer__ = "apipool-ng Contributors"
|
|
10
|
+
__maintainer_email__ = ""
|
|
11
|
+
__github_username__ = ""
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from .apikey import ApiKey
|
|
15
|
+
from .manager import ApiKeyManager
|
|
16
|
+
from .stats import StatusCollection
|
|
17
|
+
except Exception as e: # pragma: no cover
|
|
18
|
+
pass
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ApiKey(object):
|
|
6
|
+
"""
|
|
7
|
+
API key abstract class.
|
|
8
|
+
|
|
9
|
+
User has to implement these three methods to make it works.
|
|
10
|
+
|
|
11
|
+
- :meth:`ApiKey.user_01_get_primary_key`.
|
|
12
|
+
- :meth:`ApiKey.user_02_create_client`.
|
|
13
|
+
- :meth:`ApiKey.user_03_test_usable`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
_client = None
|
|
17
|
+
_apikey_manager = None
|
|
18
|
+
|
|
19
|
+
def user_01_get_primary_key(self):
|
|
20
|
+
"""
|
|
21
|
+
Get the unique identifier of this api key. Usually it is a string or
|
|
22
|
+
integer. For example, the AWS Access Key is the primary key of an
|
|
23
|
+
aws api key pair.
|
|
24
|
+
|
|
25
|
+
:return: str or int.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
|
|
30
|
+
def user_02_create_client(self):
|
|
31
|
+
"""
|
|
32
|
+
Create a client object to perform api call.
|
|
33
|
+
|
|
34
|
+
This method will use api key data to create a client class.
|
|
35
|
+
|
|
36
|
+
For example, if you use `geopy <https://geopy.readthedocs.io/en/stable/>`_,
|
|
37
|
+
and you want to use Google Geocoding API, then
|
|
38
|
+
|
|
39
|
+
.. code-block:: python
|
|
40
|
+
|
|
41
|
+
>>> from geopy.geocoders import GoogleV3
|
|
42
|
+
>>> class YourApiKey(ApiKey):
|
|
43
|
+
... def __init__(self, apikey):
|
|
44
|
+
... self.apikey = apikey
|
|
45
|
+
...
|
|
46
|
+
... def user_02_create_client(self):
|
|
47
|
+
... return GoogleV3(api_key=self.apikey)
|
|
48
|
+
|
|
49
|
+
api for ``geopy.geocoder.GoogleV3``: https://geopy.readthedocs.io/en/stable/#googlev3
|
|
50
|
+
|
|
51
|
+
:return: client object.
|
|
52
|
+
"""
|
|
53
|
+
raise NotImplementedError
|
|
54
|
+
|
|
55
|
+
def user_03_test_usable(self, client):
|
|
56
|
+
"""
|
|
57
|
+
Test if this api key is usable for making api call.
|
|
58
|
+
|
|
59
|
+
Usually this method is just to make a simple, guarantee successful
|
|
60
|
+
api call, and then check the response.
|
|
61
|
+
|
|
62
|
+
:return: bool, or raise Exception
|
|
63
|
+
"""
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def primary_key(self):
|
|
68
|
+
return self.user_01_get_primary_key()
|
|
69
|
+
|
|
70
|
+
def connect_client(self):
|
|
71
|
+
"""
|
|
72
|
+
connect
|
|
73
|
+
:return:
|
|
74
|
+
"""
|
|
75
|
+
self._client = self.user_02_create_client()
|
|
76
|
+
|
|
77
|
+
def is_usable(self):
|
|
78
|
+
if self._client is None:
|
|
79
|
+
self.connect_client()
|
|
80
|
+
try:
|
|
81
|
+
return self.user_03_test_usable(self._client)
|
|
82
|
+
except: # pragma: no cover
|
|
83
|
+
return False
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
built-in stats collector service for api usage and status.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import random
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from sqlalchemy import create_engine
|
|
12
|
+
|
|
13
|
+
from .apikey import ApiKey
|
|
14
|
+
from .stats import StatusCollection, StatsCollector
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_is_apikey(obj):
|
|
18
|
+
if not isinstance(obj, ApiKey): # pragma: no cover
|
|
19
|
+
raise TypeError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ApiCaller(object):
|
|
23
|
+
def __init__(self, apikey, apikey_manager, call_method, reach_limit_exc):
|
|
24
|
+
self.apikey = apikey
|
|
25
|
+
self.apikey_manager = apikey_manager
|
|
26
|
+
self.call_method = call_method
|
|
27
|
+
self.reach_limit_exc = reach_limit_exc
|
|
28
|
+
|
|
29
|
+
def __call__(self, *args, **kwargs):
|
|
30
|
+
try:
|
|
31
|
+
res = self.call_method(*args, **kwargs)
|
|
32
|
+
self.apikey_manager.stats.add_event(
|
|
33
|
+
self.apikey.primary_key, StatusCollection.c1_Success.id,
|
|
34
|
+
)
|
|
35
|
+
return res
|
|
36
|
+
except self.reach_limit_exc as e:
|
|
37
|
+
self.apikey_manager.remove_one(self.apikey.primary_key)
|
|
38
|
+
self.apikey_manager.stats.add_event(
|
|
39
|
+
self.apikey.primary_key, StatusCollection.c9_ReachLimit.id,
|
|
40
|
+
)
|
|
41
|
+
raise e
|
|
42
|
+
except Exception as e:
|
|
43
|
+
self.apikey_manager.stats.add_event(
|
|
44
|
+
self.apikey.primary_key, StatusCollection.c5_Failed.id,
|
|
45
|
+
)
|
|
46
|
+
raise e
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DummyClient(object):
|
|
50
|
+
def __init__(self):
|
|
51
|
+
self._apikey_manager = None
|
|
52
|
+
|
|
53
|
+
def __getattr__(self, item):
|
|
54
|
+
apikey = self._apikey_manager.random_one()
|
|
55
|
+
call_method = getattr(apikey._client, item)
|
|
56
|
+
return ApiCaller(
|
|
57
|
+
apikey=apikey,
|
|
58
|
+
apikey_manager=self._apikey_manager,
|
|
59
|
+
call_method=call_method,
|
|
60
|
+
reach_limit_exc=self._apikey_manager.reach_limit_exc,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NeverRaisesError(Exception):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ApiKeyManager(object):
|
|
69
|
+
_settings_api_client_class = None
|
|
70
|
+
|
|
71
|
+
def __init__(self, apikey_list, reach_limit_exc=None, db_engine=None):
|
|
72
|
+
# validate
|
|
73
|
+
for apikey in apikey_list:
|
|
74
|
+
validate_is_apikey(apikey)
|
|
75
|
+
|
|
76
|
+
# stats collector
|
|
77
|
+
if db_engine is None:
|
|
78
|
+
db_engine = create_sqlite()
|
|
79
|
+
|
|
80
|
+
self.stats = StatsCollector(engine=db_engine)
|
|
81
|
+
self.stats.add_all_apikey(apikey_list)
|
|
82
|
+
|
|
83
|
+
# initiate apikey chain data
|
|
84
|
+
self.apikey_chain = OrderedDict()
|
|
85
|
+
for apikey in apikey_list:
|
|
86
|
+
self.add_one(apikey, upsert=False)
|
|
87
|
+
|
|
88
|
+
self.archived_apikey_chain = OrderedDict()
|
|
89
|
+
|
|
90
|
+
if reach_limit_exc is None:
|
|
91
|
+
reach_limit_exc = NeverRaisesError
|
|
92
|
+
self.reach_limit_exc = reach_limit_exc
|
|
93
|
+
self.dummyclient = DummyClient()
|
|
94
|
+
self.dummyclient._apikey_manager = self
|
|
95
|
+
|
|
96
|
+
def add_one(self, apikey, upsert=False):
|
|
97
|
+
validate_is_apikey(apikey)
|
|
98
|
+
primary_key = apikey.primary_key
|
|
99
|
+
|
|
100
|
+
do_insert = False
|
|
101
|
+
if primary_key in self.apikey_chain:
|
|
102
|
+
if upsert:
|
|
103
|
+
do_insert = True
|
|
104
|
+
else:
|
|
105
|
+
do_insert = True
|
|
106
|
+
|
|
107
|
+
if do_insert:
|
|
108
|
+
try:
|
|
109
|
+
apikey.connect_client()
|
|
110
|
+
self.apikey_chain[primary_key] = apikey
|
|
111
|
+
except Exception as e: # pragma: no cover
|
|
112
|
+
sys.stdout.write(
|
|
113
|
+
"\nCan't create api client with {}, error: {}".format(
|
|
114
|
+
apikey.primary_key, e)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# update stats collector
|
|
118
|
+
self.stats.add_all_apikey([apikey, ])
|
|
119
|
+
|
|
120
|
+
def fetch_one(self, primary_key):
|
|
121
|
+
return self.apikey_chain[primary_key]
|
|
122
|
+
|
|
123
|
+
def remove_one(self, primary_key):
|
|
124
|
+
apikey = self.apikey_chain.pop(primary_key)
|
|
125
|
+
self.archived_apikey_chain[primary_key] = apikey
|
|
126
|
+
return apikey
|
|
127
|
+
|
|
128
|
+
def random_one(self):
|
|
129
|
+
return random.choice(list(self.apikey_chain.values()))
|
|
130
|
+
|
|
131
|
+
def check_usable(self):
|
|
132
|
+
for primary_key, apikey in self.apikey_chain.items():
|
|
133
|
+
if apikey.is_usable():
|
|
134
|
+
self.stats.add_event(
|
|
135
|
+
primary_key, StatusCollection.c1_Success.id)
|
|
136
|
+
else:
|
|
137
|
+
self.remove_one(primary_key)
|
|
138
|
+
self.stats.add_event(
|
|
139
|
+
primary_key, StatusCollection.c5_Failed.id)
|
|
140
|
+
|
|
141
|
+
if len(self.apikey_chain) == 0:
|
|
142
|
+
sys.stdout.write("\nThere's no API Key usable!")
|
|
143
|
+
elif len(self.archived_apikey_chain) == 0:
|
|
144
|
+
sys.stdout.write("\nAll API Key are usable.")
|
|
145
|
+
else:
|
|
146
|
+
sys.stdout.write("\nThese keys are not usable:")
|
|
147
|
+
for key in self.archived_apikey_chain:
|
|
148
|
+
sys.stdout.write("\n %s: %r" % (key, apikey))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def create_sqlite():
|
|
152
|
+
return create_engine("sqlite:///:memory:")
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
stats用于统计API的使用记录. 我们使用一个RDS数据库(通常是本地的sqlite), 记录每个
|
|
6
|
+
API Call所使用的api key, 返回的状态 以及 完成API Call的时间.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import Column, ForeignKey, create_engine
|
|
13
|
+
from sqlalchemy import String, Integer, DateTime, func
|
|
14
|
+
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
|
|
15
|
+
|
|
16
|
+
Base = declarative_base()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ApiKey(Base):
|
|
20
|
+
__tablename__ = "apikey"
|
|
21
|
+
|
|
22
|
+
id = Column(Integer, primary_key=True)
|
|
23
|
+
key = Column(String, unique=True)
|
|
24
|
+
|
|
25
|
+
events = relationship("Event", back_populates="apikey")
|
|
26
|
+
|
|
27
|
+
def __repr__(self):
|
|
28
|
+
return "ApiKey(id=%r, key=%r)" % (self.id, self.key)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Status(Base):
|
|
32
|
+
__tablename__ = "status"
|
|
33
|
+
|
|
34
|
+
id = Column(Integer, primary_key=True)
|
|
35
|
+
description = Column(String, unique=True)
|
|
36
|
+
|
|
37
|
+
events = relationship("Event", back_populates="status")
|
|
38
|
+
|
|
39
|
+
def __repr__(self):
|
|
40
|
+
return "Status(id=%r, description=%r)" % (self.id, self.description)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Event(Base):
|
|
44
|
+
__tablename__ = "event"
|
|
45
|
+
|
|
46
|
+
apikey_id = Column(Integer, ForeignKey("apikey.id"), primary_key=True)
|
|
47
|
+
finished_at = Column(DateTime, primary_key=True)
|
|
48
|
+
status_id = Column(Integer, ForeignKey("status.id"))
|
|
49
|
+
|
|
50
|
+
apikey = relationship("ApiKey")
|
|
51
|
+
status = relationship("Status")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class StatusCollection(object):
|
|
55
|
+
class c1_Success(object):
|
|
56
|
+
id = 1
|
|
57
|
+
description = "success"
|
|
58
|
+
|
|
59
|
+
class c5_Failed(object):
|
|
60
|
+
id = 5
|
|
61
|
+
description = "failed"
|
|
62
|
+
|
|
63
|
+
class c9_ReachLimit(object):
|
|
64
|
+
id = 9
|
|
65
|
+
description = "reach limit"
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def get_subclasses(cls):
|
|
69
|
+
return [cls.c1_Success, cls.c5_Failed, cls.c9_ReachLimit]
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def get_id_list(cls):
|
|
73
|
+
return [klass.id for klass in cls.get_subclasses()]
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def get_description_list(cls):
|
|
77
|
+
return [klass.description for klass in cls.get_subclasses()]
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def get_mapper_id_to_description(cls):
|
|
81
|
+
return {
|
|
82
|
+
klass.id: klass.description
|
|
83
|
+
for klass in cls.get_subclasses()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def get_mapper_description_to_id(cls):
|
|
88
|
+
return {
|
|
89
|
+
klass.description: klass.id
|
|
90
|
+
for klass in cls.get_subclasses()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def get_status_list(cls):
|
|
95
|
+
return [
|
|
96
|
+
Status(id=klass.id, description=klass.description)
|
|
97
|
+
for klass in cls.get_subclasses()
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_n_seconds_before(n_seconds):
|
|
102
|
+
return datetime.now() - timedelta(seconds=n_seconds)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class StatsCollector(object):
|
|
106
|
+
def __init__(self, engine):
|
|
107
|
+
Base.metadata.create_all(engine)
|
|
108
|
+
self.engine = engine
|
|
109
|
+
self.ses = self.create_session()
|
|
110
|
+
|
|
111
|
+
self._add_all_status()
|
|
112
|
+
|
|
113
|
+
self._cache_apikey = dict()
|
|
114
|
+
self._cache_status = StatusCollection.get_mapper_id_to_description()
|
|
115
|
+
|
|
116
|
+
def create_session(self):
|
|
117
|
+
return sessionmaker(bind=self.engine)()
|
|
118
|
+
|
|
119
|
+
def close(self):
|
|
120
|
+
self.ses.close()
|
|
121
|
+
|
|
122
|
+
def __enter__(self):
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
126
|
+
self.close()
|
|
127
|
+
|
|
128
|
+
def _add_all_status(self):
|
|
129
|
+
ses = self.create_session()
|
|
130
|
+
for status in StatusCollection.get_status_list():
|
|
131
|
+
existing = (
|
|
132
|
+
ses.query(Status)
|
|
133
|
+
.filter(Status.id == status.id)
|
|
134
|
+
.first()
|
|
135
|
+
)
|
|
136
|
+
if existing is None:
|
|
137
|
+
ses.add(status)
|
|
138
|
+
ses.commit()
|
|
139
|
+
ses.close()
|
|
140
|
+
|
|
141
|
+
def add_all_apikey(self, apikey_list):
|
|
142
|
+
data = [ApiKey(key=apikey.primary_key) for apikey in apikey_list]
|
|
143
|
+
ses = self.create_session()
|
|
144
|
+
for item in data:
|
|
145
|
+
existing = (
|
|
146
|
+
ses.query(ApiKey)
|
|
147
|
+
.filter(ApiKey.key == item.key)
|
|
148
|
+
.first()
|
|
149
|
+
)
|
|
150
|
+
if existing is None:
|
|
151
|
+
ses.add(item)
|
|
152
|
+
ses.commit()
|
|
153
|
+
ses.close()
|
|
154
|
+
self._update_cache()
|
|
155
|
+
|
|
156
|
+
def _update_cache(self):
|
|
157
|
+
ses = self.create_session()
|
|
158
|
+
apikey_list = ses.query(ApiKey).all()
|
|
159
|
+
for apikey in apikey_list:
|
|
160
|
+
self._cache_apikey.setdefault(apikey.key, apikey.id)
|
|
161
|
+
ses.close()
|
|
162
|
+
|
|
163
|
+
def add_event(self, primary_key, status_id):
|
|
164
|
+
event = Event(
|
|
165
|
+
apikey_id=self._cache_apikey[primary_key],
|
|
166
|
+
finished_at=datetime.now(),
|
|
167
|
+
status_id=status_id,
|
|
168
|
+
)
|
|
169
|
+
ses = self.create_session()
|
|
170
|
+
ses.add(event)
|
|
171
|
+
ses.commit()
|
|
172
|
+
ses.close()
|
|
173
|
+
|
|
174
|
+
def query_event_in_recent_n_seconds(self,
|
|
175
|
+
n_seconds,
|
|
176
|
+
primary_key=None,
|
|
177
|
+
status_id=None):
|
|
178
|
+
n_seconds_before = get_n_seconds_before(n_seconds)
|
|
179
|
+
filters = [Event.finished_at >= n_seconds_before, ]
|
|
180
|
+
if not (primary_key is None):
|
|
181
|
+
filters.append(Event.apikey_id == self._cache_apikey[primary_key])
|
|
182
|
+
if not (status_id is None):
|
|
183
|
+
filters.append(Event.status_id == status_id)
|
|
184
|
+
return self.ses.query(Event).filter(*filters)
|
|
185
|
+
|
|
186
|
+
def usage_count_in_recent_n_seconds(self,
|
|
187
|
+
n_seconds,
|
|
188
|
+
primary_key=None,
|
|
189
|
+
status_id=None):
|
|
190
|
+
q = self.query_event_in_recent_n_seconds(
|
|
191
|
+
n_seconds,
|
|
192
|
+
primary_key=primary_key,
|
|
193
|
+
status_id=status_id,
|
|
194
|
+
)
|
|
195
|
+
return q.count()
|
|
196
|
+
|
|
197
|
+
def usage_count_stats_in_recent_n_seconds(self, n_seconds):
|
|
198
|
+
n_seconds_before = get_n_seconds_before(n_seconds)
|
|
199
|
+
q = self.ses.query(ApiKey.key, func.count(Event.apikey_id)) \
|
|
200
|
+
.select_from(Event).join(ApiKey) \
|
|
201
|
+
.filter(Event.finished_at >= n_seconds_before) \
|
|
202
|
+
.group_by(Event.apikey_id) \
|
|
203
|
+
.order_by(ApiKey.key)
|
|
204
|
+
return OrderedDict(q.all())
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
from ..apikey import ApiKey
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ReachLimitError(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GoogleMapApiClient(object):
|
|
12
|
+
def __init__(self, apikey):
|
|
13
|
+
self.apikey = apikey
|
|
14
|
+
|
|
15
|
+
def get_lat_lng_by_address(self, address):
|
|
16
|
+
return {"lat": 40.762882, "lng": -73.973700}
|
|
17
|
+
|
|
18
|
+
def raise_other_error(self, address):
|
|
19
|
+
raise ValueError
|
|
20
|
+
|
|
21
|
+
def raise_reach_limit_error(self, address):
|
|
22
|
+
raise ReachLimitError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GoogleMapApiKey(ApiKey):
|
|
26
|
+
def __init__(self, apikey):
|
|
27
|
+
self.apikey = apikey
|
|
28
|
+
|
|
29
|
+
def user_01_get_primary_key(self):
|
|
30
|
+
return self.apikey
|
|
31
|
+
|
|
32
|
+
def user_02_create_client(self):
|
|
33
|
+
return GoogleMapApiClient(self.apikey)
|
|
34
|
+
|
|
35
|
+
def user_03_test_usable(self, client):
|
|
36
|
+
if "99" in self.apikey:
|
|
37
|
+
return False
|
|
38
|
+
response = client.get_lat_lng_by_address(
|
|
39
|
+
"123 North St, NewYork, NY 10001")
|
|
40
|
+
if ("lat" in response) and ("lng" in response):
|
|
41
|
+
return True
|
|
42
|
+
else:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
apikeys = [
|
|
47
|
+
"example1@gmail.com",
|
|
48
|
+
"example2@gmail.com",
|
|
49
|
+
"example3@gmail.com",
|
|
50
|
+
"example99@gmail.com",
|
|
51
|
+
]
|
|
Binary file
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apipool-ng
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Multiple API Key Manager (Next Generation, sqlalchemy_mate free)
|
|
5
|
+
Home-page: https://github.com/apipool-ng/apipool-project
|
|
6
|
+
Author: apipool-ng Contributors
|
|
7
|
+
Author-email:
|
|
8
|
+
Maintainer: apipool-ng Contributors
|
|
9
|
+
Maintainer-email:
|
|
10
|
+
License: MIT
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Natural Language :: English
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: Unix
|
|
18
|
+
Classifier: Programming Language :: Python
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE.txt
|
|
29
|
+
Requires-Dist: sqlalchemy>=1.0.0
|
|
30
|
+
Dynamic: author
|
|
31
|
+
Dynamic: classifier
|
|
32
|
+
Dynamic: description
|
|
33
|
+
Dynamic: description-content-type
|
|
34
|
+
Dynamic: home-page
|
|
35
|
+
Dynamic: license
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
Dynamic: maintainer
|
|
38
|
+
Dynamic: requires-dist
|
|
39
|
+
Dynamic: requires-python
|
|
40
|
+
Dynamic: summary
|
|
41
|
+
|
|
42
|
+
# apipool-ng
|
|
43
|
+
|
|
44
|
+
`apipool-ng` is a next-generation **Multiple API Key Manager**.
|
|
45
|
+
|
|
46
|
+
It allows developers to manage multiple API keys simultaneously. For example,
|
|
47
|
+
if a single API key has 1000/day quota, you can register 10 API keys and let
|
|
48
|
+
`apipool-ng` automatically rotate them.
|
|
49
|
+
|
|
50
|
+
## Features
|
|
51
|
+
|
|
52
|
+
- Automatically rotate API keys across multiple credentials.
|
|
53
|
+
- Built-in usage statistics, searchable by time, status, and apikey.
|
|
54
|
+
Stats collector can be deployed on any relational database (SQLite by default).
|
|
55
|
+
- Clean API, minimal code required to implement complex features.
|
|
56
|
+
- No uncommon dependencies — only requires `sqlalchemy`.
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install apipool-ng
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Quick Start
|
|
65
|
+
|
|
66
|
+
Implement an `ApiKey` subclass, then create an `ApiKeyManager`:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from apipool import ApiKey, ApiKeyManager
|
|
70
|
+
|
|
71
|
+
class MyApiKey(ApiKey):
|
|
72
|
+
def __init__(self, key):
|
|
73
|
+
self.key = key
|
|
74
|
+
|
|
75
|
+
def user_01_get_primary_key(self):
|
|
76
|
+
return self.key
|
|
77
|
+
|
|
78
|
+
def user_02_create_client(self):
|
|
79
|
+
return MyApiClient(api_key=self.key)
|
|
80
|
+
|
|
81
|
+
def user_03_test_usable(self, client):
|
|
82
|
+
return client.test_connection()
|
|
83
|
+
|
|
84
|
+
apikeys = [MyApiKey(k) for k in ["key1", "key2", "key3"]]
|
|
85
|
+
manager = ApiKeyManager(apikey_list=apikeys)
|
|
86
|
+
manager.check_usable()
|
|
87
|
+
|
|
88
|
+
# Use dummyclient like your real client — keys auto-rotate
|
|
89
|
+
result = manager.dummyclient.some_api_method(arg)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## DummyClient
|
|
93
|
+
|
|
94
|
+
Use `manager.dummyclient` just like your original API client.
|
|
95
|
+
Under the hood, it automatically selects a usable key, records usage events,
|
|
96
|
+
and rotates keys on rate-limit errors.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
manager.check_usable()
|
|
100
|
+
|
|
101
|
+
# Keys are automatically rotated
|
|
102
|
+
result = manager.dummyclient.some_method()
|
|
103
|
+
|
|
104
|
+
for _ in range(10):
|
|
105
|
+
manager.dummyclient.some_method()
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## StatsCollector
|
|
109
|
+
|
|
110
|
+
Query usage statistics through `manager.stats`:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from apipool import StatusCollection
|
|
114
|
+
|
|
115
|
+
# Usage count per key in last hour
|
|
116
|
+
manager.stats.usage_count_stats_in_recent_n_seconds(3600)
|
|
117
|
+
# {"key1": 3, "key2": 5, "key3": 2}
|
|
118
|
+
|
|
119
|
+
# Count specific events
|
|
120
|
+
count = manager.stats.usage_count_in_recent_n_seconds(
|
|
121
|
+
n_seconds=3600,
|
|
122
|
+
status_id=StatusCollection.c9_ReachLimit.id,
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT License
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
LICENSE.txt
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
release-history.rst
|
|
5
|
+
requirements.txt
|
|
6
|
+
setup.py
|
|
7
|
+
apipool/__init__.py
|
|
8
|
+
apipool/apikey.py
|
|
9
|
+
apipool/manager.py
|
|
10
|
+
apipool/stats.py
|
|
11
|
+
apipool/__pycache__/__init__.cpython-314.pyc
|
|
12
|
+
apipool/__pycache__/apikey.cpython-314.pyc
|
|
13
|
+
apipool/__pycache__/manager.cpython-314.pyc
|
|
14
|
+
apipool/__pycache__/stats.cpython-314.pyc
|
|
15
|
+
apipool/tests/__init__.py
|
|
16
|
+
apipool/tests/__pycache__/__init__.cpython-314.pyc
|
|
17
|
+
apipool_ng.egg-info/PKG-INFO
|
|
18
|
+
apipool_ng.egg-info/SOURCES.txt
|
|
19
|
+
apipool_ng.egg-info/dependency_links.txt
|
|
20
|
+
apipool_ng.egg-info/requires.txt
|
|
21
|
+
apipool_ng.egg-info/top_level.txt
|
|
22
|
+
tests/test_apipool.py
|
|
23
|
+
tests/test_import.py
|
|
24
|
+
tests/test_stats.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sqlalchemy>=1.0.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
apipool
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Release and Version History
|
|
2
|
+
==============================================================================
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
0.0.3 (todo)
|
|
6
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
7
|
+
**Features and Improvements**
|
|
8
|
+
|
|
9
|
+
**Minor Improvements**
|
|
10
|
+
|
|
11
|
+
**Bugfixes**
|
|
12
|
+
|
|
13
|
+
**Miscellaneous**
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
0.0.2 (2018-08-21)
|
|
17
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
18
|
+
**Features and Improvements**
|
|
19
|
+
|
|
20
|
+
- rewrite the core code.
|
|
21
|
+
- add database backed stats collector feature. convinent events query is provided.
|
|
22
|
+
|
|
23
|
+
**Minor Improvements**
|
|
24
|
+
|
|
25
|
+
- use pygitrepo skeleton.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
0.0.1 (2017-04-06)
|
|
29
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
30
|
+
|
|
31
|
+
- First release
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sqlalchemy>=1.0.0
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
from setuptools import setup, find_packages
|
|
5
|
+
|
|
6
|
+
import apipool as package
|
|
7
|
+
|
|
8
|
+
setup(
|
|
9
|
+
name="apipool-ng",
|
|
10
|
+
version=package.__version__,
|
|
11
|
+
description=package.__short_description__,
|
|
12
|
+
long_description=open("README.md", "rb").read().decode("utf-8"),
|
|
13
|
+
long_description_content_type="text/markdown",
|
|
14
|
+
author=package.__author__,
|
|
15
|
+
author_email=package.__author_email__,
|
|
16
|
+
maintainer=package.__maintainer__,
|
|
17
|
+
maintainer_email=package.__maintainer_email__,
|
|
18
|
+
license=package.__license__,
|
|
19
|
+
packages=["apipool"] + ["apipool.%s" % i for i in find_packages("apipool")],
|
|
20
|
+
include_package_data=True,
|
|
21
|
+
url="https://github.com/apipool-ng/apipool-project",
|
|
22
|
+
classifiers=[
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Natural Language :: English",
|
|
27
|
+
"Operating System :: Microsoft :: Windows",
|
|
28
|
+
"Operating System :: MacOS",
|
|
29
|
+
"Operating System :: Unix",
|
|
30
|
+
"Programming Language :: Python",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3.8",
|
|
33
|
+
"Programming Language :: Python :: 3.9",
|
|
34
|
+
"Programming Language :: Python :: 3.10",
|
|
35
|
+
"Programming Language :: Python :: 3.11",
|
|
36
|
+
"Programming Language :: Python :: 3.12",
|
|
37
|
+
"Programming Language :: Python :: 3.13",
|
|
38
|
+
],
|
|
39
|
+
python_requires=">=3.8",
|
|
40
|
+
install_requires=[line.strip() for line in open("requirements.txt") if line.strip() and not line.startswith("#")],
|
|
41
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from apipool import ApiKey, ApiKeyManager, StatusCollection
|
|
6
|
+
from apipool.tests import (
|
|
7
|
+
ReachLimitError,
|
|
8
|
+
GoogleMapApiClient,
|
|
9
|
+
GoogleMapApiKey,
|
|
10
|
+
apikeys,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestBaseApiKey(object):
|
|
15
|
+
def test(self):
|
|
16
|
+
apikey = GoogleMapApiKey(apikey="example@gmail.com")
|
|
17
|
+
assert apikey.primary_key == "example@gmail.com"
|
|
18
|
+
assert apikey.is_usable() is True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestApiKeyManager(object):
|
|
22
|
+
def test(self):
|
|
23
|
+
address = "123st, NewYork, NY 10001"
|
|
24
|
+
|
|
25
|
+
manager = ApiKeyManager(
|
|
26
|
+
apikey_list=[
|
|
27
|
+
GoogleMapApiKey(apikey=apikey)
|
|
28
|
+
for apikey in apikeys
|
|
29
|
+
],
|
|
30
|
+
reach_limit_exc=ReachLimitError,
|
|
31
|
+
)
|
|
32
|
+
manager.check_usable() # use each key once
|
|
33
|
+
|
|
34
|
+
res = manager.dummyclient.get_lat_lng_by_address(
|
|
35
|
+
address) # make a call
|
|
36
|
+
assert "lat" in res and "lng" in res
|
|
37
|
+
assert manager.stats.usage_count_in_recent_n_seconds(3600) == 5
|
|
38
|
+
assert max(
|
|
39
|
+
list(manager.stats.usage_count_stats_in_recent_n_seconds(3600).values())) == 2
|
|
40
|
+
|
|
41
|
+
# make 100 successful call
|
|
42
|
+
for _ in range(100):
|
|
43
|
+
manager.dummyclient.get_lat_lng_by_address(address)
|
|
44
|
+
assert manager.stats.usage_count_in_recent_n_seconds(3600) == 105
|
|
45
|
+
|
|
46
|
+
assert len(manager.apikey_chain) == 3
|
|
47
|
+
assert len(manager.archived_apikey_chain) == 1
|
|
48
|
+
|
|
49
|
+
# raise other error
|
|
50
|
+
try:
|
|
51
|
+
manager.dummyclient.raise_other_error(address)
|
|
52
|
+
except:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
assert len(manager.apikey_chain) == 3
|
|
56
|
+
assert len(manager.archived_apikey_chain) == 1
|
|
57
|
+
|
|
58
|
+
# raise ReachLimitError
|
|
59
|
+
try:
|
|
60
|
+
manager.dummyclient.raise_reach_limit_error(address)
|
|
61
|
+
except:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
assert len(manager.apikey_chain) == 2
|
|
65
|
+
assert len(manager.archived_apikey_chain) == 2
|
|
66
|
+
|
|
67
|
+
assert manager.stats.usage_count_in_recent_n_seconds(
|
|
68
|
+
3600, status_id=StatusCollection.c5_Failed.id) == 2
|
|
69
|
+
assert manager.stats.usage_count_in_recent_n_seconds(
|
|
70
|
+
3600, status_id=StatusCollection.c9_ReachLimit.id) == 1
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
import os
|
|
75
|
+
|
|
76
|
+
basename = os.path.basename(__file__)
|
|
77
|
+
pytest.main([basename, "-s", "--tb=native"])
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from pytest import raises, approx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test():
|
|
9
|
+
import apipool
|
|
10
|
+
|
|
11
|
+
apipool.ApiKey
|
|
12
|
+
apipool.ApiKeyManager
|
|
13
|
+
apipool.StatusCollection
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
basename = os.path.basename(__file__)
|
|
20
|
+
pytest.main([basename, "-s", "--tb=native"])
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
import random
|
|
6
|
+
from apipool.stats import StatsCollector, StatusCollection
|
|
7
|
+
from apipool.tests import GoogleMapApiKey, apikeys
|
|
8
|
+
from sqlalchemy import create_engine
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture()
|
|
12
|
+
def collector():
|
|
13
|
+
engine = create_engine("sqlite:///:memory:")
|
|
14
|
+
collector = StatsCollector(engine=engine)
|
|
15
|
+
collector.add_all_apikey(
|
|
16
|
+
[GoogleMapApiKey(apikey=apikey) for apikey in apikeys]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
status_id_list = StatusCollection.get_id_list()
|
|
20
|
+
for _ in range(100):
|
|
21
|
+
primary_key = random.choice(apikeys)
|
|
22
|
+
status_id = random.choice(status_id_list)
|
|
23
|
+
collector.add_event(primary_key, status_id)
|
|
24
|
+
return collector
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestDashboardQuery(object):
|
|
28
|
+
def test_usage_count_in_recent_n_seconds(self, collector):
|
|
29
|
+
assert collector.usage_count_in_recent_n_seconds(3600) == 100
|
|
30
|
+
|
|
31
|
+
assert sum([
|
|
32
|
+
collector.usage_count_in_recent_n_seconds(3600, primary_key=key)
|
|
33
|
+
for key in apikeys
|
|
34
|
+
]) == 100
|
|
35
|
+
|
|
36
|
+
assert sum([
|
|
37
|
+
collector.usage_count_in_recent_n_seconds(
|
|
38
|
+
3600, status_id=status_id)
|
|
39
|
+
for status_id in StatusCollection.get_id_list()
|
|
40
|
+
]) == 100
|
|
41
|
+
|
|
42
|
+
def test_usage_count_stats_in_recent_n_seconds(self, collector):
|
|
43
|
+
stats = collector.usage_count_stats_in_recent_n_seconds(3600)
|
|
44
|
+
assert sum(list(stats.values()))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestDashboardConstructor(object):
|
|
48
|
+
def test(self):
|
|
49
|
+
engine = create_engine("sqlite:///:memory:")
|
|
50
|
+
collector = StatsCollector(engine=engine)
|
|
51
|
+
assert len(collector._cache_apikey) == 0
|
|
52
|
+
collector.add_all_apikey(
|
|
53
|
+
[GoogleMapApiKey(apikey=apikey) for apikey in apikeys]
|
|
54
|
+
)
|
|
55
|
+
assert len(collector._cache_apikey) == 4
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
import os
|
|
60
|
+
|
|
61
|
+
basename = os.path.basename(__file__)
|
|
62
|
+
pytest.main([basename, "-s", "--tb=native"])
|