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 +12 -0
- dkais/__main__.py +140 -0
- dkais/_cli.py +496 -0
- dkais/_core.py +93 -0
- dkais/_download.py +151 -0
- dkais/_process.py +544 -0
- dkais/_report.py +374 -0
- dkais/_s3.py +151 -0
- dkais/_types.py +5 -0
- dkais/exceptions.py +17 -0
- dkais/logging/__init__.py +17 -0
- dkais/logging/_logging.py +54 -0
- dkais/logging/config.toml +52 -0
- dkais/py.typed +0 -0
- dkais-0.1.0.dist-info/METADATA +153 -0
- dkais-0.1.0.dist-info/RECORD +20 -0
- dkais-0.1.0.dist-info/WHEEL +4 -0
- dkais-0.1.0.dist-info/entry_points.txt +2 -0
- dkais-0.1.0.dist-info/licenses/COPYRIGHT +15 -0
- dkais-0.1.0.dist-info/licenses/LICENSE +201 -0
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
|
+
)
|