dkais 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.
dkais/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """__init__ for dkais."""
2
+
3
+ from importlib import metadata
4
+
5
+ __all__: list[str] = []
6
+
7
+ # Mypy-recommended method to mitigate `[no-redef]` issues
8
+ __version__: str
9
+ try:
10
+ __version__ = metadata.version("dkais")
11
+ except metadata.PackageNotFoundError:
12
+ __version__ = "0.0.0+unknown"
dkais/__main__.py ADDED
@@ -0,0 +1,140 @@
1
+ """Main entry point for the dkais CLI."""
2
+
3
+ import argparse
4
+ import io
5
+ import logging
6
+ import sys
7
+ import textwrap
8
+ from typing import NoReturn
9
+
10
+ from dkais import __version__, _core, exceptions
11
+ from dkais._cli import add_subparsers
12
+ from dkais.logging import CLI_CONSOLE, configure_cli_logging
13
+
14
+ logger = logging.getLogger(__package__)
15
+
16
+
17
+ class CLIArgumentParser(argparse.ArgumentParser):
18
+ """Custom CLI ArgumentParser."""
19
+
20
+ def error(self, message: str) -> NoReturn:
21
+ """Handle CLI errors by printing a formatted message and exiting.
22
+
23
+ Args:
24
+ message: The error message to display.
25
+ """
26
+
27
+ CLI_CONSOLE.print(f"[red]ERROR:[/red] [b]{message}[/b]", end="\n\n")
28
+ self.print_usage(sys.stderr)
29
+ CLI_CONSOLE.print(
30
+ f"\n[dim]Try [cyan]{self.prog} --help[/cyan].[/dim]",
31
+ )
32
+
33
+ sys.exit(_core.ExitCode.EX_USAGE)
34
+
35
+
36
+ def main() -> NoReturn:
37
+ """Provide a unified `dkais` CLI entrypoint - list, download, process."""
38
+
39
+ # Redirected streams default to the locale encoding (cp1252 on Windows),
40
+ # which cannot encode the output's glyphs (e.g. `✓`, `→`)
41
+ for stream in (sys.stdout, sys.stderr):
42
+ if isinstance(stream, io.TextIOWrapper):
43
+ stream.reconfigure(encoding="utf-8")
44
+
45
+ # Provides a base parser for shared arguments across subcommands
46
+ base_parser = CLIArgumentParser(add_help=False)
47
+ base_parser.add_argument(
48
+ "-h",
49
+ "--help",
50
+ action="help",
51
+ help="Show this help message and exit",
52
+ )
53
+
54
+ description = (
55
+ "dkais downloads Danish Maritime Authority historical AIS archives "
56
+ "from http://aisdata.ais.dk/ and converts them to Parquet, "
57
+ "partitioned by YEAR and MONTH."
58
+ )
59
+
60
+ epilog = (
61
+ "EXAMPLES:\n"
62
+ " dkais list --start 2026-10\n"
63
+ " dkais download --start 2026-10 --data-dir D:/ais\n"
64
+ " dkais process --start 2026-10 --data-dir D:/ais\n\n"
65
+ "For more information see https://github.com/andyrids/dkais."
66
+ )
67
+
68
+ # Main parser for the CLI
69
+ parser = CLIArgumentParser(
70
+ prog=__package__,
71
+ description=textwrap.fill(description, width=79),
72
+ usage="USAGE:\n %(prog)s [options] <command>",
73
+ epilog=epilog,
74
+ add_help=False,
75
+ parents=[base_parser],
76
+ formatter_class=_core.CLIGFormatter,
77
+ )
78
+
79
+ parser._optionals.title = "OPTIONS"
80
+
81
+ parser.add_argument(
82
+ "-d",
83
+ "--debug",
84
+ action="store_true",
85
+ help="Show debug logging output",
86
+ )
87
+
88
+ parser.add_argument(
89
+ "-v",
90
+ "--version",
91
+ action="version",
92
+ version=f"v{__version__}",
93
+ help="Show program's version number and exit",
94
+ )
95
+
96
+ subparsers = parser.add_subparsers(
97
+ title="COMMANDS",
98
+ # Mitigate subparser usage display issue by explicitly setting prog
99
+ # https://github.com/python/cpython/issues/86463
100
+ prog=parser.prog,
101
+ dest="command",
102
+ required=False,
103
+ metavar="<command>",
104
+ )
105
+
106
+ add_subparsers(subparsers, base_parser)
107
+
108
+ args = parser.parse_args()
109
+
110
+ # Empty command (`dkais`) shows header and help
111
+ if args.command is None:
112
+ sys.stdout.write(f"DMA AIS data manager [v{__version__}]\n\n")
113
+
114
+ # Prints; USAGE, description, OPTIONS, COMMANDS & epilog
115
+ parser.print_help()
116
+
117
+ sys.exit(_core.ExitCode.EX_OK)
118
+
119
+ # Configure logging based on debug flag
120
+ is_debug = args.debug
121
+ configure_cli_logging(logging.DEBUG if is_debug else logging.WARNING)
122
+
123
+ ctx = _core.CLIContext(args=args, console=CLI_CONSOLE, is_debug=is_debug)
124
+
125
+ try:
126
+ exit_code = int(args.func(ctx))
127
+ except exceptions.DataDirNotSetError as err:
128
+ logger.error(str(err))
129
+ exit_code = _core.ExitCode.EX_USAGE
130
+ except exceptions.Error as err:
131
+ logger.error(str(err))
132
+ exit_code = _core.ExitCode.EX_FAILURE
133
+ except Exception:
134
+ logger.exception("Unexpected error")
135
+ exit_code = _core.ExitCode.EX_SYNTAX
136
+ sys.exit(exit_code)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
dkais/_cli.py ADDED
@@ -0,0 +1,496 @@
1
+ """CLI commands to list, download & process DMA AIS archives.
2
+
3
+ NOTE: The `__main__` `argparse.ArgumentParser` dkais CLI entry point uses this
4
+ module to register subcommands (`add_subparsers`).
5
+ """
6
+
7
+ import argparse
8
+ import calendar
9
+ import json
10
+ import logging
11
+ import re
12
+ import sys
13
+ import time
14
+ import zipfile
15
+ from collections import deque
16
+ from collections.abc import Generator
17
+ from concurrent.futures import Future, ThreadPoolExecutor
18
+ from datetime import date
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ import polars as pl
23
+ import psutil
24
+ from rich import box
25
+ from rich.filesize import decimal
26
+ from rich.table import Table
27
+
28
+ from dkais import _download, _process, _s3, exceptions
29
+ from dkais._core import CLIContext, CLIGFormatter, ExitCode, resolve_data_dir
30
+ from dkais._report import duration, info, plural, reporter, summarise
31
+ from dkais.logging import GLOBAL_CONSOLE
32
+
33
+ if TYPE_CHECKING:
34
+ from dkais._process import Member
35
+ from dkais._s3 import Archive
36
+
37
+ logger = logging.getLogger(__package__)
38
+
39
+ _DATE_PATTERN = re.compile(r"(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?")
40
+ _POLL_SECONDS = 0.5
41
+
42
+
43
+ def _parse_date(value: str, *, end: bool) -> date:
44
+ """Parse `YYYY`, `YYYY-MM` or `YYYY-MM-DD` into a range boundary.
45
+
46
+ Args:
47
+ value: The CLI argument value.
48
+ end: Resolve a partial date to its last day, else its first.
49
+
50
+ Returns:
51
+ The boundary date.
52
+
53
+ Raises:
54
+ argparse.ArgumentTypeError: On an invalid date.
55
+ """
56
+ if not (match := _DATE_PATTERN.fullmatch(value)):
57
+ msg = f"invalid date `{value}` - use YYYY, YYYY-MM or YYYY-MM-DD"
58
+ raise argparse.ArgumentTypeError(msg)
59
+ year, month, day = match.groups()
60
+ try:
61
+ y = int(year)
62
+ m = int(month) if month else (12 if end else 1)
63
+ d = int(day) if day else (calendar.monthrange(y, m)[1] if end else 1)
64
+ return date(y, m, d)
65
+ except ValueError as e:
66
+ msg = f"invalid date `{value}` - {e}"
67
+ raise argparse.ArgumentTypeError(msg) from e
68
+
69
+
70
+ def _parse_start(value: str) -> date:
71
+ """Parse a `--start` value to the first day it covers."""
72
+ return _parse_date(value, end=False)
73
+
74
+
75
+ def _parse_end(value: str) -> date:
76
+ """Parse an `--end` value to the last day it covers."""
77
+ return _parse_date(value, end=True)
78
+
79
+
80
+ def _parse_jobs(value: str) -> int:
81
+ """Parse a `--jobs` value to a positive worker count."""
82
+ msg = f"invalid count `{value}` - use an integer >= 1"
83
+ try:
84
+ jobs = int(value)
85
+ except ValueError as e:
86
+ raise argparse.ArgumentTypeError(msg) from e
87
+ if jobs < 1:
88
+ raise argparse.ArgumentTypeError(msg)
89
+ return jobs
90
+
91
+
92
+ def _requires_selection(ctx: CLIContext) -> bool:
93
+ """Check that `--start` or `--all` was given, else print concise help.
94
+
95
+ NOTE: Guards `download` & `process` against an accidental run over the
96
+ whole ~4.2 TB bucket.
97
+
98
+ Args:
99
+ ctx: The CLI context.
100
+
101
+ Returns:
102
+ True if a selection was given.
103
+ """
104
+ args = ctx.args
105
+ if args.all and (args.start or args.end):
106
+ logger.error("`--all` cannot be combined with `--start`/`--end`")
107
+ return False
108
+ if not (args.all or args.start):
109
+ args.parser.print_help(sys.stderr)
110
+ return False
111
+ return True
112
+
113
+
114
+ def command_list(ctx: CLIContext) -> int:
115
+ """List bucket archives, with local state when a data dir is set.
116
+
117
+ Args:
118
+ ctx: The CLI context.
119
+
120
+ Returns:
121
+ The integer process exit code. Uses `_core.ExitCode`.
122
+ """
123
+ args = ctx.args
124
+ archives = _s3.list_archives(_s3.make_client(), args.start, args.end)
125
+
126
+ try:
127
+ data_dir: Path | None = resolve_data_dir(args.data_dir)
128
+ except exceptions.DataDirNotSetError:
129
+ data_dir = None
130
+ logger.warning(
131
+ "No data dir - set `--data-dir` or `DKAIS_DATA_DIR` "
132
+ "to show local state"
133
+ )
134
+
135
+ rows: list[dict[str, Any]] = []
136
+ for archive in archives:
137
+ row: dict[str, Any] = {
138
+ "key": archive.key,
139
+ "start": archive.start.isoformat(),
140
+ "end": archive.end.isoformat(),
141
+ "size": archive.size,
142
+ }
143
+ if data_dir:
144
+ row["downloaded"] = _download.is_downloaded(
145
+ archive, data_dir / "raw"
146
+ )
147
+ row["processed_days"] = _processed_days(
148
+ archive, data_dir / "parquet"
149
+ )
150
+ rows.append(row)
151
+
152
+ if args.json:
153
+ sys.stdout.write(json.dumps(rows, indent=2) + "\n")
154
+ return ExitCode.EX_OK
155
+
156
+ total = decimal(sum(a.size for a in archives))
157
+ table = Table(
158
+ caption=f"{len(archives)} archives, {total}",
159
+ box=box.SIMPLE_HEAD,
160
+ pad_edge=False,
161
+ )
162
+ table.add_column("Key", no_wrap=True)
163
+ table.add_column("Start", no_wrap=True)
164
+ table.add_column("End", no_wrap=True)
165
+ table.add_column("Size", justify="right", no_wrap=True)
166
+ if data_dir:
167
+ table.add_column("Raw", justify="center")
168
+ table.add_column("Processed", justify="right", no_wrap=True)
169
+
170
+ for archive, row in zip(archives, rows, strict=True):
171
+ cells = [row["key"], row["start"], row["end"], decimal(row["size"])]
172
+ if data_dir:
173
+ days = (archive.end - archive.start).days + 1
174
+ cells += [
175
+ "[green]✓" if row["downloaded"] else "",
176
+ f"{row['processed_days']}/{days}",
177
+ ]
178
+ table.add_row(*cells)
179
+
180
+ ctx.console.print(table)
181
+ return ExitCode.EX_OK
182
+
183
+
184
+ def _processed_days(archive: "Archive", parquet_dir: Path) -> int:
185
+ """Count the days of an archive's period with a Parquet file.
186
+
187
+ Args:
188
+ archive: The bucket archive.
189
+ parquet_dir: The root Parquet directory.
190
+
191
+ Returns:
192
+ The number of processed days.
193
+ """
194
+ days = range(archive.start.toordinal(), archive.end.toordinal() + 1)
195
+ return sum(
196
+ _process.day_path(parquet_dir, date.fromordinal(day)).exists()
197
+ for day in days
198
+ )
199
+
200
+
201
+ def command_download(ctx: CLIContext) -> int:
202
+ """Download archives in range to `<data-dir>/raw`.
203
+
204
+ Args:
205
+ ctx: The CLI context.
206
+
207
+ Returns:
208
+ The integer process exit code. Uses `_core.ExitCode`.
209
+ """
210
+ args = ctx.args
211
+ if not _requires_selection(ctx):
212
+ return ExitCode.EX_USAGE
213
+ raw_dir = resolve_data_dir(args.data_dir) / "raw"
214
+
215
+ client = _s3.make_client(args.jobs)
216
+ archives = _s3.list_archives(client, args.start, args.end)
217
+ if not archives:
218
+ logger.warning("No archives in the selected range")
219
+ return ExitCode.EX_OK
220
+
221
+ pending = _download.pending_archives(archives, raw_dir)
222
+ present = len(archives) - len(pending)
223
+ total = sum(a.size for a in pending)
224
+ info(
225
+ GLOBAL_CONSOLE,
226
+ f"{plural(len(pending), 'archive')} to download · {decimal(total)} · "
227
+ f"{present} already present",
228
+ )
229
+ began = time.monotonic()
230
+ try:
231
+ with reporter("download", len(pending), total=total) as report:
232
+ failed = _download.download_archives(
233
+ client, pending, raw_dir, report, args.jobs
234
+ )
235
+ except KeyboardInterrupt:
236
+ logger.warning(
237
+ "Interrupted - unfinished downloads discarded, "
238
+ "re-run to download them again"
239
+ )
240
+ return ExitCode.EX_INTERRUPT
241
+
242
+ elapsed = time.monotonic() - began
243
+ size = sum(a.size for a in pending if a not in failed)
244
+ done = len(pending) - len(failed)
245
+ summarise(
246
+ "Downloaded",
247
+ "archive",
248
+ done,
249
+ len(pending),
250
+ f" ({decimal(size)}) in {duration(elapsed)}"
251
+ + (f", {present} already present" if present else ""),
252
+ raw_dir,
253
+ {
254
+ "downloaded": done,
255
+ "bytes": size,
256
+ "already_present": present,
257
+ "failed": len(failed),
258
+ "elapsed": f"{elapsed:.1f}s",
259
+ "dest": raw_dir,
260
+ },
261
+ )
262
+ return ExitCode.EX_FAILURE if failed else ExitCode.EX_OK
263
+
264
+
265
+ def _extract_ahead(
266
+ pool: ThreadPoolExecutor,
267
+ members: list["Member"],
268
+ tmp_dir: Path,
269
+ available: int,
270
+ ) -> Generator[tuple["Member", "Future[bytes | Path]"], None, None]:
271
+ """Yield members with their extraction, one member ahead of the caller.
272
+
273
+ NOTE: Inflating releases the GIL, so the next member is extracted on a
274
+ single `pool` thread while the caller converts the current one. Members
275
+ that fit in `available` memory are extracted to bytes, else to a file.
276
+
277
+ Args:
278
+ pool: A single-worker executor for extraction.
279
+ members: The members to extract, in conversion order.
280
+ tmp_dir: The directory for extracted CSV files.
281
+ available: The memory available to the run, in bytes.
282
+
283
+ Yields:
284
+ Each member & the future of its `extract_member` result.
285
+ """
286
+ pending: deque[tuple[Member, Future[bytes | Path]]] = deque()
287
+ for member in members:
288
+ in_memory = _process.fits_in_memory(member, available)
289
+ future = pool.submit(
290
+ _process.extract_member, member, tmp_dir, in_memory=in_memory
291
+ )
292
+ pending.append((member, future))
293
+ if len(pending) > 1:
294
+ yield pending.popleft()
295
+ yield from pending
296
+
297
+
298
+ def _result(future: "Future[bytes | Path]") -> bytes | Path:
299
+ """Wait for a future, polling so Ctrl-C is handled on Windows."""
300
+ while True:
301
+ try:
302
+ return future.result(timeout=_POLL_SECONDS)
303
+ except TimeoutError:
304
+ continue
305
+
306
+
307
+ def command_process(ctx: CLIContext) -> int:
308
+ """Convert downloaded archives in range to `<data-dir>/parquet`.
309
+
310
+ NOTE: Works offline - archives are discovered in `<data-dir>/raw`.
311
+
312
+ Args:
313
+ ctx: The CLI context.
314
+
315
+ Returns:
316
+ The integer process exit code. Uses `_core.ExitCode`.
317
+ """
318
+ args = ctx.args
319
+ if not _requires_selection(ctx):
320
+ return ExitCode.EX_USAGE
321
+ data_dir = resolve_data_dir(args.data_dir)
322
+ parquet_dir, tmp_dir = data_dir / "parquet", data_dir / "tmp"
323
+
324
+ zip_paths = [
325
+ path
326
+ for path in sorted((data_dir / "raw").glob("*/*.zip"))
327
+ if (archive := _s3.parse_key(path.name, path.stat().st_size))
328
+ and archive.overlaps(args.start, args.end)
329
+ ]
330
+ in_range = [
331
+ member
332
+ for path in zip_paths
333
+ for member in _process.list_members(path, args.start, args.end)
334
+ ]
335
+ members = [
336
+ member
337
+ for member in in_range
338
+ if args.force or not member.target(parquet_dir).exists()
339
+ ]
340
+ if not members:
341
+ logger.warning("Nothing to process - download first or use `--force`")
342
+ return ExitCode.EX_OK
343
+ skipped = len(in_range) - len(members)
344
+
345
+ csv_size = decimal(sum(member.size for member in members))
346
+ info(
347
+ GLOBAL_CONSOLE,
348
+ f"{plural(len(members), 'day')} to process · {csv_size} CSV · "
349
+ f"{skipped} already processed",
350
+ )
351
+ _process.clean_stale(parquet_dir, tmp_dir)
352
+ available = psutil.virtual_memory().available
353
+ logger.debug(
354
+ "Inflating with %s, %s available",
355
+ _process.INFLATE.__name__,
356
+ decimal(available),
357
+ )
358
+ failed = 0
359
+ began = time.monotonic()
360
+ pool = ThreadPoolExecutor(max_workers=1)
361
+ try:
362
+ with reporter("process", len(members)) as report:
363
+ for member, extracted in _extract_ahead(
364
+ pool, members, tmp_dir, available
365
+ ):
366
+ report.item_started(member.name)
367
+ started = time.monotonic()
368
+ try:
369
+ target = _process.convert(
370
+ member, _result(extracted), parquet_dir
371
+ )
372
+ except (
373
+ exceptions.Error,
374
+ pl.exceptions.PolarsError,
375
+ zipfile.BadZipFile,
376
+ OSError,
377
+ ) as e:
378
+ logger.debug(
379
+ "Failed to process `%s`", member.name, exc_info=True
380
+ )
381
+ report.item_failed(member.name, e)
382
+ failed += 1
383
+ else:
384
+ logger.debug("Wrote `%s`", target)
385
+ size = decimal(target.stat().st_size)
386
+ took = duration(time.monotonic() - started)
387
+ path = target.relative_to(parquet_dir).as_posix()
388
+ report.item_done(member.name, f"{path} {size} in {took}")
389
+ finally:
390
+ # Free in-memory CSV bytes before the next extraction
391
+ del extracted
392
+ except KeyboardInterrupt:
393
+ logger.warning(
394
+ "Interrupted - partial output discarded, re-run to resume"
395
+ )
396
+ return ExitCode.EX_INTERRUPT
397
+ finally:
398
+ # Discard a prefetched CSV file left by an interrupted run
399
+ pool.shutdown(wait=True, cancel_futures=True)
400
+ _process.clean_stale(parquet_dir, tmp_dir)
401
+
402
+ elapsed = time.monotonic() - began
403
+ done = len(members) - failed
404
+ summarise(
405
+ "Processed",
406
+ "day",
407
+ done,
408
+ len(members),
409
+ f" in {duration(elapsed)}"
410
+ + (f", {skipped} already processed" if skipped else ""),
411
+ parquet_dir,
412
+ {
413
+ "processed": done,
414
+ "already_processed": skipped,
415
+ "failed": failed,
416
+ "elapsed": f"{elapsed:.1f}s",
417
+ "dest": parquet_dir,
418
+ },
419
+ )
420
+ return ExitCode.EX_FAILURE if failed else ExitCode.EX_OK
421
+
422
+
423
+ def add_subparsers(
424
+ subparsers: "argparse._SubParsersAction[Any]",
425
+ parent: argparse.ArgumentParser | None = None,
426
+ ) -> None:
427
+ """Add the `list`, `download` & `process` subcommands.
428
+
429
+ Args:
430
+ subparsers: The main CLI parser's subparsers action.
431
+ parent: A parent parser to include for shared arguments.
432
+ """
433
+ common = argparse.ArgumentParser(add_help=False)
434
+ common.add_argument(
435
+ "--data-dir",
436
+ type=Path,
437
+ metavar="PATH",
438
+ help="Data directory root (default: $DKAIS_DATA_DIR)",
439
+ )
440
+ common.add_argument(
441
+ "--start",
442
+ type=_parse_start,
443
+ metavar="DATE",
444
+ help="Range start, inclusive - YYYY, YYYY-MM or YYYY-MM-DD",
445
+ )
446
+ common.add_argument(
447
+ "--end",
448
+ type=_parse_end,
449
+ metavar="DATE",
450
+ help="Range end, inclusive (default: latest)",
451
+ )
452
+ parents = [parent, common] if parent else [common]
453
+
454
+ commands = {
455
+ "list": ("List bucket archives & local state", command_list),
456
+ "download": ("Download archives to <data-dir>/raw", command_download),
457
+ "process": ("Convert archives to <data-dir>/parquet", command_process),
458
+ }
459
+ for name, (text, func) in commands.items():
460
+ parser = subparsers.add_parser(
461
+ name,
462
+ help=text,
463
+ description=text,
464
+ usage="USAGE:\n %(prog)s [options]",
465
+ add_help=False,
466
+ parents=parents,
467
+ formatter_class=CLIGFormatter,
468
+ )
469
+ parser._optionals.title = "OPTIONS"
470
+ parser.set_defaults(func=func, parser=parser)
471
+
472
+ if name == "list":
473
+ parser.add_argument(
474
+ "--json", action="store_true", help="Output as a JSON array"
475
+ )
476
+ continue
477
+
478
+ parser.add_argument(
479
+ "-a", "--all", action="store_true", help="Select every archive"
480
+ )
481
+ if name == "download":
482
+ parser.add_argument(
483
+ "-j",
484
+ "--jobs",
485
+ type=_parse_jobs,
486
+ default=4,
487
+ metavar="N",
488
+ help="Concurrent file downloads (default: 4)",
489
+ )
490
+ else:
491
+ parser.add_argument(
492
+ "-f",
493
+ "--force",
494
+ action="store_true",
495
+ help="Re-process days that already have Parquet output",
496
+ )