cosmodol 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cosmodol/__init__.py +100 -0
- cosmodol/base.py +175 -0
- cosmodol/connection.py +211 -0
- cosmodol/errors.py +143 -0
- cosmodol/recipes.py +122 -0
- cosmodol/stores.py +472 -0
- cosmodol/testing.py +308 -0
- cosmodol/tests/__init__.py +0 -0
- cosmodol/tests/test_errors.py +33 -0
- cosmodol/tests/test_integration_emulator.py +374 -0
- cosmodol/tests/test_stores.py +191 -0
- cosmodol/trees.py +277 -0
- cosmodol-0.0.2.dist-info/METADATA +65 -0
- cosmodol-0.0.2.dist-info/RECORD +16 -0
- cosmodol-0.0.2.dist-info/WHEEL +4 -0
- cosmodol-0.0.2.dist-info/licenses/LICENSE +201 -0
cosmodol/__init__.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Access Azure Cosmos DB (NoSQL/Core API) through a Mapping interface.
|
|
2
|
+
|
|
3
|
+
``cosmodol`` exposes Azure Cosmos DB as ``dol``-style ``Mapping`` /
|
|
4
|
+
``MutableMapping`` interfaces, layered over the official ``azure-cosmos`` SDK.
|
|
5
|
+
|
|
6
|
+
Quick start::
|
|
7
|
+
|
|
8
|
+
from cosmodol import cosmos_store
|
|
9
|
+
|
|
10
|
+
store = cosmos_store(
|
|
11
|
+
connection_string="AccountEndpoint=https://localhost:8081/;AccountKey=...",
|
|
12
|
+
database="mydb",
|
|
13
|
+
container="mycontainer",
|
|
14
|
+
partition_key_value="tenant-X",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
store["k1"] = {"name": "Alice", "age": 30}
|
|
18
|
+
store["k1"] # → {"id": "k1", "<pk>": "tenant-X", "name": "Alice", "age": 30}
|
|
19
|
+
"k1" in store # → True
|
|
20
|
+
del store["k1"]
|
|
21
|
+
|
|
22
|
+
See ``misc/docs/architecture.md`` for the layered design.
|
|
23
|
+
|
|
24
|
+
If your Cosmos account was provisioned with the **MongoDB API**, use ``pymongo`` +
|
|
25
|
+
``mongodol`` directly — this package only targets the NoSQL/Core API.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from cosmodol.connection import CosmosConnection, resolve_credential
|
|
29
|
+
from cosmodol.base import (
|
|
30
|
+
ResponseHeaders,
|
|
31
|
+
batch,
|
|
32
|
+
point_contains,
|
|
33
|
+
point_delete,
|
|
34
|
+
point_get,
|
|
35
|
+
point_replace,
|
|
36
|
+
point_upsert,
|
|
37
|
+
query,
|
|
38
|
+
)
|
|
39
|
+
from cosmodol.errors import (
|
|
40
|
+
ContainerNotEmptyError,
|
|
41
|
+
ContainerNotFoundError,
|
|
42
|
+
CosmosThrottleError,
|
|
43
|
+
DatabaseNotEmptyError,
|
|
44
|
+
DatabaseNotFoundError,
|
|
45
|
+
ItemAlreadyExistsError,
|
|
46
|
+
ItemNotFoundError,
|
|
47
|
+
KeyMismatchError,
|
|
48
|
+
translate_cosmos_errors,
|
|
49
|
+
validate_cosmos_id,
|
|
50
|
+
)
|
|
51
|
+
from cosmodol.stores import (
|
|
52
|
+
CosmosItems,
|
|
53
|
+
CosmosPartitionedItems,
|
|
54
|
+
SYSTEM_FIELDS,
|
|
55
|
+
)
|
|
56
|
+
from cosmodol.trees import (
|
|
57
|
+
CosmosAccount,
|
|
58
|
+
CosmosDatabase,
|
|
59
|
+
)
|
|
60
|
+
from cosmodol.recipes import (
|
|
61
|
+
cosmos_store,
|
|
62
|
+
strip_system_fields,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
# connection
|
|
68
|
+
"CosmosConnection",
|
|
69
|
+
"resolve_credential",
|
|
70
|
+
# base / metal layer
|
|
71
|
+
"ResponseHeaders",
|
|
72
|
+
"batch",
|
|
73
|
+
"point_contains",
|
|
74
|
+
"point_delete",
|
|
75
|
+
"point_get",
|
|
76
|
+
"point_replace",
|
|
77
|
+
"point_upsert",
|
|
78
|
+
"query",
|
|
79
|
+
# errors
|
|
80
|
+
"ContainerNotEmptyError",
|
|
81
|
+
"ContainerNotFoundError",
|
|
82
|
+
"CosmosThrottleError",
|
|
83
|
+
"DatabaseNotEmptyError",
|
|
84
|
+
"DatabaseNotFoundError",
|
|
85
|
+
"ItemAlreadyExistsError",
|
|
86
|
+
"ItemNotFoundError",
|
|
87
|
+
"KeyMismatchError",
|
|
88
|
+
"translate_cosmos_errors",
|
|
89
|
+
"validate_cosmos_id",
|
|
90
|
+
# stores
|
|
91
|
+
"CosmosItems",
|
|
92
|
+
"CosmosPartitionedItems",
|
|
93
|
+
"SYSTEM_FIELDS",
|
|
94
|
+
# trees
|
|
95
|
+
"CosmosAccount",
|
|
96
|
+
"CosmosDatabase",
|
|
97
|
+
# recipes
|
|
98
|
+
"cosmos_store",
|
|
99
|
+
"strip_system_fields",
|
|
100
|
+
]
|
cosmodol/base.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Close-to-metal CRUD facade for Cosmos DB.
|
|
2
|
+
|
|
3
|
+
Functional layer over ``ContainerProxy``. Each function takes the container plus
|
|
4
|
+
normalized args and returns ``(value, ResponseHeaders)`` so callers can observe RU
|
|
5
|
+
charges and ETags. The Mapping-shaped stores in ``cosmodol.stores`` route all their
|
|
6
|
+
work through these.
|
|
7
|
+
|
|
8
|
+
See ``misc/docs/architecture.md`` Layer A.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Iterator, NamedTuple, Optional
|
|
14
|
+
|
|
15
|
+
from azure.cosmos import ContainerProxy
|
|
16
|
+
|
|
17
|
+
from cosmodol.errors import translate_cosmos_errors
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ResponseHeaders(NamedTuple):
|
|
21
|
+
"""Subset of Cosmos response headers we surface for observability.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
request_charge: RU consumed by the operation. ``None`` when the emulator (which
|
|
25
|
+
does not populate this header) was the backend.
|
|
26
|
+
etag: ETag of the (created / read / replaced) item, when applicable.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
request_charge: Optional[float]
|
|
30
|
+
etag: Optional[str]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _headers(container: ContainerProxy) -> ResponseHeaders:
|
|
34
|
+
"""Extract our observability tuple from the last response on the container proxy."""
|
|
35
|
+
h = container.client_connection.last_response_headers or {}
|
|
36
|
+
rc = h.get("x-ms-request-charge")
|
|
37
|
+
return ResponseHeaders(
|
|
38
|
+
request_charge=float(rc) if rc is not None else None,
|
|
39
|
+
etag=h.get("etag"),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@translate_cosmos_errors(key_arg="id")
|
|
44
|
+
def point_get(
|
|
45
|
+
container: ContainerProxy,
|
|
46
|
+
id: str,
|
|
47
|
+
partition_key: Any,
|
|
48
|
+
) -> tuple[dict, ResponseHeaders]:
|
|
49
|
+
"""Point read of one item. ~1 RU/KB. Raises ``ItemNotFoundError`` on missing."""
|
|
50
|
+
item = container.read_item(item=id, partition_key=partition_key)
|
|
51
|
+
return dict(item), _headers(container)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@translate_cosmos_errors(key_arg="id")
|
|
55
|
+
def point_upsert(
|
|
56
|
+
container: ContainerProxy,
|
|
57
|
+
body: dict,
|
|
58
|
+
*,
|
|
59
|
+
etag: Optional[str] = None,
|
|
60
|
+
) -> tuple[dict, ResponseHeaders]:
|
|
61
|
+
"""Insert-or-replace an item. ``id`` and the partition-key value are extracted from
|
|
62
|
+
``body``.
|
|
63
|
+
|
|
64
|
+
Note: ``etag`` parameter accepted for symmetry but Cosmos does not honor it on
|
|
65
|
+
``upsert_item``; use ``point_replace`` for ETag-conditional writes.
|
|
66
|
+
"""
|
|
67
|
+
_ = etag # accepted for symmetry; see docstring
|
|
68
|
+
item = container.upsert_item(body=body)
|
|
69
|
+
return dict(item), _headers(container)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@translate_cosmos_errors(key_arg="id")
|
|
73
|
+
def point_replace(
|
|
74
|
+
container: ContainerProxy,
|
|
75
|
+
id: str,
|
|
76
|
+
body: dict,
|
|
77
|
+
partition_key: Any,
|
|
78
|
+
*,
|
|
79
|
+
etag: Optional[str] = None,
|
|
80
|
+
) -> tuple[dict, ResponseHeaders]:
|
|
81
|
+
"""Full replace of an item. With ``etag``, performs an If-Match conditional write."""
|
|
82
|
+
kwargs = {}
|
|
83
|
+
if etag is not None:
|
|
84
|
+
kwargs["if_match_etag"] = etag
|
|
85
|
+
item = container.replace_item(item=id, body=body, **kwargs)
|
|
86
|
+
return dict(item), _headers(container)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@translate_cosmos_errors(key_arg="id")
|
|
90
|
+
def point_delete(
|
|
91
|
+
container: ContainerProxy,
|
|
92
|
+
id: str,
|
|
93
|
+
partition_key: Any,
|
|
94
|
+
*,
|
|
95
|
+
etag: Optional[str] = None,
|
|
96
|
+
) -> ResponseHeaders:
|
|
97
|
+
"""Point delete. Raises ``ItemNotFoundError`` on missing."""
|
|
98
|
+
kwargs = {}
|
|
99
|
+
if etag is not None:
|
|
100
|
+
kwargs["if_match_etag"] = etag
|
|
101
|
+
container.delete_item(item=id, partition_key=partition_key, **kwargs)
|
|
102
|
+
return _headers(container)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def point_contains(
|
|
106
|
+
container: ContainerProxy,
|
|
107
|
+
id: str,
|
|
108
|
+
partition_key: Any,
|
|
109
|
+
) -> tuple[bool, ResponseHeaders]:
|
|
110
|
+
"""Existence check via point read. ``True/False``. Never raises ``KeyError``.
|
|
111
|
+
|
|
112
|
+
Cheaper than a ``SELECT VALUE COUNT(1)`` query — ~1 RU vs ≥ 2.3 RU.
|
|
113
|
+
"""
|
|
114
|
+
from azure.cosmos.exceptions import CosmosResourceNotFoundError
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
container.read_item(item=id, partition_key=partition_key)
|
|
118
|
+
return True, _headers(container)
|
|
119
|
+
except CosmosResourceNotFoundError:
|
|
120
|
+
return False, _headers(container)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def query(
|
|
124
|
+
container: ContainerProxy,
|
|
125
|
+
sql: str,
|
|
126
|
+
*,
|
|
127
|
+
parameters: Optional[list[dict]] = None,
|
|
128
|
+
partition_key: Any = None,
|
|
129
|
+
cross_partition: bool = False,
|
|
130
|
+
max_item_count: Optional[int] = None,
|
|
131
|
+
) -> Iterator[dict]:
|
|
132
|
+
"""Run a SQL query. Yields dicts.
|
|
133
|
+
|
|
134
|
+
Either pass ``partition_key=`` (cheap, single-partition) or
|
|
135
|
+
``cross_partition=True`` (RU scales with data). One of the two is required by Cosmos.
|
|
136
|
+
"""
|
|
137
|
+
kwargs: dict = {"query": sql}
|
|
138
|
+
if parameters is not None:
|
|
139
|
+
kwargs["parameters"] = parameters
|
|
140
|
+
if partition_key is not None:
|
|
141
|
+
kwargs["partition_key"] = partition_key
|
|
142
|
+
elif cross_partition:
|
|
143
|
+
kwargs["enable_cross_partition_query"] = True
|
|
144
|
+
if max_item_count is not None:
|
|
145
|
+
kwargs["max_item_count"] = max_item_count
|
|
146
|
+
for item in container.query_items(**kwargs):
|
|
147
|
+
# ``SELECT VALUE ...`` queries yield raw scalars (str, int, float, bool, list).
|
|
148
|
+
# Other queries yield CosmosDict (a dict subclass). Pass through either shape
|
|
149
|
+
# so callers can distinguish; convert to plain dict in the dict-shaped case.
|
|
150
|
+
if isinstance(item, dict):
|
|
151
|
+
yield dict(item)
|
|
152
|
+
else:
|
|
153
|
+
yield item
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def batch(
|
|
157
|
+
container: ContainerProxy,
|
|
158
|
+
operations: list[tuple],
|
|
159
|
+
partition_key: Any,
|
|
160
|
+
) -> list[dict]:
|
|
161
|
+
"""Transactional batch within one logical partition.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
operations: List of ``(op_name, args_tuple, kwargs_dict)`` triples. Cosmos op
|
|
165
|
+
names: ``"create"``, ``"upsert"``, ``"replace"``, ``"patch"``, ``"read"``,
|
|
166
|
+
``"delete"``. ≤ 100 ops, ≤ 1.2 MB total.
|
|
167
|
+
partition_key: All ops must share this partition-key value.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
List of per-op result dicts as returned by the SDK.
|
|
171
|
+
"""
|
|
172
|
+
results = container.execute_item_batch(
|
|
173
|
+
batch_operations=operations, partition_key=partition_key
|
|
174
|
+
)
|
|
175
|
+
return [dict(r) for r in results]
|
cosmodol/connection.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Connection-layer for cosmodol.
|
|
2
|
+
|
|
3
|
+
Owns the expensive resource (``CosmosClient``) and the credential cascade.
|
|
4
|
+
See ``misc/docs/architecture.md`` Layer 0 and ``misc/docs/design_decisions.md`` §14.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from functools import cached_property
|
|
13
|
+
from typing import Any, Optional, Union
|
|
14
|
+
|
|
15
|
+
from azure.cosmos import CosmosClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
CredentialLike = Union[str, dict, Any, None]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Env vars consulted by ``resolve_credential``.
|
|
22
|
+
_ENV_CONNECTION_STRING = "AZURE_COSMOS_CONNECTION_STRING"
|
|
23
|
+
_ENV_ENDPOINT = "AZURE_COSMOS_ENDPOINT"
|
|
24
|
+
_ENV_KEY = "AZURE_COSMOS_KEY"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_CONN_STR_ENDPOINT_RE = re.compile(r"AccountEndpoint=([^;]+)", re.IGNORECASE)
|
|
28
|
+
_CONN_STR_KEY_RE = re.compile(r"AccountKey=([^;]+)", re.IGNORECASE)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _looks_like_connection_string(s: str) -> bool:
|
|
32
|
+
return "AccountEndpoint=" in s and "AccountKey=" in s
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_connection_string(cs: str) -> tuple[str, str]:
|
|
36
|
+
"""Parse a Cosmos connection string into ``(endpoint, key)``."""
|
|
37
|
+
ep = _CONN_STR_ENDPOINT_RE.search(cs)
|
|
38
|
+
k = _CONN_STR_KEY_RE.search(cs)
|
|
39
|
+
if not (ep and k):
|
|
40
|
+
raise ValueError(
|
|
41
|
+
"Cosmos connection string must contain AccountEndpoint=... and AccountKey=..."
|
|
42
|
+
)
|
|
43
|
+
return ep.group(1), k.group(1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_credential(
|
|
47
|
+
*,
|
|
48
|
+
credential: CredentialLike = None,
|
|
49
|
+
connection_string: Optional[str] = None,
|
|
50
|
+
endpoint: Optional[str] = None,
|
|
51
|
+
key: Optional[str] = None,
|
|
52
|
+
) -> dict:
|
|
53
|
+
"""Resolve a credential into a normalized form that can build a ``CosmosClient``.
|
|
54
|
+
|
|
55
|
+
Cascade (first hit wins):
|
|
56
|
+
|
|
57
|
+
1. Explicit ``credential=`` (with ``endpoint=`` for the URL)
|
|
58
|
+
2. Explicit ``connection_string=``
|
|
59
|
+
3. Explicit ``endpoint=`` + ``key=`` (or just ``endpoint=`` + AAD)
|
|
60
|
+
4. Env var ``AZURE_COSMOS_CONNECTION_STRING``
|
|
61
|
+
5. Env vars ``AZURE_COSMOS_ENDPOINT`` + ``AZURE_COSMOS_KEY``
|
|
62
|
+
6. Env var ``AZURE_COSMOS_ENDPOINT`` alone + ``DefaultAzureCredential``
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
A dict with: ``{"url": "...", "credential": <obj>}``
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: if no source resolves.
|
|
69
|
+
"""
|
|
70
|
+
# 1. Explicit credential
|
|
71
|
+
if credential is not None:
|
|
72
|
+
if isinstance(credential, str) and _looks_like_connection_string(credential):
|
|
73
|
+
ep, k = _parse_connection_string(credential)
|
|
74
|
+
return {"url": ep, "credential": k}
|
|
75
|
+
ep = endpoint or os.environ.get(_ENV_ENDPOINT)
|
|
76
|
+
if ep is None:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"credential=... was provided but no endpoint. Pass endpoint=... or set "
|
|
79
|
+
f"the {_ENV_ENDPOINT} env var."
|
|
80
|
+
)
|
|
81
|
+
return {"url": ep, "credential": credential}
|
|
82
|
+
|
|
83
|
+
# 2. Explicit connection string
|
|
84
|
+
if connection_string is not None:
|
|
85
|
+
ep, k = _parse_connection_string(connection_string)
|
|
86
|
+
return {"url": ep, "credential": k}
|
|
87
|
+
|
|
88
|
+
# 3. Explicit endpoint + key (or endpoint alone)
|
|
89
|
+
if endpoint is not None and key is not None:
|
|
90
|
+
return {"url": endpoint, "credential": key}
|
|
91
|
+
if endpoint is not None:
|
|
92
|
+
return {"url": endpoint, "credential": _default_aad_credential()}
|
|
93
|
+
|
|
94
|
+
# 4. Env: connection string
|
|
95
|
+
env_cs = os.environ.get(_ENV_CONNECTION_STRING)
|
|
96
|
+
if env_cs:
|
|
97
|
+
ep, k = _parse_connection_string(env_cs)
|
|
98
|
+
return {"url": ep, "credential": k}
|
|
99
|
+
|
|
100
|
+
# 5. Env: endpoint + key
|
|
101
|
+
env_ep = os.environ.get(_ENV_ENDPOINT)
|
|
102
|
+
env_k = os.environ.get(_ENV_KEY)
|
|
103
|
+
if env_ep and env_k:
|
|
104
|
+
return {"url": env_ep, "credential": env_k}
|
|
105
|
+
|
|
106
|
+
# 6. Env: endpoint alone + AAD
|
|
107
|
+
if env_ep:
|
|
108
|
+
return {"url": env_ep, "credential": _default_aad_credential()}
|
|
109
|
+
|
|
110
|
+
raise ValueError(
|
|
111
|
+
"Could not resolve Cosmos DB credentials. Provide one of:\n"
|
|
112
|
+
" - credential=<obj or connection string>\n"
|
|
113
|
+
" - connection_string=<str>\n"
|
|
114
|
+
" - endpoint=<url> + key=<key>\n"
|
|
115
|
+
" - env var AZURE_COSMOS_CONNECTION_STRING\n"
|
|
116
|
+
" - env vars AZURE_COSMOS_ENDPOINT + AZURE_COSMOS_KEY\n"
|
|
117
|
+
" - env var AZURE_COSMOS_ENDPOINT alone (uses DefaultAzureCredential)\n"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _default_aad_credential():
|
|
122
|
+
"""Lazy import to keep ``azure-identity`` an optional dependency."""
|
|
123
|
+
try:
|
|
124
|
+
from azure.identity import DefaultAzureCredential
|
|
125
|
+
except ImportError as e:
|
|
126
|
+
raise ImportError(
|
|
127
|
+
"azure-identity is required for AAD credentials. Install with: "
|
|
128
|
+
"`pip install azure-identity`"
|
|
129
|
+
) from e
|
|
130
|
+
return DefaultAzureCredential()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class CosmosConnection:
|
|
135
|
+
"""Holds a resolved credential and a lazy ``CosmosClient``.
|
|
136
|
+
|
|
137
|
+
This is the dependency-injection seam for the package. Tests construct one pointing
|
|
138
|
+
at the emulator without touching any store class.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
credential: explicit credential object or account-key string.
|
|
142
|
+
connection_string: full ``AccountEndpoint=...;AccountKey=...`` string.
|
|
143
|
+
endpoint: Cosmos endpoint URL (with or without credential).
|
|
144
|
+
key: account master key.
|
|
145
|
+
consistency_level: optional override; defaults to inheriting the account default.
|
|
146
|
+
client_kwargs: extra kwargs forwarded to ``CosmosClient``.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
credential: CredentialLike = None
|
|
150
|
+
connection_string: Optional[str] = None
|
|
151
|
+
endpoint: Optional[str] = None
|
|
152
|
+
key: Optional[str] = None
|
|
153
|
+
consistency_level: Optional[str] = None
|
|
154
|
+
client_kwargs: dict = field(default_factory=dict)
|
|
155
|
+
|
|
156
|
+
@cached_property
|
|
157
|
+
def _resolved(self) -> dict:
|
|
158
|
+
return resolve_credential(
|
|
159
|
+
credential=self.credential,
|
|
160
|
+
connection_string=self.connection_string,
|
|
161
|
+
endpoint=self.endpoint,
|
|
162
|
+
key=self.key,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@cached_property
|
|
166
|
+
def client(self) -> CosmosClient:
|
|
167
|
+
"""The lazily-constructed ``CosmosClient``. Cached for the connection's lifetime."""
|
|
168
|
+
r = self._resolved
|
|
169
|
+
kw = dict(self.client_kwargs)
|
|
170
|
+
if self.consistency_level is not None:
|
|
171
|
+
kw.setdefault("consistency_level", self.consistency_level)
|
|
172
|
+
return CosmosClient(url=r["url"], credential=r["credential"], **kw)
|
|
173
|
+
|
|
174
|
+
def database(self, name: str):
|
|
175
|
+
return self.client.get_database_client(name)
|
|
176
|
+
|
|
177
|
+
def container(self, database: str, container: str):
|
|
178
|
+
return self.client.get_database_client(database).get_container_client(container)
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def from_anything(cls, source) -> "CosmosConnection":
|
|
182
|
+
"""Convenience: build a ``CosmosConnection`` from a thing-or-spec.
|
|
183
|
+
|
|
184
|
+
Accepts:
|
|
185
|
+
- ``CosmosConnection`` (returned as-is)
|
|
186
|
+
- ``CosmosClient`` (wrapped without further resolution)
|
|
187
|
+
- ``str`` (connection string)
|
|
188
|
+
- ``dict`` (passed as kwargs)
|
|
189
|
+
- ``None`` (defer to env / AAD)
|
|
190
|
+
"""
|
|
191
|
+
if isinstance(source, cls):
|
|
192
|
+
return source
|
|
193
|
+
if isinstance(source, CosmosClient):
|
|
194
|
+
inst = cls.__new__(cls)
|
|
195
|
+
inst.credential = None
|
|
196
|
+
inst.connection_string = None
|
|
197
|
+
inst.endpoint = None
|
|
198
|
+
inst.key = None
|
|
199
|
+
inst.consistency_level = None
|
|
200
|
+
inst.client_kwargs = {}
|
|
201
|
+
inst.__dict__["client"] = source
|
|
202
|
+
return inst
|
|
203
|
+
if isinstance(source, str):
|
|
204
|
+
return cls(connection_string=source)
|
|
205
|
+
if isinstance(source, dict):
|
|
206
|
+
return cls(**source)
|
|
207
|
+
if source is None:
|
|
208
|
+
return cls()
|
|
209
|
+
raise TypeError(
|
|
210
|
+
f"Cannot build CosmosConnection from {type(source).__name__}: {source!r}"
|
|
211
|
+
)
|
cosmodol/errors.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Custom exceptions and error-translation decorator for cosmodol.
|
|
2
|
+
|
|
3
|
+
All Azure Cosmos SDK exception handling for the Mapping-shaped methods on close-to-metal
|
|
4
|
+
stores funnels through ``translate_cosmos_errors`` so the auth/throttle vs not-found
|
|
5
|
+
distinction stays auditable from one place. See ``misc/docs/design_decisions.md`` §11.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
from functools import wraps
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
from azure.cosmos.exceptions import (
|
|
15
|
+
CosmosResourceExistsError,
|
|
16
|
+
CosmosResourceNotFoundError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _extract_key(func, args, kwargs, key_arg):
|
|
21
|
+
"""Resolve the user-facing key from *args/**kwargs.
|
|
22
|
+
|
|
23
|
+
Supports either a positional int index OR a name (resolved via the function's
|
|
24
|
+
signature so it works whether the caller passed by position or by keyword).
|
|
25
|
+
"""
|
|
26
|
+
if isinstance(key_arg, int):
|
|
27
|
+
return args[key_arg] if key_arg < len(args) else None
|
|
28
|
+
# By name: prefer kwargs; fall back to positional lookup via signature.
|
|
29
|
+
if key_arg in kwargs:
|
|
30
|
+
return kwargs[key_arg]
|
|
31
|
+
try:
|
|
32
|
+
sig = inspect.signature(func)
|
|
33
|
+
params = list(sig.parameters)
|
|
34
|
+
idx = params.index(key_arg)
|
|
35
|
+
return args[idx] if idx < len(args) else None
|
|
36
|
+
except (ValueError, TypeError):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ItemNotFoundError(KeyError):
|
|
41
|
+
"""Raised when an item does not exist for a (partition_key, id)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ItemAlreadyExistsError(KeyError):
|
|
45
|
+
"""Raised on strict-create attempts when the item already exists."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ContainerNotFoundError(KeyError):
|
|
49
|
+
"""Raised when a Cosmos container does not exist."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DatabaseNotFoundError(KeyError):
|
|
53
|
+
"""Raised when a Cosmos database does not exist."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ContainerNotEmptyError(RuntimeError):
|
|
57
|
+
"""Raised on ``del db_store[name]`` when the container has items. See
|
|
58
|
+
``misc/docs/design_decisions.md`` §8.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class DatabaseNotEmptyError(RuntimeError):
|
|
63
|
+
"""Raised on ``del account_store[name]`` when the database has containers. See
|
|
64
|
+
``misc/docs/design_decisions.md`` §8.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class KeyMismatchError(ValueError):
|
|
69
|
+
"""Raised when a written body's ``id`` (or partition-key value) disagrees with the
|
|
70
|
+
inferred value (from the dict key). See ``misc/docs/design_decisions.md`` §9.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CosmosThrottleError(RuntimeError):
|
|
75
|
+
"""Wraps ``CosmosHttpResponseError`` with HTTP 429 (RU exhaustion)."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Cosmos id-string validation.
|
|
79
|
+
_ID_FORBIDDEN = set("/\\?#")
|
|
80
|
+
_ID_MAX_LEN = 255
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def validate_cosmos_id(k) -> None:
|
|
84
|
+
"""Raise ``ValueError`` if ``k`` is not a valid Cosmos item ``id``.
|
|
85
|
+
|
|
86
|
+
Cosmos may accept invalid ids on write but the item then becomes unreachable from
|
|
87
|
+
the SDK. We fail loudly at write time. See ``misc/docs/cosmos_db_reference.md``
|
|
88
|
+
§"id rules".
|
|
89
|
+
"""
|
|
90
|
+
if not isinstance(k, str):
|
|
91
|
+
raise ValueError(f"Cosmos item id must be a str, got {type(k).__name__}: {k!r}")
|
|
92
|
+
if not k:
|
|
93
|
+
raise ValueError("Cosmos item id must be a non-empty string.")
|
|
94
|
+
if len(k) > _ID_MAX_LEN:
|
|
95
|
+
raise ValueError(f"Cosmos item id length {len(k)} exceeds {_ID_MAX_LEN}: {k!r}")
|
|
96
|
+
bad = sorted(set(k) & _ID_FORBIDDEN)
|
|
97
|
+
if bad:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Cosmos item id contains forbidden characters {bad}: {k!r}. "
|
|
100
|
+
"Use URL-safe base64 if you need to encode arbitrary strings."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def translate_cosmos_errors(
|
|
105
|
+
*,
|
|
106
|
+
key_arg: int | str = 0,
|
|
107
|
+
not_found_cls: type[KeyError] = ItemNotFoundError,
|
|
108
|
+
exists_cls: type[KeyError] = ItemAlreadyExistsError,
|
|
109
|
+
) -> Callable:
|
|
110
|
+
"""Decorator: translate Cosmos SDK exceptions into ``KeyError`` subclasses.
|
|
111
|
+
|
|
112
|
+
Auth errors and any other Cosmos errors propagate untouched. HTTP 429 throttling is
|
|
113
|
+
wrapped in ``CosmosThrottleError`` so callers can distinguish it. See
|
|
114
|
+
``misc/docs/design_decisions.md`` §11.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
key_arg: Position (int) or name (str) of the key argument in the wrapped
|
|
118
|
+
method's signature. Used to populate ``KeyError(key)``. Default 0; for
|
|
119
|
+
methods the user-facing key is typically at index 1 (``self`` at 0).
|
|
120
|
+
not_found_cls: Exception class to raise on ``CosmosResourceNotFoundError``.
|
|
121
|
+
exists_cls: Exception class to raise on ``CosmosResourceExistsError``.
|
|
122
|
+
"""
|
|
123
|
+
from azure.cosmos.exceptions import CosmosHttpResponseError
|
|
124
|
+
|
|
125
|
+
def decorator(func):
|
|
126
|
+
@wraps(func)
|
|
127
|
+
def wrapper(*args, **kwargs):
|
|
128
|
+
try:
|
|
129
|
+
return func(*args, **kwargs)
|
|
130
|
+
except CosmosResourceNotFoundError as e:
|
|
131
|
+
k = _extract_key(func, args, kwargs, key_arg)
|
|
132
|
+
raise not_found_cls(k) from e
|
|
133
|
+
except CosmosResourceExistsError as e:
|
|
134
|
+
k = _extract_key(func, args, kwargs, key_arg)
|
|
135
|
+
raise exists_cls(k) from e
|
|
136
|
+
except CosmosHttpResponseError as e:
|
|
137
|
+
if getattr(e, "status_code", None) == 429:
|
|
138
|
+
raise CosmosThrottleError(str(e)) from e
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
return wrapper
|
|
142
|
+
|
|
143
|
+
return decorator
|