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.
- repeaterbook/__init__.py +1 -0
- repeaterbook/models.py +289 -0
- repeaterbook/py.typed +0 -0
- repeaterbook/services.py +396 -0
- repeaterbook/utils.py +49 -0
- repeaterbook-0.1.0.dist-info/METADATA +174 -0
- repeaterbook-0.1.0.dist-info/RECORD +9 -0
- repeaterbook-0.1.0.dist-info/WHEEL +4 -0
- repeaterbook-0.1.0.dist-info/licenses/LICENSE +21 -0
repeaterbook/__init__.py
ADDED
|
@@ -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
|
repeaterbook/services.py
ADDED
|
@@ -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,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.
|