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.
@@ -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,2 @@
1
+ # Instruct type checkers to look for inline type annotations in this package.
2
+ # See PEP 561.
@@ -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,2 @@
1
+ [console_scripts]
2
+ dxcty_parser = dxcty_parser:main
@@ -0,0 +1,11 @@
1
+
2
+ [dev]
3
+ build
4
+ flake8
5
+ ipdb
6
+ ipython
7
+ isort
8
+ mypy
9
+ pre-commit
10
+ pylint
11
+ twine
@@ -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
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+