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.
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/PKG-INFO +1 -1
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/CHANGELOG.md +51 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/pyproject.toml +2 -2
- repeaterbook-0.4.0/src/repeaterbook/__init__.py +21 -0
- repeaterbook-0.4.0/src/repeaterbook/exceptions.py +44 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/src/repeaterbook/models.py +28 -1
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/src/repeaterbook/services.py +97 -20
- repeaterbook-0.4.0/tests/integration/test_live_api.py +250 -0
- repeaterbook-0.4.0/tests/test_database.py +253 -0
- repeaterbook-0.4.0/tests/test_exceptions.py +72 -0
- repeaterbook-0.4.0/tests/test_models.py +324 -0
- repeaterbook-0.4.0/tests/test_queries.py +192 -0
- repeaterbook-0.4.0/tests/test_repeaterbook.py +29 -0
- repeaterbook-0.4.0/tests/test_services.py +407 -0
- repeaterbook-0.4.0/tests/test_utils.py +129 -0
- repeaterbook-0.4.0/uv.lock +3702 -0
- repeaterbook-0.3.0/src/repeaterbook/__init__.py +0 -11
- repeaterbook-0.3.0/tests/integration/test_live_api.py +0 -87
- repeaterbook-0.3.0/tests/test_repeaterbook.py +0 -5
- repeaterbook-0.3.0/uv.lock +0 -2997
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.all-contributorsrc +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.cruft.json +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.editorconfig +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.github/workflows/ci.yml +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.github/workflows/codecov_action.yml +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.github/workflows/semantic-release.yml +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.gitignore +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.pre-commit-config.yaml +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.python-version +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.python-versions +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/.readthedocs.yaml +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/LICENSE +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/asv.conf.json +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/benchmarks/__init__.py +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/benchmarks/benchmarks.py +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/CODE_OF_CONDUCT.md +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/CONTRIBUTING.md +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/README.md +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/__init__.py +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/api.md +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/javascripts/extra.js +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/mkdocs.yml +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/reference.md +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/stylesheets/extra.css +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/docs/wordlist.txt +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/noxfile.py +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/playground/.gitignore +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/playground/examples.ipynb +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/src/repeaterbook/database.py +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/src/repeaterbook/py.typed +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/src/repeaterbook/queries.py +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/src/repeaterbook/utils.py +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/tests/__init__.py +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/tests/conftest.py +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/tests/integration/__init__.py +0 -0
- {repeaterbook-0.3.0 → repeaterbook-0.4.0}/tests/test_api_format.py +0 -0
- {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
|
+
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
|
+
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,
|
|
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.
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
84
|
-
async with await
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
|
393
|
+
raise RepeaterBookAPIError(data.get("message", "Unknown API error"))
|
|
318
394
|
|
|
319
395
|
if "count" not in data or "results" not in data:
|
|
320
|
-
|
|
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.
|
|
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)
|