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.
@@ -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__"]
@@ -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
@@ -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]