diff --git a/modules/google/README.rst b/modules/google/README.rst index 2f8c14d8f..903c3c4a1 100644 --- a/modules/google/README.rst +++ b/modules/google/README.rst @@ -1,2 +1,4 @@ +.. autoclass:: testcontainers.google.DatastoreContainer +.. title:: testcontainers.google.DatastoreContainer .. autoclass:: testcontainers.google.PubSubContainer .. title:: testcontainers.google.PubSubContainer diff --git a/modules/google/testcontainers/google/__init__.py b/modules/google/testcontainers/google/__init__.py index b28f2ed48..92c782efc 100644 --- a/modules/google/testcontainers/google/__init__.py +++ b/modules/google/testcontainers/google/__init__.py @@ -1 +1,2 @@ +from .datastore import DatastoreContainer # noqa: F401 from .pubsub import PubSubContainer # noqa: F401 diff --git a/modules/google/testcontainers/google/datastore.py b/modules/google/testcontainers/google/datastore.py new file mode 100644 index 000000000..24edbdcd7 --- /dev/null +++ b/modules/google/testcontainers/google/datastore.py @@ -0,0 +1,68 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import os +from unittest.mock import patch + +from google.cloud import datastore +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs + + +class DatastoreContainer(DockerContainer): + """ + Datastore container for testing managed message queues. + + Example: + + The example will spin up a Google Cloud Datastore emulator that you can use for integration + tests. The :code:`datastore` instance provides convenience methods :code:`get_datastore_client` to + connect to the emulator without having to set the environment variable :code:`DATASTORE_EMULATOR_HOST`. + + .. doctest:: + + >>> from testcontainers.google import DatastoreContainer + + >>> config = DatastoreContainer() + >>> with config as datastore: + ... datastore_client = datastore.get_datastore_client() + """ + + def __init__( + self, + image: str = "google/cloud-sdk:emulators", + project: str = "test-project", + port: int = 8081, + **kwargs, + ) -> None: + super().__init__(image=image, **kwargs) + self.project = project + self.port = port + self.with_exposed_ports(self.port) + self.with_command( + f"gcloud beta emulators datastore start --no-store-on-disk --project={project} --host-port=0.0.0.0:{port}" + ) + + def get_datastore_emulator_host(self) -> str: + return f"{self.get_container_host_ip()}:{self.get_exposed_port(self.port)}" + + def get_datastore_client(self, **kwargs) -> datastore.Client: + wait_for_logs(self, "Dev App Server is now running.", timeout=30.0) + env_vars = { + "DATASTORE_DATASET": self.project, + "DATASTORE_EMULATOR_HOST": self.get_datastore_emulator_host(), + "DATASTORE_EMULATOR_HOST_PATH": f"{self.get_datastore_emulator_host()}/datastore", + "DATASTORE_HOST": f"http://{self.get_datastore_emulator_host()}", + "DATASTORE_PROJECT_ID": self.project, + } + with patch.dict(os.environ, env_vars): + return datastore.Client(**kwargs) diff --git a/modules/google/tests/test_google.py b/modules/google/tests/test_google.py index 780f5fdd6..0c412d706 100644 --- a/modules/google/tests/test_google.py +++ b/modules/google/tests/test_google.py @@ -1,7 +1,8 @@ from queue import Queue +from google.cloud.datastore import Entity from testcontainers.core.waiting_utils import wait_for_logs -from testcontainers.google import PubSubContainer +from testcontainers.google import PubSubContainer, DatastoreContainer def test_pubsub_container(): @@ -27,3 +28,49 @@ def test_pubsub_container(): message = queue.get(timeout=1) assert message.data == b"Hello world!" message.ack() + + +def test_datastore_container_creation(): + # Initialize the Datastore emulator container + with DatastoreContainer() as datastore: + # Obtain a datastore client configured to connect to the emulator + client = datastore.get_datastore_client() + + # Define a unique key for a test entity to ensure test isolation + key = client.key("TestKind", "test_id_1") + + # Create and insert a new entity + entity = Entity(key=key) + entity.update({"foo": "bar"}) + client.put(entity) + + # Fetch the just-inserted entity directly + fetched_entity = client.get(key) + + # Assert that the fetched entity matches what was inserted + assert fetched_entity is not None, "Entity was not found in the datastore." + assert fetched_entity["foo"] == "bar", "Entity attribute 'foo' did not match expected value 'bar'." + + +def test_datastore_container_isolation(): + # Initialize the Datastore emulator container + with DatastoreContainer() as datastore: + # Obtain a datastore client configured to connect to the emulator + client = datastore.get_datastore_client() + + # Define a unique key for a test entity to ensure test isolation + key = client.key("TestKind", "test_id_1") + + # Create and insert a new entity + entity = Entity(key=key) + entity.update({"foo": "bar"}) + client.put(entity) + + # Create a second container and try to fetch the entity to makesure its a different container + with DatastoreContainer() as datastore2: + assert ( + datastore.get_datastore_emulator_host() != datastore2.get_datastore_emulator_host() + ), "Datastore containers use the same port." + client2 = datastore2.get_datastore_client() + fetched_entity2 = client2.get(key) + assert fetched_entity2 is None, "Entity was found in the datastore." diff --git a/poetry.lock b/poetry.lock index 357dd6604..39b3d3d4e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -860,6 +860,47 @@ pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] reauth = ["pyu2f (>=0.1.5)"] requests = ["requests (>=2.20.0,<3.0.0.dev0)"] +[[package]] +name = "google-cloud-core" +version = "2.4.1" +description = "Google Cloud API client core library" +optional = true +python-versions = ">=3.7" +files = [ + {file = "google-cloud-core-2.4.1.tar.gz", hash = "sha256:9b7749272a812bde58fff28868d0c5e2f585b82f37e09a1f6ed2d4d10f134073"}, + {file = "google_cloud_core-2.4.1-py2.py3-none-any.whl", hash = "sha256:a9e6a4422b9ac5c29f79a0ede9485473338e2ce78d91f2370c01e730eab22e61"}, +] + +[package.dependencies] +google-api-core = ">=1.31.6,<2.0.dev0 || >2.3.0,<3.0.0dev" +google-auth = ">=1.25.0,<3.0dev" + +[package.extras] +grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"] + +[[package]] +name = "google-cloud-datastore" +version = "2.19.0" +description = "Google Cloud Datastore API client library" +optional = true +python-versions = ">=3.7" +files = [ + {file = "google-cloud-datastore-2.19.0.tar.gz", hash = "sha256:07fc5870a0261f25466c557c134df95a96dfd2537abd088b9d537fbabe99b974"}, + {file = "google_cloud_datastore-2.19.0-py2.py3-none-any.whl", hash = "sha256:c52086670d4c3779ea7bd8f8353b093a9b5e81c6606f36ffcdf46e6ce9fc80c0"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.0,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-cloud-core = ">=1.4.0,<3.0.0dev" +proto-plus = [ + {version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""}, + {version = ">=1.22.0,<2.0.0dev", markers = "python_version < \"3.11\""}, +] +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev" + +[package.extras] +libcst = ["libcst (>=0.2.5)"] + [[package]] name = "google-cloud-pubsub" version = "2.20.1" @@ -1543,6 +1584,7 @@ files = [ {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273"}, {file = "msgpack-1.0.8-cp39-cp39-win32.whl", hash = "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d"}, {file = "msgpack-1.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011"}, + {file = "msgpack-1.0.8-py3-none-any.whl", hash = "sha256:24f727df1e20b9876fa6e95f840a2a2651e34c0ad147676356f4bf5fbb0206ca"}, {file = "msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3"}, ] @@ -3267,7 +3309,7 @@ arangodb = ["python-arango"] azurite = ["azure-storage-blob"] clickhouse = ["clickhouse-driver"] elasticsearch = [] -google = ["google-cloud-pubsub"] +google = ["google-cloud-datastore", "google-cloud-pubsub"] influxdb = ["influxdb", "influxdb-client"] k3s = ["kubernetes", "pyyaml"] kafka = [] @@ -3289,4 +3331,4 @@ selenium = ["selenium"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "d58539d14fbcf79c97d6dde14d76a86b52cc11d059bd5e211655be73b74c4993" +content-hash = "d28fd579c8964fef5acec08c1c39a77d7200d77393320e92cf72f07f49100e5e" diff --git a/pyproject.toml b/pyproject.toml index c48dce7e5..a0ca924a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ python-arango = { version = "^7.8", optional = true } azure-storage-blob = { version = "^12.19", optional = true } clickhouse-driver = { version = "*", optional = true } google-cloud-pubsub = { version = ">=2", optional = true } +google-cloud-datastore = { version = ">=2", optional = true } influxdb = { version = "*", optional = true } influxdb-client = { version = "*", optional = true } kubernetes = { version = "*", optional = true } @@ -90,7 +91,7 @@ arangodb = ["python-arango"] azurite = ["azure-storage-blob"] clickhouse = ["clickhouse-driver"] elasticsearch = [] -google = ["google-cloud-pubsub"] +google = ["google-cloud-pubsub", "google-cloud-datastore"] influxdb = ["influxdb", "influxdb-client"] k3s = ["kubernetes", "pyyaml"] kafka = []