dxcty-parser 0.0.2__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.
- dxcty_parser-0.0.2/PKG-INFO +52 -0
- dxcty_parser-0.0.2/README.md +26 -0
- dxcty_parser-0.0.2/dxcty_parser/__init__.py +341 -0
- dxcty_parser-0.0.2/dxcty_parser/py.typed +2 -0
- dxcty_parser-0.0.2/dxcty_parser.egg-info/PKG-INFO +52 -0
- dxcty_parser-0.0.2/dxcty_parser.egg-info/SOURCES.txt +10 -0
- dxcty_parser-0.0.2/dxcty_parser.egg-info/dependency_links.txt +1 -0
- dxcty_parser-0.0.2/dxcty_parser.egg-info/entry_points.txt +2 -0
- dxcty_parser-0.0.2/dxcty_parser.egg-info/requires.txt +11 -0
- dxcty_parser-0.0.2/dxcty_parser.egg-info/top_level.txt +1 -0
- dxcty_parser-0.0.2/pyproject.toml +47 -0
- dxcty_parser-0.0.2/setup.cfg +4 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dxcty_parser
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: A simple ham radio cty parser library.
|
|
5
|
+
Author-email: Fred C <w6bsd@bsdworld.org>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Source, https://github.com/0x9900/dxcty_parser
|
|
8
|
+
Project-URL: Tracker, https://github.com/0x9900/dxcty_parser/issues
|
|
9
|
+
Classifier: Intended Audience :: Telecommunications Industry
|
|
10
|
+
Classifier: Operating System :: MacOS
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Classifier: Programming Language :: Python
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: build; extra == "dev"
|
|
18
|
+
Requires-Dist: flake8; extra == "dev"
|
|
19
|
+
Requires-Dist: ipdb; extra == "dev"
|
|
20
|
+
Requires-Dist: ipython; extra == "dev"
|
|
21
|
+
Requires-Dist: isort; extra == "dev"
|
|
22
|
+
Requires-Dist: mypy; extra == "dev"
|
|
23
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
24
|
+
Requires-Dist: pylint; extra == "dev"
|
|
25
|
+
Requires-Dist: twine; extra == "dev"
|
|
26
|
+
|
|
27
|
+
# cty_parser
|
|
28
|
+
|
|
29
|
+
Parse [cty.dat](https://www.country-files.com/cty-dat-format/) (the AD1C
|
|
30
|
+
"country file") and resolve amateur radio callsigns to DXCC entities —
|
|
31
|
+
country, CQ zone, ITU zone, continent, lat/long, and GMT offset.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from cty_parser import load_cty
|
|
37
|
+
|
|
38
|
+
table = load_cty() # build once, reuse for many lookups
|
|
39
|
+
|
|
40
|
+
result = table.lookup("W1AW")
|
|
41
|
+
print(result.entity.country, result.entity.cq_zone)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## CLI
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
python cty_parser.py W1AW KM6ETX
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Requirements
|
|
51
|
+
|
|
52
|
+
Python 3.10+, standard library only.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# cty_parser
|
|
2
|
+
|
|
3
|
+
Parse [cty.dat](https://www.country-files.com/cty-dat-format/) (the AD1C
|
|
4
|
+
"country file") and resolve amateur radio callsigns to DXCC entities —
|
|
5
|
+
country, CQ zone, ITU zone, continent, lat/long, and GMT offset.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from cty_parser import load_cty
|
|
11
|
+
|
|
12
|
+
table = load_cty() # build once, reuse for many lookups
|
|
13
|
+
|
|
14
|
+
result = table.lookup("W1AW")
|
|
15
|
+
print(result.entity.country, result.entity.cq_zone)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## CLI
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
python cty_parser.py W1AW KM6ETX
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
Python 3.10+, standard library only.
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Parser for cty.dat (CT9 "Country File") as used by ham radio contest
|
|
4
|
+
logging software (CT, N1MM, etc.) to resolve DXCC entities from callsigns.
|
|
5
|
+
|
|
6
|
+
Format reference: https://www.country-files.com/cty-dat-format/
|
|
7
|
+
|
|
8
|
+
Each entity record looks like:
|
|
9
|
+
|
|
10
|
+
Country Name:CQ:ITU:Cont:Lat:Long:GMTOff:PrimaryPfx:
|
|
11
|
+
=ALIAS1,ALIAS2(14)[27],ALIAS3{EU}<40.5/-74.0>~-5~;
|
|
12
|
+
|
|
13
|
+
- The 8 header fields are colon-delimited.
|
|
14
|
+
- A "*" preceding the primary prefix marks a WAEDC (DARC) entity.
|
|
15
|
+
- Alias prefixes (including the primary one) follow, comma-separated,
|
|
16
|
+
possibly spanning multiple lines, terminated by ";".
|
|
17
|
+
- A leading "=" on an alias means it must match a *full callsign* exactly,
|
|
18
|
+
not just a prefix.
|
|
19
|
+
- Trailing modifiers on an alias override fields for that alias only:
|
|
20
|
+
(#) override CQ zone
|
|
21
|
+
[#] override ITU zone
|
|
22
|
+
<lat/lon> override latitude/longitude
|
|
23
|
+
{aa} override continent
|
|
24
|
+
~#~ override local GMT offset
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import functools
|
|
30
|
+
import hashlib
|
|
31
|
+
import logging
|
|
32
|
+
import pickle
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import time
|
|
36
|
+
from dataclasses import dataclass, field, replace
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, Optional
|
|
39
|
+
from urllib.error import HTTPError
|
|
40
|
+
from urllib.request import Request, urlopen
|
|
41
|
+
|
|
42
|
+
__all__ = ['load_cty']
|
|
43
|
+
|
|
44
|
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')
|
|
45
|
+
|
|
46
|
+
SOURCE_URL = "https://www.country-files.com/cty/cty_wt_mod.dat"
|
|
47
|
+
DATA_PATH = "/tmp/cty_wt_mod.dat"
|
|
48
|
+
|
|
49
|
+
MAX_CACHE = 86400 * 7 # One week
|
|
50
|
+
|
|
51
|
+
_ALIAS_OVERRIDE_RE = re.compile(
|
|
52
|
+
r"\((?P<cq>-?\d+)\)"
|
|
53
|
+
r"|\[(?P<itu>-?\d+)\]"
|
|
54
|
+
r"|<(?P<lat>-?\d+(?:\.\d+)?)/(?P<lon>-?\d+(?:\.\d+)?)>"
|
|
55
|
+
r"|\{(?P<cont>[A-Za-z]{2})\}"
|
|
56
|
+
r"|~(?P<gmt>-?\d+(?:\.\d+)?)~"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(slots=True)
|
|
61
|
+
class Entity: # pylint: disable=too-many-instance-attributes
|
|
62
|
+
"""A DXCC entity (country) entry from cty.dat."""
|
|
63
|
+
country: str
|
|
64
|
+
cq_zone: int
|
|
65
|
+
itu_zone: int
|
|
66
|
+
continent: str
|
|
67
|
+
latitude: float
|
|
68
|
+
longitude: float
|
|
69
|
+
gmt_offset: float
|
|
70
|
+
primary_prefix: str
|
|
71
|
+
waedc: bool = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(slots=True)
|
|
75
|
+
class PrefixEntry:
|
|
76
|
+
"""One alias/prefix mapping to an (possibly overridden) Entity."""
|
|
77
|
+
prefix: str
|
|
78
|
+
exact_match: bool
|
|
79
|
+
entity: Entity
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def file_cache(cache_dir=".cache"):
|
|
83
|
+
def decorator(func):
|
|
84
|
+
# Create cache directory if it doesn't exist
|
|
85
|
+
Path(cache_dir).mkdir(parents=True, exist_ok=True)
|
|
86
|
+
|
|
87
|
+
def _gen_key(func_name, args):
|
|
88
|
+
key_str = '.'.join([str(a) for a in args]).encode('utf-8')
|
|
89
|
+
_hash = hashlib.blake2b(digest_size=8)
|
|
90
|
+
_hash.update(key_str)
|
|
91
|
+
return f"{func_name}-{_hash.hexdigest()}"
|
|
92
|
+
|
|
93
|
+
@functools.wraps(func)
|
|
94
|
+
def wrapper(*args, **kwargs):
|
|
95
|
+
# Create a unique cache key from arguments
|
|
96
|
+
cache_key = _gen_key(func.__name__, args)
|
|
97
|
+
cache_file = Path(cache_dir) / f"{cache_key}.pkl"
|
|
98
|
+
logging.info(cache_file)
|
|
99
|
+
|
|
100
|
+
# Try to load from cache
|
|
101
|
+
if cache_file.exists() and cache_file.stat().st_mtime + MAX_CACHE > time.time():
|
|
102
|
+
try:
|
|
103
|
+
with open(cache_file, 'rb') as f:
|
|
104
|
+
result = pickle.load(f)
|
|
105
|
+
logging.warning("Cache hit: loaded from %s", cache_file)
|
|
106
|
+
return result
|
|
107
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
108
|
+
logging.error("Cache read error: %s", e)
|
|
109
|
+
|
|
110
|
+
# Call the function and cache the result
|
|
111
|
+
result = func(*args, **kwargs)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
with open(cache_file, 'wb') as f:
|
|
115
|
+
pickle.dump(result, f)
|
|
116
|
+
logging.info("Cache miss: saved to %s", cache_file)
|
|
117
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
118
|
+
logging.error("Cache write error: %s", e)
|
|
119
|
+
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
return wrapper
|
|
123
|
+
return decorator
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parse_header(fields: list[str]) -> Entity:
|
|
127
|
+
country, cq_zone, itu_zone, continent, lat, lon, gmt_offset, primary = fields
|
|
128
|
+
waedc = primary.strip().startswith("*")
|
|
129
|
+
primary = primary.strip().lstrip("*")
|
|
130
|
+
return Entity(
|
|
131
|
+
country=country.strip(),
|
|
132
|
+
cq_zone=int(cq_zone.strip()),
|
|
133
|
+
itu_zone=int(itu_zone.strip()),
|
|
134
|
+
continent=continent.strip(),
|
|
135
|
+
latitude=float(lat.strip()),
|
|
136
|
+
longitude=float(lon.strip()),
|
|
137
|
+
gmt_offset=float(gmt_offset.strip()) * -1,
|
|
138
|
+
primary_prefix=primary,
|
|
139
|
+
waedc=waedc,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _parse_alias(raw: str, base_entity: Entity) -> Optional[PrefixEntry]:
|
|
144
|
+
raw = raw.strip()
|
|
145
|
+
if not raw or raw.startswith('#'):
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
exact_match = raw.startswith("=")
|
|
149
|
+
if exact_match:
|
|
150
|
+
raw = raw[1:]
|
|
151
|
+
|
|
152
|
+
m = re.match(r"^([^\(\[\{<~]+)(.*)$", raw)
|
|
153
|
+
if not m:
|
|
154
|
+
return None
|
|
155
|
+
prefix, overrides_str = m.group(1).strip(), m.group(2)
|
|
156
|
+
|
|
157
|
+
overrides: dict[str, Any] = {}
|
|
158
|
+
for om in _ALIAS_OVERRIDE_RE.finditer(overrides_str):
|
|
159
|
+
if om.group("cq"):
|
|
160
|
+
overrides["cq_zone"] = int(om.group("cq"))
|
|
161
|
+
elif om.group("itu"):
|
|
162
|
+
overrides["itu_zone"] = int(om.group("itu"))
|
|
163
|
+
elif om.group("lat"):
|
|
164
|
+
overrides["latitude"] = float(om.group("lat"))
|
|
165
|
+
overrides["longitude"] = float(om.group("lon"))
|
|
166
|
+
elif om.group("cont"):
|
|
167
|
+
overrides["continent"] = om.group("cont")
|
|
168
|
+
elif om.group("gmt"):
|
|
169
|
+
overrides["gmt_offset"] = float(om.group("gmt")) * -1
|
|
170
|
+
|
|
171
|
+
entity = replace(base_entity, **overrides) if overrides else base_entity
|
|
172
|
+
return PrefixEntry(prefix=prefix, exact_match=exact_match, entity=entity)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def parse_cty_dat(filepath: Path) -> dict[str, PrefixEntry]:
|
|
176
|
+
"""
|
|
177
|
+
Parse a cty.dat file and return a dict mapping each alias prefix
|
|
178
|
+
(or full callsign, for "=" entries) to a PrefixEntry.
|
|
179
|
+
|
|
180
|
+
If a prefix appears more than once, the first occurrence wins,
|
|
181
|
+
matching the "parse top to bottom, ignore duplicates" rule from
|
|
182
|
+
the format spec.
|
|
183
|
+
"""
|
|
184
|
+
with filepath.open("r", encoding="utf-8", errors="replace") as fd:
|
|
185
|
+
lines = (ln.strip() for ln in fd if not ln.startswith('#'))
|
|
186
|
+
content = '\n'.join(lines)
|
|
187
|
+
|
|
188
|
+
prefixes: dict[str, PrefixEntry] = {}
|
|
189
|
+
|
|
190
|
+
for record in content.split(";"):
|
|
191
|
+
record = record.strip()
|
|
192
|
+
if not record:
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
parts = record.split(":", 8)
|
|
196
|
+
if len(parts) < 8:
|
|
197
|
+
continue # malformed/trailing junk
|
|
198
|
+
|
|
199
|
+
base_entity = _parse_header(parts[:8])
|
|
200
|
+
aliases_str = parts[8] if len(parts) > 8 else ""
|
|
201
|
+
aliases_str = aliases_str.replace("\n", " ").replace("\r", " ")
|
|
202
|
+
|
|
203
|
+
for raw_alias in aliases_str.split(","):
|
|
204
|
+
entry = _parse_alias(raw_alias, base_entity)
|
|
205
|
+
if entry is None:
|
|
206
|
+
continue
|
|
207
|
+
key = entry.prefix
|
|
208
|
+
if key not in prefixes:
|
|
209
|
+
prefixes[key] = entry
|
|
210
|
+
|
|
211
|
+
return prefixes
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(slots=True)
|
|
215
|
+
class _TrieNode:
|
|
216
|
+
entry: Optional[PrefixEntry] = None
|
|
217
|
+
children: dict = field(default_factory=dict)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class CtyTable:
|
|
221
|
+
# pylint: disable=too-few-public-methods
|
|
222
|
+
"""
|
|
223
|
+
Callsign -> DXCC entity lookup, built once from parse_cty_dat() output.
|
|
224
|
+
|
|
225
|
+
Longest-prefix matching is done via a character trie, so lookup() is
|
|
226
|
+
O(len(callsign)) regardless of how many prefixes are loaded (cty.dat
|
|
227
|
+
typically has 20,000+ of them), instead of O(n) over every prefix.
|
|
228
|
+
"=exact" full-callsign entries are kept in a separate dict for O(1)
|
|
229
|
+
lookup and take priority, per the format spec.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
def __init__(self, prefixes: dict[str, PrefixEntry]):
|
|
233
|
+
self._exact: dict[str, PrefixEntry] = {}
|
|
234
|
+
self._root = _TrieNode()
|
|
235
|
+
|
|
236
|
+
for key, entry in prefixes.items():
|
|
237
|
+
if entry.exact_match:
|
|
238
|
+
self._exact[key] = entry
|
|
239
|
+
continue
|
|
240
|
+
node = self._root
|
|
241
|
+
for ch in key:
|
|
242
|
+
node = node.children.setdefault(ch, _TrieNode())
|
|
243
|
+
node.entry = entry
|
|
244
|
+
|
|
245
|
+
def lookup(self, callsign: str) -> Optional[PrefixEntry]:
|
|
246
|
+
callsign = callsign.strip().upper()
|
|
247
|
+
exact = self._exact.get(callsign)
|
|
248
|
+
if exact is not None:
|
|
249
|
+
return exact
|
|
250
|
+
|
|
251
|
+
node = self._root
|
|
252
|
+
best: Optional[PrefixEntry] = None
|
|
253
|
+
for ch in callsign:
|
|
254
|
+
if (next_node := node.children.get(ch)) is None:
|
|
255
|
+
break
|
|
256
|
+
node = next_node
|
|
257
|
+
if node.entry is not None:
|
|
258
|
+
best = node.entry
|
|
259
|
+
return best
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def url_retrieve(url: str, filepath: Path):
|
|
263
|
+
etag_file = filepath.with_suffix('.etag')
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
with open(etag_file, 'rb') as f:
|
|
267
|
+
etag = f.read().strip()
|
|
268
|
+
except FileNotFoundError:
|
|
269
|
+
etag = None
|
|
270
|
+
|
|
271
|
+
headers = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:155.0) Gecko/20100101 CtyParser"}
|
|
272
|
+
if etag:
|
|
273
|
+
headers["If-None-Match"] = str(etag)
|
|
274
|
+
|
|
275
|
+
request = Request(url, headers=headers)
|
|
276
|
+
try:
|
|
277
|
+
with urlopen(request) as response:
|
|
278
|
+
with open(filepath, "wb") as f:
|
|
279
|
+
shutil.copyfileobj(response, f)
|
|
280
|
+
|
|
281
|
+
new_etag = response.headers.get("ETag")
|
|
282
|
+
if new_etag:
|
|
283
|
+
with open(etag_file, "wb") as f:
|
|
284
|
+
f.write(new_etag)
|
|
285
|
+
|
|
286
|
+
logging.info("Downloaded: %s, etag: %s", url, new_etag)
|
|
287
|
+
|
|
288
|
+
except HTTPError as e:
|
|
289
|
+
if e.code == 304:
|
|
290
|
+
logging.info("File has not changed")
|
|
291
|
+
else:
|
|
292
|
+
raise
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@file_cache('/tmp')
|
|
296
|
+
def load_cty(filepath: Path | str = DATA_PATH) -> CtyTable:
|
|
297
|
+
if isinstance(filepath, str):
|
|
298
|
+
filepath = Path(filepath)
|
|
299
|
+
|
|
300
|
+
url_retrieve(SOURCE_URL, filepath)
|
|
301
|
+
prefixes = parse_cty_dat(filepath)
|
|
302
|
+
logging.info("Loaded %s prefixes", len(prefixes))
|
|
303
|
+
table = CtyTable(prefixes)
|
|
304
|
+
return table
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def lookup_callsign(callsign: str, prefixes: dict[str, PrefixEntry]) -> Optional[PrefixEntry]:
|
|
308
|
+
"""
|
|
309
|
+
Resolve a callsign to its DXCC entity using longest-prefix matching,
|
|
310
|
+
with "=exact" full-callsign entries taking priority over prefix matches.
|
|
311
|
+
|
|
312
|
+
Convenience wrapper for one-off lookups. It builds a fresh CtyTable
|
|
313
|
+
(and thus a fresh trie) on every call, so it's still O(n) overall for
|
|
314
|
+
that call and is NOT suitable for looking up many callsigns — build a
|
|
315
|
+
CtyTable once with CtyTable(prefixes) and call .lookup() instead.
|
|
316
|
+
"""
|
|
317
|
+
return CtyTable(prefixes).lookup(callsign)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def main():
|
|
321
|
+
import sys # pylint: disable=import-outside-toplevel
|
|
322
|
+
|
|
323
|
+
if len(sys.argv) < 1:
|
|
324
|
+
print("Usage: python dxcty_parser.py [CALLSIGN ...]")
|
|
325
|
+
raise SystemExit(1)
|
|
326
|
+
|
|
327
|
+
table = load_cty()
|
|
328
|
+
|
|
329
|
+
for call in sys.argv[1:]:
|
|
330
|
+
result = table.lookup(call)
|
|
331
|
+
if result:
|
|
332
|
+
e = result.entity
|
|
333
|
+
print(f"{call}: {e.country} ({e.primary_prefix}) CQ={e.cq_zone} "
|
|
334
|
+
f"ITU={e.itu_zone} Cont={e.continent} GMT={e.gmt_offset} "
|
|
335
|
+
f"Latitude={e.latitude}, Longitude={e.longitude}")
|
|
336
|
+
else:
|
|
337
|
+
print(f"{call}: no match")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
if __name__ == "__main__":
|
|
341
|
+
main()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dxcty_parser
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: A simple ham radio cty parser library.
|
|
5
|
+
Author-email: Fred C <w6bsd@bsdworld.org>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Source, https://github.com/0x9900/dxcty_parser
|
|
8
|
+
Project-URL: Tracker, https://github.com/0x9900/dxcty_parser/issues
|
|
9
|
+
Classifier: Intended Audience :: Telecommunications Industry
|
|
10
|
+
Classifier: Operating System :: MacOS
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Classifier: Programming Language :: Python
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: build; extra == "dev"
|
|
18
|
+
Requires-Dist: flake8; extra == "dev"
|
|
19
|
+
Requires-Dist: ipdb; extra == "dev"
|
|
20
|
+
Requires-Dist: ipython; extra == "dev"
|
|
21
|
+
Requires-Dist: isort; extra == "dev"
|
|
22
|
+
Requires-Dist: mypy; extra == "dev"
|
|
23
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
24
|
+
Requires-Dist: pylint; extra == "dev"
|
|
25
|
+
Requires-Dist: twine; extra == "dev"
|
|
26
|
+
|
|
27
|
+
# cty_parser
|
|
28
|
+
|
|
29
|
+
Parse [cty.dat](https://www.country-files.com/cty-dat-format/) (the AD1C
|
|
30
|
+
"country file") and resolve amateur radio callsigns to DXCC entities —
|
|
31
|
+
country, CQ zone, ITU zone, continent, lat/long, and GMT offset.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from cty_parser import load_cty
|
|
37
|
+
|
|
38
|
+
table = load_cty() # build once, reuse for many lookups
|
|
39
|
+
|
|
40
|
+
result = table.lookup("W1AW")
|
|
41
|
+
print(result.entity.country, result.entity.cq_zone)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## CLI
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
python cty_parser.py W1AW KM6ETX
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Requirements
|
|
51
|
+
|
|
52
|
+
Python 3.10+, standard library only.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
dxcty_parser/__init__.py
|
|
4
|
+
dxcty_parser/py.typed
|
|
5
|
+
dxcty_parser.egg-info/PKG-INFO
|
|
6
|
+
dxcty_parser.egg-info/SOURCES.txt
|
|
7
|
+
dxcty_parser.egg-info/dependency_links.txt
|
|
8
|
+
dxcty_parser.egg-info/entry_points.txt
|
|
9
|
+
dxcty_parser.egg-info/requires.txt
|
|
10
|
+
dxcty_parser.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dxcty_parser
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dxcty_parser"
|
|
7
|
+
version = "0.0.2"
|
|
8
|
+
description = "A simple ham radio cty parser library."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "Fred C", email = "w6bsd@bsdworld.org" }
|
|
12
|
+
]
|
|
13
|
+
license = "BSD-3-Clause"
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Intended Audience :: Telecommunications Industry",
|
|
17
|
+
"Operating System :: MacOS",
|
|
18
|
+
"Operating System :: POSIX :: Linux",
|
|
19
|
+
"Programming Language :: Python",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools]
|
|
24
|
+
packages = ["dxcty_parser"]
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.package-data]
|
|
27
|
+
"dxcty_parser" = ["py.typed"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Source = "https://github.com/0x9900/dxcty_parser"
|
|
31
|
+
Tracker = "https://github.com/0x9900/dxcty_parser/issues"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
dxcty_parser = "dxcty_parser:main"
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = [
|
|
38
|
+
"build",
|
|
39
|
+
"flake8",
|
|
40
|
+
"ipdb",
|
|
41
|
+
"ipython",
|
|
42
|
+
"isort",
|
|
43
|
+
"mypy",
|
|
44
|
+
"pre-commit",
|
|
45
|
+
"pylint",
|
|
46
|
+
"twine",
|
|
47
|
+
]
|