xpectral-utils 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.
- xpectral_utils/__init__.py +20 -0
- xpectral_utils/cache.py +107 -0
- xpectral_utils/dates.py +61 -0
- xpectral_utils/logger.py +84 -0
- xpectral_utils/py.typed +0 -0
- xpectral_utils/rate_limiter.py +63 -0
- xpectral_utils/s3.py +136 -0
- xpectral_utils-0.1.0.dist-info/METADATA +55 -0
- xpectral_utils-0.1.0.dist-info/RECORD +11 -0
- xpectral_utils-0.1.0.dist-info/WHEEL +4 -0
- xpectral_utils-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Shared utility functions for xpectral projects."""
|
|
2
|
+
|
|
3
|
+
# ----------------------------------------------------------------------------
|
|
4
|
+
# Imports
|
|
5
|
+
# ----------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
# Local imports
|
|
8
|
+
from .logger import setup_logging
|
|
9
|
+
|
|
10
|
+
# ----------------------------------------------------------------------------
|
|
11
|
+
# Globals and constants
|
|
12
|
+
# ----------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
# Importing ``logger`` (above) registers the package's NullHandler on import,
|
|
15
|
+
# keeping the library silent until an app opts in. Because importing any
|
|
16
|
+
# ``xpectral_utils.*`` submodule runs this ``__init__`` first, that registration
|
|
17
|
+
# happens automatically for every module that logs -- no per-module wiring needed.
|
|
18
|
+
# ``setup_logging`` is re-exported for convenience:
|
|
19
|
+
# ``from xpectral_utils import setup_logging``.
|
|
20
|
+
__all__ = ["setup_logging"]
|
xpectral_utils/cache.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""On-disk function memoization built on diskcache (install ``xpectral-utils[cache]``)."""
|
|
2
|
+
|
|
3
|
+
# -----------------------------------------------------------------------------
|
|
4
|
+
# Imports
|
|
5
|
+
# -----------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
# Standard library imports
|
|
8
|
+
import os
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
# Third-party imports
|
|
14
|
+
from diskcache import Cache
|
|
15
|
+
|
|
16
|
+
# -----------------------------------------------------------------------------
|
|
17
|
+
# Globals and constants
|
|
18
|
+
# -----------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
__all__ = ["EvictionPolicy", "disk_cache"]
|
|
21
|
+
|
|
22
|
+
# The eviction policies diskcache accepts once ``size_limit`` is reached.
|
|
23
|
+
EvictionPolicy = Literal[
|
|
24
|
+
"none",
|
|
25
|
+
"least-recently-stored",
|
|
26
|
+
"least-recently-used",
|
|
27
|
+
"least-frequently-used",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# Root under which every function gets its own cache subdirectory. Overridable
|
|
31
|
+
# per call, or globally via the XPECTRAL_CACHE_DIR environment variable.
|
|
32
|
+
_DEFAULT_CACHE_DIR = Path.home() / ".cache" / "xpectral"
|
|
33
|
+
|
|
34
|
+
# Default per-function size cap before least-recently-used eviction kicks in.
|
|
35
|
+
_SIZE_LIMIT = 2 * 2**30 # 2 GiB
|
|
36
|
+
|
|
37
|
+
# -----------------------------------------------------------------------------
|
|
38
|
+
# General API
|
|
39
|
+
# -----------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def disk_cache(
|
|
43
|
+
name: str | None = None,
|
|
44
|
+
*,
|
|
45
|
+
expire: float | None = None,
|
|
46
|
+
cache_dir: str | os.PathLike | None = None,
|
|
47
|
+
size_limit: int = _SIZE_LIMIT,
|
|
48
|
+
eviction_policy: EvictionPolicy = "least-recently-used",
|
|
49
|
+
typed: bool = False,
|
|
50
|
+
) -> Callable:
|
|
51
|
+
"""Cache a function's return values on disk, keyed by its arguments.
|
|
52
|
+
|
|
53
|
+
Each decorated function gets its own :class:`diskcache.Cache` backed by its
|
|
54
|
+
own subdirectory -- there is no shared global cache instance. Values are
|
|
55
|
+
pickled, so the function must return picklable objects and take
|
|
56
|
+
picklable/hashable arguments.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
name: Subdirectory name for this function's cache. Defaults to the
|
|
60
|
+
function's qualified name.
|
|
61
|
+
expire: Time-to-live in seconds. ``None`` caches indefinitely.
|
|
62
|
+
cache_dir: Root directory for the cache. Defaults to the
|
|
63
|
+
``XPECTRAL_CACHE_DIR`` environment variable, else
|
|
64
|
+
``~/.cache/xpectral``.
|
|
65
|
+
size_limit: Byte cap for this function's cache before entries are evicted
|
|
66
|
+
per ``eviction_policy``.
|
|
67
|
+
eviction_policy: How entries are chosen for eviction once ``size_limit``
|
|
68
|
+
is reached. One of ``"least-recently-stored"``,
|
|
69
|
+
``"least-recently-used"``, ``"least-frequently-used"``, or ``"none"``
|
|
70
|
+
(no automatic eviction).
|
|
71
|
+
typed: When ``True``, arguments of different types are cached separately
|
|
72
|
+
(e.g. ``1`` and ``1.0``).
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
A decorator that wraps the target function with on-disk memoization.
|
|
76
|
+
The wrapped function exposes its backing store as ``func.cache`` (a
|
|
77
|
+
:class:`diskcache.Cache`), so callers can ``func.cache.clear()`` or
|
|
78
|
+
check ``len(func.cache)``.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def decorator(func: Callable) -> Callable:
|
|
82
|
+
directory = _resolve_dir(cache_dir) / (name or func.__qualname__)
|
|
83
|
+
cache = Cache(
|
|
84
|
+
directory=str(directory),
|
|
85
|
+
size_limit=size_limit,
|
|
86
|
+
eviction_policy=eviction_policy,
|
|
87
|
+
)
|
|
88
|
+
memoized = cache.memoize(expire=expire, typed=typed)(func)
|
|
89
|
+
# Expose the backing Cache so callers can inspect or clear it, e.g.
|
|
90
|
+
# ``func.cache.clear()`` or ``len(func.cache)``.
|
|
91
|
+
memoized.cache = cache
|
|
92
|
+
return memoized
|
|
93
|
+
|
|
94
|
+
return decorator
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# -----------------------------------------------------------------------------
|
|
98
|
+
# Private API
|
|
99
|
+
# -----------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolve_dir(cache_dir: str | os.PathLike | None) -> Path:
|
|
103
|
+
"""Resolve the cache root: explicit arg, else env var, else default."""
|
|
104
|
+
if cache_dir is not None:
|
|
105
|
+
return Path(cache_dir)
|
|
106
|
+
env = os.getenv("XPECTRAL_CACHE_DIR")
|
|
107
|
+
return Path(env) if env else _DEFAULT_CACHE_DIR
|
xpectral_utils/dates.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Date and datetime normalization helpers."""
|
|
2
|
+
|
|
3
|
+
# ----------------------------------------------------------------------------
|
|
4
|
+
# Imports
|
|
5
|
+
# ----------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
# Standard library imports
|
|
8
|
+
from datetime import UTC
|
|
9
|
+
from datetime import date
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
# ----------------------------------------------------------------------------
|
|
13
|
+
# Globals and constants
|
|
14
|
+
# ----------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
__all__ = ["to_date", "to_datetime"]
|
|
17
|
+
|
|
18
|
+
# ----------------------------------------------------------------------------
|
|
19
|
+
# General API
|
|
20
|
+
# ----------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def to_date(value: str | date) -> date:
|
|
24
|
+
"""Normalize a calendar day to a ``date``.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
value: ISO 8601 date string (``2024-03-01``) or a ``date``.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The calendar day as a ``date``.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
TypeError: If ``value`` is a ``datetime``.
|
|
34
|
+
ValueError: If ``value`` is a malformed string.
|
|
35
|
+
"""
|
|
36
|
+
# datetime is a subclass of date, so reject it before anything else.
|
|
37
|
+
if isinstance(value, datetime):
|
|
38
|
+
raise TypeError("expected a date, got a datetime")
|
|
39
|
+
if isinstance(value, str):
|
|
40
|
+
return date.fromisoformat(value)
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def to_datetime(value: str | datetime) -> datetime:
|
|
45
|
+
"""Normalize an instant to a timezone-aware ``datetime`` in UTC.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
value: ISO 8601 string with an offset (``2024-03-01T14:30:00Z``) or an
|
|
49
|
+
aware ``datetime``.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The instant as an aware ``datetime`` in UTC.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: If ``value`` is naive or a malformed string.
|
|
56
|
+
"""
|
|
57
|
+
if isinstance(value, str):
|
|
58
|
+
value = datetime.fromisoformat(value)
|
|
59
|
+
if value.tzinfo is None:
|
|
60
|
+
raise ValueError("datetime must be timezone-aware")
|
|
61
|
+
return value.astimezone(UTC)
|
xpectral_utils/logger.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Opt-in console logging for the xpectral_utils package logger."""
|
|
2
|
+
|
|
3
|
+
# -----------------------------------------------------------------------------
|
|
4
|
+
# Imports
|
|
5
|
+
# -----------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
# Standard library imports
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
# -----------------------------------------------------------------------------
|
|
13
|
+
# Globals and constants
|
|
14
|
+
# -----------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
__all__ = ["LogLevel", "setup_logging"]
|
|
17
|
+
|
|
18
|
+
# Package-root logger. Modules log via ``logging.getLogger(__name__)``, which
|
|
19
|
+
# descends from this as ``xpectral_utils.*``.
|
|
20
|
+
_ROOT = "xpectral_utils"
|
|
21
|
+
|
|
22
|
+
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
23
|
+
|
|
24
|
+
# ANSI colors per level for the optional colored console handler.
|
|
25
|
+
_COLORS = {
|
|
26
|
+
"DEBUG": "\033[94m",
|
|
27
|
+
"INFO": "\033[92m",
|
|
28
|
+
"WARNING": "\033[93m",
|
|
29
|
+
"ERROR": "\033[91m",
|
|
30
|
+
"CRITICAL": "\033[95m",
|
|
31
|
+
}
|
|
32
|
+
_RESET = "\033[0m"
|
|
33
|
+
|
|
34
|
+
# Silent by default: a no-op handler on the package root stops Python's
|
|
35
|
+
# last-resort handler from printing until the app opts in (via setup_logging or
|
|
36
|
+
# its own logging config).
|
|
37
|
+
logging.getLogger(_ROOT).addHandler(logging.NullHandler())
|
|
38
|
+
|
|
39
|
+
# -----------------------------------------------------------------------------
|
|
40
|
+
# General API
|
|
41
|
+
# -----------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def setup_logging(
|
|
45
|
+
level: LogLevel = "INFO", *, color: bool = True, force_color: bool = False
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Turn on console logging for xpectral_utils (opt-in, to stderr).
|
|
48
|
+
|
|
49
|
+
Apps that manage their own logging can ignore this and configure the
|
|
50
|
+
standard ``logging`` module directly. Color is applied only on a TTY, so
|
|
51
|
+
ANSI escapes don't leak into files or pipes; pass ``force_color=True`` to
|
|
52
|
+
color output regardless (e.g. in a notebook, which renders ANSI but is not
|
|
53
|
+
a TTY).
|
|
54
|
+
"""
|
|
55
|
+
logger = logging.getLogger(_ROOT)
|
|
56
|
+
# Replace a handler a previous call added so repeats don't duplicate output.
|
|
57
|
+
logger.handlers = [h for h in logger.handlers if isinstance(h, logging.NullHandler)]
|
|
58
|
+
logger.setLevel(level)
|
|
59
|
+
logger.propagate = False
|
|
60
|
+
|
|
61
|
+
handler = logging.StreamHandler()
|
|
62
|
+
handler.setFormatter(
|
|
63
|
+
_ColorFormatter(color and (force_color or sys.stderr.isatty()))
|
|
64
|
+
)
|
|
65
|
+
logger.addHandler(handler)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# -----------------------------------------------------------------------------
|
|
69
|
+
# Private API
|
|
70
|
+
# -----------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _ColorFormatter(logging.Formatter):
|
|
74
|
+
"""Render ``[LEVEL] name: message``, coloring the line when enabled."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, color: bool):
|
|
77
|
+
super().__init__("[%(levelname)s] %(name)s: %(message)s")
|
|
78
|
+
self._color = color
|
|
79
|
+
|
|
80
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
81
|
+
text = super().format(record)
|
|
82
|
+
if not self._color:
|
|
83
|
+
return text
|
|
84
|
+
return f"{_COLORS.get(record.levelname, '')}{text}{_RESET}"
|
xpectral_utils/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Sliding-window rate limiter for throttling API calls."""
|
|
2
|
+
|
|
3
|
+
# ----------------------------------------------------------------------------
|
|
4
|
+
# Imports
|
|
5
|
+
# ----------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
# Standard library imports
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from collections import deque
|
|
11
|
+
|
|
12
|
+
# ----------------------------------------------------------------------------
|
|
13
|
+
# Globals and constants
|
|
14
|
+
# ----------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
__all__ = ["RateLimiter"]
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# ----------------------------------------------------------------------------
|
|
21
|
+
# General API
|
|
22
|
+
# ----------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RateLimiter:
|
|
26
|
+
"""Allow at most ``calls`` acquisitions per ``per_seconds`` window.
|
|
27
|
+
|
|
28
|
+
Call :meth:`acquire` before each request. It returns immediately while the
|
|
29
|
+
window has room, and otherwise blocks until the oldest call leaves the
|
|
30
|
+
window.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
calls: Maximum acquisitions allowed in one window.
|
|
34
|
+
per_seconds: Window length in seconds.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ValueError: If ``calls`` or ``per_seconds`` is not positive.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, calls: int, per_seconds: float):
|
|
41
|
+
if calls <= 0 or per_seconds <= 0:
|
|
42
|
+
raise ValueError("calls and per_seconds must be positive")
|
|
43
|
+
self.calls = calls
|
|
44
|
+
self.per_seconds = per_seconds
|
|
45
|
+
self._hits: deque[float] = deque()
|
|
46
|
+
|
|
47
|
+
def acquire(self) -> None:
|
|
48
|
+
"""Block until a call is allowed, then record it."""
|
|
49
|
+
now = time.monotonic()
|
|
50
|
+
window_start = now - self.per_seconds
|
|
51
|
+
|
|
52
|
+
while self._hits and self._hits[0] <= window_start:
|
|
53
|
+
self._hits.popleft()
|
|
54
|
+
|
|
55
|
+
if len(self._hits) >= self.calls:
|
|
56
|
+
sleep_for = self.per_seconds - (now - self._hits[0])
|
|
57
|
+
if sleep_for > 0:
|
|
58
|
+
logger.info("sleeping for %.0f seconds", sleep_for)
|
|
59
|
+
time.sleep(sleep_for)
|
|
60
|
+
self.acquire()
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
self._hits.append(time.monotonic())
|
xpectral_utils/s3.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Read-only S3 downloader built on boto3 (install ``xpectral-utils[s3]``)."""
|
|
2
|
+
|
|
3
|
+
# -----------------------------------------------------------------------------
|
|
4
|
+
# Imports
|
|
5
|
+
# -----------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
# Standard library imports
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# Third-party imports
|
|
13
|
+
import boto3
|
|
14
|
+
from botocore.config import Config
|
|
15
|
+
from botocore.exceptions import BotoCoreError
|
|
16
|
+
from botocore.exceptions import ClientError
|
|
17
|
+
|
|
18
|
+
# -----------------------------------------------------------------------------
|
|
19
|
+
# Globals and constants
|
|
20
|
+
# -----------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
__all__ = ["S3Downloader"]
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# -----------------------------------------------------------------------------
|
|
27
|
+
# General API
|
|
28
|
+
# -----------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class S3Downloader:
|
|
32
|
+
"""Download objects from an S3-compatible bucket to local disk.
|
|
33
|
+
|
|
34
|
+
A thin read-only wrapper over a boto3 S3 client: it lists object keys under
|
|
35
|
+
a prefix and downloads them to a local root, preserving each key's path,
|
|
36
|
+
skipping files already on disk, and tolerating missing objects. It knows
|
|
37
|
+
nothing about the data it moves -- object-key layout, parsing, caching,
|
|
38
|
+
and whether to attempt the network at all belong to the caller.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
bucket: Name of the S3 bucket to read from.
|
|
42
|
+
dest_dir: Default local directory downloaded objects are written under,
|
|
43
|
+
preserving each key's path. Overridable per :meth:`download` call.
|
|
44
|
+
endpoint_url: S3-compatible endpoint. ``None`` uses the AWS default.
|
|
45
|
+
access_key: Access key id. ``None`` falls back to boto3's own
|
|
46
|
+
credential resolution.
|
|
47
|
+
secret_key: Secret access key. ``None`` falls back to boto3's own
|
|
48
|
+
credential resolution.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
bucket: str,
|
|
54
|
+
dest_dir: str | os.PathLike,
|
|
55
|
+
endpoint_url: str | None = None,
|
|
56
|
+
access_key: str | None = None,
|
|
57
|
+
secret_key: str | None = None,
|
|
58
|
+
):
|
|
59
|
+
# boto3 resolves credentials lazily -- building the session and client
|
|
60
|
+
# requires none; they are only needed on an actual API call.
|
|
61
|
+
session = boto3.Session(
|
|
62
|
+
aws_access_key_id=access_key,
|
|
63
|
+
aws_secret_access_key=secret_key,
|
|
64
|
+
)
|
|
65
|
+
self._client = session.client(
|
|
66
|
+
"s3",
|
|
67
|
+
endpoint_url=endpoint_url,
|
|
68
|
+
config=Config(signature_version="s3v4"),
|
|
69
|
+
)
|
|
70
|
+
self._bucket = bucket
|
|
71
|
+
self._dest_dir = Path(dest_dir)
|
|
72
|
+
|
|
73
|
+
def list_keys(self, prefix: str) -> list[str]:
|
|
74
|
+
"""List every object key under ``prefix`` in the bucket."""
|
|
75
|
+
keys = []
|
|
76
|
+
paginator = self._client.get_paginator("list_objects_v2")
|
|
77
|
+
for page in paginator.paginate(Bucket=self._bucket, Prefix=prefix):
|
|
78
|
+
keys.extend(obj["Key"] for obj in page.get("Contents", []))
|
|
79
|
+
return keys
|
|
80
|
+
|
|
81
|
+
def download(
|
|
82
|
+
self,
|
|
83
|
+
keys: list[str],
|
|
84
|
+
dest_dir: str | os.PathLike | None = None,
|
|
85
|
+
overwrite: bool = False,
|
|
86
|
+
) -> list[Path]:
|
|
87
|
+
"""Download ``keys`` to ``dest_dir`` and return the local paths present.
|
|
88
|
+
|
|
89
|
+
Files already on disk are reused rather than re-downloaded, and keys
|
|
90
|
+
with no object (a 404/403) are logged and omitted from the result.
|
|
91
|
+
Whether to attempt the network at all is the caller's decision, not
|
|
92
|
+
this method's -- e.g. skip calling this entirely when working offline.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
keys: Object keys to download.
|
|
96
|
+
dest_dir: Destination directory; the key path is preserved beneath
|
|
97
|
+
it. Defaults to the instance ``dest_dir``.
|
|
98
|
+
overwrite: When ``True``, re-download and replace files that
|
|
99
|
+
already exist locally instead of reusing them.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Local paths, one per key that exists locally after the call.
|
|
103
|
+
"""
|
|
104
|
+
root = Path(dest_dir) if dest_dir is not None else self._dest_dir
|
|
105
|
+
paths = []
|
|
106
|
+
for key in keys:
|
|
107
|
+
path = root / key
|
|
108
|
+
is_cached = path.exists()
|
|
109
|
+
if is_cached and not overwrite:
|
|
110
|
+
paths.append(path)
|
|
111
|
+
continue
|
|
112
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
try:
|
|
114
|
+
self._client.download_file(self._bucket, key, str(path))
|
|
115
|
+
except ClientError as error:
|
|
116
|
+
# S3-compatible buckets that withhold ``s3:ListBucket`` from
|
|
117
|
+
# download-only credentials hide object existence: a missing
|
|
118
|
+
# key comes back as 403/AccessDenied rather than 404/NoSuchKey.
|
|
119
|
+
# Treat both as "not there" so gaps (weekends, holidays,
|
|
120
|
+
# unpublished days) are skipped. Trade-off: genuinely bad
|
|
121
|
+
# credentials also land here, surfacing as an empty result
|
|
122
|
+
# instead of a raised auth error.
|
|
123
|
+
code = error.response.get("Error", {}).get("Code")
|
|
124
|
+
if code in {"404", "NoSuchKey", "403", "AccessDenied"}:
|
|
125
|
+
logger.warning("no object for key, skipping: %s", key)
|
|
126
|
+
continue
|
|
127
|
+
raise
|
|
128
|
+
except BotoCoreError:
|
|
129
|
+
# Unlike a missing object, this means the request never
|
|
130
|
+
# reached S3 at all (DNS/connection/timeout) -- surface it
|
|
131
|
+
# loudly rather than treating it as "no data available".
|
|
132
|
+
logger.error("connection failure downloading key: %s", key)
|
|
133
|
+
raise
|
|
134
|
+
logger.info("downloaded: %s", key)
|
|
135
|
+
paths.append(path)
|
|
136
|
+
return paths
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: xpectral-utils
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared utility functions for xpectral projects
|
|
5
|
+
Project-URL: Homepage, https://github.com/bayquant/xpectral-utils
|
|
6
|
+
Author: BayQuant
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2025 BayQuant
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Keywords: quant,utils
|
|
30
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
31
|
+
Classifier: Operating System :: OS Independent
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
34
|
+
Requires-Python: >=3.12
|
|
35
|
+
Provides-Extra: cache
|
|
36
|
+
Requires-Dist: diskcache>=5.6.3; extra == 'cache'
|
|
37
|
+
Provides-Extra: s3
|
|
38
|
+
Requires-Dist: boto3>=1.34; extra == 's3'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# xpectral-utils
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
Shared utility functions used across xpectral projects.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
uv pip install xpectral-utils
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
xpectral_utils/__init__.py,sha256=INMGXKV3o2eGLy6oE1kOk2BSDskYCV3DnD8Em6WmF2Y,901
|
|
2
|
+
xpectral_utils/cache.py,sha256=WMWfYwmVvA9b5kPBfQRmmmU-PiC-feuJ8KZRvWvSDGI,4134
|
|
3
|
+
xpectral_utils/dates.py,sha256=yepN2TdXQmBUWd3XXdikGQZhldL0xBhXqhs16dkJx2U,1923
|
|
4
|
+
xpectral_utils/logger.py,sha256=xNPSRk2VH9ua8nGRVJIwZpz7U-X07PgVs8qiZWfe7Ok,2999
|
|
5
|
+
xpectral_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
xpectral_utils/rate_limiter.py,sha256=6hfhyfX_KjK-ceGOH3MkkuohP8Ye8pKo5AElULRj-Xg,2081
|
|
7
|
+
xpectral_utils/s3.py,sha256=xptoXM7NiROV-1SQ-iMUx6eQA3hX1D824nvxBqra9zc,5691
|
|
8
|
+
xpectral_utils-0.1.0.dist-info/METADATA,sha256=aGUzMIp3E9f7BlH38AD17OHG0OGEPIfOZnrneLiDLIs,2174
|
|
9
|
+
xpectral_utils-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
xpectral_utils-0.1.0.dist-info/licenses/LICENSE,sha256=oKFaOASRExmxKyg4h4tzD8yffNm1zGNFwubGUOVwq_E,1065
|
|
11
|
+
xpectral_utils-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 BayQuant
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|