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 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