quantilica-core 0.3.1__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.
- quantilica/core/__init__.py +10 -0
- quantilica/core/cache.py +124 -0
- quantilica/core/cli.py +109 -0
- quantilica/core/config.py +97 -0
- quantilica/core/dates.py +61 -0
- quantilica/core/exceptions.py +29 -0
- quantilica/core/fetcher.py +157 -0
- quantilica/core/files.py +165 -0
- quantilica/core/ftp.py +287 -0
- quantilica/core/http.py +909 -0
- quantilica/core/logging.py +90 -0
- quantilica/core/manifests.py +335 -0
- quantilica/core/metadata.py +316 -0
- quantilica/core/platform.py +210 -0
- quantilica/core/progress.py +61 -0
- quantilica/core/py.typed +1 -0
- quantilica/core/retry.py +163 -0
- quantilica/core/storage.py +256 -0
- quantilica_core-0.3.1.dist-info/METADATA +103 -0
- quantilica_core-0.3.1.dist-info/RECORD +22 -0
- quantilica_core-0.3.1.dist-info/WHEEL +4 -0
- quantilica_core-0.3.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Common foundation utilities for Quantilica data projects."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("quantilica-core")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
quantilica/core/cache.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""File-based cache helpers for deterministic data downloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urlencode
|
|
12
|
+
|
|
13
|
+
from .dates import isoformat_utc, parse_iso_datetime, utc_now
|
|
14
|
+
from .exceptions import StorageError
|
|
15
|
+
from .files import ensure_dir, sha256_bytes, write_bytes_atomic, write_text_atomic
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class CacheEntry:
|
|
20
|
+
"""Metadata for one cached object."""
|
|
21
|
+
|
|
22
|
+
key: str
|
|
23
|
+
path: str
|
|
24
|
+
created_at: str
|
|
25
|
+
size_bytes: int
|
|
26
|
+
sha256: str
|
|
27
|
+
metadata: dict[str, Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FileCache:
|
|
31
|
+
"""Simple content cache stored on the local filesystem."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, root: str | os.PathLike[str]) -> None:
|
|
34
|
+
self.root = ensure_dir(root)
|
|
35
|
+
|
|
36
|
+
def key_for_url(
|
|
37
|
+
self,
|
|
38
|
+
url: str,
|
|
39
|
+
*,
|
|
40
|
+
params: Mapping[str, Any] | None = None,
|
|
41
|
+
headers: Mapping[str, str] | None = None,
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Return a deterministic key for a URL request."""
|
|
44
|
+
parts = [url]
|
|
45
|
+
if params:
|
|
46
|
+
parts.append(urlencode(sorted(params.items()), doseq=True))
|
|
47
|
+
if headers:
|
|
48
|
+
normalized_headers = {
|
|
49
|
+
key.lower(): value for key, value in sorted(headers.items())
|
|
50
|
+
}
|
|
51
|
+
parts.append(json.dumps(normalized_headers, sort_keys=True))
|
|
52
|
+
return sha256_bytes("\n".join(parts).encode("utf-8"))
|
|
53
|
+
|
|
54
|
+
def content_path(self, key: str) -> Path:
|
|
55
|
+
"""Return the content path for a cache key."""
|
|
56
|
+
return self.root / key[:2] / key[2:]
|
|
57
|
+
|
|
58
|
+
def metadata_path(self, key: str) -> Path:
|
|
59
|
+
"""Return the metadata path for a cache key."""
|
|
60
|
+
return self.root / key[:2] / f"{key[2:]}.json"
|
|
61
|
+
|
|
62
|
+
def exists(self, key: str) -> bool:
|
|
63
|
+
"""Return True when content and metadata exist for a key."""
|
|
64
|
+
return self.content_path(key).exists() and self.metadata_path(key).exists()
|
|
65
|
+
|
|
66
|
+
def read_bytes(self, key: str) -> bytes:
|
|
67
|
+
"""Read cached bytes."""
|
|
68
|
+
path = self.content_path(key)
|
|
69
|
+
try:
|
|
70
|
+
return path.read_bytes()
|
|
71
|
+
except OSError as exc:
|
|
72
|
+
raise StorageError(f"Could not read cache entry: {key}") from exc
|
|
73
|
+
|
|
74
|
+
def read_metadata(self, key: str) -> CacheEntry:
|
|
75
|
+
"""Read cache metadata."""
|
|
76
|
+
path = self.metadata_path(key)
|
|
77
|
+
try:
|
|
78
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
79
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
80
|
+
raise StorageError(f"Could not read cache metadata: {key}") from exc
|
|
81
|
+
return CacheEntry(**payload)
|
|
82
|
+
|
|
83
|
+
def write_bytes(
|
|
84
|
+
self,
|
|
85
|
+
key: str,
|
|
86
|
+
content: bytes,
|
|
87
|
+
*,
|
|
88
|
+
metadata: Mapping[str, Any] | None = None,
|
|
89
|
+
) -> CacheEntry:
|
|
90
|
+
"""Write content and metadata for a cache key."""
|
|
91
|
+
content_path = self.content_path(key)
|
|
92
|
+
metadata_path = self.metadata_path(key)
|
|
93
|
+
write_bytes_atomic(content_path, content)
|
|
94
|
+
|
|
95
|
+
entry = CacheEntry(
|
|
96
|
+
key=key,
|
|
97
|
+
path=str(content_path.relative_to(self.root)),
|
|
98
|
+
created_at=isoformat_utc(),
|
|
99
|
+
size_bytes=len(content),
|
|
100
|
+
sha256=sha256_bytes(content),
|
|
101
|
+
metadata=dict(metadata or {}),
|
|
102
|
+
)
|
|
103
|
+
write_text_atomic(
|
|
104
|
+
metadata_path,
|
|
105
|
+
json.dumps(asdict(entry), ensure_ascii=False, indent=2, sort_keys=True),
|
|
106
|
+
)
|
|
107
|
+
return entry
|
|
108
|
+
|
|
109
|
+
def is_fresh(self, key: str, *, ttl_seconds: int | None = None) -> bool:
|
|
110
|
+
"""Return True if a cache entry exists and is within TTL."""
|
|
111
|
+
if not self.exists(key):
|
|
112
|
+
return False
|
|
113
|
+
if ttl_seconds is None:
|
|
114
|
+
return True
|
|
115
|
+
entry = self.read_metadata(key)
|
|
116
|
+
created_at = parse_iso_datetime(entry.created_at)
|
|
117
|
+
age = (utc_now() - created_at).total_seconds()
|
|
118
|
+
return age <= ttl_seconds
|
|
119
|
+
|
|
120
|
+
def get_bytes(self, key: str, *, ttl_seconds: int | None = None) -> bytes | None:
|
|
121
|
+
"""Return cached bytes when present and fresh, otherwise None."""
|
|
122
|
+
if not self.is_fresh(key, ttl_seconds=ttl_seconds):
|
|
123
|
+
return None
|
|
124
|
+
return self.read_bytes(key)
|
quantilica/core/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Shared Rich-based helpers for quantilica fetcher CLI plugins.
|
|
2
|
+
|
|
3
|
+
These helpers are host-only: ``plugin.py`` modules run inside the
|
|
4
|
+
``quantilica-cli`` host, which pulls in ``rich`` via the ``cli`` extra
|
|
5
|
+
(``quantilica-core[cli]``). Standalone argparse CLIs should keep using
|
|
6
|
+
:func:`quantilica.core.logging.configure_cli_logging` instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.logging import RichHandler
|
|
16
|
+
from rich.progress import (
|
|
17
|
+
BarColumn,
|
|
18
|
+
DownloadColumn,
|
|
19
|
+
MofNCompleteColumn,
|
|
20
|
+
Progress,
|
|
21
|
+
SpinnerColumn,
|
|
22
|
+
TextColumn,
|
|
23
|
+
TimeElapsedColumn,
|
|
24
|
+
TimeRemainingColumn,
|
|
25
|
+
TransferSpeedColumn,
|
|
26
|
+
)
|
|
27
|
+
except ModuleNotFoundError as exc: # pragma: no cover
|
|
28
|
+
raise ModuleNotFoundError(
|
|
29
|
+
"quantilica.core.cli requires the 'cli' extra; install quantilica-core[cli]"
|
|
30
|
+
) from exc
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_console: Console | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_console() -> Console:
|
|
37
|
+
"""Return a process-wide shared Rich console."""
|
|
38
|
+
global _console
|
|
39
|
+
if _console is None:
|
|
40
|
+
_console = Console()
|
|
41
|
+
return _console
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def setup_rich_logging(
|
|
45
|
+
verbose: bool,
|
|
46
|
+
*,
|
|
47
|
+
console: Console | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Configure logging via ``RichHandler`` without breaking progress bars.
|
|
50
|
+
|
|
51
|
+
``verbose=False`` → WARNING only; ``verbose=True`` → DEBUG.
|
|
52
|
+
"""
|
|
53
|
+
level = logging.DEBUG if verbose else logging.WARNING
|
|
54
|
+
logging.basicConfig(
|
|
55
|
+
level=level,
|
|
56
|
+
format="%(message)s",
|
|
57
|
+
datefmt="[%X]",
|
|
58
|
+
handlers=[RichHandler(console=console or get_console(), show_path=False)],
|
|
59
|
+
force=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def make_batch_progress(console: Console | None = None) -> Progress:
|
|
64
|
+
"""Build a Progress for overall/batch tracking (file counts)."""
|
|
65
|
+
return Progress(
|
|
66
|
+
SpinnerColumn(),
|
|
67
|
+
TextColumn("[progress.description]{task.description}"),
|
|
68
|
+
BarColumn(),
|
|
69
|
+
MofNCompleteColumn(),
|
|
70
|
+
TimeElapsedColumn(),
|
|
71
|
+
TimeRemainingColumn(),
|
|
72
|
+
console=console or get_console(),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def make_download_progress(console: Console | None = None) -> Progress:
|
|
77
|
+
"""Build a Progress for individual file downloads (bytes/speed)."""
|
|
78
|
+
return Progress(
|
|
79
|
+
SpinnerColumn(),
|
|
80
|
+
TextColumn("[dim]{task.description}[/dim]"),
|
|
81
|
+
BarColumn(),
|
|
82
|
+
DownloadColumn(),
|
|
83
|
+
TransferSpeedColumn(),
|
|
84
|
+
TimeRemainingColumn(),
|
|
85
|
+
console=console or get_console(),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def expand_years_cli(
|
|
90
|
+
years: list[str] | None,
|
|
91
|
+
default_range: str | None = None,
|
|
92
|
+
console: Console | None = None,
|
|
93
|
+
) -> list[int]:
|
|
94
|
+
"""Expand CLI year/range arguments (e.g. ``["2020:2022", "2024"]``).
|
|
95
|
+
|
|
96
|
+
If ``years`` is empty and ``default_range`` is provided, it expands the
|
|
97
|
+
default range. Prints a warning to the console/stderr for any invalid specs.
|
|
98
|
+
"""
|
|
99
|
+
from quantilica.core.dates import expand_year_range
|
|
100
|
+
|
|
101
|
+
con = console or get_console()
|
|
102
|
+
specs = years if years else ([default_range] if default_range else [])
|
|
103
|
+
result: list[int] = []
|
|
104
|
+
for arg in specs:
|
|
105
|
+
try:
|
|
106
|
+
result.extend(expand_year_range(arg))
|
|
107
|
+
except ValueError:
|
|
108
|
+
con.print(f"[yellow]Aviso:[/yellow] ano/intervalo inválido '{arg}'")
|
|
109
|
+
return result
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Minimal configuration helpers for Quantilica projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .exceptions import ConfigError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class EnvSettings:
|
|
15
|
+
"""Read settings from an environment mapping using an optional prefix."""
|
|
16
|
+
|
|
17
|
+
prefix: str = ""
|
|
18
|
+
environ: Mapping[str, str] | None = None
|
|
19
|
+
|
|
20
|
+
def _source(self) -> Mapping[str, str]:
|
|
21
|
+
return os.environ if self.environ is None else self.environ
|
|
22
|
+
|
|
23
|
+
def _key(self, name: str) -> str:
|
|
24
|
+
return f"{self.prefix}{name}".upper()
|
|
25
|
+
|
|
26
|
+
def get(self, name: str, default: str | None = None) -> str | None:
|
|
27
|
+
"""Return a setting value or a default."""
|
|
28
|
+
return self._source().get(self._key(name), default)
|
|
29
|
+
|
|
30
|
+
def require(self, name: str) -> str:
|
|
31
|
+
"""Return a setting value or raise ConfigError."""
|
|
32
|
+
value = self.get(name)
|
|
33
|
+
if value is None or value == "":
|
|
34
|
+
raise ConfigError(f"Missing required setting: {self._key(name)}")
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
def get_bool(self, name: str, default: bool = False) -> bool:
|
|
38
|
+
"""Return a boolean setting."""
|
|
39
|
+
value = self.get(name)
|
|
40
|
+
if value is None:
|
|
41
|
+
return default
|
|
42
|
+
normalized = value.strip().lower()
|
|
43
|
+
if normalized in {"1", "true", "yes", "y", "on"}:
|
|
44
|
+
return True
|
|
45
|
+
if normalized in {"0", "false", "no", "n", "off"}:
|
|
46
|
+
return False
|
|
47
|
+
raise ConfigError(f"Invalid boolean setting: {self._key(name)}={value!r}")
|
|
48
|
+
|
|
49
|
+
def get_int(self, name: str, default: int | None = None) -> int | None:
|
|
50
|
+
"""Return an integer setting."""
|
|
51
|
+
value = self.get(name)
|
|
52
|
+
if value is None:
|
|
53
|
+
return default
|
|
54
|
+
try:
|
|
55
|
+
return int(value)
|
|
56
|
+
except ValueError as exc:
|
|
57
|
+
message = f"Invalid integer setting: {self._key(name)}={value!r}"
|
|
58
|
+
raise ConfigError(message) from exc
|
|
59
|
+
|
|
60
|
+
def path(self, name: str, default: str | Path | None = None) -> Path | None:
|
|
61
|
+
"""Return a setting as an expanded Path."""
|
|
62
|
+
value = self.get(name)
|
|
63
|
+
if value is None:
|
|
64
|
+
if default is None:
|
|
65
|
+
return None
|
|
66
|
+
return Path(default).expanduser()
|
|
67
|
+
return Path(value).expanduser()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_dotenv(
|
|
71
|
+
path: str | os.PathLike[str] = ".env",
|
|
72
|
+
*,
|
|
73
|
+
override: bool = False,
|
|
74
|
+
) -> int:
|
|
75
|
+
"""Load a simple dotenv file without adding a runtime dependency.
|
|
76
|
+
|
|
77
|
+
This parser intentionally supports only ``KEY=VALUE`` lines, comments, and
|
|
78
|
+
optional single or double quotes. It returns the number of variables loaded.
|
|
79
|
+
"""
|
|
80
|
+
dotenv_path = Path(path).expanduser()
|
|
81
|
+
if not dotenv_path.exists():
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
loaded = 0
|
|
85
|
+
for raw_line in dotenv_path.read_text(encoding="utf-8").splitlines():
|
|
86
|
+
line = raw_line.strip()
|
|
87
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
88
|
+
continue
|
|
89
|
+
key, value = line.split("=", 1)
|
|
90
|
+
key = key.strip()
|
|
91
|
+
value = value.strip().strip("'\"")
|
|
92
|
+
if not key:
|
|
93
|
+
continue
|
|
94
|
+
if override or key not in os.environ:
|
|
95
|
+
os.environ[key] = value
|
|
96
|
+
loaded += 1
|
|
97
|
+
return loaded
|
quantilica/core/dates.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Datetime helpers with explicit UTC behavior."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def utc_now() -> datetime:
|
|
9
|
+
"""Return the current timezone-aware UTC datetime."""
|
|
10
|
+
return datetime.now(UTC)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_utc(value: datetime) -> datetime:
|
|
14
|
+
"""Convert a datetime to timezone-aware UTC.
|
|
15
|
+
|
|
16
|
+
Naive datetimes are treated as UTC to avoid silently applying a local
|
|
17
|
+
machine timezone during ingestion jobs.
|
|
18
|
+
"""
|
|
19
|
+
if value.tzinfo is None:
|
|
20
|
+
return value.replace(tzinfo=UTC)
|
|
21
|
+
return value.astimezone(UTC)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def isoformat_utc(value: datetime | None = None) -> str:
|
|
25
|
+
"""Return an ISO 8601 UTC timestamp using a trailing ``Z``."""
|
|
26
|
+
current = utc_now() if value is None else to_utc(value)
|
|
27
|
+
return current.isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_iso_datetime(value: str) -> datetime:
|
|
31
|
+
"""Parse an ISO 8601 datetime and normalize it to UTC."""
|
|
32
|
+
normalized = value.replace("Z", "+00:00")
|
|
33
|
+
return to_utc(datetime.fromisoformat(normalized))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def expand_year_range(*specs: str | int) -> list[int]:
|
|
37
|
+
"""Expand year specs into a list of ints, preserving order.
|
|
38
|
+
|
|
39
|
+
Each spec is either a single year (``"2020"``) or an inclusive range
|
|
40
|
+
(``"2020:2025"``). Ranges may be descending (``"2025:2020"``). Raises
|
|
41
|
+
``ValueError`` on malformed specs.
|
|
42
|
+
"""
|
|
43
|
+
years: list[int] = []
|
|
44
|
+
for spec in specs:
|
|
45
|
+
text = str(spec).strip()
|
|
46
|
+
if ":" in text:
|
|
47
|
+
start_str, end_str = text.split(":", 1)
|
|
48
|
+
start, end = int(start_str), int(end_str)
|
|
49
|
+
step = 1 if start <= end else -1
|
|
50
|
+
years.extend(range(start, end + step, step))
|
|
51
|
+
else:
|
|
52
|
+
years.append(int(text))
|
|
53
|
+
return years
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def year_month_partition(year: int | str, month: int | str | None = None) -> str:
|
|
57
|
+
"""Return a partition string: ``"YYYY"`` or ``"YYYYMM"``."""
|
|
58
|
+
y = int(year)
|
|
59
|
+
if month is None:
|
|
60
|
+
return f"{y:04d}"
|
|
61
|
+
return f"{y:04d}{int(month):02d}"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Exception hierarchy shared by Quantilica projects."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class QuantilicaError(Exception):
|
|
5
|
+
"""Base class for all domain-neutral Quantilica errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(QuantilicaError):
|
|
9
|
+
"""Raised when configuration is missing or invalid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FetchError(QuantilicaError):
|
|
13
|
+
"""Raised when remote data cannot be fetched."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ParseError(QuantilicaError):
|
|
17
|
+
"""Raised when input data cannot be parsed."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StorageError(QuantilicaError):
|
|
21
|
+
"""Raised when object or file storage operations fail."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MetadataError(QuantilicaError):
|
|
25
|
+
"""Raised when generic metadata is invalid or incomplete."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ValidationError(QuantilicaError):
|
|
29
|
+
"""Raised when a value fails validation."""
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Concurrent download orchestration for data fetcher packages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from tqdm import tqdm
|
|
12
|
+
|
|
13
|
+
from .http import AsyncHttpClient
|
|
14
|
+
from .logging import get_logger
|
|
15
|
+
from .storage import StampedDataRepository
|
|
16
|
+
|
|
17
|
+
_logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class RemoteResource:
|
|
22
|
+
"""Description of a downloadable remote resource.
|
|
23
|
+
|
|
24
|
+
``filename`` must be pre-computed by the caller (e.g. via ``slugify`` +
|
|
25
|
+
``stamp_filename``) so the orchestrator can determine the local destination
|
|
26
|
+
and skip-check slug without knowing the source-specific naming rules.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
url: str
|
|
31
|
+
filename: str
|
|
32
|
+
size: int = 0
|
|
33
|
+
format: str = ""
|
|
34
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _download_one(
|
|
38
|
+
resource: RemoteResource,
|
|
39
|
+
repo: StampedDataRepository,
|
|
40
|
+
dataset_id: str,
|
|
41
|
+
client: AsyncHttpClient,
|
|
42
|
+
semaphore: asyncio.Semaphore,
|
|
43
|
+
*,
|
|
44
|
+
source_id: str,
|
|
45
|
+
producer: str,
|
|
46
|
+
ext: str,
|
|
47
|
+
logger: logging.Logger,
|
|
48
|
+
show_progress: bool,
|
|
49
|
+
on_file_done: Callable[[dict | None], None] | None,
|
|
50
|
+
) -> dict | None:
|
|
51
|
+
dest = repo.dataset_path(dataset_id, resource.filename)
|
|
52
|
+
|
|
53
|
+
if resource.size > 0:
|
|
54
|
+
slug = resource.filename.partition("@")[0]
|
|
55
|
+
latest = repo.get_latest_stamped_file(dataset_id, slug, ext)
|
|
56
|
+
if latest is not None and latest.stat().st_size == resource.size:
|
|
57
|
+
logger.debug(
|
|
58
|
+
f"Skipping {resource.filename}: matching local copy {latest.name}"
|
|
59
|
+
)
|
|
60
|
+
if on_file_done:
|
|
61
|
+
on_file_done(None)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
pbar = None
|
|
65
|
+
_on_progress = None
|
|
66
|
+
if show_progress:
|
|
67
|
+
pbar = tqdm(
|
|
68
|
+
total=resource.size or None,
|
|
69
|
+
unit="B",
|
|
70
|
+
unit_scale=True,
|
|
71
|
+
desc=f"Downloading {resource.filename[:30]}...",
|
|
72
|
+
leave=False,
|
|
73
|
+
)
|
|
74
|
+
last_seen = 0
|
|
75
|
+
|
|
76
|
+
def _on_progress(downloaded: int, total: int) -> None:
|
|
77
|
+
nonlocal last_seen
|
|
78
|
+
if total and pbar.total != total:
|
|
79
|
+
pbar.total = total
|
|
80
|
+
pbar.update(downloaded - last_seen)
|
|
81
|
+
last_seen = downloaded
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
async with semaphore:
|
|
85
|
+
await client.download_with_manifest(
|
|
86
|
+
resource.url,
|
|
87
|
+
dest,
|
|
88
|
+
source_id=source_id,
|
|
89
|
+
dataset_id=dataset_id,
|
|
90
|
+
producer=producer,
|
|
91
|
+
params=None,
|
|
92
|
+
progress=_on_progress,
|
|
93
|
+
)
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
logger.error(f"Failed to download {resource.url}: {exc}")
|
|
96
|
+
if dest.exists():
|
|
97
|
+
try:
|
|
98
|
+
dest.unlink()
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
if on_file_done:
|
|
102
|
+
on_file_done(None)
|
|
103
|
+
return None
|
|
104
|
+
finally:
|
|
105
|
+
if pbar is not None:
|
|
106
|
+
pbar.close()
|
|
107
|
+
|
|
108
|
+
result = {
|
|
109
|
+
"url": resource.url,
|
|
110
|
+
"filename": resource.filename,
|
|
111
|
+
"destination": dest,
|
|
112
|
+
"file_size": dest.stat().st_size,
|
|
113
|
+
}
|
|
114
|
+
if on_file_done:
|
|
115
|
+
on_file_done(result)
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def download_resources(
|
|
120
|
+
resources: list[RemoteResource],
|
|
121
|
+
repo: StampedDataRepository,
|
|
122
|
+
dataset_id: str,
|
|
123
|
+
client: AsyncHttpClient,
|
|
124
|
+
*,
|
|
125
|
+
source_id: str,
|
|
126
|
+
producer: str = "",
|
|
127
|
+
ext: str = "csv",
|
|
128
|
+
max_concurrency: int = 3,
|
|
129
|
+
logger: logging.Logger | None = None,
|
|
130
|
+
show_progress: bool = False,
|
|
131
|
+
on_file_done: Callable[[dict | None], None] | None = None,
|
|
132
|
+
) -> list[dict]:
|
|
133
|
+
"""Download resources concurrently; skip when local file size matches remote.
|
|
134
|
+
|
|
135
|
+
Returns a list of dicts with keys ``url``, ``filename``, ``destination``,
|
|
136
|
+
and ``file_size`` for each successfully downloaded resource.
|
|
137
|
+
"""
|
|
138
|
+
_log = logger or _logger
|
|
139
|
+
semaphore = asyncio.Semaphore(max_concurrency)
|
|
140
|
+
tasks = [
|
|
141
|
+
_download_one(
|
|
142
|
+
resource,
|
|
143
|
+
repo,
|
|
144
|
+
dataset_id,
|
|
145
|
+
client,
|
|
146
|
+
semaphore,
|
|
147
|
+
source_id=source_id,
|
|
148
|
+
producer=producer,
|
|
149
|
+
ext=ext,
|
|
150
|
+
logger=_log,
|
|
151
|
+
show_progress=show_progress,
|
|
152
|
+
on_file_done=on_file_done,
|
|
153
|
+
)
|
|
154
|
+
for resource in resources
|
|
155
|
+
]
|
|
156
|
+
results = await asyncio.gather(*tasks)
|
|
157
|
+
return [r for r in results if r is not None]
|