repeaterbook 0.3.0__tar.gz → 0.4.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 (60) hide show
  1. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/PKG-INFO +1 -1
  2. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/CHANGELOG.md +51 -0
  3. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/pyproject.toml +2 -2
  4. repeaterbook-0.4.0/src/repeaterbook/__init__.py +21 -0
  5. repeaterbook-0.4.0/src/repeaterbook/exceptions.py +44 -0
  6. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/src/repeaterbook/models.py +28 -1
  7. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/src/repeaterbook/services.py +97 -20
  8. repeaterbook-0.4.0/tests/integration/test_live_api.py +250 -0
  9. repeaterbook-0.4.0/tests/test_database.py +253 -0
  10. repeaterbook-0.4.0/tests/test_exceptions.py +72 -0
  11. repeaterbook-0.4.0/tests/test_models.py +324 -0
  12. repeaterbook-0.4.0/tests/test_queries.py +192 -0
  13. repeaterbook-0.4.0/tests/test_repeaterbook.py +29 -0
  14. repeaterbook-0.4.0/tests/test_services.py +407 -0
  15. repeaterbook-0.4.0/tests/test_utils.py +129 -0
  16. repeaterbook-0.4.0/uv.lock +3702 -0
  17. repeaterbook-0.3.0/src/repeaterbook/__init__.py +0 -11
  18. repeaterbook-0.3.0/tests/integration/test_live_api.py +0 -87
  19. repeaterbook-0.3.0/tests/test_repeaterbook.py +0 -5
  20. repeaterbook-0.3.0/uv.lock +0 -2997
  21. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.all-contributorsrc +0 -0
  22. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.cruft.json +0 -0
  23. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.editorconfig +0 -0
  24. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  25. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  26. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  27. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.github/workflows/ci.yml +0 -0
  28. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.github/workflows/codecov_action.yml +0 -0
  29. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.github/workflows/semantic-release.yml +0 -0
  30. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.gitignore +0 -0
  31. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.pre-commit-config.yaml +0 -0
  32. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.python-version +0 -0
  33. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.python-versions +0 -0
  34. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.readthedocs.yaml +0 -0
  35. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/LICENSE +0 -0
  36. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/asv.conf.json +0 -0
  37. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/benchmarks/__init__.py +0 -0
  38. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/benchmarks/benchmarks.py +0 -0
  39. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/CODE_OF_CONDUCT.md +0 -0
  40. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/CONTRIBUTING.md +0 -0
  41. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/README.md +0 -0
  42. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/__init__.py +0 -0
  43. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/api.md +0 -0
  44. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/javascripts/extra.js +0 -0
  45. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/mkdocs.yml +0 -0
  46. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/reference.md +0 -0
  47. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/stylesheets/extra.css +0 -0
  48. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/wordlist.txt +0 -0
  49. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/noxfile.py +0 -0
  50. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/playground/.gitignore +0 -0
  51. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/playground/examples.ipynb +0 -0
  52. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/src/repeaterbook/database.py +0 -0
  53. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/src/repeaterbook/py.typed +0 -0
  54. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/src/repeaterbook/queries.py +0 -0
  55. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/src/repeaterbook/utils.py +0 -0
  56. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/tests/__init__.py +0 -0
  57. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/tests/conftest.py +0 -0
  58. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/tests/integration/__init__.py +0 -0
  59. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/tests/test_api_format.py +0 -0
  60. {repeaterbook-0.3.0 → repeaterbook-0.4.0}/tests/test_fetch_json_cache.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repeaterbook
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Python utility to work with data from RepeaterBook.
5
5
  Project-URL: homepage, https://github.com/MicaelJarniac/repeaterbook
6
6
  Project-URL: source, https://github.com/MicaelJarniac/repeaterbook
@@ -1,6 +1,57 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.4.0 (2026-02-04)
5
+
6
+ ### Features
7
+
8
+ - Comprehensive codebase improvements ([#8](https://github.com/MicaelJarniac/repeaterbook/pull/8),
9
+ [`c893c4b`](https://github.com/MicaelJarniac/repeaterbook/commit/c893c4b39125d4f843d11cb06f633122b809b769))
10
+
11
+ * feat: comprehensive codebase improvements
12
+
13
+ - Add custom exception classes (RepeaterBookError, RepeaterBookAPIError, RepeaterBookCacheError,
14
+ RepeaterBookValidationError) - Enable North America endpoint in urls_export() - Fix cache race
15
+ conditions with atomic write pattern - Add model validation for latitude, longitude, and frequency
16
+ fields - Replace MD5 with SHA256 for cache key generation - Make configuration injectable
17
+ (max_cache_age, max_count) - Remove commented-out operating_mode field - Improve type safety with
18
+ explanatory comments for casts - Optimize cache stat calls (single stat instead of exists + stat)
19
+
20
+ Test suite expansion: - Add test_exceptions.py for exception hierarchy - Add test_services.py for
21
+ services module - Add test_models.py for model validation - Add test_queries.py for query builders
22
+ - Add test_database.py for database operations - Add test_utils.py for utility functions - Expand
23
+ test_repeaterbook.py for public API
24
+
25
+ Total: 108 tests passing
26
+
27
+ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
28
+
29
+ * feat: add smart routing for NA/ROW endpoints
30
+
31
+ Implement intelligent endpoint routing in urls_export(): - NA-specific fields (state_id, county,
32
+ emcomm, stype) route to NA only - ROW-specific fields (region) route to ROW only - NA countries
33
+ (US, Canada, Mexico) route to NA only - ROW countries route to ROW only - Mixed or common-only
34
+ queries route to both
35
+
36
+ This prevents redundant API calls and avoids unfiltered queries that could return thousands of
37
+ irrelevant results.
38
+
39
+ Added tests for all routing scenarios.
40
+
41
+ * test: add comprehensive smart routing integration tests
42
+
43
+ Add live API integration tests to verify smart routing behavior: - NA-only queries (state_id) route
44
+ to export.php only - ROW-only queries (region) route to exportROW.php only - NA country queries
45
+ route to NA endpoint - ROW country queries route to ROW endpoint - Mixed country queries route to
46
+ both endpoints - Empty queries route to both endpoints - Mode-only queries route to both endpoints
47
+
48
+ Also fix linting warnings (use next(iter()) instead of list()[0]).
49
+
50
+ ---------
51
+
52
+ Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
53
+
54
+
4
55
  ## v0.3.0 (2026-02-03)
5
56
 
6
57
  ### Chores
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "repeaterbook"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Python utility to work with data from RepeaterBook."
5
5
  authors = [
6
6
  {name = "Micael Jarniac", email = "micael@jarniac.dev"},
@@ -110,7 +110,7 @@ ignore = [
110
110
  unfixable = ["ERA001"]
111
111
 
112
112
  [tool.ruff.lint.per-file-ignores]
113
- "tests/*" = ["S101"]
113
+ "tests/*" = ["S101", "PLR2004"]
114
114
 
115
115
  [tool.ruff.lint.flake8-builtins]
116
116
  builtins-ignorelist = ["id", "type"]
@@ -0,0 +1,21 @@
1
+ """Python utility to work with data from RepeaterBook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: tuple[str, ...] = (
6
+ "Repeater",
7
+ "RepeaterBook",
8
+ "RepeaterBookAPIError",
9
+ "RepeaterBookCacheError",
10
+ "RepeaterBookError",
11
+ "RepeaterBookValidationError",
12
+ )
13
+
14
+ from repeaterbook.database import RepeaterBook
15
+ from repeaterbook.exceptions import (
16
+ RepeaterBookAPIError,
17
+ RepeaterBookCacheError,
18
+ RepeaterBookError,
19
+ RepeaterBookValidationError,
20
+ )
21
+ from repeaterbook.models import Repeater
@@ -0,0 +1,44 @@
1
+ """Custom exceptions for RepeaterBook library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: tuple[str, ...] = (
6
+ "RepeaterBookAPIError",
7
+ "RepeaterBookCacheError",
8
+ "RepeaterBookError",
9
+ "RepeaterBookValidationError",
10
+ )
11
+
12
+
13
+ class RepeaterBookError(Exception):
14
+ """Base exception for RepeaterBook library.
15
+
16
+ All RepeaterBook-specific exceptions inherit from this class,
17
+ making it easy to catch all library errors with a single except clause.
18
+ """
19
+
20
+
21
+ class RepeaterBookAPIError(RepeaterBookError):
22
+ """Error returned by the RepeaterBook API.
23
+
24
+ Raised when the API returns an error response (status: "error").
25
+ The error message from the API is preserved in the exception message.
26
+ """
27
+
28
+
29
+ class RepeaterBookCacheError(RepeaterBookError):
30
+ """Error during cache operations.
31
+
32
+ Raised when reading from or writing to the cache fails,
33
+ such as file permission issues or disk full errors.
34
+ """
35
+
36
+
37
+ class RepeaterBookValidationError(RepeaterBookError):
38
+ """Invalid data or response format.
39
+
40
+ Raised when:
41
+ - API response is not in expected format (not a dict)
42
+ - Required fields are missing from the response
43
+ - Data values fail validation (e.g., invalid coordinates)
44
+ """
@@ -34,6 +34,7 @@ from typing import Literal, TypeAlias, TypedDict
34
34
 
35
35
  import attrs
36
36
  from pycountry.db import Country # noqa: TC002
37
+ from pydantic import field_validator
37
38
  from sqlmodel import Field, SQLModel
38
39
 
39
40
 
@@ -103,7 +104,6 @@ class Repeater(SQLModel, table=True):
103
104
  races: str | None
104
105
  skywarn: str | None
105
106
  canwarn: str | None
106
- #' operating_mode: str
107
107
  allstar_node: str | None
108
108
  echolink_node: str | None
109
109
  irlp_node: str | None
@@ -129,6 +129,33 @@ class Repeater(SQLModel, table=True):
129
129
  notes: str | None
130
130
  last_update: date
131
131
 
132
+ @field_validator("latitude")
133
+ @classmethod
134
+ def validate_latitude(cls, v: Decimal) -> Decimal:
135
+ """Validate latitude is within valid range."""
136
+ if not Decimal(-90) <= v <= Decimal(90):
137
+ msg = f"Latitude must be between -90 and 90, got {v}"
138
+ raise ValueError(msg)
139
+ return v
140
+
141
+ @field_validator("longitude")
142
+ @classmethod
143
+ def validate_longitude(cls, v: Decimal) -> Decimal:
144
+ """Validate longitude is within valid range."""
145
+ if not Decimal(-180) <= v <= Decimal(180):
146
+ msg = f"Longitude must be between -180 and 180, got {v}"
147
+ raise ValueError(msg)
148
+ return v
149
+
150
+ @field_validator("frequency", "input_frequency")
151
+ @classmethod
152
+ def validate_frequency(cls, v: Decimal) -> Decimal:
153
+ """Validate frequency is positive."""
154
+ if v <= 0:
155
+ msg = f"Frequency must be positive, got {v}"
156
+ raise ValueError(msg)
157
+ return v
158
+
132
159
 
133
160
  ZeroOneJSON: TypeAlias = Literal[
134
161
  0,
@@ -16,7 +16,7 @@ import hashlib
16
16
  import json
17
17
  import time
18
18
  from datetime import date, timedelta
19
- from typing import Any, ClassVar, Final, cast
19
+ from typing import Any, Final, cast
20
20
 
21
21
  import aiohttp
22
22
  import attrs
@@ -25,6 +25,10 @@ from loguru import logger
25
25
  from tqdm import tqdm
26
26
  from yarl import URL
27
27
 
28
+ from repeaterbook.exceptions import (
29
+ RepeaterBookAPIError,
30
+ RepeaterBookValidationError,
31
+ )
28
32
  from repeaterbook.models import (
29
33
  Emergency,
30
34
  EmergencyJSON,
@@ -51,7 +55,7 @@ async def fetch_json(
51
55
  cache_dir: Path | None = None,
52
56
  max_cache_age: timedelta = timedelta(seconds=3600),
53
57
  chunk_size: int = 1024,
54
- ) -> Any: # noqa: ANN401
58
+ ) -> Any: # noqa: ANN401 - json.loads() returns Any; validation done by callers
55
59
  """Fetches JSON data from the specified URL using a streaming response.
56
60
 
57
61
  - If a cached copy exists and is recent (not older than max_cache_age seconds) and
@@ -62,17 +66,19 @@ async def fetch_json(
62
66
  # Create a unique filename for caching based on the URL hash.
63
67
  if cache_dir is None:
64
68
  cache_dir = Path()
65
- hashed_url = hashlib.md5(str(url).encode("utf-8")).hexdigest() # noqa: S324
69
+ hashed_url = hashlib.sha256(str(url).encode("utf-8")).hexdigest()
66
70
  cache_file = cache_dir / f"api_cache_{hashed_url}.json"
71
+ temp_file = cache_dir / f"api_cache_{hashed_url}.tmp"
67
72
 
68
- # Check if fresh cached data exists.
69
- if await cache_file.exists():
70
- file_age = time.time() - (await cache_file.stat()).st_mtime
73
+ # Check if fresh cached data exists using a single stat call.
74
+ try:
75
+ stat = await cache_file.stat()
76
+ file_age = time.time() - stat.st_mtime
71
77
  if file_age < max_cache_age.total_seconds():
72
78
  logger.info("Using cached data.")
73
79
  return json.loads(await cache_file.read_text(encoding="utf-8"))
74
-
75
- await cache_file.unlink(missing_ok=True)
80
+ except FileNotFoundError:
81
+ pass # Cache doesn't exist, continue to fetch
76
82
 
77
83
  logger.info("Fetching new data from API...")
78
84
  async with (
@@ -80,8 +86,8 @@ async def fetch_json(
80
86
  session.get(url, headers=headers) as response,
81
87
  ):
82
88
  response.raise_for_status()
83
- # Open file for writing in binary mode and stream content into it.
84
- async with await cache_file.open("wb") as f:
89
+ # Write to temp file first for atomic cache updates.
90
+ async with await temp_file.open("wb") as f:
85
91
  with tqdm(
86
92
  total=response.content_length,
87
93
  unit="B",
@@ -91,6 +97,11 @@ async def fetch_json(
91
97
  await f.write(chunk)
92
98
  progress.update(len(chunk))
93
99
 
100
+ # Atomic rename from temp file to cache file.
101
+ # This prevents race conditions where concurrent requests might read
102
+ # a partially written cache file.
103
+ await temp_file.rename(cache_file)
104
+
94
105
  # After saving the file, load and parse the JSON data.
95
106
  return json.loads(await cache_file.read_text(encoding="utf-8"))
96
107
 
@@ -209,6 +220,16 @@ class RepeaterBookAPI:
209
220
  """RepeaterBook API client.
210
221
 
211
222
  Must read https://www.repeaterbook.com/wiki/doku.php?id=api before using.
223
+
224
+ Attributes:
225
+ base_url: The RepeaterBook API base URL.
226
+ app_name: Application name for User-Agent header.
227
+ app_email: Contact email for User-Agent header.
228
+ working_dir: Directory for cache and database files.
229
+ max_cache_age: Maximum age of cached API responses before refresh.
230
+ Defaults to 1 hour.
231
+ max_count: Maximum expected results per API request. Used to warn
232
+ when response may have been trimmed. Defaults to 3500.
212
233
  """
213
234
 
214
235
  base_url: URL = attrs.Factory(lambda: URL("https://repeaterbook.com"))
@@ -217,7 +238,8 @@ class RepeaterBookAPI:
217
238
 
218
239
  working_dir: Path = attrs.Factory(Path)
219
240
 
220
- MAX_COUNT: ClassVar[int] = 3500
241
+ max_cache_age: timedelta = timedelta(hours=1)
242
+ max_count: int = 3500
221
243
 
222
244
  async def cache_dir(self) -> Path:
223
245
  """Cache directory for API responses."""
@@ -246,11 +268,26 @@ class RepeaterBookAPI:
246
268
  """Rest of world (not north-america) export URL."""
247
269
  return self.url_api / "exportROW.php"
248
270
 
271
+ # North America countries served by export.php endpoint
272
+ NA_COUNTRIES: frozenset[str] = frozenset({
273
+ "United States",
274
+ "Canada",
275
+ "Mexico",
276
+ })
277
+
249
278
  def urls_export(
250
279
  self,
251
280
  query: ExportQuery,
252
281
  ) -> set[URL]:
253
- """Generate export URLs for given query."""
282
+ """Generate export URLs for given query.
283
+
284
+ Smart routing logic:
285
+ - If NA-specific fields are used (state_id, county, emcomm, stype),
286
+ only query the NA endpoint
287
+ - If ROW-specific fields are used (region), only query the ROW endpoint
288
+ - If countries are specified, route based on whether they're NA or ROW
289
+ - If no routing hints, query both endpoints
290
+ """
254
291
  mode_map: dict[Mode, ModeJSON] = {
255
292
  Mode.ANALOG: "analog",
256
293
  Mode.DMR: "DMR",
@@ -268,6 +305,33 @@ class RepeaterBookAPI:
268
305
  ServiceType.GMRS: "GMRS",
269
306
  }
270
307
 
308
+ # Determine which endpoints to query based on the query parameters
309
+ has_na_specific = bool(
310
+ query.state_ids or query.counties or
311
+ query.emergency_services or query.service_types
312
+ )
313
+ has_row_specific = bool(query.regions)
314
+
315
+ # Check if countries are specified and categorize them
316
+ query_countries = {country.name for country in query.countries}
317
+ has_na_countries = bool(query_countries & self.NA_COUNTRIES)
318
+ has_row_countries = bool(query_countries - self.NA_COUNTRIES)
319
+
320
+ # Determine which endpoints to query
321
+ query_na_endpoint = True
322
+ query_row_endpoint = True
323
+
324
+ if has_na_specific and not has_row_specific:
325
+ # NA-specific fields used, only query NA
326
+ query_row_endpoint = False
327
+ elif has_row_specific and not has_na_specific:
328
+ # ROW-specific fields used, only query ROW
329
+ query_na_endpoint = False
330
+ elif query_countries:
331
+ # Countries specified - route based on country location
332
+ query_na_endpoint = has_na_countries
333
+ query_row_endpoint = has_row_countries
334
+
271
335
  query_na = ExportNorthAmericaQuery(
272
336
  callsign=list(query.callsigns),
273
337
  city=list(query.cities),
@@ -280,6 +344,8 @@ class RepeaterBookAPI:
280
344
  emcomm=[emergency_map[emergency] for emergency in query.emergency_services],
281
345
  stype=[type_map[service_type] for service_type in query.service_types],
282
346
  )
347
+ # Safe cast: dict comprehension preserves TypedDict structure, only removes
348
+ # empty values (which are optional in ExportNorthAmericaQuery).
283
349
  query_na = cast(
284
350
  "ExportNorthAmericaQuery", {k: v for k, v in query_na.items() if v}
285
351
  )
@@ -293,14 +359,22 @@ class RepeaterBookAPI:
293
359
  mode=[mode_map[mode] for mode in query.modes],
294
360
  region=list(query.regions),
295
361
  )
362
+ # Safe cast: dict comprehension preserves TypedDict structure, only removes
363
+ # empty values (which are optional in ExportWorldQuery).
296
364
  query_world = cast(
297
365
  "ExportWorldQuery", {k: v for k, v in query_world.items() if v}
298
366
  )
299
367
 
300
- return {
301
- #' self.url_export_north_america % cast("dict[str, str]", query_na),
302
- self.url_export_rest_of_world % cast("dict[str, str]", query_world),
303
- }
368
+ # Safe casts: URL % operator expects dict[str, str], and TypedDict values
369
+ # are all list[str] which serialize correctly for query parameters.
370
+ urls: set[URL] = set()
371
+ if query_na_endpoint:
372
+ na_params = cast("dict[str, str]", query_na)
373
+ urls.add(self.url_export_north_america % na_params)
374
+ if query_row_endpoint:
375
+ row_params = cast("dict[str, str]", query_world)
376
+ urls.add(self.url_export_rest_of_world % row_params)
377
+ return urls
304
378
 
305
379
  async def export_json(self, url: URL) -> ExportJSON:
306
380
  """Export data for given URL."""
@@ -308,20 +382,23 @@ class RepeaterBookAPI:
308
382
  url,
309
383
  headers={"User-Agent": f"{self.app_name} <{self.app_email}>"},
310
384
  cache_dir=await self.cache_dir(),
385
+ max_cache_age=self.max_cache_age,
311
386
  )
312
387
 
313
388
  if not isinstance(data, dict):
314
- raise TypeError
389
+ msg = f"Expected dict response from API, got {type(data).__name__}"
390
+ raise RepeaterBookValidationError(msg)
315
391
 
316
392
  if data.get("status") == "error":
317
- raise ValueError(data.get("message"))
393
+ raise RepeaterBookAPIError(data.get("message", "Unknown API error"))
318
394
 
319
395
  if "count" not in data or "results" not in data:
320
- raise ValueError
396
+ msg = "API response missing required 'count' or 'results' field"
397
+ raise RepeaterBookValidationError(msg)
321
398
 
322
399
  data = cast("ExportJSON", data)
323
400
 
324
- if data["count"] >= self.MAX_COUNT:
401
+ if data["count"] >= self.max_count:
325
402
  logger.warning(
326
403
  "Reached max count for API response. Response may have been trimmed."
327
404
  )
@@ -0,0 +1,250 @@
1
+ """Live API integration tests.
2
+
3
+ These hit repeaterbook.com over the network, so they are disabled by default.
4
+
5
+ Enable with:
6
+
7
+ REPEATERBOOK_LIVE=1 uv run pytest -q -m integration
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from typing import TYPE_CHECKING
14
+
15
+ import pycountry
16
+ import pytest
17
+
18
+ if TYPE_CHECKING:
19
+ from pathlib import Path as StdPath
20
+
21
+ from anyio import Path
22
+ from yarl import URL
23
+
24
+ from repeaterbook.models import ExportQuery, Mode
25
+ from repeaterbook.services import RepeaterBookAPI, json_to_model
26
+
27
+ # Limit parsed rows in NA test to keep runtime reasonable.
28
+ _NA_SAMPLE_SIZE = 200
29
+
30
+
31
+ def _live_enabled() -> bool:
32
+ return os.environ.get("REPEATERBOOK_LIVE", "").lower() in {"1", "true", "yes"}
33
+
34
+
35
+ pytestmark = pytest.mark.skipif(
36
+ not _live_enabled(), reason="Set REPEATERBOOK_LIVE=1 to run live integration tests"
37
+ )
38
+
39
+
40
+ @pytest.mark.integration
41
+ @pytest.mark.anyio
42
+ async def test_live_export_row_brazil_downloads_and_parses(
43
+ tmp_path: StdPath,
44
+ ) -> None:
45
+ """Brazil repeaters download and parse correctly from ROW endpoint."""
46
+ api = RepeaterBookAPI(
47
+ app_name="repeaterbook-live-test",
48
+ app_email="micael@jarniac.dev",
49
+ working_dir=Path(tmp_path),
50
+ )
51
+
52
+ # Brazil is served by ROW endpoint.
53
+ q = ExportQuery(countries=frozenset({pycountry.countries.lookup("Brazil")}))
54
+ reps = await api.download(q)
55
+
56
+ assert len(reps) > 0
57
+ assert all(r.country for r in reps)
58
+
59
+
60
+ @pytest.mark.integration
61
+ @pytest.mark.anyio
62
+ async def test_live_export_north_america_payload_parses_first_rows(
63
+ tmp_path: StdPath,
64
+ ) -> None:
65
+ """NA payload shape differs; ensure json_to_model handles it.
66
+
67
+ We don't route NA through urls_export() yet, so we call export.php directly.
68
+ """
69
+ api = RepeaterBookAPI(
70
+ app_name="repeaterbook-live-test",
71
+ app_email="micael@jarniac.dev",
72
+ working_dir=Path(tmp_path),
73
+ )
74
+
75
+ url = URL("https://repeaterbook.com/api/export.php") % {
76
+ "state_id": "06", # California
77
+ "country": "United States",
78
+ }
79
+
80
+ payload = await api.export_json(url)
81
+ assert payload["count"] == len(payload["results"])
82
+ assert payload["count"] > 0
83
+
84
+ # Parse a small sample so the test stays fast.
85
+ for row in payload["results"][:_NA_SAMPLE_SIZE]:
86
+ rep = json_to_model(row)
87
+ assert rep.country in {"United States", "USA", "United States of America"}
88
+
89
+
90
+ # Smart routing tests
91
+
92
+
93
+ @pytest.mark.integration
94
+ @pytest.mark.anyio
95
+ async def test_smart_routing_na_only_via_state_id(tmp_path: StdPath) -> None:
96
+ """Query with state_id routes only to NA endpoint."""
97
+ api = RepeaterBookAPI(
98
+ app_name="repeaterbook-live-test",
99
+ app_email="micael@jarniac.dev",
100
+ working_dir=Path(tmp_path),
101
+ )
102
+
103
+ # state_id is NA-specific, so only NA endpoint should be queried
104
+ q = ExportQuery(state_ids=frozenset({"06"})) # California
105
+ urls = api.urls_export(q)
106
+
107
+ assert len(urls) == 1
108
+ url_str = str(next(iter(urls)))
109
+ assert "export.php" in url_str
110
+ assert "exportROW" not in url_str
111
+ assert "state_id=06" in url_str
112
+
113
+ # Verify it actually works
114
+ reps = await api.download(q)
115
+ assert len(reps) > 0
116
+ # All results should be from California
117
+ assert all(r.state_id == "06" for r in reps)
118
+
119
+
120
+ @pytest.mark.integration
121
+ @pytest.mark.anyio
122
+ async def test_smart_routing_row_only_via_region(tmp_path: StdPath) -> None:
123
+ """Query with region routes only to ROW endpoint."""
124
+ api = RepeaterBookAPI(
125
+ app_name="repeaterbook-live-test",
126
+ app_email="micael@jarniac.dev",
127
+ working_dir=Path(tmp_path),
128
+ )
129
+
130
+ # region is ROW-specific, so only ROW endpoint should be queried
131
+ q = ExportQuery(regions=frozenset({"South America"}))
132
+ urls = api.urls_export(q)
133
+
134
+ assert len(urls) == 1
135
+ url_str = str(next(iter(urls)))
136
+ assert "exportROW.php" in url_str
137
+ assert "export.php?" not in url_str # not confused with exportROW.php
138
+ assert "South+America" in url_str
139
+
140
+ # Verify it actually works
141
+ reps = await api.download(q)
142
+ assert len(reps) > 0
143
+
144
+
145
+ @pytest.mark.integration
146
+ @pytest.mark.anyio
147
+ async def test_smart_routing_na_country_only(tmp_path: StdPath) -> None:
148
+ """Query with NA country routes only to NA endpoint."""
149
+ api = RepeaterBookAPI(
150
+ app_name="repeaterbook-live-test",
151
+ app_email="micael@jarniac.dev",
152
+ working_dir=Path(tmp_path),
153
+ )
154
+
155
+ us = pycountry.countries.lookup("United States")
156
+ q = ExportQuery(
157
+ countries=frozenset({us}),
158
+ state_ids=frozenset({"48"}), # Texas - smaller result set
159
+ )
160
+ urls = api.urls_export(q)
161
+
162
+ assert len(urls) == 1
163
+ url_str = str(next(iter(urls)))
164
+ assert "export.php" in url_str
165
+ assert "exportROW" not in url_str
166
+
167
+
168
+ @pytest.mark.integration
169
+ @pytest.mark.anyio
170
+ async def test_smart_routing_row_country_only(tmp_path: StdPath) -> None:
171
+ """Query with ROW country routes only to ROW endpoint."""
172
+ api = RepeaterBookAPI(
173
+ app_name="repeaterbook-live-test",
174
+ app_email="micael@jarniac.dev",
175
+ working_dir=Path(tmp_path),
176
+ )
177
+
178
+ germany = pycountry.countries.lookup("Germany")
179
+ q = ExportQuery(countries=frozenset({germany}))
180
+ urls = api.urls_export(q)
181
+
182
+ assert len(urls) == 1
183
+ url_str = str(next(iter(urls)))
184
+ assert "exportROW.php" in url_str
185
+
186
+ # Verify it actually works
187
+ reps = await api.download(q)
188
+ assert len(reps) > 0
189
+
190
+
191
+ @pytest.mark.integration
192
+ @pytest.mark.anyio
193
+ async def test_smart_routing_mixed_countries_both_endpoints(tmp_path: StdPath) -> None:
194
+ """Query with both NA and ROW countries routes to both endpoints."""
195
+ api = RepeaterBookAPI(
196
+ app_name="repeaterbook-live-test",
197
+ app_email="micael@jarniac.dev",
198
+ working_dir=Path(tmp_path),
199
+ )
200
+
201
+ us = pycountry.countries.lookup("United States")
202
+ germany = pycountry.countries.lookup("Germany")
203
+ q = ExportQuery(countries=frozenset({us, germany}))
204
+ urls = api.urls_export(q)
205
+
206
+ # Should route to both endpoints
207
+ assert len(urls) == 2
208
+ url_strs = [str(url) for url in urls]
209
+ assert any("export.php" in u and "exportROW" not in u for u in url_strs)
210
+ assert any("exportROW.php" in u for u in url_strs)
211
+
212
+
213
+ @pytest.mark.integration
214
+ @pytest.mark.anyio
215
+ async def test_smart_routing_empty_query_both_endpoints(tmp_path: StdPath) -> None:
216
+ """Empty query routes to both endpoints."""
217
+ api = RepeaterBookAPI(
218
+ app_name="repeaterbook-live-test",
219
+ app_email="micael@jarniac.dev",
220
+ working_dir=Path(tmp_path),
221
+ )
222
+
223
+ q = ExportQuery() # Empty query
224
+ urls = api.urls_export(q)
225
+
226
+ # Should query both endpoints
227
+ assert len(urls) == 2
228
+ url_strs = [str(url) for url in urls]
229
+ assert any("export.php" in u and "exportROW" not in u for u in url_strs)
230
+ assert any("exportROW.php" in u for u in url_strs)
231
+
232
+
233
+ @pytest.mark.integration
234
+ @pytest.mark.anyio
235
+ async def test_smart_routing_mode_filter_both_endpoints(tmp_path: StdPath) -> None:
236
+ """Query with only mode (common filter) routes to both endpoints."""
237
+ api = RepeaterBookAPI(
238
+ app_name="repeaterbook-live-test",
239
+ app_email="micael@jarniac.dev",
240
+ working_dir=Path(tmp_path),
241
+ )
242
+
243
+ # Mode is a common filter, not NA or ROW-specific
244
+ q = ExportQuery(modes=frozenset({Mode.DMR}))
245
+ urls = api.urls_export(q)
246
+
247
+ # Should query both endpoints since mode is common
248
+ assert len(urls) == 2
249
+ url_strs = [str(url) for url in urls]
250
+ assert all("DMR" in u for u in url_strs)