From baf824aa1957c1707476200548f8f5977eb7a515 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 20:00:20 +0000 Subject: [PATCH 1/9] Add explicit per-path S3 profile selection (issue #8) Support `s3://@bucket/key` to select an isolated, name-keyed profile directly in the URL, so pos3-powered CLIs can target custom S3-compatible endpoints with scoped credentials without touching global AWS state or threading a profile= argument through every tool. - URL userinfo profile is parsed in the _parse_s3_url chokepoint and takes precedence over the profile= argument. - Profiles auto-load from ~/.config/pos3/profiles.toml (overridable via POS3_PROFILES_FILE / XDG_CONFIG_HOME), with the secret credentials file kept separate from the non-secret endpoint/region config. - Credentialed profiles build their own isolated boto3.Session. - Unknown profiles are a hard error with no silent default-chain fallback. - Version bump to 0.3.0 so downstream tools can pin the new behavior. https://claude.ai/code/session_011QGQRbcxj9d47BRWkWAWM1 --- CHANGELOG.md | 16 +++++ README.md | 49 ++++++++++++++ pos3/__init__.py | 169 ++++++++++++++++++++++++++++++++++++++++++++--- pyproject.toml | 3 +- tests/test_s3.py | 137 ++++++++++++++++++++++++++++++++++++++ uv.lock | 4 +- 6 files changed, 365 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d38ca..88b105d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [0.3.0] - 2026-05-19 + +### Added +- Explicit per-path S3 profile selection via the URL userinfo slot: + `s3://@bucket/key` ([#8](https://github.com/Positronic-Robotics/pos3/issues/8)). + A profile in the URL takes precedence over the `profile=` argument and works + for every pos3-powered CLI without code or env-var changes. +- Name-keyed local profile registry auto-loaded from + `~/.config/pos3/profiles.toml` (override with `POS3_PROFILES_FILE`). + Non-secret config (`endpoint`/`region`/`local_name`/`public`) is kept + separate from a secret `credentials_file`. +- Profiles with explicit credentials build their own isolated `boto3.Session`, + never reading or mutating the user's ambient AWS configuration. +- Unknown profile names (in URL or argument) are a hard error with no silent + fallback to the default credential chain. + ## [0.2.2] - 2026-02-06 ### Fixed diff --git a/README.md b/README.md index 82b6356..d207e67 100644 --- a/README.md +++ b/README.md @@ -173,3 +173,52 @@ with pos3.mirror(default_profile='nebius-public'): ``` Each profile has a `local_name` used in the cache path to keep files from different endpoints separate. When registering profiles, `local_name` defaults to the profile name. The default AWS profile uses `_` as its local name. + +#### Explicit profile selection in the URL + +For CLI tools where the S3 path is the only thing the user controls, a profile +can be selected directly in the URL using the userinfo slot: + +```bash +some-pos3-cli --dataset.path=s3://acme@bucket/dataset/ +``` + +- The scheme stays `s3://`, so the URL still parses with standard tooling. +- pos3 extracts the profile name, resolves it, and strips it before talking to + boto3 — no changes are needed in consuming tools. +- A profile in the URL **takes precedence** over an explicit `profile=` + argument. +- An unknown profile is a **hard error** — there is no silent fallback to the + default credential chain. +- No userinfo means the behavior is unchanged (boto3 default chain or the + context default profile). + +#### Local profile registry + +pos3 auto-loads named profiles from `~/.config/pos3/profiles.toml` (override +the location with the `POS3_PROFILES_FILE` environment variable, or use +`XDG_CONFIG_HOME`). This lets a recipient run any pos3-powered CLI against a +custom S3-compatible endpoint with isolated credentials — no env vars, no code, +no collision with their own AWS setup: + +```toml +[profiles.acme] +endpoint = "https://s3.example-provider.com" +region = "us-east-1" +# Optional: secret kept in a separate file (AWS-style INI). +credentials_file = "~/.config/pos3/acme.creds" +``` + +```ini +# ~/.config/pos3/acme.creds +[acme] +aws_access_key_id = AKIA... +aws_secret_access_key = ... +# aws_session_token = ... # optional +``` + +The non-secret config (endpoint/region) is safe to share or commit; the +credentials file is the only secret and lives outside code. Each profile builds +its **own isolated `boto3.Session`**, so it never reads or mutates the user's +ambient AWS configuration in either direction. Programmatically registered +profiles take precedence over registry entries of the same name. diff --git a/pos3/__init__.py b/pos3/__init__.py index 4e43a22..2f60591 100644 --- a/pos3/__init__.py +++ b/pos3/__init__.py @@ -2,7 +2,9 @@ from __future__ import annotations +import configparser import logging +import os import shutil import threading import time @@ -22,6 +24,11 @@ from botocore.exceptions import ClientError from tqdm import tqdm +try: + import tomllib # Python 3.11+ +except ModuleNotFoundError: # pragma: no cover - exercised on Python < 3.11 + import tomli as tomllib + logger = logging.getLogger(__name__) @@ -34,12 +41,20 @@ class Profile: endpoint: S3 endpoint URL (e.g., 'https://storage.eu-north1.nebius.cloud'). public: If True, use anonymous access (no credentials required). region: Optional AWS region name. + access_key: Optional explicit AWS access key id. When set together with + secret_key, the profile builds its own isolated boto3.Session that + never reads or mutates the ambient AWS configuration. + secret_key: Optional explicit AWS secret access key. + session_token: Optional explicit AWS session token. """ local_name: str endpoint: str public: bool = False region: str | None = None + access_key: str | None = field(default=None, repr=False) + secret_key: str | None = field(default=None, repr=False) + session_token: str | None = field(default=None, repr=False) def __post_init__(self): if self.local_name == "_": @@ -70,6 +85,97 @@ def register_profile( _PROFILES[name] = config +_REGISTRY_LOADED = False +_REGISTRY_LOCK = threading.Lock() + + +def _default_profiles_path() -> Path: + """Resolve the location of the auto-loaded profile registry file. + + Honors POS3_PROFILES_FILE, then XDG_CONFIG_HOME, defaulting to + ~/.config/pos3/profiles.toml. + """ + override = os.environ.get("POS3_PROFILES_FILE") + if override: + return Path(override).expanduser() + xdg = os.environ.get("XDG_CONFIG_HOME") + base = Path(xdg).expanduser() if xdg else Path.home() / ".config" + return base / "pos3" / "profiles.toml" + + +def _load_credentials_file(creds_path: Path, profile_name: str) -> tuple[str | None, str | None, str | None]: + """Read AWS-style credentials for a profile from a separate secret file. + + The file uses INI sections (like ~/.aws/credentials). The section named + after the profile is preferred, then 'default', then the first section. + """ + parser = configparser.ConfigParser() + if not parser.read(creds_path): + raise ValueError(f"Profile credentials_file not found or unreadable: {creds_path}") + + if parser.has_section(profile_name): + section = profile_name + elif parser.has_section("default"): + section = "default" + elif parser.sections(): + section = parser.sections()[0] + else: + raise ValueError(f"No credentials section found in {creds_path}") + + sec = parser[section] + return ( + sec.get("aws_access_key_id"), + sec.get("aws_secret_access_key"), + sec.get("aws_session_token"), + ) + + +def _profile_from_config(name: str, cfg: dict[str, Any], source: Path) -> Profile: + """Build a Profile from one [profiles.] table of the registry file.""" + if not isinstance(cfg, dict): + raise ValueError(f"Profile '{name}' in {source} must be a table") + endpoint = cfg.get("endpoint") + if not endpoint: + raise ValueError(f"Profile '{name}' in {source} is missing required 'endpoint'") + + access_key = secret_key = session_token = None + creds_file = cfg.get("credentials_file") + if creds_file: + access_key, secret_key, session_token = _load_credentials_file(Path(creds_file).expanduser(), name) + + return Profile( + local_name=cfg.get("local_name", name), + endpoint=endpoint, + public=cfg.get("public", False), + region=cfg.get("region"), + access_key=access_key, + secret_key=secret_key, + session_token=session_token, + ) + + +def _load_profile_registry(path: Path | None = None, force: bool = False) -> None: + """Auto-load named profiles from the local registry file (once per process). + + Programmatically registered profiles take precedence: a registry entry never + overrides a name already present in _PROFILES. + """ + global _REGISTRY_LOADED + with _REGISTRY_LOCK: + if _REGISTRY_LOADED and not force: + return + _REGISTRY_LOADED = True + registry_path = path if path is not None else _default_profiles_path() + if not registry_path.exists(): + return + with open(registry_path, "rb") as fh: + data = tomllib.load(fh) + for name, cfg in data.get("profiles", {}).items(): + if name in _PROFILES: + continue + _PROFILES[name] = _profile_from_config(name, cfg, registry_path) + + def _resolve_profile(profile: str | Profile | None) -> Profile | None: """Resolve a profile name to a Profile object. @@ -85,10 +191,26 @@ def _resolve_profile(profile: str | Profile | None) -> Profile | None: if profile is None or isinstance(profile, Profile): return profile if profile not in _PROFILES: - raise ValueError(f"Unknown profile: '{profile}'. Register with pos3.register_profile() first.") + _load_profile_registry() + if profile not in _PROFILES: + raise ValueError( + f"Unknown profile: '{profile}'. Register with pos3.register_profile() " + f"or define it in {_default_profiles_path()}." + ) return _PROFILES[profile] +def _url_profile(s3_url: str) -> str | None: + """Extract an explicit profile name from the userinfo slot of an S3 URL. + + `s3://@bucket/key` selects ``. No userinfo -> None. + """ + parsed = urlparse(s3_url) + if parsed.scheme != "s3": + return None + return parsed.username or None + + def _create_s3_client(profile: Profile | None = None): """Create boto3 S3 client, optionally using a profile. @@ -100,12 +222,23 @@ def _create_s3_client(profile: Profile | None = None): kwargs: dict[str, Any] = {"endpoint_url": profile.endpoint} - if profile.region: - kwargs["region_name"] = profile.region - if profile.public: kwargs["config"] = Config(signature_version=UNSIGNED) + if profile.access_key and profile.secret_key: + # Isolated session: explicit credentials, never reads or mutates the + # user's ambient AWS configuration in either direction. + session = boto3.session.Session( + aws_access_key_id=profile.access_key, + aws_secret_access_key=profile.secret_key, + aws_session_token=profile.session_token, + region_name=profile.region, + ) + return session.client("s3", **kwargs) + + if profile.region: + kwargs["region_name"] = profile.region + return boto3.client("s3", **kwargs) @@ -121,7 +254,12 @@ def _parse_s3_url(s3_url: str) -> tuple[str, str]: parsed = urlparse(s3_url) if parsed.scheme != "s3": raise ValueError(f"Not an S3 URL: {s3_url}") - return parsed.netloc, parsed.path.lstrip("/") + netloc = parsed.netloc + if "@" in netloc: + # Drop the explicit profile selector from the userinfo slot; it is a + # routing hint, not part of the bucket identity. + netloc = netloc.rsplit("@", 1)[1] + return netloc, parsed.path.lstrip("/") def _normalize_s3_url(s3_url: str) -> str: @@ -327,8 +465,17 @@ def __init__(self, options: _Options): self._stop_event: threading.Event | None = None self._sync_thread: threading.Thread | None = None - def _effective_profile(self, profile: str | Profile | None) -> Profile | None: - """Resolve profile name and substitute default if None.""" + def _effective_profile(self, profile: str | Profile | None, remote: str | None = None) -> Profile | None: + """Resolve the effective profile for an operation. + + An explicit profile in the S3 URL userinfo slot + (`s3://@bucket/key`) takes precedence over the `profile=` + argument. Falls back to the context default when nothing is specified. + """ + if remote is not None and _is_s3_path(remote): + url_profile = _url_profile(remote) + if url_profile is not None: + profile = url_profile resolved = _resolve_profile(profile) return resolved if resolved is not None else self._default_profile @@ -383,7 +530,7 @@ def download( FileNotFoundError: If remote is a local path that does not exist. ValueError: If download registration conflicts with an existing download or upload or parameters differ. """ - effective_profile = self._effective_profile(profile) + effective_profile = self._effective_profile(profile, remote) if not _is_s3_path(remote): path = Path(remote).expanduser().resolve() @@ -459,7 +606,7 @@ def upload( Raises: ValueError: If upload registration conflicts with an existing download or upload or parameters differ. """ - effective_profile = self._effective_profile(profile) + effective_profile = self._effective_profile(profile, remote) if not _is_s3_path(remote): path = Path(remote).expanduser().resolve() @@ -516,14 +663,14 @@ def sync( return local_path normalized = _normalize_s3_url(remote) - effective_profile = self._effective_profile(profile) + effective_profile = self._effective_profile(profile, remote) # Unregister the download to allow upload registration for the same remote self._downloads.pop((normalized, effective_profile), None) return self.upload(remote, local_path, interval, delete_remote, sync_on_error, exclude, profile) def ls(self, prefix: str, recursive: bool = False, profile: str | Profile | None = None) -> list[str]: """Lists objects under the given prefix, working for both local directories and S3 prefixes.""" - effective_profile = self._effective_profile(profile) + effective_profile = self._effective_profile(profile, prefix) if _is_s3_path(prefix): normalized = _normalize_s3_url(prefix) diff --git a/pyproject.toml b/pyproject.toml index 30ab31f..e012efd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pos3" -version = "0.2.2" +version = "0.3.0" description = "S3 Simple Sync - Make using S3 as simple as using local files" readme = "README.md" requires-python = ">=3.9" @@ -21,6 +21,7 @@ classifiers = [ dependencies = [ "boto3>=1.26.0", "tqdm>=4.65.0", + "tomli>=1.1.0; python_version < '3.11'", ] [project.urls] diff --git a/tests/test_s3.py b/tests/test_s3.py index 2c2181b..8e4aaa8 100644 --- a/tests/test_s3.py +++ b/tests/test_s3.py @@ -1,3 +1,4 @@ +import os import tempfile import time from concurrent.futures import ThreadPoolExecutor @@ -10,6 +11,7 @@ import pos3 as s3 BOTO3_PATCH_TARGET = "pos3.boto3.client" +SESSION_PATCH_TARGET = "pos3.boto3.session.Session" def _make_404_error(*_args, **_kwargs): @@ -1263,3 +1265,138 @@ def do_download(): found_late_call = True break assert found_late_call, "Expected profile to be resolved at call time" + + +class TestUrlProfileParsing: + def test_url_profile_extracted(self): + assert s3._url_profile("s3://acme@bucket/key") == "acme" + + def test_url_profile_absent(self): + assert s3._url_profile("s3://bucket/key") is None + + def test_url_profile_non_s3(self): + assert s3._url_profile("/local/path") is None + + def test_parse_strips_userinfo(self): + assert s3._parse_s3_url("s3://acme@bucket/path/to/data") == ("bucket", "path/to/data") + + def test_normalize_strips_userinfo(self): + assert s3._normalize_s3_url("s3://acme@bucket/path/") == "s3://bucket/path" + + +class TestProfileRegistry: + def setup_method(self): + s3._PROFILES.clear() + s3._REGISTRY_LOADED = False + self._saved_env = os.environ.get("POS3_PROFILES_FILE") + + def teardown_method(self): + s3._PROFILES.clear() + s3._REGISTRY_LOADED = False + if self._saved_env is None: + os.environ.pop("POS3_PROFILES_FILE", None) + else: + os.environ["POS3_PROFILES_FILE"] = self._saved_env + + def _write_registry(self, tmpdir, body): + path = Path(tmpdir) / "profiles.toml" + path.write_text(body) + os.environ["POS3_PROFILES_FILE"] = str(path) + return path + + def test_registry_loaded_lazily(self): + with tempfile.TemporaryDirectory() as tmpdir: + self._write_registry( + tmpdir, + '[profiles.acme]\nendpoint = "https://s3.acme.example.com"\nregion = "us-east-1"\n', + ) + profile = s3._resolve_profile("acme") + + assert profile.endpoint == "https://s3.acme.example.com" + assert profile.region == "us-east-1" + assert profile.local_name == "acme" + + def test_unknown_profile_hard_error(self): + with tempfile.TemporaryDirectory() as tmpdir: + self._write_registry(tmpdir, '[profiles.known]\nendpoint = "https://k.example.com"\n') + with pytest.raises(ValueError, match="Unknown profile"): + s3._resolve_profile("missing") + + def test_missing_endpoint_raises(self): + with tempfile.TemporaryDirectory() as tmpdir: + self._write_registry(tmpdir, '[profiles.bad]\nregion = "us-east-1"\n') + with pytest.raises(ValueError, match="missing required 'endpoint'"): + s3._resolve_profile("bad") + + def test_programmatic_registration_wins(self): + with tempfile.TemporaryDirectory() as tmpdir: + self._write_registry(tmpdir, '[profiles.dup]\nendpoint = "https://from-file.example.com"\n') + s3.register_profile("dup", endpoint="https://from-code.example.com") + profile = s3._resolve_profile("dup") + + assert profile.endpoint == "https://from-code.example.com" + + def test_credentials_file_isolated_session(self): + with tempfile.TemporaryDirectory() as tmpdir: + creds = Path(tmpdir) / "acme.creds" + creds.write_text("[acme]\naws_access_key_id = AKIAEXAMPLE\naws_secret_access_key = secret123\n") + self._write_registry( + tmpdir, + f'[profiles.acme]\nendpoint = "https://s3.acme.example.com"\n' + f'region = "eu-west-1"\ncredentials_file = "{creds}"\n', + ) + profile = s3._resolve_profile("acme") + assert profile.access_key == "AKIAEXAMPLE" + assert profile.secret_key == "secret123" + + with patch(SESSION_PATCH_TARGET) as mock_session: + s3._create_s3_client(profile) + + mock_session.assert_called_once() + session_kwargs = mock_session.call_args[1] + assert session_kwargs["aws_access_key_id"] == "AKIAEXAMPLE" + assert session_kwargs["aws_secret_access_key"] == "secret123" + assert session_kwargs["region_name"] == "eu-west-1" + client_kwargs = mock_session.return_value.client.call_args[1] + assert client_kwargs["endpoint_url"] == "https://s3.acme.example.com" + + def test_secrets_not_in_repr(self): + profile = s3.Profile( + local_name="acme", + endpoint="https://s3.acme.example.com", + access_key="AKIAEXAMPLE", + secret_key="topsecret", + ) + assert "topsecret" not in repr(profile) + assert "AKIAEXAMPLE" not in repr(profile) + + @patch(BOTO3_PATCH_TARGET) + def test_url_profile_takes_precedence_over_argument(self, mock_boto_client): + paginate = [{"Contents": [{"Key": "data/file.txt", "Size": 5}]}] + _setup_s3_mock(mock_boto_client, paginate) + + with tempfile.TemporaryDirectory() as tmpdir: + self._write_registry( + tmpdir, + '[profiles.url_prof]\nendpoint = "https://url.example.com"\n' + '[profiles.arg_prof]\nendpoint = "https://arg.example.com"\n', + ) + with s3.mirror(cache_root=tmpdir, show_progress=False): + path = s3.download("s3://url_prof@bucket/data", profile="arg_prof") + + # Cache path keyed by the URL profile's local_name, not the argument. + assert "url_prof" in str(path) + assert "arg_prof" not in str(path) + + endpoints = [c[1].get("endpoint_url") for c in mock_boto_client.call_args_list] + assert "https://url.example.com" in endpoints + assert "https://arg.example.com" not in endpoints + + @patch(BOTO3_PATCH_TARGET) + def test_url_unknown_profile_hard_error(self, mock_boto_client): + _setup_s3_mock(mock_boto_client) + with tempfile.TemporaryDirectory() as tmpdir: + self._write_registry(tmpdir, '[profiles.known]\nendpoint = "https://k.example.com"\n') + with s3.mirror(cache_root=tmpdir, show_progress=False): + with pytest.raises(ValueError, match="Unknown profile"): + s3.download("s3://ghost@bucket/data") diff --git a/uv.lock b/uv.lock index 5cea668..ae8621b 100644 --- a/uv.lock +++ b/uv.lock @@ -434,10 +434,11 @@ wheels = [ [[package]] name = "pos3" -version = "0.2.2" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "boto3" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "tqdm" }, ] @@ -458,6 +459,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1.1.0" }, { name = "tqdm", specifier = ">=4.65.0" }, ] provides-extras = ["dev"] From 10130928d3b2e7a4cd22ebfe783cb9b24234d687 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 20:11:43 +0000 Subject: [PATCH 2/9] Require Python >=3.11 instead of backporting tomllib CI only ever runs 3.11 and release.yml already uses stdlib tomllib, so the >=3.9 claim was nominal and untested. Drop the conditional tomli dependency, import tomllib directly, and align requires-python and the ruff target-version with what is actually supported. https://claude.ai/code/session_011QGQRbcxj9d47BRWkWAWM1 --- pos3/__init__.py | 6 +- pyproject.toml | 5 +- uv.lock | 348 +++-------------------------------------------- 3 files changed, 22 insertions(+), 337 deletions(-) diff --git a/pos3/__init__.py b/pos3/__init__.py index 2f60591..1c56755 100644 --- a/pos3/__init__.py +++ b/pos3/__init__.py @@ -8,6 +8,7 @@ import shutil import threading import time +import tomllib from collections.abc import Iterable, Iterator from concurrent.futures import ThreadPoolExecutor, as_completed from contextlib import contextmanager, nullcontext @@ -24,11 +25,6 @@ from botocore.exceptions import ClientError from tqdm import tqdm -try: - import tomllib # Python 3.11+ -except ModuleNotFoundError: # pragma: no cover - exercised on Python < 3.11 - import tomli as tomllib - logger = logging.getLogger(__name__) diff --git a/pyproject.toml b/pyproject.toml index e012efd..9421ebf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pos3" version = "0.3.0" description = "S3 Simple Sync - Make using S3 as simple as using local files" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.11" license = { text = "Apache-2.0" } authors = [ { name = "Positronic Robotics", email = "hi@positronic.ro" } @@ -21,7 +21,6 @@ classifiers = [ dependencies = [ "boto3>=1.26.0", "tqdm>=4.65.0", - "tomli>=1.1.0; python_version < '3.11'", ] [project.urls] @@ -47,7 +46,7 @@ addopts = "--cov=pos3 --cov-report=term-missing" [tool.ruff] line-length = 120 -target-version = "py39" +target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "B"] diff --git a/uv.lock b/uv.lock index ae8621b..9a2d586 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.9" -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version < '3.10'", -] +requires-python = ">=3.11" [[package]] name = "boto3" @@ -27,33 +23,17 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "urllib3", version = "2.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8c/46/5b40b1deb780869ca9f0c1de47062a78a0494b53d6f9d6bad10fc38eef9d/botocore-1.42.5.tar.gz", hash = "sha256:37bfc487f14286d9795920807fcb8318b940835b18fff6bec5253449f377136f", size = 14851117, upload-time = "2025-12-08T20:28:26.876Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8b/9a/da5e6cabf4da855d182fcdacf3573b69f30899e0e6c3e0d91ce6ad92ce74/botocore-1.42.5-py3-none-any.whl", hash = "sha256:6aa487f1876c881e2143f6a186b7d8faaf042fc05e0ba7421d821f145356a0c9", size = 14525346, upload-time = "2025-12-08T20:28:24.06Z" }, ] -[[package]] -name = "cfgv" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, -] - [[package]] name = "cfgv" version = "3.5.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, @@ -68,146 +48,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "coverage" -version = "7.10.7" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, - { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, - { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, - { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, - { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, - { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, - { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, - { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, - { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, - { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, - { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, - { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, - { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, - { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, - { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, - { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, - { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, - { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, - { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, - { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, - { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, - { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, - { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, - { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, - { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, - { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, - { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, - { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, - { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, - { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, - { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, - { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, - { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, - { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, - { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, - { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, - { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, - { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, - { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, - { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, - { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, - { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, - { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, - { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, - { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, - { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, - { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, - { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, - { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, - { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, - { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, - { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, - { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, - { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, - { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, - { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, - { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, - { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, - { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, - { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, - { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, - { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, - { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, - { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, - { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, - { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, - { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, - { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, - { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, -] - -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version < '3.10'" }, -] - [[package]] name = "coverage" version = "7.13.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/08/bdd7ccca14096f7eb01412b87ac11e5d16e4cb54b6e328afc9dee8bdaec1/coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070", size = 217979, upload-time = "2025-12-08T13:12:14.505Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/d1302e3416298a28b5663ae1117546a745d9d19fde7e28402b2c5c3e2109/coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98", size = 218496, upload-time = "2025-12-08T13:12:16.237Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/d36c354c8b2a320819afcea6bffe72839efd004b98d1d166b90801d49d57/coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5", size = 245237, upload-time = "2025-12-08T13:12:17.858Z" }, - { url = "https://files.pythonhosted.org/packages/91/52/be5e85631e0eec547873d8b08dd67a5f6b111ecfe89a86e40b89b0c1c61c/coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e", size = 247061, upload-time = "2025-12-08T13:12:19.132Z" }, - { url = "https://files.pythonhosted.org/packages/0f/45/a5e8fa0caf05fbd8fa0402470377bff09cc1f026d21c05c71e01295e55ab/coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33", size = 248928, upload-time = "2025-12-08T13:12:20.702Z" }, - { url = "https://files.pythonhosted.org/packages/f5/42/ffb5069b6fd1b95fae482e02f3fecf380d437dd5a39bae09f16d2e2e7e01/coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791", size = 245931, upload-time = "2025-12-08T13:12:22.243Z" }, - { url = "https://files.pythonhosted.org/packages/95/6e/73e809b882c2858f13e55c0c36e94e09ce07e6165d5644588f9517efe333/coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032", size = 246968, upload-time = "2025-12-08T13:12:23.52Z" }, - { url = "https://files.pythonhosted.org/packages/87/08/64ebd9e64b6adb8b4a4662133d706fbaccecab972e0b3ccc23f64e2678ad/coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9", size = 244972, upload-time = "2025-12-08T13:12:24.781Z" }, - { url = "https://files.pythonhosted.org/packages/12/97/f4d27c6fe0cb375a5eced4aabcaef22de74766fb80a3d5d2015139e54b22/coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f", size = 245241, upload-time = "2025-12-08T13:12:28.041Z" }, - { url = "https://files.pythonhosted.org/packages/0c/94/42f8ae7f633bf4c118bf1038d80472f9dade88961a466f290b81250f7ab7/coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8", size = 245847, upload-time = "2025-12-08T13:12:29.337Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2f/6369ca22b6b6d933f4f4d27765d313d8914cc4cce84f82a16436b1a233db/coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f", size = 220573, upload-time = "2025-12-08T13:12:30.905Z" }, - { url = "https://files.pythonhosted.org/packages/f1/dc/a6a741e519acceaeccc70a7f4cfe5d030efc4b222595f0677e101af6f1f3/coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303", size = 221509, upload-time = "2025-12-08T13:12:32.09Z" }, { url = "https://files.pythonhosted.org/packages/f1/dc/888bf90d8b1c3d0b4020a40e52b9f80957d75785931ec66c7dfaccc11c7d/coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820", size = 218104, upload-time = "2025-12-08T13:12:33.333Z" }, { url = "https://files.pythonhosted.org/packages/8d/ea/069d51372ad9c380214e86717e40d1a743713a2af191cfba30a0911b0a4a/coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f", size = 218606, upload-time = "2025-12-08T13:12:34.498Z" }, { url = "https://files.pythonhosted.org/packages/68/09/77b1c3a66c2aa91141b6c4471af98e5b1ed9b9e6d17255da5eb7992299e3/coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96", size = 248999, upload-time = "2025-12-08T13:12:36.02Z" }, @@ -291,7 +137,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, + { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] @@ -303,37 +149,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - -[[package]] -name = "filelock" -version = "3.19.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, -] - [[package]] name = "filelock" version = "3.20.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, @@ -348,25 +167,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, ] -[[package]] -name = "iniconfig" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, @@ -399,25 +203,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] -[[package]] -name = "platformdirs" -version = "4.4.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, -] - [[package]] name = "platformdirs" version = "4.5.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, @@ -438,16 +227,13 @@ version = "0.3.0" source = { editable = "." } dependencies = [ { name = "boto3" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "tqdm" }, ] [package.optional-dependencies] dev = [ - { name = "pre-commit", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pre-commit", version = "4.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pre-commit" }, + { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, ] @@ -459,43 +245,20 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, - { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1.1.0" }, { name = "tqdm", specifier = ">=4.65.0" }, ] provides-extras = ["dev"] -[[package]] -name = "pre-commit" -version = "4.3.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "cfgv", version = "3.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "identify", marker = "python_full_version < '3.10'" }, - { name = "nodeenv", marker = "python_full_version < '3.10'" }, - { name = "pyyaml", marker = "python_full_version < '3.10'" }, - { name = "virtualenv", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, -] - [[package]] name = "pre-commit" version = "4.5.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] dependencies = [ - { name = "cfgv", version = "3.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "identify", marker = "python_full_version >= '3.10'" }, - { name = "nodeenv", marker = "python_full_version >= '3.10'" }, - { name = "pyyaml", marker = "python_full_version >= '3.10'" }, - { name = "virtualenv", marker = "python_full_version >= '3.10'" }, + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f4/9b/6a4ffb4ed980519da959e1cf3122fc6cb41211daa58dbae1c73c0e519a37/pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b", size = 198428, upload-time = "2025-11-22T21:02:42.304Z" } wheels = [ @@ -511,42 +274,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] -[[package]] -name = "pytest" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pluggy", marker = "python_full_version < '3.10'" }, - { name = "pygments", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, -] - [[package]] name = "pytest" version = "9.0.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", marker = "python_full_version >= '3.10'" }, - { name = "pygments", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ @@ -558,11 +295,9 @@ name = "pytest-cov" version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, - { name = "coverage", version = "7.13.0", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } wheels = [ @@ -587,15 +322,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, @@ -643,15 +369,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, - { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, - { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, - { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, - { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, - { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, ] [[package]] @@ -762,34 +479,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "urllib3" -version = "1.26.20" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380, upload-time = "2024-08-29T15:43:11.37Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225, upload-time = "2024-08-29T15:43:08.921Z" }, -] - [[package]] name = "urllib3" version = "2.6.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] sdist = { url = "https://files.pythonhosted.org/packages/5e/1d/0f3a93cca1ac5e8287842ed4eebbd0f7a991315089b1a0b01c7788aa7b63/urllib3-2.6.1.tar.gz", hash = "sha256:5379eb6e1aba4088bae84f8242960017ec8d8e3decf30480b3a1abdaa9671a3f", size = 432678, upload-time = "2025-12-08T15:25:26.773Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bc/56/190ceb8cb10511b730b564fb1e0293fa468363dbad26145c34928a60cb0c/urllib3-2.6.1-py3-none-any.whl", hash = "sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b", size = 131138, upload-time = "2025-12-08T15:25:25.51Z" }, @@ -801,11 +494,8 @@ version = "20.35.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, - { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "filelock", version = "3.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "platformdirs", version = "4.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "filelock" }, + { name = "platformdirs" }, ] sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } wheels = [ From 13476dbcd5995814cb891973af6f5329b246aa19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 20:28:48 +0000 Subject: [PATCH 3/9] Extract profile logic into pos3.profiles module Move the Profile dataclass, registry loading, resolution, and isolated client creation out of __init__.py into a self-contained pos3.profiles module (no dependency back on the mirror engine). __init__.py is now just the sync logic. Public API (pos3.Profile, pos3.register_profile) is unchanged via re-export. Also document the Python 3.11+ requirement in the release notes. https://claude.ai/code/session_011QGQRbcxj9d47BRWkWAWM1 --- CHANGELOG.md | 8 ++ pos3/__init__.py | 225 ++-------------------------------------------- pos3/profiles.py | 226 +++++++++++++++++++++++++++++++++++++++++++++++ tests/test_s3.py | 8 +- 4 files changed, 247 insertions(+), 220 deletions(-) create mode 100644 pos3/profiles.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 88b105d..607c6d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ - Unknown profile names (in URL or argument) are a hard error with no silent fallback to the default credential chain. +### Changed +- **Python 3.11+ is now required** (`requires-python = ">=3.11"`). The TOML + profile registry uses the standard-library `tomllib`; the previously + declared 3.9/3.10 support was never exercised by CI. +- Profile logic (the `Profile` dataclass, registry loading, resolution, and + client creation) moved into the internal `pos3.profiles` module. The public + API (`pos3.Profile`, `pos3.register_profile`) is unchanged. + ## [0.2.2] - 2026-02-06 ### Fixed diff --git a/pos3/__init__.py b/pos3/__init__.py index 1c56755..6892179 100644 --- a/pos3/__init__.py +++ b/pos3/__init__.py @@ -2,13 +2,10 @@ from __future__ import annotations -import configparser import logging -import os import shutil import threading import time -import tomllib from collections.abc import Iterable, Iterator from concurrent.futures import ThreadPoolExecutor, as_completed from contextlib import contextmanager, nullcontext @@ -19,223 +16,19 @@ from typing import Any from urllib.parse import urlparse -import boto3 -from botocore import UNSIGNED -from botocore.config import Config from botocore.exceptions import ClientError from tqdm import tqdm -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class Profile: - """Configuration for an S3-compatible endpoint. - - Attributes: - local_name: Identifier used in cache path (e.g., 'nebius'). Cannot be '_' (reserved). - endpoint: S3 endpoint URL (e.g., 'https://storage.eu-north1.nebius.cloud'). - public: If True, use anonymous access (no credentials required). - region: Optional AWS region name. - access_key: Optional explicit AWS access key id. When set together with - secret_key, the profile builds its own isolated boto3.Session that - never reads or mutates the ambient AWS configuration. - secret_key: Optional explicit AWS secret access key. - session_token: Optional explicit AWS session token. - """ - - local_name: str - endpoint: str - public: bool = False - region: str | None = None - access_key: str | None = field(default=None, repr=False) - secret_key: str | None = field(default=None, repr=False) - session_token: str | None = field(default=None, repr=False) - - def __post_init__(self): - if self.local_name == "_": - raise ValueError("Profile local_name cannot be '_' (reserved for default)") - if not self.local_name or not all(c.isalnum() or c in "-_" for c in self.local_name): - raise ValueError(f"Invalid local_name '{self.local_name}': use only alphanumeric, dash, underscore") - - -_PROFILES: dict[str, Profile] = {} - - -def register_profile( - name: str, - endpoint: str, - public: bool = False, - region: str | None = None, - local_name: str | None = None, -) -> None: - """Register a named profile for S3 access. - - Creates a Profile with the given parameters. See Profile class for field details. - The `local_name` defaults to the profile `name` if not specified. - """ - config = Profile(local_name=local_name or name, endpoint=endpoint, public=public, region=region) - existing = _PROFILES.get(name) - if existing is not None and existing != config: - raise ValueError(f"Profile '{name}' already registered with different config") - _PROFILES[name] = config - - -_REGISTRY_LOADED = False -_REGISTRY_LOCK = threading.Lock() - - -def _default_profiles_path() -> Path: - """Resolve the location of the auto-loaded profile registry file. - - Honors POS3_PROFILES_FILE, then XDG_CONFIG_HOME, defaulting to - ~/.config/pos3/profiles.toml. - """ - override = os.environ.get("POS3_PROFILES_FILE") - if override: - return Path(override).expanduser() - xdg = os.environ.get("XDG_CONFIG_HOME") - base = Path(xdg).expanduser() if xdg else Path.home() / ".config" - return base / "pos3" / "profiles.toml" - - -def _load_credentials_file(creds_path: Path, profile_name: str) -> tuple[str | None, str | None, str | None]: - """Read AWS-style credentials for a profile from a separate secret file. - - The file uses INI sections (like ~/.aws/credentials). The section named - after the profile is preferred, then 'default', then the first section. - """ - parser = configparser.ConfigParser() - if not parser.read(creds_path): - raise ValueError(f"Profile credentials_file not found or unreadable: {creds_path}") - - if parser.has_section(profile_name): - section = profile_name - elif parser.has_section("default"): - section = "default" - elif parser.sections(): - section = parser.sections()[0] - else: - raise ValueError(f"No credentials section found in {creds_path}") - - sec = parser[section] - return ( - sec.get("aws_access_key_id"), - sec.get("aws_secret_access_key"), - sec.get("aws_session_token"), - ) - - -def _profile_from_config(name: str, cfg: dict[str, Any], source: Path) -> Profile: - """Build a Profile from one [profiles.] table of the registry file.""" - if not isinstance(cfg, dict): - raise ValueError(f"Profile '{name}' in {source} must be a table") - endpoint = cfg.get("endpoint") - if not endpoint: - raise ValueError(f"Profile '{name}' in {source} is missing required 'endpoint'") - - access_key = secret_key = session_token = None - creds_file = cfg.get("credentials_file") - if creds_file: - access_key, secret_key, session_token = _load_credentials_file(Path(creds_file).expanduser(), name) - - return Profile( - local_name=cfg.get("local_name", name), - endpoint=endpoint, - public=cfg.get("public", False), - region=cfg.get("region"), - access_key=access_key, - secret_key=secret_key, - session_token=session_token, - ) +from .profiles import _PROFILES as _PROFILES # re-exported for tests/back-compat +from .profiles import ( + Profile, + _create_s3_client, + _resolve_profile, + _url_profile, + register_profile, +) - -def _load_profile_registry(path: Path | None = None, force: bool = False) -> None: - """Auto-load named profiles from the local registry file (once per process). - - Programmatically registered profiles take precedence: a registry entry never - overrides a name already present in _PROFILES. - """ - global _REGISTRY_LOADED - with _REGISTRY_LOCK: - if _REGISTRY_LOADED and not force: - return - _REGISTRY_LOADED = True - registry_path = path if path is not None else _default_profiles_path() - if not registry_path.exists(): - return - with open(registry_path, "rb") as fh: - data = tomllib.load(fh) - for name, cfg in data.get("profiles", {}).items(): - if name in _PROFILES: - continue - _PROFILES[name] = _profile_from_config(name, cfg, registry_path) - - -def _resolve_profile(profile: str | Profile | None) -> Profile | None: - """Resolve a profile name to a Profile object. - - Args: - profile: None, registered profile name (string), or Profile object. - - Returns: - Profile object or None. - - Raises: - ValueError: If profile is a string that is not registered. - """ - if profile is None or isinstance(profile, Profile): - return profile - if profile not in _PROFILES: - _load_profile_registry() - if profile not in _PROFILES: - raise ValueError( - f"Unknown profile: '{profile}'. Register with pos3.register_profile() " - f"or define it in {_default_profiles_path()}." - ) - return _PROFILES[profile] - - -def _url_profile(s3_url: str) -> str | None: - """Extract an explicit profile name from the userinfo slot of an S3 URL. - - `s3://@bucket/key` selects ``. No userinfo -> None. - """ - parsed = urlparse(s3_url) - if parsed.scheme != "s3": - return None - return parsed.username or None - - -def _create_s3_client(profile: Profile | None = None): - """Create boto3 S3 client, optionally using a profile. - - Args: - profile: None (use boto3 defaults) or Profile config. - """ - if profile is None: - return boto3.client("s3") - - kwargs: dict[str, Any] = {"endpoint_url": profile.endpoint} - - if profile.public: - kwargs["config"] = Config(signature_version=UNSIGNED) - - if profile.access_key and profile.secret_key: - # Isolated session: explicit credentials, never reads or mutates the - # user's ambient AWS configuration in either direction. - session = boto3.session.Session( - aws_access_key_id=profile.access_key, - aws_secret_access_key=profile.secret_key, - aws_session_token=profile.session_token, - region_name=profile.region, - ) - return session.client("s3", **kwargs) - - if profile.region: - kwargs["region_name"] = profile.region - - return boto3.client("s3", **kwargs) +logger = logging.getLogger(__name__) class _NullTqdm(nullcontext): diff --git a/pos3/profiles.py b/pos3/profiles.py new file mode 100644 index 0000000..1072f58 --- /dev/null +++ b/pos3/profiles.py @@ -0,0 +1,226 @@ +"""Named S3 profiles: registry, resolution, and isolated client creation.""" + +from __future__ import annotations + +import configparser +import os +import threading +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import boto3 +from botocore import UNSIGNED +from botocore.config import Config + + +@dataclass(frozen=True) +class Profile: + """Configuration for an S3-compatible endpoint. + + Attributes: + local_name: Identifier used in cache path (e.g., 'nebius'). Cannot be '_' (reserved). + endpoint: S3 endpoint URL (e.g., 'https://storage.eu-north1.nebius.cloud'). + public: If True, use anonymous access (no credentials required). + region: Optional AWS region name. + access_key: Optional explicit AWS access key id. When set together with + secret_key, the profile builds its own isolated boto3.Session that + never reads or mutates the ambient AWS configuration. + secret_key: Optional explicit AWS secret access key. + session_token: Optional explicit AWS session token. + """ + + local_name: str + endpoint: str + public: bool = False + region: str | None = None + access_key: str | None = field(default=None, repr=False) + secret_key: str | None = field(default=None, repr=False) + session_token: str | None = field(default=None, repr=False) + + def __post_init__(self): + if self.local_name == "_": + raise ValueError("Profile local_name cannot be '_' (reserved for default)") + if not self.local_name or not all(c.isalnum() or c in "-_" for c in self.local_name): + raise ValueError(f"Invalid local_name '{self.local_name}': use only alphanumeric, dash, underscore") + + +_PROFILES: dict[str, Profile] = {} + + +def register_profile( + name: str, + endpoint: str, + public: bool = False, + region: str | None = None, + local_name: str | None = None, +) -> None: + """Register a named profile for S3 access. + + Creates a Profile with the given parameters. See Profile class for field details. + The `local_name` defaults to the profile `name` if not specified. + """ + config = Profile(local_name=local_name or name, endpoint=endpoint, public=public, region=region) + existing = _PROFILES.get(name) + if existing is not None and existing != config: + raise ValueError(f"Profile '{name}' already registered with different config") + _PROFILES[name] = config + + +_REGISTRY_LOADED = False +_REGISTRY_LOCK = threading.Lock() + + +def _default_profiles_path() -> Path: + """Resolve the location of the auto-loaded profile registry file. + + Honors POS3_PROFILES_FILE, then XDG_CONFIG_HOME, defaulting to + ~/.config/pos3/profiles.toml. + """ + override = os.environ.get("POS3_PROFILES_FILE") + if override: + return Path(override).expanduser() + xdg = os.environ.get("XDG_CONFIG_HOME") + base = Path(xdg).expanduser() if xdg else Path.home() / ".config" + return base / "pos3" / "profiles.toml" + + +def _load_credentials_file(creds_path: Path, profile_name: str) -> tuple[str | None, str | None, str | None]: + """Read AWS-style credentials for a profile from a separate secret file. + + The file uses INI sections (like ~/.aws/credentials). The section named + after the profile is preferred, then 'default', then the first section. + """ + parser = configparser.ConfigParser() + if not parser.read(creds_path): + raise ValueError(f"Profile credentials_file not found or unreadable: {creds_path}") + + if parser.has_section(profile_name): + section = profile_name + elif parser.has_section("default"): + section = "default" + elif parser.sections(): + section = parser.sections()[0] + else: + raise ValueError(f"No credentials section found in {creds_path}") + + sec = parser[section] + return ( + sec.get("aws_access_key_id"), + sec.get("aws_secret_access_key"), + sec.get("aws_session_token"), + ) + + +def _profile_from_config(name: str, cfg: dict[str, Any], source: Path) -> Profile: + """Build a Profile from one [profiles.] table of the registry file.""" + if not isinstance(cfg, dict): + raise ValueError(f"Profile '{name}' in {source} must be a table") + endpoint = cfg.get("endpoint") + if not endpoint: + raise ValueError(f"Profile '{name}' in {source} is missing required 'endpoint'") + + access_key = secret_key = session_token = None + creds_file = cfg.get("credentials_file") + if creds_file: + access_key, secret_key, session_token = _load_credentials_file(Path(creds_file).expanduser(), name) + + return Profile( + local_name=cfg.get("local_name", name), + endpoint=endpoint, + public=cfg.get("public", False), + region=cfg.get("region"), + access_key=access_key, + secret_key=secret_key, + session_token=session_token, + ) + + +def _load_profile_registry(path: Path | None = None, force: bool = False) -> None: + """Auto-load named profiles from the local registry file (once per process). + + Programmatically registered profiles take precedence: a registry entry never + overrides a name already present in _PROFILES. + """ + global _REGISTRY_LOADED + with _REGISTRY_LOCK: + if _REGISTRY_LOADED and not force: + return + _REGISTRY_LOADED = True + registry_path = path if path is not None else _default_profiles_path() + if not registry_path.exists(): + return + with open(registry_path, "rb") as fh: + data = tomllib.load(fh) + for name, cfg in data.get("profiles", {}).items(): + if name in _PROFILES: + continue + _PROFILES[name] = _profile_from_config(name, cfg, registry_path) + + +def _resolve_profile(profile: str | Profile | None) -> Profile | None: + """Resolve a profile name to a Profile object. + + Args: + profile: None, registered profile name (string), or Profile object. + + Returns: + Profile object or None. + + Raises: + ValueError: If profile is a string that is not registered. + """ + if profile is None or isinstance(profile, Profile): + return profile + if profile not in _PROFILES: + _load_profile_registry() + if profile not in _PROFILES: + raise ValueError( + f"Unknown profile: '{profile}'. Register with pos3.register_profile() " + f"or define it in {_default_profiles_path()}." + ) + return _PROFILES[profile] + + +def _url_profile(s3_url: str) -> str | None: + """Extract an explicit profile name from the userinfo slot of an S3 URL. + + `s3://@bucket/key` selects ``. No userinfo -> None. + """ + parsed = urlparse(s3_url) + if parsed.scheme != "s3": + return None + return parsed.username or None + + +def _create_s3_client(profile: Profile | None = None): + """Create boto3 S3 client, optionally using a profile. + + Args: + profile: None (use boto3 defaults) or Profile config. + """ + if profile is None: + return boto3.client("s3") + + kwargs: dict[str, Any] = {"endpoint_url": profile.endpoint} + + if profile.public: + kwargs["config"] = Config(signature_version=UNSIGNED) + + if profile.access_key and profile.secret_key: + # Isolated session: explicit credentials, never reads or mutates the + # user's ambient AWS configuration in either direction. + session = boto3.session.Session( + aws_access_key_id=profile.access_key, + aws_secret_access_key=profile.secret_key, + aws_session_token=profile.session_token, + region_name=profile.region, + ) + return session.client("s3", **kwargs) + + if profile.region: + kwargs["region_name"] = profile.region + + return boto3.client("s3", **kwargs) diff --git a/tests/test_s3.py b/tests/test_s3.py index 8e4aaa8..637d15d 100644 --- a/tests/test_s3.py +++ b/tests/test_s3.py @@ -10,8 +10,8 @@ import pos3 as s3 -BOTO3_PATCH_TARGET = "pos3.boto3.client" -SESSION_PATCH_TARGET = "pos3.boto3.session.Session" +BOTO3_PATCH_TARGET = "pos3.profiles.boto3.client" +SESSION_PATCH_TARGET = "pos3.profiles.boto3.session.Session" def _make_404_error(*_args, **_kwargs): @@ -1287,12 +1287,12 @@ def test_normalize_strips_userinfo(self): class TestProfileRegistry: def setup_method(self): s3._PROFILES.clear() - s3._REGISTRY_LOADED = False + s3.profiles._REGISTRY_LOADED = False self._saved_env = os.environ.get("POS3_PROFILES_FILE") def teardown_method(self): s3._PROFILES.clear() - s3._REGISTRY_LOADED = False + s3.profiles._REGISTRY_LOADED = False if self._saved_env is None: os.environ.pop("POS3_PROFILES_FILE", None) else: From 0d8681cba217768f053693ab03340a0a2db82a37 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 07:44:52 +0000 Subject: [PATCH 4/9] Fail loudly on incomplete credentials and transient registry errors - Require both aws_access_key_id and aws_secret_access_key in a credentials_file. A half-populated section previously silently fell back to the ambient AWS credential chain in _create_s3_client, defeating the point of isolated per-profile credentials. - Build registry profiles atomically and set _REGISTRY_LOADED only after a successful load, so a malformed entry doesn't leave the process in a permanent "loaded with no profiles" state. Adds regression tests for both. https://claude.ai/code/session_011QGQRbcxj9d47BRWkWAWM1 --- pos3/profiles.py | 39 ++++++++++++++++++++++++--------------- tests/test_s3.py | 25 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/pos3/profiles.py b/pos3/profiles.py index 1072f58..4aa5b20 100644 --- a/pos3/profiles.py +++ b/pos3/profiles.py @@ -87,11 +87,14 @@ def _default_profiles_path() -> Path: return base / "pos3" / "profiles.toml" -def _load_credentials_file(creds_path: Path, profile_name: str) -> tuple[str | None, str | None, str | None]: +def _load_credentials_file(creds_path: Path, profile_name: str) -> tuple[str, str, str | None]: """Read AWS-style credentials for a profile from a separate secret file. The file uses INI sections (like ~/.aws/credentials). The section named after the profile is preferred, then 'default', then the first section. + Both `aws_access_key_id` and `aws_secret_access_key` are required: a + half-populated credentials file would otherwise silently fall back to the + ambient AWS credential chain, defeating the point of isolated profiles. """ parser = configparser.ConfigParser() if not parser.read(creds_path): @@ -107,11 +110,14 @@ def _load_credentials_file(creds_path: Path, profile_name: str) -> tuple[str | N raise ValueError(f"No credentials section found in {creds_path}") sec = parser[section] - return ( - sec.get("aws_access_key_id"), - sec.get("aws_secret_access_key"), - sec.get("aws_session_token"), - ) + access_key = sec.get("aws_access_key_id") + secret_key = sec.get("aws_secret_access_key") + if not access_key or not secret_key: + raise ValueError( + f"Credentials section '{section}' in {creds_path} must define both " + "'aws_access_key_id' and 'aws_secret_access_key'." + ) + return access_key, secret_key, sec.get("aws_session_token") def _profile_from_config(name: str, cfg: dict[str, Any], source: Path) -> Profile: @@ -148,16 +154,19 @@ def _load_profile_registry(path: Path | None = None, force: bool = False) -> Non with _REGISTRY_LOCK: if _REGISTRY_LOADED and not force: return - _REGISTRY_LOADED = True registry_path = path if path is not None else _default_profiles_path() - if not registry_path.exists(): - return - with open(registry_path, "rb") as fh: - data = tomllib.load(fh) - for name, cfg in data.get("profiles", {}).items(): - if name in _PROFILES: - continue - _PROFILES[name] = _profile_from_config(name, cfg, registry_path) + if registry_path.exists(): + with open(registry_path, "rb") as fh: + data = tomllib.load(fh) + # Build all profiles first so a malformed entry doesn't leave the + # registry half-loaded with a sticky _REGISTRY_LOADED flag. + new_profiles = { + name: _profile_from_config(name, cfg, registry_path) + for name, cfg in data.get("profiles", {}).items() + if name not in _PROFILES + } + _PROFILES.update(new_profiles) + _REGISTRY_LOADED = True def _resolve_profile(profile: str | Profile | None) -> Profile | None: diff --git a/tests/test_s3.py b/tests/test_s3.py index 637d15d..adbfa73 100644 --- a/tests/test_s3.py +++ b/tests/test_s3.py @@ -1336,6 +1336,31 @@ def test_programmatic_registration_wins(self): assert profile.endpoint == "https://from-code.example.com" + def test_credentials_file_missing_secret_raises(self): + """A half-populated credentials file must fail loudly, not fall back to ambient creds.""" + with tempfile.TemporaryDirectory() as tmpdir: + creds = Path(tmpdir) / "acme.creds" + creds.write_text("[acme]\naws_access_key_id = AKIAEXAMPLE\n") # no secret + self._write_registry( + tmpdir, + f'[profiles.acme]\nendpoint = "https://s.example.com"\ncredentials_file = "{creds}"\n', + ) + with pytest.raises(ValueError, match="aws_access_key_id.*aws_secret_access_key"): + s3._resolve_profile("acme") + + def test_load_failure_is_not_sticky(self): + """A malformed registry must not flip _REGISTRY_LOADED on; subsequent loads should retry.""" + with tempfile.TemporaryDirectory() as tmpdir: + self._write_registry(tmpdir, '[profiles.bad]\nregion = "us-east-1"\n') # missing endpoint + with pytest.raises(ValueError, match="missing required 'endpoint'"): + s3._resolve_profile("bad") + assert s3.profiles._REGISTRY_LOADED is False + + # Repair the registry and retry without manual reset. + self._write_registry(tmpdir, '[profiles.bad]\nendpoint = "https://fixed.example.com"\n') + profile = s3._resolve_profile("bad") + assert profile.endpoint == "https://fixed.example.com" + def test_credentials_file_isolated_session(self): with tempfile.TemporaryDirectory() as tmpdir: creds = Path(tmpdir) / "acme.creds" From c0c83772523d1c22c646a7413fa494bbbd5fa2d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 07:53:41 +0000 Subject: [PATCH 5/9] Resolve relative credentials_file against the registry file's directory A relative path like credentials_file = "acme.creds" previously resolved against the process CWD, so running pos3 from a different directory would fail with "not found or unreadable" even though the creds file sat next to profiles.toml. Resolve relative paths against the registry file's parent instead, which is the location users naturally expect. Absolute and ~-expanded paths are unchanged. https://claude.ai/code/session_011QGQRbcxj9d47BRWkWAWM1 --- pos3/profiles.py | 8 +++++++- tests/test_s3.py | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/pos3/profiles.py b/pos3/profiles.py index 4aa5b20..06bf632 100644 --- a/pos3/profiles.py +++ b/pos3/profiles.py @@ -131,7 +131,13 @@ def _profile_from_config(name: str, cfg: dict[str, Any], source: Path) -> Profil access_key = secret_key = session_token = None creds_file = cfg.get("credentials_file") if creds_file: - access_key, secret_key, session_token = _load_credentials_file(Path(creds_file).expanduser(), name) + # Resolve relative paths against the registry file's directory, not + # CWD, so a profiles.toml co-located with its .creds files works + # regardless of where pos3 is invoked from. + creds_path = Path(creds_file).expanduser() + if not creds_path.is_absolute(): + creds_path = source.parent / creds_path + access_key, secret_key, session_token = _load_credentials_file(creds_path, name) return Profile( local_name=cfg.get("local_name", name), diff --git a/tests/test_s3.py b/tests/test_s3.py index adbfa73..2e9c031 100644 --- a/tests/test_s3.py +++ b/tests/test_s3.py @@ -1336,6 +1336,27 @@ def test_programmatic_registration_wins(self): assert profile.endpoint == "https://from-code.example.com" + def test_credentials_file_relative_to_registry(self): + """A relative credentials_file path must resolve next to profiles.toml, not CWD.""" + with tempfile.TemporaryDirectory() as tmpdir: + (Path(tmpdir) / "acme.creds").write_text( + "[acme]\naws_access_key_id = AKIAEXAMPLE\naws_secret_access_key = secret123\n" + ) + # Bare filename — would fail against CWD, must resolve against the registry dir. + self._write_registry( + tmpdir, + '[profiles.acme]\nendpoint = "https://s.example.com"\ncredentials_file = "acme.creds"\n', + ) + saved_cwd = os.getcwd() + try: + os.chdir(Path(tmpdir).parent) # any dir that doesn't contain acme.creds + profile = s3._resolve_profile("acme") + finally: + os.chdir(saved_cwd) + + assert profile.access_key == "AKIAEXAMPLE" + assert profile.secret_key == "secret123" + def test_credentials_file_missing_secret_raises(self): """A half-populated credentials file must fail loudly, not fall back to ambient creds.""" with tempfile.TemporaryDirectory() as tmpdir: From be68460e27f55cc559230b176a91177e0ce752b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 07:59:09 +0000 Subject: [PATCH 6/9] Reject empty URL profile selector s3://@bucket/key or s3://:token@bucket/key (which a CLI template variable can produce when the profile name expands to empty) previously slipped through _url_profile as "no URL profile" and silently fell back to the argument/default profile, violating the explicit no-silent- fallback contract for URL-based selection. Treat any '@' in the netloc without a profile name as a hard error. https://claude.ai/code/session_011QGQRbcxj9d47BRWkWAWM1 --- pos3/profiles.py | 12 +++++++++++- tests/test_s3.py | 8 ++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pos3/profiles.py b/pos3/profiles.py index 06bf632..5137547 100644 --- a/pos3/profiles.py +++ b/pos3/profiles.py @@ -203,11 +203,21 @@ def _url_profile(s3_url: str) -> str | None: """Extract an explicit profile name from the userinfo slot of an S3 URL. `s3://@bucket/key` selects ``. No userinfo -> None. + An empty selector (`s3://@bucket/key`, `s3://:token@bucket/key`) is a + hard error: a templated CLI variable that expanded to nothing must not + silently fall back to the argument/default profile. """ parsed = urlparse(s3_url) if parsed.scheme != "s3": return None - return parsed.username or None + if "@" not in parsed.netloc: + return None + if not parsed.username: + raise ValueError( + f"Empty profile selector in S3 URL: {s3_url!r}. " + "Use s3://@bucket/key or omit the '@'." + ) + return parsed.username def _create_s3_client(profile: Profile | None = None): diff --git a/tests/test_s3.py b/tests/test_s3.py index 2e9c031..815f48e 100644 --- a/tests/test_s3.py +++ b/tests/test_s3.py @@ -1277,6 +1277,14 @@ def test_url_profile_absent(self): def test_url_profile_non_s3(self): assert s3._url_profile("/local/path") is None + def test_empty_url_profile_selector_raises(self): + """An '@' with no profile name (e.g. from a template variable that expanded empty) + must fail loudly, not silently fall back to arg/default profile.""" + with pytest.raises(ValueError, match="Empty profile selector"): + s3._url_profile("s3://@bucket/key") + with pytest.raises(ValueError, match="Empty profile selector"): + s3._url_profile("s3://:token@bucket/key") + def test_parse_strips_userinfo(self): assert s3._parse_s3_url("s3://acme@bucket/path/to/data") == ("bucket", "path/to/data") From 6ddc4d2a9391bd193ae1dfd77a50fa614def1aea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 08:05:51 +0000 Subject: [PATCH 7/9] Reject credentials files lacking the selected or default section Falling back to parser.sections()[0] would silently bind a profile to an unrelated INI section, so a typo in the section header (e.g. [acne] instead of [acme]) could route operations to the wrong AWS account. Only [] and [default] are accepted now; anything else raises with the list of sections found. Same no-silent-fallback rule as the earlier credentials/empty-selector guards. https://claude.ai/code/session_011QGQRbcxj9d47BRWkWAWM1 --- pos3/profiles.py | 22 ++++++++++++++-------- tests/test_s3.py | 13 +++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/pos3/profiles.py b/pos3/profiles.py index 5137547..c507ea6 100644 --- a/pos3/profiles.py +++ b/pos3/profiles.py @@ -90,11 +90,13 @@ def _default_profiles_path() -> Path: def _load_credentials_file(creds_path: Path, profile_name: str) -> tuple[str, str, str | None]: """Read AWS-style credentials for a profile from a separate secret file. - The file uses INI sections (like ~/.aws/credentials). The section named - after the profile is preferred, then 'default', then the first section. - Both `aws_access_key_id` and `aws_secret_access_key` are required: a - half-populated credentials file would otherwise silently fall back to the - ambient AWS credential chain, defeating the point of isolated profiles. + The file uses INI sections (like ~/.aws/credentials). Only the section + named after the profile or '[default]' is accepted; anything else is a + hard error. Both `aws_access_key_id` and `aws_secret_access_key` are + required. These guards exist because any silent fallback (to an + arbitrary section, or to the ambient AWS credential chain) could route + operations to the wrong account, defeating the point of isolated + per-profile credentials. """ parser = configparser.ConfigParser() if not parser.read(creds_path): @@ -104,10 +106,14 @@ def _load_credentials_file(creds_path: Path, profile_name: str) -> tuple[str, st section = profile_name elif parser.has_section("default"): section = "default" - elif parser.sections(): - section = parser.sections()[0] else: - raise ValueError(f"No credentials section found in {creds_path}") + # Don't fall back to an arbitrary section: a typo in the profile-name + # header would otherwise silently bind credentials from an unrelated + # account, defeating the point of isolated per-profile credentials. + raise ValueError( + f"Credentials file {creds_path} must contain a [{profile_name}] " + f"or [default] section; found sections: {parser.sections()}" + ) sec = parser[section] access_key = sec.get("aws_access_key_id") diff --git a/tests/test_s3.py b/tests/test_s3.py index 815f48e..7640437 100644 --- a/tests/test_s3.py +++ b/tests/test_s3.py @@ -1377,6 +1377,19 @@ def test_credentials_file_missing_secret_raises(self): with pytest.raises(ValueError, match="aws_access_key_id.*aws_secret_access_key"): s3._resolve_profile("acme") + def test_credentials_file_wrong_section_raises(self): + """A typo in the section name must not silently bind credentials from an unrelated section.""" + with tempfile.TemporaryDirectory() as tmpdir: + creds = Path(tmpdir) / "acme.creds" + # Typo: section says 'acne' instead of 'acme'; no [default] either. + creds.write_text("[acne]\naws_access_key_id = WRONG\naws_secret_access_key = wrong\n") + self._write_registry( + tmpdir, + f'[profiles.acme]\nendpoint = "https://s.example.com"\ncredentials_file = "{creds}"\n', + ) + with pytest.raises(ValueError, match=r"\[acme\] or \[default\] section"): + s3._resolve_profile("acme") + def test_load_failure_is_not_sticky(self): """A malformed registry must not flip _REGISTRY_LOADED on; subsequent loads should retry.""" with tempfile.TemporaryDirectory() as tmpdir: From a5f777e794e915a9875e1aab9b62fbbc18522e12 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 08:15:31 +0000 Subject: [PATCH 8/9] Make code registration always win and reject partial credentials - Programmatic register_profile() must take precedence over a registry entry regardless of which loaded first. Previously, lazy registry load could populate _PROFILES before user code, causing a later register_profile() with different config to raise. Split storage: code registrations live in _PROFILES (consulted first); registry entries live in _REGISTRY_PROFILES (fallback). Both override the registry, neither overrides another code registration. - A Profile constructed with only one of access_key/secret_key previously slipped through _create_s3_client and fell back to the ambient credential chain. Validate the pair in Profile.__post_init__ so any wrong-shape Profile fails at construction, no matter the caller path. https://claude.ai/code/session_011QGQRbcxj9d47BRWkWAWM1 --- pos3/profiles.py | 48 ++++++++++++++++++++++++++++++++++-------------- tests/test_s3.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/pos3/profiles.py b/pos3/profiles.py index c507ea6..3aa0a1a 100644 --- a/pos3/profiles.py +++ b/pos3/profiles.py @@ -45,9 +45,22 @@ def __post_init__(self): raise ValueError("Profile local_name cannot be '_' (reserved for default)") if not self.local_name or not all(c.isalnum() or c in "-_" for c in self.local_name): raise ValueError(f"Invalid local_name '{self.local_name}': use only alphanumeric, dash, underscore") - - + # access_key and secret_key are an AWS pair; one without the other + # would silently fall back to the ambient credential chain in + # _create_s3_client and route operations to the wrong account. + if bool(self.access_key) != bool(self.secret_key): + raise ValueError( + "Profile access_key and secret_key must be set together " + "(or both omitted to use the default credential chain)." + ) + + +# Profiles registered programmatically via register_profile. Lookups consult +# this dict first, so a code registration unconditionally wins over a registry +# entry of the same name regardless of load order. _PROFILES: dict[str, Profile] = {} +# Profiles loaded from the on-disk registry. Consulted only as a fallback. +_REGISTRY_PROFILES: dict[str, Profile] = {} def register_profile( @@ -60,7 +73,9 @@ def register_profile( """Register a named profile for S3 access. Creates a Profile with the given parameters. See Profile class for field details. - The `local_name` defaults to the profile `name` if not specified. + The `local_name` defaults to the profile `name` if not specified. A code + registration always takes precedence over a registry-file entry of the same + name; re-registering the same name with a different config raises. """ config = Profile(local_name=local_name or name, endpoint=endpoint, public=public, region=region) existing = _PROFILES.get(name) @@ -159,8 +174,9 @@ def _profile_from_config(name: str, cfg: dict[str, Any], source: Path) -> Profil def _load_profile_registry(path: Path | None = None, force: bool = False) -> None: """Auto-load named profiles from the local registry file (once per process). - Programmatically registered profiles take precedence: a registry entry never - overrides a name already present in _PROFILES. + Registry entries are kept in a separate dict from programmatic registrations, + so a code register_profile() always wins over a registry entry of the same + name regardless of which happened first. """ global _REGISTRY_LOADED with _REGISTRY_LOCK: @@ -175,9 +191,8 @@ def _load_profile_registry(path: Path | None = None, force: bool = False) -> Non new_profiles = { name: _profile_from_config(name, cfg, registry_path) for name, cfg in data.get("profiles", {}).items() - if name not in _PROFILES } - _PROFILES.update(new_profiles) + _REGISTRY_PROFILES.update(new_profiles) _REGISTRY_LOADED = True @@ -195,14 +210,19 @@ def _resolve_profile(profile: str | Profile | None) -> Profile | None: """ if profile is None or isinstance(profile, Profile): return profile - if profile not in _PROFILES: + # Code registrations always win; only consult the on-disk registry as a fallback. + if profile in _PROFILES: + return _PROFILES[profile] + if profile not in _REGISTRY_PROFILES: _load_profile_registry() - if profile not in _PROFILES: - raise ValueError( - f"Unknown profile: '{profile}'. Register with pos3.register_profile() " - f"or define it in {_default_profiles_path()}." - ) - return _PROFILES[profile] + if profile in _PROFILES: + return _PROFILES[profile] + if profile in _REGISTRY_PROFILES: + return _REGISTRY_PROFILES[profile] + raise ValueError( + f"Unknown profile: '{profile}'. Register with pos3.register_profile() " + f"or define it in {_default_profiles_path()}." + ) def _url_profile(s3_url: str) -> str | None: diff --git a/tests/test_s3.py b/tests/test_s3.py index 7640437..b18c644 100644 --- a/tests/test_s3.py +++ b/tests/test_s3.py @@ -1106,6 +1106,19 @@ def test_profile_local_name_invalid_chars_rejected(self): Profile(local_name="valid_name", endpoint="https://storage.example.com") Profile(local_name="ValidName123", endpoint="https://storage.example.com") + def test_profile_partial_credentials_rejected(self): + """Half-set access_key/secret_key would fall back to ambient creds; reject at construction.""" + from pos3 import Profile + + common = {"local_name": "p", "endpoint": "https://s.example.com"} + with pytest.raises(ValueError, match="must be set together"): + Profile(**common, access_key="AKIA") + with pytest.raises(ValueError, match="must be set together"): + Profile(**common, secret_key="shh") + # Both set or both absent is fine. + Profile(**common, access_key="AKIA", secret_key="shh") + Profile(**common) + def test_create_client_unknown_profile(self): """Test that using unknown profile raises error.""" with pytest.raises(ValueError, match="Unknown profile"): @@ -1295,11 +1308,13 @@ def test_normalize_strips_userinfo(self): class TestProfileRegistry: def setup_method(self): s3._PROFILES.clear() + s3.profiles._REGISTRY_PROFILES.clear() s3.profiles._REGISTRY_LOADED = False self._saved_env = os.environ.get("POS3_PROFILES_FILE") def teardown_method(self): s3._PROFILES.clear() + s3.profiles._REGISTRY_PROFILES.clear() s3.profiles._REGISTRY_LOADED = False if self._saved_env is None: os.environ.pop("POS3_PROFILES_FILE", None) @@ -1344,6 +1359,20 @@ def test_programmatic_registration_wins(self): assert profile.endpoint == "https://from-code.example.com" + def test_code_override_after_registry_load(self): + """Code-precedence must hold even when the registry was loaded first.""" + with tempfile.TemporaryDirectory() as tmpdir: + self._write_registry( + tmpdir, + '[profiles.dup]\nendpoint = "https://from-file.example.com"\n' + '[profiles.other]\nendpoint = "https://other.example.com"\n', + ) + # Resolving any name first triggers registry load, populating "dup". + assert s3._resolve_profile("other").endpoint == "https://other.example.com" + # Now overriding "dup" in code must succeed and win. + s3.register_profile("dup", endpoint="https://from-code.example.com") + assert s3._resolve_profile("dup").endpoint == "https://from-code.example.com" + def test_credentials_file_relative_to_registry(self): """A relative credentials_file path must resolve next to profiles.toml, not CWD.""" with tempfile.TemporaryDirectory() as tmpdir: From dc5325ada98c567c7e97a260a1c3dc02d59a6735 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 08:22:23 +0000 Subject: [PATCH 9/9] Rebuild registry snapshot on forced reload A forced _load_profile_registry(..., force=True) previously merged new entries into _REGISTRY_PROFILES, so profiles that were removed or renamed in the updated profiles.toml stayed resolvable with their old endpoint/credential config. Treat a forced reload as "re-read the source of truth": clear and replace, including when the file is now absent. The clear runs only after the new mapping is successfully built, so a malformed reload leaves the previous snapshot intact. https://claude.ai/code/session_011QGQRbcxj9d47BRWkWAWM1 --- pos3/profiles.py | 7 ++++++- tests/test_s3.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/pos3/profiles.py b/pos3/profiles.py index 3aa0a1a..beb7f27 100644 --- a/pos3/profiles.py +++ b/pos3/profiles.py @@ -192,7 +192,12 @@ def _load_profile_registry(path: Path | None = None, force: bool = False) -> Non name: _profile_from_config(name, cfg, registry_path) for name, cfg in data.get("profiles", {}).items() } - _REGISTRY_PROFILES.update(new_profiles) + else: + new_profiles = {} + # Replace, don't merge: a forced reload must reflect the file's current + # state, not accumulate stale entries from previous loads. + _REGISTRY_PROFILES.clear() + _REGISTRY_PROFILES.update(new_profiles) _REGISTRY_LOADED = True diff --git a/tests/test_s3.py b/tests/test_s3.py index b18c644..98662a7 100644 --- a/tests/test_s3.py +++ b/tests/test_s3.py @@ -1394,6 +1394,25 @@ def test_credentials_file_relative_to_registry(self): assert profile.access_key == "AKIAEXAMPLE" assert profile.secret_key == "secret123" + def test_forced_reload_drops_stale_entries(self): + """force=True must replace the registry snapshot, not merge into it.""" + with tempfile.TemporaryDirectory() as tmpdir: + self._write_registry( + tmpdir, + '[profiles.alpha]\nendpoint = "https://a.example.com"\n' + '[profiles.beta]\nendpoint = "https://b.example.com"\n', + ) + assert s3._resolve_profile("alpha").endpoint == "https://a.example.com" + assert s3._resolve_profile("beta").endpoint == "https://b.example.com" + + # Rewrite the registry without 'beta' and force a reload. + self._write_registry(tmpdir, '[profiles.alpha]\nendpoint = "https://a2.example.com"\n') + s3.profiles._load_profile_registry(force=True) + + assert s3._resolve_profile("alpha").endpoint == "https://a2.example.com" + with pytest.raises(ValueError, match="Unknown profile"): + s3._resolve_profile("beta") + def test_credentials_file_missing_secret_raises(self): """A half-populated credentials file must fail loudly, not fall back to ambient creds.""" with tempfile.TemporaryDirectory() as tmpdir: