bdcdata 2.0.0__tar.gz → 2.1.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.
- {bdcdata-2.0.0 → bdcdata-2.1.0}/.gitignore +1 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/CHANGELOG.md +27 -1
- {bdcdata-2.0.0 → bdcdata-2.1.0}/PKG-INFO +3 -2
- {bdcdata-2.0.0 → bdcdata-2.1.0}/pyproject.toml +2 -1
- {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/__init__.py +2 -2
- {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/_client.py +53 -50
- {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/conftest.py +12 -8
- {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_client.py +29 -21
- bdcdata-2.1.0/uv.lock +1413 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/.github/workflows/ci.yml +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/.github/workflows/publish.yml +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/LICENSE.txt +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/README.md +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/docs/availability.md +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/docs/challenges.md +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/docs/credentials.md +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/docs/funding.md +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/docs/index.md +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/docs/migrating-from-v1.md +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/docs/quickstart.md +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/mkdocs.yml +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/_cache.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/_fetch.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/_normalize.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/_readers.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/_schemas.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/availability.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/catalog.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/challenges.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/config.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/credentials.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/exceptions.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/funding.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/lookups.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/py.typed +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/helpers.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/integration/test_live.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_availability.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_cache.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_catalog.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_challenges.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_doctests.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_funding.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_import_purity.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_lookups.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_normalize.py +0 -0
- {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_readers.py +0 -0
|
@@ -5,7 +5,33 @@ All notable changes to this project are documented here.
|
|
|
5
5
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
7
|
|
|
8
|
-
## [2.
|
|
8
|
+
## [2.1.0]
|
|
9
|
+
|
|
10
|
+
### Removed/Changed
|
|
11
|
+
Switch rate limiting to requests-ratelimiter
|
|
12
|
+
|
|
13
|
+
Replace the hand-rolled sliding-window _RateLimiter with a shared
|
|
14
|
+
LimiterSession (10 calls/minute). The session now paces every request
|
|
15
|
+
it sends, retries included, and backs off for the rest of the window
|
|
16
|
+
after a 429.
|
|
17
|
+
|
|
18
|
+
Breaking: reset_session() is removed from the public API.
|
|
19
|
+
|
|
20
|
+
- Comment out _RateLimiter, _limiter, and reset_session in _client.py
|
|
21
|
+
- _get_session() returns a LimiterSession, still guarded by a lock
|
|
22
|
+
- Add requests-ratelimiter as a dependency
|
|
23
|
+
- conftest: give each test a fresh high-limit session in place of
|
|
24
|
+
reset_session() and the _limiter.acquire patch
|
|
25
|
+
- TestRateLimiter: check the session's configured rate and that the
|
|
26
|
+
11th call in a minute is held back
|
|
27
|
+
|
|
28
|
+
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
Added uv.lock to the repository.
|
|
33
|
+
|
|
34
|
+
## [2.0.0]
|
|
9
35
|
|
|
10
36
|
A ground-up rewrite. The submodule layout (`bdcdata.availability.fixed()`) is
|
|
11
37
|
unchanged, but arguments, credential handling, and return types all changed.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bdcdata
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Work with FCC Broadband Data Collection (BDC) data in pandas.
|
|
5
5
|
Project-URL: Homepage, https://github.com/npappin/bdcdata
|
|
6
6
|
Project-URL: Documentation, https://github.com/npappin/bdcdata/tree/main/docs
|
|
7
7
|
Project-URL: Issues, https://github.com/npappin/bdcdata/issues
|
|
8
|
-
Author-email: "W. Nick Pappin" <
|
|
8
|
+
Author-email: "W. Nick Pappin" <npappin@gmail.com>
|
|
9
9
|
Maintainer-email: "W. Nick Pappin" <npappin@gmail.com>
|
|
10
10
|
License-Expression: MIT
|
|
11
11
|
License-File: LICENSE.txt
|
|
@@ -25,6 +25,7 @@ Requires-Python: >=3.10
|
|
|
25
25
|
Requires-Dist: pandas>=2
|
|
26
26
|
Requires-Dist: pyarrow>=12
|
|
27
27
|
Requires-Dist: python-dotenv
|
|
28
|
+
Requires-Dist: requests-ratelimiter
|
|
28
29
|
Requires-Dist: requests>=2.28
|
|
29
30
|
Provides-Extra: all
|
|
30
31
|
Requires-Dist: pyogrio>=0.7; extra == 'all'
|
|
@@ -11,7 +11,7 @@ requires-python = ">=3.10"
|
|
|
11
11
|
license = "MIT"
|
|
12
12
|
license-files = ["LICEN[CS]E*"]
|
|
13
13
|
authors = [
|
|
14
|
-
{ name = "W. Nick Pappin", email = "
|
|
14
|
+
{ name = "W. Nick Pappin", email = "npappin@gmail.com" },
|
|
15
15
|
]
|
|
16
16
|
maintainers = [
|
|
17
17
|
{ name = "W. Nick Pappin", email = "npappin@gmail.com" },
|
|
@@ -22,6 +22,7 @@ dependencies = [
|
|
|
22
22
|
"pandas>=2",
|
|
23
23
|
"pyarrow>=12",
|
|
24
24
|
"python-dotenv",
|
|
25
|
+
"requests-ratelimiter",
|
|
25
26
|
]
|
|
26
27
|
classifiers = [
|
|
27
28
|
"Development Status :: 3 - Alpha",
|
|
@@ -26,7 +26,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
|
26
26
|
|
|
27
27
|
from . import availability, catalog, challenges, funding, lookups # noqa: E402
|
|
28
28
|
from ._cache import cache_info, clear_cache # noqa: E402
|
|
29
|
-
from ._client import check_credentials
|
|
29
|
+
from ._client import check_credentials # noqa: E402
|
|
30
30
|
from .config import get_cache_settings, set_base_url, set_cache, set_timeout # noqa: E402
|
|
31
31
|
from .credentials import ( # noqa: E402
|
|
32
32
|
clear_credentials,
|
|
@@ -77,7 +77,7 @@ __all__ = [ # noqa: RUF022
|
|
|
77
77
|
"clear_cache",
|
|
78
78
|
"set_base_url",
|
|
79
79
|
"set_timeout",
|
|
80
|
-
"reset_session",
|
|
80
|
+
# "reset_session",
|
|
81
81
|
# exceptions
|
|
82
82
|
"BdcError",
|
|
83
83
|
"BdcAuthError",
|
|
@@ -13,11 +13,13 @@ import contextlib
|
|
|
13
13
|
import logging
|
|
14
14
|
import threading
|
|
15
15
|
import time
|
|
16
|
-
|
|
16
|
+
|
|
17
|
+
# from collections import deque # only used by the old _RateLimiter
|
|
17
18
|
from collections.abc import Mapping
|
|
18
19
|
from typing import Any
|
|
19
20
|
|
|
20
21
|
import requests
|
|
22
|
+
from requests_ratelimiter import LimiterSession
|
|
21
23
|
|
|
22
24
|
from ._cache import cache_key, read_cached, write_cached
|
|
23
25
|
from .config import RATE_LIMIT_PER_MINUTE, get_base_url, options
|
|
@@ -32,7 +34,8 @@ from .exceptions import (
|
|
|
32
34
|
BdcUnprocessableError,
|
|
33
35
|
)
|
|
34
36
|
|
|
35
|
-
__all__ = ["check_credentials", "get_bytes", "get_json", "reset_session"]
|
|
37
|
+
# __all__ = ["check_credentials", "get_bytes", "get_json", "reset_session"]
|
|
38
|
+
__all__ = ["check_credentials", "get_bytes", "get_json"]
|
|
36
39
|
|
|
37
40
|
logger = logging.getLogger("bdcdata")
|
|
38
41
|
|
|
@@ -49,64 +52,64 @@ def _user_agent() -> str:
|
|
|
49
52
|
return f"bdcdata/{pkg_version} (+https://github.com/npappin/bdcdata)"
|
|
50
53
|
|
|
51
54
|
|
|
52
|
-
class _RateLimiter:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
_limiter = _RateLimiter(RATE_LIMIT_PER_MINUTE, 60.0)
|
|
85
|
-
_session:
|
|
55
|
+
# class _RateLimiter:
|
|
56
|
+
# """Sliding-window limiter shared by every request bdcdata makes.
|
|
57
|
+
|
|
58
|
+
# The FCC documents 10 calls per minute on each endpoint. Pacing here means
|
|
59
|
+
# a 50-state pull is slow rather than a wall of 429s.
|
|
60
|
+
# """
|
|
61
|
+
|
|
62
|
+
# def __init__(self, calls: int, period: float) -> None:
|
|
63
|
+
# self._calls = calls
|
|
64
|
+
# self._period = period
|
|
65
|
+
# self._times: deque[float] = deque()
|
|
66
|
+
# self._lock = threading.Lock()
|
|
67
|
+
|
|
68
|
+
# def acquire(self) -> None:
|
|
69
|
+
# with self._lock:
|
|
70
|
+
# while True:
|
|
71
|
+
# now = time.monotonic()
|
|
72
|
+
# while self._times and now - self._times[0] >= self._period:
|
|
73
|
+
# self._times.popleft()
|
|
74
|
+
# if len(self._times) < self._calls:
|
|
75
|
+
# self._times.append(now)
|
|
76
|
+
# return
|
|
77
|
+
# wait = self._period - (now - self._times[0])
|
|
78
|
+
# if wait > 0:
|
|
79
|
+
# logger.debug("Rate limit reached; waiting %.1fs", wait)
|
|
80
|
+
# time.sleep(wait)
|
|
81
|
+
|
|
82
|
+
# def reset(self) -> None:
|
|
83
|
+
# with self._lock:
|
|
84
|
+
# self._times.clear()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# _limiter = _RateLimiter(RATE_LIMIT_PER_MINUTE, 60.0)
|
|
88
|
+
_session: LimiterSession | None = None
|
|
86
89
|
_session_lock = threading.Lock()
|
|
87
90
|
|
|
88
91
|
|
|
89
|
-
def _get_session() ->
|
|
92
|
+
def _get_session() -> LimiterSession:
|
|
90
93
|
global _session
|
|
91
94
|
with _session_lock:
|
|
92
95
|
if _session is None:
|
|
93
|
-
_session =
|
|
96
|
+
_session = LimiterSession(per_minute=RATE_LIMIT_PER_MINUTE)
|
|
94
97
|
_session.headers.update({"User-Agent": _user_agent()})
|
|
95
98
|
logger.debug("HTTP session created")
|
|
96
|
-
|
|
99
|
+
return _session
|
|
97
100
|
|
|
98
101
|
|
|
99
|
-
def reset_session() -> None:
|
|
100
|
-
|
|
102
|
+
# def reset_session() -> None:
|
|
103
|
+
# """Close the pooled HTTP session and clear the rate-limit window.
|
|
101
104
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
105
|
+
# Mostly useful in tests.
|
|
106
|
+
# """
|
|
107
|
+
# global _session
|
|
108
|
+
# with _session_lock:
|
|
109
|
+
# if _session is not None:
|
|
110
|
+
# _session.close()
|
|
111
|
+
# _session = None
|
|
112
|
+
# _limiter.reset()
|
|
110
113
|
|
|
111
114
|
|
|
112
115
|
def _raise_for_status(response: requests.Response, url: str) -> None:
|
|
@@ -168,7 +171,7 @@ def _request(
|
|
|
168
171
|
last_error: Exception | None = None
|
|
169
172
|
|
|
170
173
|
for attempt in range(1, attempts + 1):
|
|
171
|
-
_limiter.acquire()
|
|
174
|
+
# _limiter.acquire()
|
|
172
175
|
logger.debug("GET %s params=%s (attempt %d/%d)", url, clean_params, attempt, attempts)
|
|
173
176
|
try:
|
|
174
177
|
response = session.get(
|
|
@@ -9,10 +9,10 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import pytest
|
|
11
11
|
import responses
|
|
12
|
+
from requests_ratelimiter import LimiterSession
|
|
12
13
|
|
|
13
14
|
import bdcdata
|
|
14
|
-
from bdcdata import catalog, credentials
|
|
15
|
-
from bdcdata._client import reset_session
|
|
15
|
+
from bdcdata import _client, catalog, credentials
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
@pytest.fixture(autouse=True)
|
|
@@ -30,18 +30,22 @@ def _isolate(monkeypatch, tmp_path):
|
|
|
30
30
|
catalog.clear_release_cache()
|
|
31
31
|
bdcdata.set_base_url(None)
|
|
32
32
|
bdcdata.set_cache(False, path=tmp_path / "cache")
|
|
33
|
-
reset_session()
|
|
33
|
+
# reset_session()
|
|
34
34
|
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
# files would otherwise sleep for six real minutes.
|
|
38
|
-
|
|
35
|
+
# A fresh session per test with pacing effectively off. The real
|
|
36
|
+
# 10/minute limit is covered by TestRateLimiter; a test that downloads
|
|
37
|
+
# 60 files would otherwise sleep for six real minutes. limit_statuses=()
|
|
38
|
+
# stops a mocked 429 from locking the session for the rest of the minute.
|
|
39
|
+
fast = LimiterSession(per_minute=100_000, limit_statuses=())
|
|
40
|
+
fast.headers.update({"User-Agent": _client._user_agent()})
|
|
41
|
+
monkeypatch.setattr("bdcdata._client._session", fast)
|
|
39
42
|
|
|
40
43
|
yield
|
|
41
44
|
|
|
42
45
|
credentials.clear_credentials()
|
|
43
46
|
catalog.clear_release_cache()
|
|
44
|
-
reset_session()
|
|
47
|
+
# reset_session()
|
|
48
|
+
fast.close()
|
|
45
49
|
|
|
46
50
|
|
|
47
51
|
@pytest.fixture
|
|
@@ -211,27 +211,35 @@ class TestCheckCredentials:
|
|
|
211
211
|
|
|
212
212
|
|
|
213
213
|
class TestRateLimiter:
|
|
214
|
-
def
|
|
215
|
-
from
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
214
|
+
def test_session_is_rate_limited_to_the_published_rate(self, monkeypatch):
|
|
215
|
+
from requests_ratelimiter import LimiterSession
|
|
216
|
+
from requests_ratelimiter.buckets import HostBucketFactory
|
|
217
|
+
|
|
218
|
+
monkeypatch.setattr(_client, "_session", None) # bypass conftest's fast session
|
|
219
|
+
session = _client._get_session()
|
|
220
|
+
try:
|
|
221
|
+
assert isinstance(session, LimiterSession)
|
|
222
|
+
factory = session.limiter.bucket_factory
|
|
223
|
+
assert isinstance(factory, HostBucketFactory)
|
|
224
|
+
[rate] = factory.rates
|
|
225
|
+
assert (rate.limit, rate.interval) == (10, 60_000)
|
|
226
|
+
finally:
|
|
227
|
+
session.close()
|
|
228
|
+
|
|
229
|
+
def test_eleventh_call_in_a_minute_is_held_back(self, monkeypatch, mock_api):
|
|
230
|
+
import requests
|
|
231
|
+
|
|
232
|
+
monkeypatch.setattr(_client, "_session", None)
|
|
233
|
+
session = _client._get_session()
|
|
234
|
+
session.max_delay = 0.5 # fail fast instead of waiting out the minute
|
|
235
|
+
mock_api.add(responses.GET, AS_OF_DATES, json={"data": []})
|
|
236
|
+
try:
|
|
237
|
+
for _ in range(10):
|
|
238
|
+
session.get(AS_OF_DATES)
|
|
239
|
+
with pytest.raises(requests.exceptions.Timeout):
|
|
240
|
+
session.get(AS_OF_DATES)
|
|
241
|
+
finally:
|
|
242
|
+
session.close()
|
|
235
243
|
|
|
236
244
|
def test_limit_matches_the_published_rate(self):
|
|
237
245
|
from bdcdata.config import RATE_LIMIT_PER_MINUTE
|