Skip to content

Commit af58cdd

Browse files
Mehdi BEN ABDALLAHmbenabda
authored andcommitted
feat(cosmosdb) : add support for the CosmosDB emulator
1 parent e575b28 commit af58cdd

File tree

5 files changed

+190
-61
lines changed

5 files changed

+190
-61
lines changed

index.rst

Lines changed: 21 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ testcontainers-python
1313
testcontainers-python facilitates the use of Docker containers for functional and integration testing. The collection of packages currently supports the following features.
1414

1515
.. toctree::
16-
:maxdepth: 1
1716

1817
core/README
1918
modules/index
@@ -60,15 +59,12 @@ Installation
6059
------------
6160

6261
The suite of testcontainers packages is available on `PyPI <https://pypi.org/project/testcontainers/>`_,
63-
and the package can be installed using :code:`pip`.
62+
and individual packages can be installed using :code:`pip`.
6463

65-
Version `4.0.0` onwards we do not support the `testcontainers-*` packages as it is unsustainable to maintain ownership.
64+
Version `4.0.0` onwards we do not support the `testcontainers-*` packages as it is unsutainable to maintain ownership.
6665

6766
Instead packages can be installed by specifying `extras <https://setuptools.readthedocs.io/en/latest/setuptools.html#declaring-extras-optional-features-with-their-own-dependencies>`__, e.g., :code:`pip install testcontainers[postgres]`.
6867

69-
Please note, that community modules are supported on a best-effort basis and breaking changes DO NOT create major versions in the package.
70-
Therefore, only the package core is strictly following SemVer. If your workflow is broken by a minor update, please look at the changelogs for guidance.
71-
7268

7369
Custom Containers
7470
-----------------
@@ -84,75 +80,40 @@ For common use cases, you can also use the generic containers provided by the `t
8480
Docker in Docker (DinD)
8581
-----------------------
8682

87-
When trying to launch Testcontainers from within a Docker container, e.g., in continuous integration testing, two things have to be provided:
83+
When trying to launch a testcontainer from within a Docker container, e.g., in continuous integration testing, two things have to be provided:
8884

8985
1. The container has to provide a docker client installation. Either use an image that has docker pre-installed (e.g. the `official docker images <https://hub.docker.com/_/docker>`_) or install the client from within the `Dockerfile` specification.
9086
2. The container has to have access to the docker daemon which can be achieved by mounting `/var/run/docker.sock` or setting the `DOCKER_HOST` environment variable as part of your `docker run` command.
9187

92-
Private Docker registry
93-
-----------------------
94-
95-
Using a private docker registry requires the `DOCKER_AUTH_CONFIG` environment variable to be set.
96-
`official documentation <https://docs.docker.com/engine/reference/commandline/login/#credential-helpers>`_
97-
98-
The value of this variable should be a JSON string containing the authentication information for the registry.
99-
100-
Example:
101-
102-
.. code-block:: bash
103-
104-
DOCKER_AUTH_CONFIG='{"auths": {"https://myregistry.com": {"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="}}}'
105-
106-
In order to generate the JSON string, you can use the following command:
107-
108-
.. code-block:: bash
109-
110-
echo -n '{"auths": {"<url>": {"auth": "'$(echo -n "<username>:<password>" | base64 -w 0)'"}}}'
111-
112-
Fetching passwords from cloud providers:
113-
114-
.. code-block:: bash
115-
116-
ECR_PASSWORD = $(aws ecr get-login-password --region eu-west-1)
117-
GCP_PASSWORD = $(gcloud auth print-access-token)
118-
AZURE_PASSWORD = $(az acr login --name <registry-name> --expose-token --output tsv)
119-
120-
12188
Configuration
12289
-------------
12390

124-
+-------------------------------------------+---------------------------------------------------+------------------------------------------+
125-
| Env Variable | Example | Description |
126-
+===========================================+===================================================+==========================================+
127-
| ``TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE`` | ``/var/run/docker.sock`` | Path to Docker's socket used by ryuk |
128-
+-------------------------------------------+---------------------------------------------------+------------------------------------------+
129-
| ``TESTCONTAINERS_RYUK_PRIVILEGED`` | ``false`` | Run ryuk as a privileged container |
130-
+-------------------------------------------+---------------------------------------------------+------------------------------------------+
131-
| ``TESTCONTAINERS_RYUK_DISABLED`` | ``false`` | Disable ryuk |
132-
+-------------------------------------------+---------------------------------------------------+------------------------------------------+
133-
| ``RYUK_CONTAINER_IMAGE`` | ``testcontainers/ryuk:0.7.0`` | Custom image for ryuk |
134-
+-------------------------------------------+---------------------------------------------------+------------------------------------------+
135-
| ``DOCKER_AUTH_CONFIG`` | ``{"auths": {"<url>": {"auth": "<encoded>"}}}`` | Custom registry auth config |
136-
+-------------------------------------------+---------------------------------------------------+------------------------------------------+
91+
+-------------------------------------------+-------------------------------+------------------------------------------+
92+
| Env Variable | Example | Description |
93+
+===========================================+===============================+==========================================+
94+
| ``TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE`` | ``/var/run/docker.sock`` | Path to Docker's socket used by ryuk |
95+
+-------------------------------------------+-------------------------------+------------------------------------------+
96+
| ``TESTCONTAINERS_RYUK_PRIVILEGED`` | ``false`` | Run ryuk as a privileged container |
97+
+-------------------------------------------+-------------------------------+------------------------------------------+
98+
| ``TESTCONTAINERS_RYUK_DISABLED`` | ``false`` | Disable ryuk |
99+
+-------------------------------------------+-------------------------------+------------------------------------------+
100+
| ``RYUK_CONTAINER_IMAGE`` | ``testcontainers/ryuk:0.7.0`` | Custom image for ryuk |
101+
+-------------------------------------------+-------------------------------+------------------------------------------+
137102

138103
Development and Contributing
139104
----------------------------
140105

141-
We recommend you use a `Poetry <https://python-poetry.org/docs/>`_ for development.
142-
After having installed `poetry`, you can run the following snippet to set up your local dev environment.
106+
We recommend you use a `virtual environment <https://virtualenv.pypa.io/en/stable/>`_ for development (:code:`python>=3.7` is required). After setting up your virtual environment, you can install all dependencies and test the installation by running the following snippet.
143107

144108
.. code-block:: bash
145109
146-
make install
110+
poetry install --all-extras
111+
make <your-module>/tests
147112
148113
Package Structure
149114
^^^^^^^^^^^^^^^^^
150115

151-
Testcontainers is a collection of `implicit namespace packages <https://peps.python.org/pep-0420/>`__
152-
to decouple the development of different extensions,
153-
e.g., :code:`testcontainers[mysql]` and :code:`testcontainers[postgres]` for MySQL and PostgreSQL database containers, respectively.
154-
155-
The folder structure is as follows:
116+
Testcontainers is a collection of `implicit namespace packages <https://peps.python.org/pep-0420/>`__ to decouple the development of different extensions, e.g., :code:`testcontainers-mysql` and :code:`testcontainers-postgres` for MySQL and PostgreSQL database containers, respectively. The folder structure is as follows.
156117

157118
.. code-block:: bash
158119
@@ -172,11 +133,10 @@ The folder structure is as follows:
172133
...
173134
# README for this feature.
174135
README.rst
136+
# Setup script for this feature.
137+
setup.py
175138
176139
Contributing a New Feature
177140
^^^^^^^^^^^^^^^^^^^^^^^^^^
178141

179-
You want to contribute a new feature or container? Great!
180-
- We recommend you first `open an issue <https://github.com/testcontainers/testcontainers-python/issues/new/choose>`_
181-
- Then follow the suggestions from the team
182-
- We also have a Pull Request `template <https://github.com/testcontainers/testcontainers-python/blob/main/.github/PULL_REQUEST_TEMPLATE/new_container.md>`_ for new containers!
142+
You want to contribute a new feature or container? Great! You can do that in six steps as outlined `here <https://github.com/testcontainers/testcontainers-python/blob/main/.github/PULL_REQUEST_TEMPLATE/new_container.md>__`.

modules/cosmosdb/README.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.. autoclass:: testcontainers.cosmosdb.CosmosDBEmulatorContainer
2+
.. title:: testcontainers.cosmosdb.CosmosDBEmulatorContainer
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
from testcontainers.core.container import DockerContainer
2+
from testcontainers.core.waiting_utils import wait_for_logs, wait_container_is_ready
3+
import os
4+
import ssl
5+
import socket
6+
from typing import Iterable, Callable
7+
from typing_extensions import Self
8+
from azure.cosmos import CosmosClient as SyncCosmosClient
9+
from azure.cosmos.aio import CosmosClient as AsyncCosmosClient
10+
from azure.core.exceptions import ServiceRequestError
11+
12+
from urllib.request import urlopen
13+
from urllib.error import HTTPError, URLError
14+
15+
from enum import Enum, auto
16+
17+
__all__ = ["CosmosDBEmulatorContainer", "Endpoints"]
18+
19+
class Endpoints(Enum):
20+
Direct = auto()
21+
Gremlin = auto()
22+
Table = auto()
23+
MongoDB = auto()
24+
Cassandra = auto()
25+
26+
ALL_ENDPOINTS = { e for e in Endpoints }
27+
28+
# Ports mostly derived from https://docs.microsoft.com/en-us/azure/cosmos-db/emulator-command-line-parameters
29+
EMULATOR_PORT = 8081
30+
endpoint_ports = {
31+
Endpoints.Direct : frozenset([10251, 10252, 10253, 10254]),
32+
Endpoints.Gremlin : frozenset([8901]),
33+
Endpoints.Table : frozenset([8902]),
34+
Endpoints.MongoDB : frozenset([10255]),
35+
Endpoints.Cassandra: frozenset([10350]),
36+
}
37+
38+
def is_truthy_string(s: str):
39+
return s.lower().strip() in {"true", "yes", "y", "1"}
40+
41+
class CosmosDBEmulatorContainer(DockerContainer):
42+
"""
43+
CosmosDB Emulator container.
44+
45+
Example:
46+
47+
.. doctest::
48+
>>> from testcontainers.cosmosdb import CosmosDBEmulatorContainer
49+
>>> with CosmosDBEmulatorContainer() as cosmosdb:
50+
... db = cosmosdb.sync_client().create_database_if_not_exists("test")
51+
52+
.. doctest::
53+
>>> from testcontainers.cosmosdb import CosmosDBEmulatorContainer
54+
>>> with CosmosDBEmulatorContainer() as emulator:
55+
... cosmosdb = CosmosClient(url=emulator.url, credential=emulator.key, connection_verify=False)
56+
... db = cosmosdb.create_database_if_not_exists("test")
57+
58+
.. doctest::
59+
>>> from testcontainers.cosmosdb import CosmosDBEmulatorContainer, Endpoints
60+
>>> with CosmosDBEmulatorContainer(endpoints=[Endpoints.MongoDB]) as emulator:
61+
... print(f"Point yout MongoDB client to {emulator.host}:{emulator.ports(Endpoints.MongoDB)[0]}")
62+
"""
63+
def __init__(
64+
self,
65+
image: str = os.getenv("AZURE_COSMOS_EMULATOR_IMAGE", "mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest"),
66+
partition_count: int = os.getenv("AZURE_COSMOS_EMULATOR_PARTITION_COUNT", None),
67+
enable_data_persistence: bool = is_truthy_string(os.getenv("AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE", "false")),
68+
bind_ports: bool = is_truthy_string(os.getenv("AZURE_COSMOS_EMULATOR_BIND_PORTS", "true")),
69+
key: str = os.getenv("AZURE_COSMOS_EMULATOR_KEY", "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="),
70+
endpoints: Iterable[Endpoints] = ALL_ENDPOINTS, # the emulator image does not support host-container port mapping
71+
**docker_client_kw,
72+
):
73+
super().__init__(image=image, **docker_client_kw)
74+
75+
self.partition_count = partition_count
76+
self.key = key
77+
self.enable_data_persistence = enable_data_persistence
78+
self.endpoints = frozenset(endpoints)
79+
80+
self.with_bind_ports(EMULATOR_PORT, EMULATOR_PORT)
81+
82+
endpoints_ports = []
83+
for endpoint in self.endpoints:
84+
endpoints_ports.extend(endpoint_ports[endpoint])
85+
86+
if bind_ports:
87+
[ self.with_bind_ports(port, port) for port in endpoints_ports ]
88+
else:
89+
self.with_exposed_ports(*endpoints_ports)
90+
91+
def start(self) -> Self:
92+
self._configure()
93+
super().start()
94+
self._wait_until_ready()
95+
return self
96+
97+
@property
98+
def url(self) -> str:
99+
"""
100+
Returns the url to interact with the emulator
101+
"""
102+
return f"https://{self.host}:{self.get_exposed_port(EMULATOR_PORT)}"
103+
104+
@property
105+
def host(self) -> str:
106+
return self.get_container_host_ip()
107+
108+
def ports(self, endpoint: Endpoints) -> Iterable[int]:
109+
assert endpoint in self.endpoints, f"Endpoint {endpoint} is not exposed"
110+
return { self.get_exposed_port(p) for p in endpoint_ports[endpoint] }
111+
112+
def async_client(self) -> AsyncCosmosClient:
113+
"""
114+
Returns an asynchronous CosmosClient instance to interact with the CosmosDB server
115+
"""
116+
return AsyncCosmosClient(url=self.url, credential=self.key, connection_verify=False)
117+
118+
def sync_client(self) -> SyncCosmosClient:
119+
"""
120+
Returns a synchronous CosmosClient instance to interact with the CosmosDB server
121+
"""
122+
return SyncCosmosClient(url=self.url, credential=self.key, connection_verify=False)
123+
124+
def _configure(self) -> None:
125+
(
126+
self
127+
.with_env("AZURE_COSMOS_EMULATOR_PARTITION_COUNT", str(self.partition_count))
128+
.with_env("AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE", socket.gethostbyname(socket.gethostname()))
129+
.with_env("AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE", str(self.enable_data_persistence))
130+
.with_env("AZURE_COSMOS_EMULATOR_KEY", str(self.key))
131+
)
132+
133+
@wait_container_is_ready(HTTPError, URLError, ServiceRequestError)
134+
def _wait_until_ready(self) -> Self:
135+
"""
136+
Waits until the CosmosDB Emulator image is ready to be used.
137+
"""
138+
(
139+
self
140+
._wait_for_logs(container=self, predicate="Started\\s*$")
141+
._wait_for_url(f"{self.url}/_explorer/index.html")
142+
._wait_for_query_success(lambda sync_client: list(sync_client.list_databases()))
143+
)
144+
return self
145+
146+
def _wait_for_url(self, url: str) -> Self:
147+
with urlopen(url, context=ssl._create_unverified_context()) as response:
148+
response.read()
149+
return self
150+
151+
def _wait_for_logs(self, *args, **kwargs) -> Self:
152+
wait_for_logs(*args, **kwargs)
153+
return self
154+
155+
def _wait_for_query_success(self, query: Callable[[SyncCosmosClient], None]) -> Self:
156+
with self.sync_client() as c:
157+
query(c)
158+
return self
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import pytest
2+
from testcontainers.cosmosdb import CosmosDBEmulatorContainer
3+
4+
def test_docker_run():
5+
with CosmosDBEmulatorContainer(partition_count=1) as cosmosdb:
6+
list(cosmosdb.sync_client().list_databases())

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ packages = [
3434
{ include = "testcontainers", from = "modules/chroma" },
3535
{ include = "testcontainers", from = "modules/clickhouse" },
3636
{ include = "testcontainers", from = "modules/cockroachdb" },
37+
{ include = "testcontainers", from = "modules/cosmosdb" },
3738
{ include = "testcontainers", from = "modules/elasticsearch" },
3839
{ include = "testcontainers", from = "modules/generic" },
3940
{ include = "testcontainers", from = "modules/testmoduleimport"},
@@ -106,12 +107,14 @@ chromadb-client = { version = "*", optional = true }
106107
qdrant-client = { version = "*", optional = true }
107108
bcrypt = { version = "*", optional = true }
108109
httpx = { version = "*", optional = true }
110+
azure-cosmos = { version = "*", optional = true }
109111

110112
[tool.poetry.extras]
111113
arangodb = ["python-arango"]
112114
azurite = ["azure-storage-blob"]
113115
cassandra = []
114116
clickhouse = ["clickhouse-driver"]
117+
cosmosdb = ["azure-cosmos"]
115118
cockroachdb = []
116119
elasticsearch = []
117120
generic = ["httpx"]

0 commit comments

Comments
 (0)