repeaterbook 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """Python utility to work with data from RepeaterBook."""
repeaterbook/models.py ADDED
@@ -0,0 +1,289 @@
1
+ """Models."""
2
+ # ruff: noqa: TC003
3
+
4
+ from __future__ import annotations
5
+
6
+ __all__: list[str] = [
7
+ "Emergency",
8
+ "EmergencyJSON",
9
+ "ErrorJSON",
10
+ "ExportBaseQuery",
11
+ "ExportErrorJSON",
12
+ "ExportJSON",
13
+ "ExportNorthAmericaQuery",
14
+ "ExportQuery",
15
+ "ExportWorldQuery",
16
+ "Mode",
17
+ "ModeJSON",
18
+ "Repeater",
19
+ "RepeaterJSON",
20
+ "ServiceType",
21
+ "ServiceTypeJSON",
22
+ "Status",
23
+ "StatusJSON",
24
+ "Use",
25
+ "UseJSON",
26
+ "YesNoJSON",
27
+ "ZeroOneJSON",
28
+ ]
29
+
30
+ from datetime import date
31
+ from decimal import Decimal
32
+ from enum import Enum, auto
33
+ from typing import Literal, TypeAlias, TypedDict
34
+
35
+ import attrs
36
+ from pycountry.db import Country # noqa: TC002
37
+ from sqlmodel import Field, SQLModel
38
+
39
+
40
+ class Status(Enum):
41
+ """Status."""
42
+
43
+ OFF_AIR = auto()
44
+ ON_AIR = auto()
45
+ UNKNOWN = auto()
46
+
47
+
48
+ class Use(Enum):
49
+ """Use."""
50
+
51
+ OPEN = auto()
52
+ PRIVATE = auto()
53
+ CLOSED = auto()
54
+
55
+
56
+ class Mode(Enum):
57
+ """Mode."""
58
+
59
+ ANALOG = auto()
60
+ DMR = auto()
61
+ NXDN = auto()
62
+ P25 = auto()
63
+ TETRA = auto()
64
+
65
+
66
+ class Emergency(Enum):
67
+ """Emergency."""
68
+
69
+ ARES = auto()
70
+ RACES = auto()
71
+ SKYWARN = auto()
72
+ CANWARN = auto()
73
+
74
+
75
+ class ServiceType(Enum):
76
+ """Service type."""
77
+
78
+ GMRS = auto()
79
+
80
+
81
+ class Repeater(SQLModel, table=True):
82
+ """Repeater."""
83
+
84
+ state_id: str = Field(primary_key=True)
85
+ repeater_id: int = Field(primary_key=True)
86
+ frequency: Decimal
87
+ input_frequency: Decimal
88
+ pl_ctcss_uplink: str | None
89
+ pl_ctcss_tsq_downlink: str | None
90
+ location_nearest_city: str
91
+ landmark: str | None
92
+ region: str | None
93
+ country: str | None
94
+ county: str | None
95
+ state: str | None
96
+ latitude: Decimal
97
+ longitude: Decimal
98
+ precise: bool
99
+ callsign: str | None
100
+ use_membership: Use
101
+ operational_status: Status
102
+ ares: str | None
103
+ races: str | None
104
+ skywarn: str | None
105
+ canwarn: str | None
106
+ #' operating_mode: str
107
+ allstar_node: str | None
108
+ echolink_node: str | None
109
+ irlp_node: str | None
110
+ wires_node: str | None
111
+ dmr_capable: bool
112
+ dmr_id: str | None
113
+ dmr_color_code: str | None
114
+ d_star_capable: bool
115
+ nxdn_capable: bool
116
+ apco_p_25_capable: bool
117
+ p_25_nac: str | None
118
+ m17_capable: bool
119
+ m17_can: str | None
120
+ tetra_capable: bool
121
+ tetra_mcc: str | None
122
+ tetra_mnc: str | None
123
+ yaesu_system_fusion_capable: bool
124
+ ysf_digital_id_uplink: str | None
125
+ ysf_digital_id_downlink: str | None
126
+ ysf_dsc: str | None
127
+ analog_capable: bool
128
+ fm_bandwidth: Decimal | None
129
+ notes: str | None
130
+ last_update: date
131
+
132
+
133
+ ZeroOneJSON: TypeAlias = Literal[
134
+ 0,
135
+ 1,
136
+ ]
137
+ YesNoJSON: TypeAlias = Literal[
138
+ "Yes",
139
+ "No",
140
+ ]
141
+ UseJSON: TypeAlias = Literal[
142
+ "OPEN",
143
+ "PRIVATE",
144
+ "CLOSED",
145
+ ]
146
+ StatusJSON: TypeAlias = Literal[
147
+ "Off-air",
148
+ "On-air",
149
+ "Unknown",
150
+ ]
151
+ ErrorJSON: TypeAlias = Literal["error"]
152
+ ModeJSON: TypeAlias = Literal[
153
+ "analog",
154
+ "DMR",
155
+ "NXDN",
156
+ "P25",
157
+ "tetra",
158
+ ]
159
+ EmergencyJSON: TypeAlias = Literal[
160
+ "ARES",
161
+ "RACES",
162
+ "SKYWARN",
163
+ "CANWARN",
164
+ ]
165
+ ServiceTypeJSON: TypeAlias = Literal["GMRS"]
166
+
167
+
168
+ RepeaterJSON = TypedDict(
169
+ "RepeaterJSON",
170
+ {
171
+ "State ID": str,
172
+ "Rptr ID": int,
173
+ "Frequency": str,
174
+ "Input Freq": str,
175
+ "PL": str,
176
+ "TSQ": str,
177
+ "Nearest City": str,
178
+ "Landmark": str,
179
+ "Region": str | None,
180
+ "State": str,
181
+ "Country": str,
182
+ "Lat": str,
183
+ "Long": str,
184
+ "Precise": ZeroOneJSON,
185
+ "Callsign": str,
186
+ "Use": UseJSON,
187
+ "Operational Status": StatusJSON,
188
+ "AllStar Node": str,
189
+ "EchoLink Node": str | int,
190
+ "IRLP Node": str,
191
+ "Wires Node": str,
192
+ "FM Analog": YesNoJSON,
193
+ "FM Bandwidth": str,
194
+ "DMR": YesNoJSON,
195
+ "DMR Color Code": str,
196
+ "DMR ID": str | int,
197
+ "D-Star": YesNoJSON,
198
+ "NXDN": YesNoJSON,
199
+ "APCO P-25": YesNoJSON,
200
+ "P-25 NAC": str,
201
+ "M17": YesNoJSON,
202
+ "M17 CAN": str,
203
+ "Tetra": YesNoJSON,
204
+ "Tetra MCC": str,
205
+ "Tetra MNC": str,
206
+ "System Fusion": YesNoJSON,
207
+ "Notes": str,
208
+ "Last Update": str,
209
+ },
210
+ )
211
+
212
+
213
+ class ExportJSON(TypedDict):
214
+ """RepeaterBook API export response."""
215
+
216
+ count: int
217
+ results: list[RepeaterJSON]
218
+
219
+
220
+ class ExportErrorJSON(TypedDict):
221
+ """RepeaterBook API export error response."""
222
+
223
+ status: ErrorJSON
224
+ message: str
225
+
226
+
227
+ class ExportBaseQuery(TypedDict, total=False):
228
+ """RepeaterBook API export query.
229
+
230
+ `%` - wildcard
231
+ """
232
+
233
+ callsign: list[str]
234
+ """Repeater callsign."""
235
+ city: list[str]
236
+ """Repeater city."""
237
+ landmark: list[str]
238
+ country: list[str]
239
+ """Repeater country."""
240
+ frequency: list[str]
241
+ """Repeater frequency."""
242
+ mode: list[ModeJSON]
243
+ """Repeater operating mode (analog, DMR, NXDN, P25, tetra)."""
244
+
245
+
246
+ class ExportNorthAmericaQuery(ExportBaseQuery, total=False):
247
+ """RepeaterBook API export North America query.
248
+
249
+ `%` - wildcard
250
+ """
251
+
252
+ state_id: list[str]
253
+ """State / province."""
254
+ county: list[str]
255
+ """Repeater county."""
256
+ emcomm: list[EmergencyJSON]
257
+ """ARES, RACES, SKYWARN, CANWARN."""
258
+ stype: list[ServiceTypeJSON]
259
+ """Service type. Only required when searching for GMRS repeaters."""
260
+
261
+
262
+ class ExportWorldQuery(ExportBaseQuery, total=False):
263
+ """RepeaterBook API export World query.
264
+
265
+ `%` - wildcard
266
+ """
267
+
268
+ region: list[str]
269
+ """Repeater region (if available)."""
270
+
271
+
272
+ @attrs.frozen
273
+ class ExportQuery:
274
+ """RepeaterBook API export query.
275
+
276
+ `%` - wildcard
277
+ """
278
+
279
+ callsigns: frozenset[str] = frozenset()
280
+ cities: frozenset[str] = frozenset()
281
+ landmarks: frozenset[str] = frozenset()
282
+ countries: frozenset[Country] = frozenset()
283
+ frequencies: frozenset[Decimal] = frozenset()
284
+ modes: frozenset[Mode] = frozenset()
285
+ state_ids: frozenset[str] = frozenset()
286
+ counties: frozenset[str] = frozenset()
287
+ emergency_services: frozenset[Emergency] = frozenset()
288
+ service_types: frozenset[ServiceType] = frozenset()
289
+ regions: frozenset[str] = frozenset()
repeaterbook/py.typed ADDED
File without changes
@@ -0,0 +1,396 @@
1
+ """Services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = [
6
+ "BOOL_MAP",
7
+ "STATUS_MAP",
8
+ "USE_MAP",
9
+ "RepeaterBook",
10
+ "fetch_json",
11
+ "json_to_model",
12
+ ]
13
+
14
+ import asyncio
15
+ import hashlib
16
+ import json
17
+ import time
18
+ from datetime import date, timedelta
19
+ from functools import cached_property
20
+ from typing import TYPE_CHECKING, Any, ClassVar, Final, NamedTuple, cast
21
+
22
+ import aiohttp
23
+ import attrs
24
+ from anyio import Path
25
+ from haversine import Unit, haversine # type: ignore[import-untyped]
26
+ from loguru import logger
27
+ from sqlmodel import Session, SQLModel, create_engine, select
28
+ from tqdm import tqdm
29
+ from yarl import URL
30
+
31
+ from repeaterbook.models import (
32
+ Emergency,
33
+ EmergencyJSON,
34
+ ExportErrorJSON,
35
+ ExportJSON,
36
+ ExportNorthAmericaQuery,
37
+ ExportQuery,
38
+ ExportWorldQuery,
39
+ Mode,
40
+ ModeJSON,
41
+ Repeater,
42
+ RepeaterJSON,
43
+ ServiceType,
44
+ ServiceTypeJSON,
45
+ Status,
46
+ Use,
47
+ )
48
+ from repeaterbook.utils import LatLon, square_bounds
49
+
50
+ if TYPE_CHECKING: # pragma: no cover
51
+ from sqlalchemy import Engine
52
+
53
+
54
+ async def fetch_json(
55
+ url: URL,
56
+ *,
57
+ headers: dict[str, str] | None = None,
58
+ cache_dir: Path | None = None,
59
+ max_cache_age: timedelta = timedelta(seconds=3600),
60
+ chunk_size: int = 1024,
61
+ ) -> Any: # noqa: ANN401
62
+ """Fetches JSON data from the specified URL using a streaming response.
63
+
64
+ - If a cached copy exists and is recent (not older than max_cache_age seconds) and
65
+ not forced, it loads and returns the cached data.
66
+ - Otherwise, it streams the data in chunks while displaying a progress bar, caches
67
+ it, and returns the parsed JSON data.
68
+ """
69
+ # Create a unique filename for caching based on the URL hash.
70
+ if cache_dir is None:
71
+ cache_dir = Path()
72
+ hashed_url = hashlib.md5(str(url).encode("utf-8")).hexdigest() # noqa: S324
73
+ cache_file = cache_dir / f"api_cache_{hashed_url}.json"
74
+
75
+ # Check if fresh cached data exists.
76
+ if await cache_file.exists():
77
+ file_age = time.time() - (await cache_file.stat()).st_mtime
78
+ if file_age < max_cache_age.total_seconds():
79
+ logger.info("Using cached data.")
80
+ return json.loads(await cache_file.read_text(encoding="utf-8"))
81
+
82
+ await cache_file.unlink(missing_ok=True)
83
+
84
+ logger.info("Fetching new data from API...")
85
+ async with (
86
+ aiohttp.ClientSession() as session,
87
+ session.get(url, headers=headers) as response,
88
+ ):
89
+ response.raise_for_status()
90
+ # Open file for writing in binary mode and stream content into it.
91
+ async with await cache_file.open("wb") as f:
92
+ with tqdm(
93
+ total=response.content_length,
94
+ unit="B",
95
+ unit_scale=True,
96
+ ) as progress:
97
+ async for chunk in response.content.iter_chunked(chunk_size):
98
+ await f.write(chunk)
99
+ progress.update(len(chunk))
100
+
101
+ # After saving the file, load and parse the JSON data.
102
+ return json.loads(await cache_file.read_text(encoding="utf-8"))
103
+
104
+
105
+ BOOL_MAP: Final = {
106
+ "Yes": True,
107
+ "No": False,
108
+ 1: True,
109
+ 0: False,
110
+ }
111
+
112
+
113
+ USE_MAP: Final = {
114
+ "OPEN": Use.OPEN,
115
+ "PRIVATE": Use.PRIVATE,
116
+ "CLOSED": Use.CLOSED,
117
+ }
118
+
119
+ STATUS_MAP: Final = {
120
+ "Off-air": Status.OFF_AIR,
121
+ "On-air": Status.ON_AIR,
122
+ "Unknown": Status.UNKNOWN,
123
+ }
124
+
125
+
126
+ def parse_date(date_str: str) -> date:
127
+ """Parses a date string in the format YYYY-MM-DD."""
128
+ try:
129
+ return date.fromisoformat(date_str)
130
+ except ValueError:
131
+ return date.min
132
+
133
+
134
+ def json_to_model(j: RepeaterJSON, /) -> Repeater:
135
+ """Converts a JSON object to a Repeater model."""
136
+ return Repeater.model_validate(
137
+ Repeater(
138
+ state_id=j["State ID"],
139
+ repeater_id=j["Rptr ID"],
140
+ frequency=j["Frequency"],
141
+ input_frequency=j["Input Freq"],
142
+ pl_ctcss_uplink=j["PL"] or None,
143
+ pl_ctcss_tsq_downlink=j["TSQ"] or None,
144
+ location_nearest_city=j["Nearest City"],
145
+ landmark=j["Landmark"] or None,
146
+ region=j["Region"],
147
+ state=j["State"],
148
+ country=j["Country"],
149
+ latitude=j["Lat"],
150
+ longitude=j["Long"],
151
+ precise=BOOL_MAP[j["Precise"]],
152
+ callsign=j["Callsign"],
153
+ use_membership=USE_MAP[j["Use"]],
154
+ operational_status=STATUS_MAP[j["Operational Status"]],
155
+ allstar_node=j["AllStar Node"],
156
+ echolink_node=str(j["EchoLink Node"]) or None,
157
+ irlp_node=j["IRLP Node"] or None,
158
+ wires_node=j["Wires Node"] or None,
159
+ analog_capable=BOOL_MAP[j["FM Analog"]],
160
+ fm_bandwidth=j["FM Bandwidth"].replace(" kHz", "") or None,
161
+ dmr_capable=BOOL_MAP[j["DMR"]],
162
+ dmr_color_code=j["DMR Color Code"] or None,
163
+ dmr_id=str(j["DMR ID"]) or None,
164
+ d_star_capable=BOOL_MAP[j["D-Star"]],
165
+ nxdn_capable=BOOL_MAP[j["NXDN"]],
166
+ apco_p_25_capable=BOOL_MAP[j["APCO P-25"]],
167
+ p_25_nac=j["P-25 NAC"] or None,
168
+ m17_capable=BOOL_MAP[j["M17"]],
169
+ m17_can=j["M17 CAN"] or None,
170
+ tetra_capable=BOOL_MAP[j["Tetra"]],
171
+ tetra_mcc=j["Tetra MCC"] or None,
172
+ tetra_mnc=j["Tetra MNC"] or None,
173
+ yaesu_system_fusion_capable=BOOL_MAP[j["System Fusion"]],
174
+ notes=j["Notes"] or None,
175
+ last_update=parse_date(j["Last Update"]),
176
+ )
177
+ )
178
+
179
+
180
+ @attrs.frozen
181
+ class RepeaterBook:
182
+ """RepeaterBook API client."""
183
+
184
+ base_url: URL = attrs.Factory(lambda: URL("https://repeaterbook.com"))
185
+ app_name: str = "RepeaterBook Python SDK"
186
+ app_email: str = "micael@jarniac.dev"
187
+
188
+ working_dir: Path = attrs.Factory(lambda: Path())
189
+ database: str = "repeaterbook.db"
190
+
191
+ MAX_COUNT: ClassVar[int] = 3500
192
+
193
+ async def cache_dir(self) -> Path:
194
+ """Cache directory for API responses."""
195
+ cache = self.working_dir / ".repeaterbook_cache"
196
+ if not await cache.exists():
197
+ logger.info("Creating cache directory.")
198
+ await cache.mkdir(parents=True, exist_ok=True)
199
+ gitignore = cache / ".gitignore"
200
+ if not await gitignore.exists():
201
+ logger.info("Creating .gitignore file.")
202
+ await gitignore.write_text("*\n", encoding="utf-8")
203
+ return cache
204
+
205
+ @property
206
+ def database_path(self) -> Path:
207
+ """Database path."""
208
+ return self.working_dir / self.database
209
+
210
+ @property
211
+ def database_uri(self) -> str:
212
+ """Database URI."""
213
+ return f"sqlite:///{self.database_path}"
214
+
215
+ @cached_property
216
+ def engine(self) -> Engine:
217
+ """Create database engine."""
218
+ return create_engine(self.database_uri)
219
+
220
+ def init_db(self) -> None:
221
+ """Initialize database."""
222
+ SQLModel.metadata.create_all(self.engine)
223
+
224
+ @property
225
+ def url_api(self) -> URL:
226
+ """RepeaterBook API base URL."""
227
+ return self.base_url / "api"
228
+
229
+ @property
230
+ def url_export_north_america(self) -> URL:
231
+ """North-america export URL."""
232
+ return self.url_api / "export.php"
233
+
234
+ @property
235
+ def url_export_rest_of_world(self) -> URL:
236
+ """Rest of world (not north-america) export URL."""
237
+ return self.url_api / "exportROW.php"
238
+
239
+ def urls_export(
240
+ self,
241
+ query: ExportQuery,
242
+ ) -> set[URL]:
243
+ """Generate export URLs for given query."""
244
+ mode_map: dict[Mode, ModeJSON] = {
245
+ Mode.ANALOG: "analog",
246
+ Mode.DMR: "DMR",
247
+ Mode.NXDN: "NXDN",
248
+ Mode.P25: "P25",
249
+ Mode.TETRA: "tetra",
250
+ }
251
+ emergency_map: dict[Emergency, EmergencyJSON] = {
252
+ Emergency.ARES: "ARES",
253
+ Emergency.RACES: "RACES",
254
+ Emergency.SKYWARN: "SKYWARN",
255
+ Emergency.CANWARN: "CANWARN",
256
+ }
257
+ type_map: dict[ServiceType, ServiceTypeJSON] = {
258
+ ServiceType.GMRS: "GMRS",
259
+ }
260
+
261
+ query_na = ExportNorthAmericaQuery(
262
+ callsign=list(query.callsigns),
263
+ city=list(query.cities),
264
+ landmark=list(query.landmarks),
265
+ country=[country.name for country in query.countries],
266
+ frequency=[str(frequency) for frequency in query.frequencies],
267
+ mode=[mode_map[mode] for mode in query.modes],
268
+ state_id=list(query.state_ids),
269
+ county=list(query.counties),
270
+ emcomm=[emergency_map[emergency] for emergency in query.emergency_services],
271
+ stype=[type_map[service_type] for service_type in query.service_types],
272
+ )
273
+ query_na = cast(
274
+ "ExportNorthAmericaQuery", {k: v for k, v in query_na.items() if v}
275
+ )
276
+
277
+ query_world = ExportWorldQuery(
278
+ callsign=list(query.callsigns),
279
+ city=list(query.cities),
280
+ landmark=list(query.landmarks),
281
+ country=[country.name for country in query.countries],
282
+ frequency=[str(frequency) for frequency in query.frequencies],
283
+ mode=[mode_map[mode] for mode in query.modes],
284
+ region=list(query.regions),
285
+ )
286
+ query_world = cast(
287
+ "ExportWorldQuery", {k: v for k, v in query_world.items() if v}
288
+ )
289
+
290
+ return {
291
+ #' self.url_export_north_america % cast("dict[str, str]", query_na),
292
+ self.url_export_rest_of_world % cast("dict[str, str]", query_world),
293
+ }
294
+
295
+ async def export_json(self, url: URL) -> ExportJSON:
296
+ """Export data for given URL."""
297
+ data: ExportJSON | ExportErrorJSON = await fetch_json(
298
+ url,
299
+ headers={"User-Agent": f"{self.app_name} <{self.app_email}>"},
300
+ cache_dir=await self.cache_dir(),
301
+ )
302
+
303
+ if not isinstance(data, dict):
304
+ raise TypeError
305
+
306
+ if data.get("status") == "error":
307
+ raise ValueError(data.get("message"))
308
+
309
+ if "count" not in data or "results" not in data:
310
+ raise ValueError
311
+
312
+ data = cast("ExportJSON", data)
313
+
314
+ if data["count"] >= self.MAX_COUNT:
315
+ logger.warning(
316
+ "Reached max count for API response. Response may have been trimmed."
317
+ )
318
+
319
+ if data["count"] != len(data["results"]):
320
+ logger.warning("Mismatched count and length of results.")
321
+
322
+ return data
323
+
324
+ async def export_multi_json(self, urls: set[URL]) -> list[ExportJSON]:
325
+ """Export data for given URLs."""
326
+ tasks = [self.export_json(url) for url in urls]
327
+ return await asyncio.gather(*tasks)
328
+
329
+ async def download(self, query: ExportQuery) -> list[Repeater]:
330
+ """Download data and populate internal database."""
331
+ data = await self.export_multi_json(self.urls_export(query))
332
+
333
+ results: list[RepeaterJSON] = []
334
+ for export in data:
335
+ results.extend(export["results"])
336
+
337
+ self.init_db()
338
+
339
+ repeaters: list[Repeater] = []
340
+ with Session(self.engine) as session:
341
+ for result in results:
342
+ repeater = json_to_model(result)
343
+ session.add(repeater)
344
+ repeaters.append(repeater)
345
+ session.commit()
346
+
347
+ logger.info(f"Downloaded {len(repeaters)} repeaters.")
348
+ return repeaters
349
+
350
+ def find_nearest(
351
+ self,
352
+ latitude: float,
353
+ longitude: float,
354
+ *,
355
+ max_distance: float = 80.0,
356
+ unit: Unit = Unit.KILOMETERS,
357
+ ) -> list[Repeater]:
358
+ """Find repeaters within a given distance."""
359
+
360
+ class RepDist(NamedTuple):
361
+ """Repeater distance."""
362
+
363
+ repeater: Repeater
364
+ distance: float
365
+
366
+ rep_dists: list[RepDist] = []
367
+ with Session(self.engine) as session:
368
+ # Calculate the square bounds for the given distance.
369
+ bounds = square_bounds(LatLon(latitude, longitude), max_distance, unit=unit)
370
+ statement = select(Repeater).where(
371
+ Repeater.latitude >= bounds.south,
372
+ Repeater.latitude <= bounds.north,
373
+ Repeater.longitude >= bounds.west,
374
+ Repeater.longitude <= bounds.east,
375
+ )
376
+ for repeater in session.exec(statement):
377
+ # Calculate the distance to the repeater.
378
+ distance = haversine(
379
+ (latitude, longitude),
380
+ (repeater.latitude, repeater.longitude),
381
+ unit=unit,
382
+ )
383
+
384
+ if distance <= max_distance:
385
+ rep_dists.append(RepDist(repeater=repeater, distance=distance))
386
+
387
+ # Sort by distance.
388
+ rep_dists.sort(key=lambda x: x.distance)
389
+
390
+ # Log the number of repeaters found.
391
+ logger.info(
392
+ f"Found {len(rep_dists)} repeaters within {max_distance} {unit.name}."
393
+ )
394
+
395
+ # Convert to a list of repeaters.
396
+ return [rep_dist.repeater for rep_dist in rep_dists]
repeaterbook/utils.py ADDED
@@ -0,0 +1,49 @@
1
+ """Utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = [
6
+ "LatLon",
7
+ "SquareBounds",
8
+ "square_bounds",
9
+ ]
10
+
11
+ from typing import NamedTuple
12
+
13
+ from haversine import Direction, Unit, inverse_haversine # type: ignore[import-untyped]
14
+
15
+
16
+ class LatLon(NamedTuple):
17
+ """Latitude and Longitude."""
18
+
19
+ lat: float
20
+ lon: float
21
+
22
+
23
+ class SquareBounds(NamedTuple):
24
+ """Square bounds."""
25
+
26
+ north: float
27
+ south: float
28
+ east: float
29
+ west: float
30
+
31
+
32
+ def square_bounds(
33
+ origin: LatLon, distance: float, unit: Unit = Unit.KILOMETERS
34
+ ) -> SquareBounds:
35
+ """Get square bounds around a point."""
36
+ north = inverse_haversine(origin, distance, Direction.NORTH, unit=unit)[0]
37
+ south = inverse_haversine(origin, distance, Direction.SOUTH, unit=unit)[0]
38
+ east = inverse_haversine(origin, distance, Direction.EAST, unit=unit)[1]
39
+ west = inverse_haversine(origin, distance, Direction.WEST, unit=unit)[1]
40
+
41
+ # If we've gone all the way around, things get messy. Just open it up to everything.
42
+ if south > north:
43
+ north = 90.0
44
+ south = -90.0
45
+ if west > east:
46
+ west = -180.0
47
+ east = 180.0
48
+
49
+ return SquareBounds(north=north, south=south, east=east, west=west)
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: repeaterbook
3
+ Version: 0.1.0
4
+ Summary: Python utility to work with data from RepeaterBook.
5
+ Project-URL: homepage, https://github.com/MicaelJarniac/repeaterbook
6
+ Project-URL: source, https://github.com/MicaelJarniac/repeaterbook
7
+ Project-URL: download, https://pypi.org/project/repeaterbook/#files
8
+ Project-URL: changelog, https://github.com/MicaelJarniac/repeaterbook/blob/main/docs/CHANGELOG.md
9
+ Project-URL: documentation, https://repeaterbook.readthedocs.io
10
+ Project-URL: issues, https://github.com/MicaelJarniac/repeaterbook/issues
11
+ Author-email: Micael Jarniac <micael@jarniac.dev>
12
+ License: MIT
13
+ License-File: LICENSE
14
+ Classifier: Development Status :: 1 - Planning
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Natural Language :: English
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: aiohttp>=3.11.14
22
+ Requires-Dist: anyio>=4.9.0
23
+ Requires-Dist: attrs>=25.3.0
24
+ Requires-Dist: haversine>=2.9.0
25
+ Requires-Dist: loguru>=0.7.3
26
+ Requires-Dist: pycountry>=24.6.1
27
+ Requires-Dist: sqlmodel>=0.0.24
28
+ Requires-Dist: tqdm>=4.67.1
29
+ Requires-Dist: yarl>=1.18.3
30
+ Description-Content-Type: text/markdown
31
+
32
+ <div align="center">
33
+
34
+ [![Discord][badge-chat]][chat]
35
+ <br>
36
+ <br>
37
+
38
+ | | ![Badges][label-badges] |
39
+ |:-|:-|
40
+ | ![Build][label-build] | [![Nox][badge-actions]][actions] [![semantic-release][badge-semantic-release]][semantic-release] [![PyPI][badge-pypi]][pypi] [![Read the Docs][badge-docs]][docs] |
41
+ | ![Tests][label-tests] | [![coverage][badge-coverage]][coverage] [![pre-commit][badge-pre-commit]][pre-commit] [![asv][badge-asv]][asv] |
42
+ | ![Standards][label-standards] | [![SemVer 2.0.0][badge-semver]][semver] [![Conventional Commits][badge-conventional-commits]][conventional-commits] |
43
+ | ![Code][label-code] | [![uv][badge-uv]][uv] [![Ruff][badge-ruff]][ruff] [![Nox][badge-nox]][nox] [![Checked with mypy][badge-mypy]][mypy] |
44
+ | ![Repo][label-repo] | [![GitHub issues][badge-issues]][issues] [![GitHub stars][badge-stars]][stars] [![GitHub license][badge-license]][license] [![All Contributors][badge-all-contributors]][contributors] [![Contributor Covenant][badge-code-of-conduct]][code-of-conduct] |
45
+ </div>
46
+
47
+ <!-- Badges -->
48
+ [badge-chat]: https://img.shields.io/badge/dynamic/json?color=green&label=chat&query=%24.approximate_presence_count&suffix=%20online&logo=discord&style=flat-square&url=https%3A%2F%2Fdiscord.com%2Fapi%2Fv10%2Finvites%2FYe9yJtZQuN%3Fwith_counts%3Dtrue
49
+ [chat]: https://discord.gg/Ye9yJtZQuN
50
+
51
+ <!-- Labels -->
52
+ [label-badges]: https://img.shields.io/badge/%F0%9F%94%96-badges-purple?style=for-the-badge
53
+ [label-build]: https://img.shields.io/badge/%F0%9F%94%A7-build-darkblue?style=flat-square
54
+ [label-tests]: https://img.shields.io/badge/%F0%9F%A7%AA-tests-darkblue?style=flat-square
55
+ [label-standards]: https://img.shields.io/badge/%F0%9F%93%91-standards-darkblue?style=flat-square
56
+ [label-code]: https://img.shields.io/badge/%F0%9F%92%BB-code-darkblue?style=flat-square
57
+ [label-repo]: https://img.shields.io/badge/%F0%9F%93%81-repo-darkblue?style=flat-square
58
+
59
+ <!-- Build -->
60
+ [badge-actions]: https://img.shields.io/github/actions/workflow/status/MicaelJarniac/repeaterbook/ci.yml?branch=main&style=flat-square
61
+ [actions]: https://github.com/MicaelJarniac/repeaterbook/actions
62
+ [badge-semantic-release]: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079?style=flat-square
63
+ [semantic-release]: https://github.com/semantic-release/semantic-release
64
+ [badge-pypi]: https://img.shields.io/pypi/v/repeaterbook?style=flat-square
65
+ [pypi]: https://pypi.org/project/repeaterbook
66
+ [badge-docs]: https://img.shields.io/readthedocs/repeaterbook?style=flat-square
67
+ [docs]: https://repeaterbook.readthedocs.io
68
+
69
+ <!-- Tests -->
70
+ [badge-coverage]: https://img.shields.io/codecov/c/gh/MicaelJarniac/repeaterbook?logo=codecov&style=flat-square
71
+ [coverage]: https://codecov.io/gh/MicaelJarniac/repeaterbook
72
+ [badge-pre-commit]: https://img.shields.io/badge/pre--commit-enabled-brightgreen?style=flat-square&logo=pre-commit&logoColor=white
73
+ [pre-commit]: https://github.com/pre-commit/pre-commit
74
+ [badge-asv]: https://img.shields.io/badge/benchmarked%20by-asv-blue?style=flat-square
75
+ [asv]: https://github.com/airspeed-velocity/asv
76
+
77
+ <!-- Standards -->
78
+ [badge-semver]: https://img.shields.io/badge/SemVer-2.0.0-blue?style=flat-square&logo=semver
79
+ [semver]: https://semver.org/spec/v2.0.0.html
80
+ [badge-conventional-commits]: https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow?style=flat-square
81
+ [conventional-commits]: https://conventionalcommits.org
82
+
83
+ <!-- Code -->
84
+ [badge-uv]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json&style=flat-square
85
+ [uv]: https://github.com/astral-sh/uv
86
+ [badge-ruff]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json&style=flat-square
87
+ [ruff]: https://github.com/astral-sh/ruff
88
+ [badge-nox]: https://img.shields.io/badge/%F0%9F%A6%8A-Nox-D85E00.svg?style=flat-square
89
+ [nox]: https://github.com/wntrblm/nox
90
+ [badge-mypy]: https://img.shields.io/badge/mypy-checked-2A6DB2?style=flat-square
91
+ [mypy]: http://mypy-lang.org
92
+
93
+ <!-- Repo -->
94
+ [badge-issues]: https://img.shields.io/github/issues/MicaelJarniac/repeaterbook?style=flat-square
95
+ [issues]: https://github.com/MicaelJarniac/repeaterbook/issues
96
+ [badge-stars]: https://img.shields.io/github/stars/MicaelJarniac/repeaterbook?style=flat-square
97
+ [stars]: https://github.com/MicaelJarniac/repeaterbook/stargazers
98
+ [badge-license]: https://img.shields.io/github/license/MicaelJarniac/repeaterbook?style=flat-square
99
+ [license]: https://github.com/MicaelJarniac/repeaterbook/blob/main/LICENSE
100
+ <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
101
+ [badge-all-contributors]: https://img.shields.io/badge/all_contributors-0-orange.svg?style=flat-square
102
+ <!-- ALL-CONTRIBUTORS-BADGE:END -->
103
+ [contributors]: #Contributors-✨
104
+ [badge-code-of-conduct]: https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa?style=flat-square
105
+ [code-of-conduct]: CODE_OF_CONDUCT.md
106
+ <!---->
107
+
108
+ # RepeaterBook
109
+ Python utility to work with data from RepeaterBook.
110
+
111
+ [Read the Docs][docs]
112
+
113
+ Read RepeaterBook's official [API documentation](https://www.repeaterbook.com/wiki/doku.php?id=api) for more information.
114
+
115
+ ## See Also
116
+ - https://github.com/afourney/hamkit/tree/main/packages/repeaterbook
117
+ - https://github.com/desertblade/OpenGD77-Repeaterbook
118
+ - https://github.com/TomHW/OpenGD77
119
+
120
+ ## Installation
121
+
122
+ ### PyPI
123
+ [*repeaterbook*][pypi] is available on PyPI:
124
+
125
+ ```bash
126
+ # With uv
127
+ uv add repeaterbook
128
+ # With pip
129
+ pip install repeaterbook
130
+ # With Poetry
131
+ poetry add repeaterbook
132
+ ```
133
+
134
+ ### GitHub
135
+ You can also install the latest version of the code directly from GitHub:
136
+ ```bash
137
+ # With uv
138
+ uv add git+https://github.com/MicaelJarniac/repeaterbook
139
+ # With pip
140
+ pip install git+git://github.com/MicaelJarniac/repeaterbook
141
+ # With Poetry
142
+ poetry add git+git://github.com/MicaelJarniac/repeaterbook
143
+ ```
144
+
145
+ ## Usage
146
+ For more examples, see the [full documentation][docs].
147
+
148
+ ```python
149
+ from repeaterbook import repeaterbook
150
+ ```
151
+
152
+ ## Contributing
153
+ Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
154
+
155
+ Please make sure to update tests as appropriate.
156
+
157
+ More details can be found in [CONTRIBUTING](CONTRIBUTING.md).
158
+
159
+ ## Contributors ✨
160
+ <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
161
+ <!-- prettier-ignore-start -->
162
+ <!-- markdownlint-disable -->
163
+ <table>
164
+ </table>
165
+
166
+ <!-- markdownlint-restore -->
167
+ <!-- prettier-ignore-end -->
168
+
169
+ <!-- ALL-CONTRIBUTORS-LIST:END -->
170
+
171
+ ## License
172
+ [MIT](../LICENSE)
173
+
174
+ This project was created with the [MicaelJarniac/crustypy](https://github.com/MicaelJarniac/crustypy) template.
@@ -0,0 +1,9 @@
1
+ repeaterbook/__init__.py,sha256=S_Y8t081UnaTlJu4bqSLPb-Zpo4NpwfSOlftqlrR6Bg,58
2
+ repeaterbook/models.py,sha256=XZhpAyWuIIrYLy4i8DEp5R_KTwoGqtUiCVkH3A6L5eY,6116
3
+ repeaterbook/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ repeaterbook/services.py,sha256=Oam2rxTAg8Te_FLy7u0FugiYmQZhrbGLnLQAXfmU6xQ,12995
5
+ repeaterbook/utils.py,sha256=6I0NQdxPdNlBP_AKYidhRbM1XOZOHdPJpN8gQ-jNd2I,1227
6
+ repeaterbook-0.1.0.dist-info/METADATA,sha256=vXldXpUbTdCsqZboeyQku3yS3LVpyHrz6Q0GaLUuuP8,7824
7
+ repeaterbook-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
+ repeaterbook-0.1.0.dist-info/licenses/LICENSE,sha256=TtbMt69RbQyifR_It2bTHKdlLR1Dj6x2A5y_oLOyoVk,1071
9
+ repeaterbook-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Micael Jarniac
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.