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.
Files changed (47) hide show
  1. {bdcdata-2.0.0 → bdcdata-2.1.0}/.gitignore +1 -0
  2. {bdcdata-2.0.0 → bdcdata-2.1.0}/CHANGELOG.md +27 -1
  3. {bdcdata-2.0.0 → bdcdata-2.1.0}/PKG-INFO +3 -2
  4. {bdcdata-2.0.0 → bdcdata-2.1.0}/pyproject.toml +2 -1
  5. {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/__init__.py +2 -2
  6. {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/_client.py +53 -50
  7. {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/conftest.py +12 -8
  8. {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_client.py +29 -21
  9. bdcdata-2.1.0/uv.lock +1413 -0
  10. {bdcdata-2.0.0 → bdcdata-2.1.0}/.github/workflows/ci.yml +0 -0
  11. {bdcdata-2.0.0 → bdcdata-2.1.0}/.github/workflows/publish.yml +0 -0
  12. {bdcdata-2.0.0 → bdcdata-2.1.0}/LICENSE.txt +0 -0
  13. {bdcdata-2.0.0 → bdcdata-2.1.0}/README.md +0 -0
  14. {bdcdata-2.0.0 → bdcdata-2.1.0}/docs/availability.md +0 -0
  15. {bdcdata-2.0.0 → bdcdata-2.1.0}/docs/challenges.md +0 -0
  16. {bdcdata-2.0.0 → bdcdata-2.1.0}/docs/credentials.md +0 -0
  17. {bdcdata-2.0.0 → bdcdata-2.1.0}/docs/funding.md +0 -0
  18. {bdcdata-2.0.0 → bdcdata-2.1.0}/docs/index.md +0 -0
  19. {bdcdata-2.0.0 → bdcdata-2.1.0}/docs/migrating-from-v1.md +0 -0
  20. {bdcdata-2.0.0 → bdcdata-2.1.0}/docs/quickstart.md +0 -0
  21. {bdcdata-2.0.0 → bdcdata-2.1.0}/mkdocs.yml +0 -0
  22. {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/_cache.py +0 -0
  23. {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/_fetch.py +0 -0
  24. {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/_normalize.py +0 -0
  25. {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/_readers.py +0 -0
  26. {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/_schemas.py +0 -0
  27. {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/availability.py +0 -0
  28. {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/catalog.py +0 -0
  29. {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/challenges.py +0 -0
  30. {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/config.py +0 -0
  31. {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/credentials.py +0 -0
  32. {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/exceptions.py +0 -0
  33. {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/funding.py +0 -0
  34. {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/lookups.py +0 -0
  35. {bdcdata-2.0.0 → bdcdata-2.1.0}/src/bdcdata/py.typed +0 -0
  36. {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/helpers.py +0 -0
  37. {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/integration/test_live.py +0 -0
  38. {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_availability.py +0 -0
  39. {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_cache.py +0 -0
  40. {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_catalog.py +0 -0
  41. {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_challenges.py +0 -0
  42. {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_doctests.py +0 -0
  43. {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_funding.py +0 -0
  44. {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_import_purity.py +0 -0
  45. {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_lookups.py +0 -0
  46. {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_normalize.py +0 -0
  47. {bdcdata-2.0.0 → bdcdata-2.1.0}/tests/test_readers.py +0 -0
@@ -13,6 +13,7 @@ venv/
13
13
  .ruff_cache/
14
14
  .coverage
15
15
  htmlcov/
16
+ .python-version
16
17
 
17
18
  # Credentials -- never commit these
18
19
  .env
@@ -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.0.0] — unreleased
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.0.0
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" <nick.pappin@wsu.edu>
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 = "nick.pappin@wsu.edu" },
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, reset_session # noqa: E402
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
- from collections import deque
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
- """Sliding-window limiter shared by every request bdcdata makes.
54
-
55
- The FCC documents 10 calls per minute on each endpoint. Pacing here means
56
- a 50-state pull is slow rather than a wall of 429s.
57
- """
58
-
59
- def __init__(self, calls: int, period: float) -> None:
60
- self._calls = calls
61
- self._period = period
62
- self._times: deque[float] = deque()
63
- self._lock = threading.Lock()
64
-
65
- def acquire(self) -> None:
66
- with self._lock:
67
- while True:
68
- now = time.monotonic()
69
- while self._times and now - self._times[0] >= self._period:
70
- self._times.popleft()
71
- if len(self._times) < self._calls:
72
- self._times.append(now)
73
- return
74
- wait = self._period - (now - self._times[0])
75
- if wait > 0:
76
- logger.debug("Rate limit reached; waiting %.1fs", wait)
77
- time.sleep(wait)
78
-
79
- def reset(self) -> None:
80
- with self._lock:
81
- self._times.clear()
82
-
83
-
84
- _limiter = _RateLimiter(RATE_LIMIT_PER_MINUTE, 60.0)
85
- _session: requests.Session | None = None
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() -> requests.Session:
92
+ def _get_session() -> LimiterSession:
90
93
  global _session
91
94
  with _session_lock:
92
95
  if _session is None:
93
- _session = requests.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
- return _session
99
+ return _session
97
100
 
98
101
 
99
- def reset_session() -> None:
100
- """Close the pooled HTTP session and clear the rate-limit window.
102
+ # def reset_session() -> None:
103
+ # """Close the pooled HTTP session and clear the rate-limit window.
101
104
 
102
- Mostly useful in tests.
103
- """
104
- global _session
105
- with _session_lock:
106
- if _session is not None:
107
- _session.close()
108
- _session = None
109
- _limiter.reset()
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
- # Neutralize the 10-calls-per-minute pacing. It works (TestRateLimiter
36
- # builds its own _RateLimiter to prove it), but a test that downloads 60
37
- # files would otherwise sleep for six real minutes.
38
- monkeypatch.setattr("bdcdata._client._limiter.acquire", lambda: None)
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 test_paces_calls_beyond_the_limit(self, monkeypatch):
215
- from bdcdata._client import _RateLimiter
216
-
217
- clock = {"now": 0.0}
218
- slept: list[float] = []
219
- monkeypatch.setattr("bdcdata._client.time.monotonic", lambda: clock["now"])
220
-
221
- def fake_sleep(seconds):
222
- slept.append(seconds)
223
- clock["now"] += seconds
224
-
225
- monkeypatch.setattr("bdcdata._client.time.sleep", fake_sleep)
226
-
227
- limiter = _RateLimiter(calls=10, period=60.0)
228
- for _ in range(10):
229
- limiter.acquire()
230
- assert slept == []
231
-
232
- # The 11th call must wait for the window to roll over.
233
- limiter.acquire()
234
- assert slept and slept[0] == pytest.approx(60.0)
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