From 73aa3275021997085de89ae1c3c68373837f2c89 Mon Sep 17 00:00:00 2001 From: Jonathan Remy Date: Mon, 3 Nov 2025 11:46:25 +0100 Subject: [PATCH 1/2] feat(mongodb): assume UTC for naive expires_at with custom APIs and add VCR test and cassette --- .../scaleway_async/mongodb/v1/custom_api.py | 31 ++++++++++ .../mongodb/v1alpha1/custom_api.py | 31 ++++++++++ scaleway/scaleway/mongodb/v1/custom_api.py | 32 ++++++++++ scaleway/scaleway/mongodb/v1/tests/README.md | 21 +++++++ ...ot_with_naive_expires_at_vcr.cassette.yaml | 62 +++++++++++++++++++ .../mongodb/v1/tests/test_custom_api.py | 46 ++++++++++++++ .../scaleway/mongodb/v1alpha1/custom_api.py | 32 ++++++++++ 7 files changed, 255 insertions(+) create mode 100644 scaleway-async/scaleway_async/mongodb/v1/custom_api.py create mode 100644 scaleway-async/scaleway_async/mongodb/v1alpha1/custom_api.py create mode 100644 scaleway/scaleway/mongodb/v1/custom_api.py create mode 100644 scaleway/scaleway/mongodb/v1/tests/README.md create mode 100644 scaleway/scaleway/mongodb/v1/tests/cassettes/test_create_snapshot_with_naive_expires_at_vcr.cassette.yaml create mode 100644 scaleway/scaleway/mongodb/v1/tests/test_custom_api.py create mode 100644 scaleway/scaleway/mongodb/v1alpha1/custom_api.py diff --git a/scaleway-async/scaleway_async/mongodb/v1/custom_api.py b/scaleway-async/scaleway_async/mongodb/v1/custom_api.py new file mode 100644 index 000000000..4e5643803 --- /dev/null +++ b/scaleway-async/scaleway_async/mongodb/v1/custom_api.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional, Any + +from .api import MongodbV1API + + +def _ensure_tzaware_utc(value: Optional[datetime]) -> Optional[datetime]: + if value is None: + return None + if value.tzinfo is None or value.tzinfo.utcoffset(value) is None: + return value.replace(tzinfo=timezone.utc) + return value + + +class MongodbUtilsV1API(MongodbV1API): + """ + Async extensions for MongoDB V1. + - Naive datetimes for expires_at are assumed to be UTC. + """ + + async def create_snapshot(self, **kwargs: Any) -> Any: + expires_at = kwargs.get("expires_at") + kwargs["expires_at"] = _ensure_tzaware_utc(expires_at) + return await super().create_snapshot(**kwargs) + + async def update_snapshot(self, **kwargs: Any) -> Any: + expires_at = kwargs.get("expires_at") + kwargs["expires_at"] = _ensure_tzaware_utc(expires_at) + return await super().update_snapshot(**kwargs) diff --git a/scaleway-async/scaleway_async/mongodb/v1alpha1/custom_api.py b/scaleway-async/scaleway_async/mongodb/v1alpha1/custom_api.py new file mode 100644 index 000000000..cc174c2ff --- /dev/null +++ b/scaleway-async/scaleway_async/mongodb/v1alpha1/custom_api.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional, Any + +from .api import MongodbV1Alpha1API + + +def _ensure_tzaware_utc(value: Optional[datetime]) -> Optional[datetime]: + if value is None: + return None + if value.tzinfo is None or value.tzinfo.utcoffset(value) is None: + return value.replace(tzinfo=timezone.utc) + return value + + +class MongodbUtilsV1Alpha1API(MongodbV1Alpha1API): + """ + Async extensions for MongoDB V1alpha1. + - Naive datetimes for expires_at are assumed to be UTC. + """ + + async def create_snapshot(self, **kwargs: Any) -> Any: + expires_at = kwargs.get("expires_at") + kwargs["expires_at"] = _ensure_tzaware_utc(expires_at) + return await super().create_snapshot(**kwargs) + + async def update_snapshot(self, **kwargs: Any) -> Any: + expires_at = kwargs.get("expires_at") + kwargs["expires_at"] = _ensure_tzaware_utc(expires_at) + return await super().update_snapshot(**kwargs) diff --git a/scaleway/scaleway/mongodb/v1/custom_api.py b/scaleway/scaleway/mongodb/v1/custom_api.py new file mode 100644 index 000000000..855707ea7 --- /dev/null +++ b/scaleway/scaleway/mongodb/v1/custom_api.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Optional + +from scaleway.mongodb.v1.api import MongodbV1API # type: ignore[import-untyped] + + +def _ensure_tzaware_utc(value: Optional[datetime]) -> Optional[datetime]: + if value is None: + return None + if value.tzinfo is None or value.tzinfo.utcoffset(value) is None: + return value.replace(tzinfo=timezone.utc) + return value + + +class MongodbUtilsV1API(MongodbV1API): # type: ignore[misc] + """ + Extensions for MongoDB V1 that provide safer ergonomics. + + - Naive datetimes for expires_at are assumed to be UTC. + """ + + def create_snapshot(self, **kwargs: Any) -> Any: + expires_at = kwargs.get("expires_at") + kwargs["expires_at"] = _ensure_tzaware_utc(expires_at) + return super().create_snapshot(**kwargs) + + def update_snapshot(self, **kwargs: Any) -> Any: + expires_at = kwargs.get("expires_at") + kwargs["expires_at"] = _ensure_tzaware_utc(expires_at) + return super().update_snapshot(**kwargs) diff --git a/scaleway/scaleway/mongodb/v1/tests/README.md b/scaleway/scaleway/mongodb/v1/tests/README.md new file mode 100644 index 000000000..49e9b8eb9 --- /dev/null +++ b/scaleway/scaleway/mongodb/v1/tests/README.md @@ -0,0 +1,21 @@ +# MongoDB tests (VCR) + +This suite uses VCR cassettes to replay HTTP calls in CI. + +How to record locally: + +1. Ensure you have valid Scaleway credentials exported (access key, secret key, default region). +2. Pick a MongoDB instance to target for snapshot creation. +3. Temporarily replace the fixed `instance_id` in `test_custom_api.py` with your instance id or set breakpoints to inject it. +4. Run the specific test once to record the cassette: + + ```bash + pytest -k test_create_snapshot_with_naive_expires_at_vcr + ``` + +5. Commit the generated cassette file: + - Path: `scaleway/scaleway/mongodb/v1/tests/cassettes/test_create_snapshot_with_naive_expires_at_vcr.cassette.yaml` + +Notes: +- The test will skip in CI if the cassette file is missing. +- After recording, restore the fixed `instance_id` value used by the test to keep requests stable across replays. diff --git a/scaleway/scaleway/mongodb/v1/tests/cassettes/test_create_snapshot_with_naive_expires_at_vcr.cassette.yaml b/scaleway/scaleway/mongodb/v1/tests/cassettes/test_create_snapshot_with_naive_expires_at_vcr.cassette.yaml new file mode 100644 index 000000000..2e97a5ead --- /dev/null +++ b/scaleway/scaleway/mongodb/v1/tests/cassettes/test_create_snapshot_with_naive_expires_at_vcr.cassette.yaml @@ -0,0 +1,62 @@ +interactions: +- request: + body: '{"instance_id": "dd5cd838-525b-4395-b2ae-4dec3381ceaf", "name": "sdk-python-test-snapshot", + "expires_at": "2025-11-04T11:16:49.007353+00:00"}' + headers: + Content-Length: + - '141' + user-agent: + - scaleway-sdk-python/2.0.0 + method: POST + uri: https://api.scaleway.com/mongodb/v1/regions/fr-par/snapshots + response: + body: + string: '{"id": "7d68eb22-4529-452b-af73-160cc621427f", "instance_id": "dd5cd838-525b-4395-b2ae-4dec3381ceaf", + "instance_name": "mgdb-magical-curie", "name": "sdk-python-test-snapshot", + "status": "creating", "created_at": "2025-11-03T10:16:49.164635Z", "updated_at": + "2025-11-03T10:16:49.164635Z", "expires_at": "2025-11-04T11:16:49.007353Z", + "size_bytes": 0, "node_type": "mgdb-play2-nano", "volume_type": "sbs_5k", + "region": "fr-par"}' + headers: + content-length: + - '415' + date: + - Mon, 03 Nov 2025 10:16:49 GMT + server: + - Scaleway API Gateway (fr-par-3;edge01) + x-request-id: + - 68289a8d-c836-47bb-8e92-9ff15be8b012 + status: + code: 200 + message: OK +- request: + body: '{"instance_id": "dd5cd838-525b-4395-b2ae-4dec3381ceaf", "name": "sdk-python-test-snapshot", + "expires_at": "2025-11-04T11:19:08.742558+00:00"}' + headers: + Content-Length: + - '141' + user-agent: + - scaleway-sdk-python/2.0.0 + method: POST + uri: https://api.scaleway.com/mongodb/v1/regions/fr-par/snapshots + response: + body: + string: '{"id": "e604d001-e413-4c55-a27c-909a852a8343", "instance_id": "dd5cd838-525b-4395-b2ae-4dec3381ceaf", + "instance_name": "mgdb-magical-curie", "name": "sdk-python-test-snapshot", + "status": "creating", "created_at": "2025-11-03T10:19:08.876365Z", "updated_at": + "2025-11-03T10:19:08.876365Z", "expires_at": "2025-11-04T11:19:08.742558Z", + "size_bytes": 0, "node_type": "mgdb-play2-nano", "volume_type": "sbs_5k", + "region": "fr-par"}' + headers: + content-length: + - '415' + date: + - Mon, 03 Nov 2025 10:19:09 GMT + server: + - Scaleway API Gateway (fr-par-3;edge01) + x-request-id: + - 408c3493-f594-4fc6-a6eb-f00782ea820a + status: + code: 200 + message: OK +version: 1 diff --git a/scaleway/scaleway/mongodb/v1/tests/test_custom_api.py b/scaleway/scaleway/mongodb/v1/tests/test_custom_api.py new file mode 100644 index 000000000..a62a235e5 --- /dev/null +++ b/scaleway/scaleway/mongodb/v1/tests/test_custom_api.py @@ -0,0 +1,46 @@ +import os +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from vcr_config import scw_vcr +from vcr_config import PYTHON_UPDATE_CASSETTE +from tests.utils import initialize_client_test +from scaleway.mongodb.v1.custom_api import MongodbUtilsV1API + + +# mypy: ignore-errors + + +@scw_vcr.use_cassette +def test_create_snapshot_with_naive_expires_at_vcr() -> None: + cassette = ( + Path(__file__).with_name("cassettes") + / "test_create_snapshot_with_naive_expires_at_vcr.cassette.yaml" + ) + if not cassette.exists() and not os.getenv("PYTHON_UPDATE_CASSETTE"): + pytest.skip( + "cassette not recorded yet; set PYTHON_UPDATE_CASSETTE=true to record" + ) + client = initialize_client_test() + api = MongodbUtilsV1API(client, bypass_validation=True) + + # During recording, require a real instance_id via env; during replay, use the fixed value matching the cassette + if PYTHON_UPDATE_CASSETTE: + instance_id = os.getenv("SCW_TEST_MONGODB_INSTANCE_ID") + if not instance_id: + pytest.skip("SCW_TEST_MONGODB_INSTANCE_ID not set while recording") + else: + instance_id = "00000000-0000-0000-0000-000000000000" + + # Naive datetime should be handled as UTC by the utils API + naive_dt = datetime.now().replace(tzinfo=None) + timedelta(days=1) + + snapshot = api.create_snapshot( + instance_id=instance_id, + name="sdk-python-test-snapshot", + expires_at=naive_dt, + ) + + assert snapshot is not None diff --git a/scaleway/scaleway/mongodb/v1alpha1/custom_api.py b/scaleway/scaleway/mongodb/v1alpha1/custom_api.py new file mode 100644 index 000000000..25ed7438f --- /dev/null +++ b/scaleway/scaleway/mongodb/v1alpha1/custom_api.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Optional + +from scaleway.mongodb.v1alpha1.api import MongodbV1Alpha1API # type: ignore[import-untyped] + + +def _ensure_tzaware_utc(value: Optional[datetime]) -> Optional[datetime]: + if value is None: + return None + if value.tzinfo is None or value.tzinfo.utcoffset(value) is None: + return value.replace(tzinfo=timezone.utc) + return value + + +class MongodbUtilsV1Alpha1API(MongodbV1Alpha1API): # type: ignore[misc] + """ + Extensions for MongoDB V1alpha1 that provide safer ergonomics. + + - Naive datetimes for expires_at are assumed to be UTC. + """ + + def create_snapshot(self, **kwargs: Any) -> Any: + expires_at = kwargs.get("expires_at") + kwargs["expires_at"] = _ensure_tzaware_utc(expires_at) + return super().create_snapshot(**kwargs) + + def update_snapshot(self, **kwargs: Any) -> Any: + expires_at = kwargs.get("expires_at") + kwargs["expires_at"] = _ensure_tzaware_utc(expires_at) + return super().update_snapshot(**kwargs) From 2245d9cb087a49b1279a330a76a95a54df70c186 Mon Sep 17 00:00:00 2001 From: Jonathan Remy Date: Mon, 3 Nov 2025 14:09:58 +0100 Subject: [PATCH 2/2] test(mongodb): fix ruff warnings by using timezone-aware now() before stripping tz --- scaleway/scaleway/mongodb/v1/tests/test_custom_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scaleway/scaleway/mongodb/v1/tests/test_custom_api.py b/scaleway/scaleway/mongodb/v1/tests/test_custom_api.py index a62a235e5..081bc0722 100644 --- a/scaleway/scaleway/mongodb/v1/tests/test_custom_api.py +++ b/scaleway/scaleway/mongodb/v1/tests/test_custom_api.py @@ -35,7 +35,7 @@ def test_create_snapshot_with_naive_expires_at_vcr() -> None: instance_id = "00000000-0000-0000-0000-000000000000" # Naive datetime should be handled as UTC by the utils API - naive_dt = datetime.now().replace(tzinfo=None) + timedelta(days=1) + naive_dt = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(days=1) snapshot = api.create_snapshot( instance_id=instance_id,