diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 43575ac..bdc53f0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,6 +42,10 @@ jobs: run: | uv pip install --python .venv -e ".[all]" --group dev + - name: Run AWS Secrets Manager tests + run: | + .venv/bin/pytest tests/test_aws_secrets_manager.py -v + - name: Run log handling tests run: | .venv/bin/pytest tests/test_log_handlers.py -v diff --git a/pyproject.toml b/pyproject.toml index ab5f718..7d168e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "jinja2>=3.1.6", "types-requests==2.33.0.20260503", "pydantic-extra-types>=2.11.1", + "boto3>=1.43.90", ] [project.optional-dependencies] diff --git a/src/openhound/core/clients/aws_secrets_manager.py b/src/openhound/core/clients/aws_secrets_manager.py new file mode 100644 index 0000000..567369c --- /dev/null +++ b/src/openhound/core/clients/aws_secrets_manager.py @@ -0,0 +1,295 @@ +import json +import logging +from collections.abc import Iterable, Mapping +from typing import Any, Protocol + +import boto3 +from botocore.exceptions import ( + BotoCoreError, + ClientError, + ConfigNotFound, + CredentialRetrievalError, + InvalidConfigError, + NoCredentialsError, + NoRegionError, + PartialCredentialsError, + ProfileNotFound, +) + +logger = logging.getLogger(__name__) + +MAX_BATCH_SIZE = 20 +_PROVIDER = "aws.secretsmanager" + + +class SecretsManagerClient(Protocol): + def get_secret_value(self, *, SecretId: str) -> Mapping[str, Any]: ... + + def batch_get_secret_value( + self, *, SecretIdList: list[str] + ) -> Mapping[str, Any]: ... + + +class SecretRetrievalError(Exception): + """Base class for errors retrieving a secret.""" + + +class SecretNotFoundError(SecretRetrievalError): + """The requested secret does not exist.""" + + +class SecretPermissionError(SecretRetrievalError): + """The configured AWS identity cannot read the requested secret.""" + + +class AWSConfigurationError(SecretRetrievalError): + """The AWS SDK or Secrets Manager client is not configured correctly.""" + + +class SecretRequestError(SecretRetrievalError): + """AWS rejected a request for an otherwise unclassified reason.""" + + +class SecretBatchError(SecretRetrievalError): + """One or more requested secrets could not be retrieved.""" + + def __init__(self, failures: Mapping[str, SecretRetrievalError]) -> None: + self.failures = dict(failures) + super().__init__("One or more AWS secret retrievals failed") + + +SecretValue = str | dict[str, Any] + + +class AWSSecretsManager: + def __init__(self, client: SecretsManagerClient | None = None) -> None: + self._client = client + + def get_secret(self, secret_id: str) -> SecretValue: + """Retrieve one text or JSON-object secret.""" + try: + client = self._get_client() + except SecretRetrievalError as error: + _log_failure("get_secret_value", error, 1, 1) + raise + return self._retrieve_secret(client, secret_id) + + def get_secrets(self, secret_ids: Iterable[str]) -> dict[str, SecretValue]: + """Retrieve any number of secrets in AWS-supported batch sizes.""" + if isinstance(secret_ids, str): + raise TypeError("secret_ids must be an iterable of secret identifiers") + requested_ids = list(secret_ids) + if not requested_ids: + return {} + + try: + client = self._get_client() + except SecretRetrievalError as error: + failures = {secret_id: error for secret_id in requested_ids} + _log_failure( + "batch_get_secret_value", + error, + len(requested_ids), + len(failures), + ) + raise SecretBatchError(failures) from None + return self._get_secrets_in_batches(client, requested_ids) + + def _get_secrets_in_batches( + self, client: SecretsManagerClient, secret_ids: list[str] + ) -> dict[str, SecretValue]: + values: dict[str, SecretValue] = {} + failures: dict[str, SecretRetrievalError] = {} + for chunk_index, chunk in enumerate(_chunks(secret_ids, MAX_BATCH_SIZE)): + _log_debug("batch_get_secret_value", "attempt", len(chunk), chunk_index + 1) + try: + response = client.batch_get_secret_value(SecretIdList=chunk) + except (ClientError, BotoCoreError) as error: + classified = _classify_aws_error(error) + chunk_values: dict[str, SecretValue] = {} + chunk_failures = { + secret_id: classified for secret_id in chunk + } + else: + chunk_values, chunk_failures = _parse_batch_response(response, chunk) + + values.update(chunk_values) + failures.update(chunk_failures) + if chunk_failures: + _log_failure( + "batch_get_secret_value", + SecretBatchError(chunk_failures), + len(chunk), + len(chunk_failures), + chunk_index + 1, + ) + else: + _log_debug( + "batch_get_secret_value", "success", len(chunk), chunk_index + 1 + ) + + if failures: + raise SecretBatchError(failures) from None + + return { + secret_id: values[secret_id] + for secret_id in secret_ids + if secret_id in values + } + + def _get_client(self) -> SecretsManagerClient: + if self._client is not None: + return self._client + try: + return boto3.client("secretsmanager") + except BotoCoreError as error: + raise _classify_aws_error(error) from None + + def _retrieve_secret( + self, client: SecretsManagerClient, secret_id: str + ) -> SecretValue: + _log_debug("get_secret_value", "attempt", 1) + try: + response = client.get_secret_value(SecretId=secret_id) + value = _parse_secret_value(response) + except (ClientError, BotoCoreError) as error: + classified = _classify_aws_error(error) + _log_failure("get_secret_value", classified, 1, 1) + raise classified from None + except SecretRetrievalError as error: + _log_failure("get_secret_value", error, 1, 1) + raise + + _log_debug("get_secret_value", "success", 1) + return value + + +def _chunks(values: list[str], size: int) -> Iterable[list[str]]: + for start in range(0, len(values), size): + yield values[start : start + size] + + +def _parse_secret_value(response: Mapping[str, Any]) -> SecretValue: + secret_string = response.get("SecretString") + if not isinstance(secret_string, str): + raise SecretRequestError( + "AWS Secrets Manager response did not contain a SecretString payload" + ) + + if secret_string.lstrip().startswith("{"): + try: + parsed = json.loads(secret_string) + except json.JSONDecodeError: + return secret_string + if isinstance(parsed, dict): + return parsed + + return secret_string + + +def _parse_batch_response( + response: Mapping[str, Any], + requested_ids: list[str], +) -> tuple[dict[str, SecretValue], dict[str, SecretRetrievalError]]: + values: dict[str, SecretValue] = {} + failures: dict[str, SecretRetrievalError] = {} + for entry in response.get("SecretValues", []): + secret_id = next( + ( + entry.get(field) + for field in ("Name", "ARN") + if entry.get(field) in requested_ids + ), + None, + ) + if not isinstance(secret_id, str): + continue + try: + values[secret_id] = _parse_secret_value(entry) + except SecretRetrievalError as value_error: + failures[secret_id] = value_error + + for entry in response.get("Errors", []): + secret_id = entry.get("SecretId") + if isinstance(secret_id, str) and secret_id in requested_ids: + failures[secret_id] = _classify_aws_error(entry.get("ErrorCode")) + + for secret_id in requested_ids: + if secret_id not in values and secret_id not in failures: + failures[secret_id] = SecretRequestError( + "AWS batch response omitted a requested secret" + ) + + return values, failures + + +def _classify_aws_error( + error: ClientError | BotoCoreError | str | None, +) -> SecretRetrievalError: + """Map a boto error or AWS error code to a public retrieval exception.""" + if isinstance(error, ClientError): + error = error.response.get("Error", {}).get("Code") + elif isinstance( + error, + ( + ConfigNotFound, + CredentialRetrievalError, + InvalidConfigError, + NoCredentialsError, + NoRegionError, + PartialCredentialsError, + ProfileNotFound, + ), + ): + return AWSConfigurationError( + "AWS Secrets Manager client configuration is invalid" + ) + if error == "ResourceNotFoundException": + return SecretNotFoundError("AWS secret was not found") + if error in { + "AccessDenied", + "AccessDeniedException", + "AuthorizationError", + "UnauthorizedOperation", + "UnrecognizedClientException", + }: + return SecretPermissionError("AWS Secrets Manager denied access to the secret") + return SecretRequestError("AWS Secrets Manager request could not be completed") + + +def _log_debug( + operation: str, + outcome: str, + secret_count: int, + batch_number: int | None = None, +) -> None: + extra: dict[str, str | int] = { + "provider": _PROVIDER, + "operation": operation, + "outcome": outcome, + "secret_count": secret_count, + } + if batch_number is not None: + extra["batch_number"] = batch_number + message = "Secret retrieval attempt" if outcome == "attempt" else "Secret retrieval result" + logger.debug(message, extra=extra) + + +def _log_failure( + operation: str, + error: SecretRetrievalError, + secret_count: int, + failure_count: int, + batch_number: int | None = None, +) -> None: + extra: dict[str, str | int] = { + "provider": _PROVIDER, + "operation": operation, + "outcome": "failure", + "failure_category": type(error).__name__, + "secret_count": secret_count, + "failure_count": failure_count, + } + if batch_number is not None: + extra["batch_number"] = batch_number + logger.error("Secret retrieval failed", extra=extra) diff --git a/tests/test_aws_secrets_manager.py b/tests/test_aws_secrets_manager.py new file mode 100644 index 0000000..98b01e1 --- /dev/null +++ b/tests/test_aws_secrets_manager.py @@ -0,0 +1,333 @@ +import logging +from collections.abc import Mapping, Sequence +from typing import Any, NoReturn, cast + +import pytest +from botocore.exceptions import ( + BotoCoreError, + ClientError, + ConfigNotFound, + CredentialRetrievalError, + EndpointConnectionError, + InvalidConfigError, + NoCredentialsError, + NoRegionError, + PartialCredentialsError, + ProfileNotFound, +) + +from openhound.core.clients.aws_secrets_manager import ( + AWSConfigurationError, + AWSSecretsManager, + SecretBatchError, + SecretNotFoundError, + SecretPermissionError, + SecretRequestError, +) + +LOGGER_NAME = "openhound.core.clients.aws_secrets_manager" +SECRET_ID = "secret-id-that-must-not-be-logged" +SECRET_VALUE = "secret-value-that-must-not-be-logged" +PROVIDER_MESSAGE = "provider message that must not be exposed" + + +class FakeSecretsManagerClient: + def __init__( + self, + *, + get_response: Mapping[str, Any] | None = None, + get_error: BaseException | None = None, + batch_responses: Sequence[Mapping[str, Any] | BaseException] | None = None, + ) -> None: + self.get_response = get_response + self.get_error = get_error + self.batch_responses = list(batch_responses or []) + self.get_requests: list[str] = [] + self.batch_requests: list[list[str]] = [] + + def get_secret_value(self, *, SecretId: str) -> Mapping[str, Any]: + self.get_requests.append(SecretId) + if self.get_error is not None: + raise self.get_error + return self.get_response or {} + + def batch_get_secret_value( + self, *, SecretIdList: list[str] + ) -> Mapping[str, Any]: + self.batch_requests.append(list(SecretIdList)) + if not self.batch_responses: + return {"SecretValues": [], "Errors": []} + response = self.batch_responses.pop(0) + if isinstance(response, BaseException): + raise response + return response + + +def aws_error( + code: str, + message: str, + operation: str = "GetSecretValue", +) -> ClientError: + return ClientError( + { + "Error": {"Code": code, "Message": message}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400, + "HostId": "", + "RequestId": "", + "RetryAttempts": 0, + }, + }, + operation, + ) + + +def assert_safe_failure(error: BaseException, forbidden: set[str]) -> None: + assert all(value not in str(error) for value in forbidden) + + +def assert_safe_logs( + caplog: pytest.LogCaptureFixture, forbidden: set[str] +) -> None: + assert all(value not in caplog.text for value in forbidden) + assert all( + all(value not in record.getMessage() for value in forbidden) + and all(value not in repr(record.__dict__) for value in forbidden) + for record in caplog.records + ) + + +@pytest.mark.parametrize( + ("secret_text", "expected"), + [ + (SECRET_VALUE, SECRET_VALUE), + ('{"username":"user","enabled":true}', {"username": "user", "enabled": True}), + ('{"malformed":', '{"malformed":'), + ('[{"not": "an object result"}]', '[{"not": "an object result"}]'), + ], +) +def test_get_secret_uses_get_secret_value_for_text_and_json_objects( + secret_text: str, expected: str | dict[str, Any] +) -> None: + client = FakeSecretsManagerClient(get_response={"SecretString": secret_text}) + + assert AWSSecretsManager(client).get_secret(SECRET_ID) == expected + assert client.get_requests == [SECRET_ID] + assert client.batch_requests == [] + + +def test_get_secrets_returns_text_and_json_objects_from_batch_values() -> None: + json_secret = "json-secret" + client = FakeSecretsManagerClient( + batch_responses=[ + { + "SecretValues": [ + {"ARN": json_secret, "SecretString": '{"value": 2}'}, + {"Name": "text-secret", "SecretString": "value-1"}, + ], + "Errors": [], + } + ] + ) + + result = AWSSecretsManager(client).get_secrets(["text-secret", json_secret]) + + assert result == {"text-secret": "value-1", json_secret: {"value": 2}} + assert list(result) == ["text-secret", json_secret] + assert client.batch_requests == [["text-secret", json_secret]] + assert client.get_requests == [] + + +def test_get_secrets_splits_requests_into_chunks_of_20_and_merges_values() -> None: + secret_ids = [f"secret-{index}" for index in range(41)] + responses = [ + { + "SecretValues": [ + {"Name": secret_id, "SecretString": f"value-{secret_id}"} + for secret_id in reversed(secret_ids[start : start + 20]) + ] + } + for start in range(0, len(secret_ids), 20) + ] + client = FakeSecretsManagerClient(batch_responses=responses) + + result = AWSSecretsManager(client).get_secrets(secret_ids) + + assert result == {secret_id: f"value-{secret_id}" for secret_id in secret_ids} + assert list(result) == secret_ids + assert [len(request) for request in client.batch_requests] == [20, 20, 1] + assert client.batch_requests == [ + secret_ids[:20], + secret_ids[20:40], + secret_ids[40:], + ] + + +def test_get_secrets_continues_and_aggregates_typed_failures_safely( + caplog: pytest.LogCaptureFixture, +) -> None: + secret_ids = [f"secret-{index}" for index in range(21)] + first_failure = secret_ids[2] + second_failure = secret_ids[-1] + client = FakeSecretsManagerClient( + batch_responses=[ + { + "SecretValues": [ + {"Name": secret_id, "SecretString": SECRET_VALUE} + for secret_id in secret_ids[:20] + if secret_id != first_failure + ], + "Errors": [ + { + "SecretId": first_failure, + "ErrorCode": "ResourceNotFoundException", + "Message": PROVIDER_MESSAGE, + } + ], + }, + aws_error( + "AccessDeniedException", + PROVIDER_MESSAGE, + operation="BatchGetSecretValue", + ), + ] + ) + forbidden = set(secret_ids) | {SECRET_VALUE, PROVIDER_MESSAGE} + + with caplog.at_level(logging.DEBUG, logger=LOGGER_NAME), pytest.raises( + SecretBatchError + ) as raised: + AWSSecretsManager(client).get_secrets(secret_ids) + + assert client.batch_requests == [secret_ids[:20], secret_ids[20:]] + assert set(raised.value.failures) == {first_failure, second_failure} + assert isinstance(raised.value.failures[first_failure], SecretNotFoundError) + assert isinstance(raised.value.failures[second_failure], SecretPermissionError) + assert_safe_failure(raised.value, forbidden) + for failure in raised.value.failures.values(): + assert_safe_failure(failure, forbidden) + + attempts = [ + cast(Any, record) + for record in caplog.records + if getattr(record, "outcome", None) == "attempt" + ] + assert [record.batch_number for record in attempts] == [1, 2] + error_records = [ + cast(Any, record) + for record in caplog.records + if record.levelno == logging.ERROR + ] + assert len(error_records) == 2 + assert all(record.failure_category == "SecretBatchError" for record in error_records) + assert_safe_logs(caplog, forbidden) + + +@pytest.mark.parametrize( + ("error", "expected_type"), + [ + ( + aws_error("ResourceNotFoundException", PROVIDER_MESSAGE), + SecretNotFoundError, + ), + ( + aws_error("AccessDeniedException", PROVIDER_MESSAGE), + SecretPermissionError, + ), + ( + EndpointConnectionError(endpoint_url="https://secretsmanager.example"), + SecretRequestError, + ), + ], +) +def test_get_secret_classifies_aws_errors( + error: BotoCoreError | ClientError, + expected_type: type[Exception], +) -> None: + client = FakeSecretsManagerClient(get_error=error) + + with pytest.raises(expected_type) as raised: + AWSSecretsManager(client).get_secret(SECRET_ID) + + assert_safe_failure(raised.value, {SECRET_ID, PROVIDER_MESSAGE}) + + +@pytest.mark.parametrize( + "sdk_error", + [ + NoCredentialsError(), + PartialCredentialsError(provider="aws", cred_var="secret-key"), + NoRegionError(), + InvalidConfigError(error_msg=PROVIDER_MESSAGE), + ConfigNotFound(path=PROVIDER_MESSAGE), + ProfileNotFound(profile=PROVIDER_MESSAGE), + CredentialRetrievalError( + provider="credential-provider", error_msg=PROVIDER_MESSAGE + ), + ], +) +def test_client_creation_configuration_failures_are_classified( + monkeypatch: pytest.MonkeyPatch, + sdk_error: BotoCoreError, +) -> None: + def fail_client(*_args: object, **_kwargs: object) -> NoReturn: + raise sdk_error + + monkeypatch.setattr( + "openhound.core.clients.aws_secrets_manager.boto3.client", fail_client + ) + + with pytest.raises(AWSConfigurationError): + AWSSecretsManager().get_secret(SECRET_ID) + + +def test_successful_retrieval_logs_safe_attempt_and_result_records( + caplog: pytest.LogCaptureFixture, +) -> None: + client = FakeSecretsManagerClient(get_response={"SecretString": SECRET_VALUE}) + + with caplog.at_level(logging.DEBUG, logger=LOGGER_NAME): + AWSSecretsManager(client).get_secret(SECRET_ID) + + records = [cast(Any, record) for record in caplog.records] + assert [(record.levelno, record.outcome) for record in records] == [ + (logging.DEBUG, "attempt"), + (logging.DEBUG, "success"), + ] + assert all(record.provider == "aws.secretsmanager" for record in records) + assert all(record.operation == "get_secret_value" for record in records) + assert all(record.secret_count == 1 for record in records) + assert_safe_logs(caplog, {SECRET_ID, SECRET_VALUE}) + + caplog.clear() + batch_client = FakeSecretsManagerClient( + batch_responses=[ + { + "SecretValues": [ + {"Name": "first", "SecretString": SECRET_VALUE}, + {"Name": "second", "SecretString": "second-value"}, + ] + } + ] + ) + with caplog.at_level(logging.DEBUG, logger=LOGGER_NAME): + AWSSecretsManager(batch_client).get_secrets(["first", "second"]) + + batch_records = [cast(Any, record) for record in caplog.records] + assert [(record.levelno, record.outcome) for record in batch_records] == [ + (logging.DEBUG, "attempt"), + (logging.DEBUG, "success"), + ] + assert all(record.operation == "batch_get_secret_value" for record in batch_records) + assert all(record.secret_count == 2 for record in batch_records) + assert all(record.batch_number == 1 for record in batch_records) + assert_safe_logs( + caplog, + {"first", "second", SECRET_VALUE, "second-value"}, + ) + + +def test_get_secrets_rejects_a_string_instead_of_ids() -> None: + with pytest.raises(TypeError, match="iterable of secret identifiers"): + AWSSecretsManager(FakeSecretsManagerClient()).get_secrets(SECRET_ID) diff --git a/uv.lock b/uv.lock index d00e9cb..20a4cf4 100644 --- a/uv.lock +++ b/uv.lock @@ -159,6 +159,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cd/0c/31cfaa6b56fe23488ecb993bc9fc526c0d84d89607decdf2a10776426c2e/binaryornot-0.6.0-py3-none-any.whl", hash = "sha256:900adfd5e1b821255ba7e63139b0396b14c88b9286e74e03b6f51e0200331337", size = 14185, upload-time = "2026-03-08T16:26:27.466Z" }, ] +[[package]] +name = "boto3" +version = "1.43.90" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/0b/53f833efea7331aab106a65f1156e0035beed9bacbfcb309b128204468d4/boto3-1.43.90.tar.gz", hash = "sha256:4b669742d5b45b8fd20ca50ac414a4e4cf995ebb8f280d21be28676e71c97594", size = 112689, upload-time = "2026-09-08T19:22:30.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/c3/1d5c5ea7f599e2b80d122beeb2a9c1a8daff78325ea2391521e5fd5af1fd/boto3-1.43.90-py3-none-any.whl", hash = "sha256:aaaa1216d65ddb3dcf86bf9d93cfa436af05cbcd1e1f8b2847f2b83116012186", size = 140026, upload-time = "2026-09-08T19:22:28.555Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.90" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/3e/29872261075d878581a31e9e98c512c0b85322a36e6d6784b9f0694a918d/botocore-1.43.90.tar.gz", hash = "sha256:a139ed601e8b8fb1d730022355fe2b284b8c15cfe9ac0f100254a35d2273e1d3", size = 16081908, upload-time = "2026-09-08T19:22:25.645Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/b8/65cbd0d65553c3e51d27159de4c74308ea008c652b05ed3f830f32667efb/botocore-1.43.90-py3-none-any.whl", hash = "sha256:65f3394ef07314e45c92a90120988531d651136390d464a94938a92f665893f7", size = 15775313, upload-time = "2026-09-08T19:22:22.713Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -800,6 +828,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "joserfc" version = "1.7.4" @@ -1269,6 +1306,7 @@ name = "openhound" source = { editable = "." } dependencies = [ { name = "alive-progress" }, + { name = "boto3" }, { name = "cookiecutter" }, { name = "dlt" }, { name = "duckdb" }, @@ -1320,6 +1358,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "alive-progress", specifier = ">=3.3.0" }, + { name = "boto3", specifier = ">=1.43.90" }, { name = "cookiecutter", specifier = ">=2.6.0" }, { name = "dlt", specifier = "==1.26.0" }, { name = "duckdb", specifier = "==1.5.2" }, @@ -2091,6 +2130,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, ] +[[package]] +name = "s3transfer" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" }, +] + [[package]] name = "semver" version = "3.0.4"