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.
@@ -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,2 @@
1
+ include README.md LICENSE.txt requirements.txt release-history.rst
2
+ recursive-include apipool *.*
@@ -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
@@ -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
+ ]
@@ -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
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"])