cnovatech-sdk-core 0.1.0__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.
@@ -0,0 +1,24 @@
1
+ """Shared foundation of the Cnovatech Python SDKs.
2
+
3
+ This package is not used directly by application code. The product SDKs build
4
+ on its modules (``config``, ``transport``, ``client``, ``resource``, ``models``,
5
+ ``pagination``, ``errors``, ...) and re-export what their users need. It knows
6
+ nothing about any product: no endpoint, field name or domain concept lives
7
+ here, and CI enforces that boundary.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+
14
+ from .config import CORE_DISTRIBUTION_NAME
15
+ from .metadata import read_distribution_version
16
+
17
+ __version__ = read_distribution_version(CORE_DISTRIBUTION_NAME)
18
+
19
+ # Libraries log but never configure logging. The null handler keeps Python from
20
+ # printing "No handlers could be found" when the application has not set up
21
+ # logging; the application routes the `cnovatech` logger wherever it wants.
22
+ logging.getLogger("cnovatech").addHandler(logging.NullHandler())
23
+
24
+ __all__ = ["__version__"]
@@ -0,0 +1,146 @@
1
+ """What every product client shares: configuration, transport, accounting and lifecycle.
2
+
3
+ A product SDK subclasses :class:`BaseSyncClient` and :class:`BaseAsyncClient`,
4
+ binds its :class:`ProductSettings` and attaches its resources. Everything
5
+ else, from reading the environment to closing the connection pool, is here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from .config import DEFAULT_MAX_RETRIES, DEFAULT_READ_TIMEOUT_SECONDS, build_client_config
13
+ from .transport import AsyncTransport, SyncTransport
14
+
15
+ if TYPE_CHECKING:
16
+ from types import TracebackType
17
+
18
+ import httpx
19
+ from typing_extensions import Self
20
+
21
+ from .config import ProductSettings
22
+ from .usage import RateLimitState, UsageTracker
23
+
24
+
25
+ class BaseSyncClient:
26
+ """A client using blocking I/O. See the product client for the constructor arguments."""
27
+
28
+ def __init__(
29
+ self,
30
+ product: ProductSettings,
31
+ api_key: str | None = None,
32
+ *,
33
+ base_url: str | None = None,
34
+ timeout: float | httpx.Timeout = DEFAULT_READ_TIMEOUT_SECONDS,
35
+ max_retries: int = DEFAULT_MAX_RETRIES,
36
+ max_units_per_request: int | None = None,
37
+ http_client: httpx.Client | None = None,
38
+ transport: httpx.BaseTransport | None = None,
39
+ ) -> None:
40
+ self._config = build_client_config(
41
+ product,
42
+ api_key=api_key,
43
+ base_url=base_url,
44
+ timeout=timeout,
45
+ max_retries=max_retries,
46
+ max_units_per_request=max_units_per_request,
47
+ )
48
+ self._transport = SyncTransport(self._config, http_client=http_client, transport=transport)
49
+
50
+ @property
51
+ def base_url(self) -> str:
52
+ """Origin the client talks to."""
53
+ return self._config.base_url
54
+
55
+ @property
56
+ def usage(self) -> UsageTracker:
57
+ """Quota spent through this client: ``spent``, ``calls`` and the ``last`` usage block."""
58
+ return self._transport.usage
59
+
60
+ @property
61
+ def rate_limit(self) -> RateLimitState:
62
+ """The per-minute rate limit as last reported by the API."""
63
+ return self._transport.rate_limit
64
+
65
+ def close(self) -> None:
66
+ """Release the connection pool. Clients supplied through ``http_client`` are left open."""
67
+ self._transport.close()
68
+
69
+ def __enter__(self) -> Self:
70
+ return self
71
+
72
+ def __exit__(
73
+ self,
74
+ exc_type: type[BaseException] | None,
75
+ exc: BaseException | None,
76
+ traceback: TracebackType | None,
77
+ ) -> None:
78
+ self.close()
79
+
80
+ def __repr__(self) -> str:
81
+ return (
82
+ f"{type(self).__name__}(base_url={self._config.base_url!r}, "
83
+ f"api_key={self._config.masked_api_key!r})"
84
+ )
85
+
86
+
87
+ class BaseAsyncClient:
88
+ """A client using asyncio. See the product client for the constructor arguments."""
89
+
90
+ def __init__(
91
+ self,
92
+ product: ProductSettings,
93
+ api_key: str | None = None,
94
+ *,
95
+ base_url: str | None = None,
96
+ timeout: float | httpx.Timeout = DEFAULT_READ_TIMEOUT_SECONDS,
97
+ max_retries: int = DEFAULT_MAX_RETRIES,
98
+ max_units_per_request: int | None = None,
99
+ http_client: httpx.AsyncClient | None = None,
100
+ transport: httpx.AsyncBaseTransport | None = None,
101
+ ) -> None:
102
+ self._config = build_client_config(
103
+ product,
104
+ api_key=api_key,
105
+ base_url=base_url,
106
+ timeout=timeout,
107
+ max_retries=max_retries,
108
+ max_units_per_request=max_units_per_request,
109
+ )
110
+ self._transport = AsyncTransport(self._config, http_client=http_client, transport=transport)
111
+
112
+ @property
113
+ def base_url(self) -> str:
114
+ """Origin the client talks to."""
115
+ return self._config.base_url
116
+
117
+ @property
118
+ def usage(self) -> UsageTracker:
119
+ """Quota spent through this client: ``spent``, ``calls`` and the ``last`` usage block."""
120
+ return self._transport.usage
121
+
122
+ @property
123
+ def rate_limit(self) -> RateLimitState:
124
+ """The per-minute rate limit as last reported by the API."""
125
+ return self._transport.rate_limit
126
+
127
+ async def aclose(self) -> None:
128
+ """Release the connection pool. Clients supplied through ``http_client`` are left open."""
129
+ await self._transport.aclose()
130
+
131
+ async def __aenter__(self) -> Self:
132
+ return self
133
+
134
+ async def __aexit__(
135
+ self,
136
+ exc_type: type[BaseException] | None,
137
+ exc: BaseException | None,
138
+ traceback: TracebackType | None,
139
+ ) -> None:
140
+ await self.aclose()
141
+
142
+ def __repr__(self) -> str:
143
+ return (
144
+ f"{type(self).__name__}(base_url={self._config.base_url!r}, "
145
+ f"api_key={self._config.masked_api_key!r})"
146
+ )
@@ -0,0 +1,257 @@
1
+ """Client configuration shared by every Cnovatech SDK.
2
+
3
+ A product SDK describes itself once through :class:`ProductSettings`. The core
4
+ combines that with the constructor arguments and the environment into an
5
+ immutable :class:`ClientConfig`, validating everything before any request is
6
+ sent.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import os
13
+ import sys
14
+ from dataclasses import dataclass, field
15
+ from typing import TYPE_CHECKING
16
+ from urllib.parse import urlsplit
17
+
18
+ import httpx
19
+ from pydantic import SecretStr
20
+
21
+ from .errors import APIStatusError, ConfigurationError
22
+ from .metadata import read_distribution_version
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Mapping
26
+
27
+ CORE_DISTRIBUTION_NAME = "cnovatech-sdk-core"
28
+
29
+ DEFAULT_READ_TIMEOUT_SECONDS = 150.0
30
+ DEFAULT_CONNECT_TIMEOUT_SECONDS = 10.0
31
+ DEFAULT_WRITE_TIMEOUT_SECONDS = 10.0
32
+ DEFAULT_POOL_TIMEOUT_SECONDS = 10.0
33
+ # The server closes any request that runs longer than this.
34
+ MAX_READ_TIMEOUT_SECONDS = 300.0
35
+ DEFAULT_MAX_RETRIES = 2
36
+
37
+ API_KEY_ENV_SUFFIX = "API_KEY"
38
+ BASE_URL_ENV_SUFFIX = "BASE_URL"
39
+
40
+ _LOOPBACK_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "::1"})
41
+ _MASK_PREFIX_MAX_LENGTH = 4
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class ProductSettings:
46
+ """What a product SDK tells the core about itself.
47
+
48
+ Attributes:
49
+ distribution_name: Name on PyPI. Appears in the ``User-Agent`` header.
50
+ version: Installed version of the product SDK.
51
+ env_prefix: Prefix of the environment variables the client reads,
52
+ for example ``"DATAMARKET_"``.
53
+ default_base_url: Production origin of the API, without a trailing slash.
54
+ api_prefix: Path prefix shared by the authenticated endpoints.
55
+ api_key_header: Header that carries the API key.
56
+ error_overrides: Exception classes to raise for specific error codes,
57
+ in place of the class chosen by HTTP status.
58
+ """
59
+
60
+ distribution_name: str
61
+ version: str
62
+ env_prefix: str
63
+ default_base_url: str
64
+ api_prefix: str = "/api/v1"
65
+ api_key_header: str = "X-API-Key"
66
+ error_overrides: Mapping[str, type[APIStatusError]] = field(default_factory=dict)
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class ClientConfig:
71
+ """Validated settings of one client instance. Build it with :func:`build_client_config`."""
72
+
73
+ product: ProductSettings
74
+ api_key: SecretStr | None
75
+ base_url: str
76
+ timeout: httpx.Timeout
77
+ max_retries: int
78
+ max_units_per_request: int | None
79
+ user_agent: str
80
+
81
+ @property
82
+ def hostname(self) -> str:
83
+ """Host part of ``base_url``, safe to show in messages."""
84
+ return urlsplit(self.base_url).hostname or self.base_url
85
+
86
+ @property
87
+ def read_timeout_seconds(self) -> float:
88
+ """Read timeout in seconds. :func:`build_timeout` guarantees it is set."""
89
+ read_timeout = self.timeout.read
90
+ if read_timeout is None:
91
+ raise ConfigurationError("The read timeout must be set.")
92
+ return float(read_timeout)
93
+
94
+ @property
95
+ def masked_api_key(self) -> str:
96
+ """The API key as it may appear in ``repr`` and logs."""
97
+ return mask_api_key(self.api_key)
98
+
99
+
100
+ def mask_api_key(api_key: SecretStr | None) -> str:
101
+ """Redact a key, keeping only a short public prefix such as ``dm_****``."""
102
+ if api_key is None:
103
+ return "<not set>"
104
+ prefix, separator, _ = api_key.get_secret_value().partition("_")
105
+ if separator and 0 < len(prefix) <= _MASK_PREFIX_MAX_LENGTH:
106
+ return f"{prefix}_****"
107
+ return "****"
108
+
109
+
110
+ def build_user_agent(product: ProductSettings) -> str:
111
+ """Identify the product SDK, the core, Python and httpx, in that order."""
112
+ python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
113
+ core_version = read_distribution_version(CORE_DISTRIBUTION_NAME)
114
+ return (
115
+ f"{product.distribution_name}/{product.version} "
116
+ f"{CORE_DISTRIBUTION_NAME}/{core_version} "
117
+ f"python/{python_version} httpx/{httpx.__version__}"
118
+ )
119
+
120
+
121
+ def build_client_config(
122
+ product: ProductSettings,
123
+ *,
124
+ api_key: str | None = None,
125
+ base_url: str | None = None,
126
+ timeout: float | httpx.Timeout = DEFAULT_READ_TIMEOUT_SECONDS,
127
+ max_retries: int = DEFAULT_MAX_RETRIES,
128
+ max_units_per_request: int | None = None,
129
+ environ: Mapping[str, str] | None = None,
130
+ ) -> ClientConfig:
131
+ """Resolve and validate the settings of a client.
132
+
133
+ Explicit arguments win over environment variables, which win over the
134
+ product defaults. A blank string counts as not given, at every level.
135
+ ``environ`` replaces ``os.environ`` in tests.
136
+
137
+ Raises:
138
+ ConfigurationError: A value is invalid. Nothing has been sent.
139
+ """
140
+ env = os.environ if environ is None else environ
141
+ api_key_value = _first_present(api_key, env.get(product.env_prefix + API_KEY_ENV_SUFFIX))
142
+ base_url_value = (
143
+ _first_present(base_url, env.get(product.env_prefix + BASE_URL_ENV_SUFFIX))
144
+ or product.default_base_url
145
+ )
146
+ return ClientConfig(
147
+ product=product,
148
+ api_key=_validate_api_key(api_key_value) if api_key_value is not None else None,
149
+ base_url=validate_base_url(base_url_value),
150
+ timeout=build_timeout(timeout),
151
+ max_retries=_validate_max_retries(max_retries),
152
+ max_units_per_request=_validate_max_units_per_request(max_units_per_request),
153
+ user_agent=build_user_agent(product),
154
+ )
155
+
156
+
157
+ def validate_base_url(base_url: str) -> str:
158
+ """Normalise a base URL and reject anything the client must not talk to.
159
+
160
+ The URL must be absolute, name a host, and use HTTPS. Plain HTTP is accepted
161
+ only for loopback addresses, so a local API can be used in development
162
+ without ever sending a key in clear text over a network.
163
+ """
164
+ candidate = base_url.strip().rstrip("/")
165
+ parts = urlsplit(candidate)
166
+ if parts.scheme not in {"http", "https"} or not parts.hostname:
167
+ raise ConfigurationError(
168
+ f"base_url must be an absolute http(s) URL with a host, got {base_url!r}."
169
+ )
170
+ if parts.query or parts.fragment:
171
+ raise ConfigurationError("base_url must not carry a query string or fragment.")
172
+ if parts.scheme == "http" and parts.hostname not in _LOOPBACK_HOSTNAMES:
173
+ raise ConfigurationError(
174
+ f"base_url must use https; plain http is only accepted for loopback, got {base_url!r}."
175
+ )
176
+ return candidate
177
+
178
+
179
+ def build_timeout(timeout: float | httpx.Timeout) -> httpx.Timeout:
180
+ """Turn the constructor's ``timeout`` into a complete httpx timeout.
181
+
182
+ A number sets the read timeout only; connect, write and pool keep their
183
+ defaults. An ``httpx.Timeout`` is used as given. The read timeout must be
184
+ a finite number greater than zero and at most :data:`MAX_READ_TIMEOUT_SECONDS`.
185
+ """
186
+ if isinstance(timeout, httpx.Timeout):
187
+ resolved = timeout
188
+ else:
189
+ if not _is_number(timeout):
190
+ raise ConfigurationError(
191
+ f"timeout must be a number of seconds or an httpx.Timeout, got {timeout!r}."
192
+ )
193
+ resolved = httpx.Timeout(
194
+ connect=DEFAULT_CONNECT_TIMEOUT_SECONDS,
195
+ read=float(timeout),
196
+ write=DEFAULT_WRITE_TIMEOUT_SECONDS,
197
+ pool=DEFAULT_POOL_TIMEOUT_SECONDS,
198
+ )
199
+ read_timeout = resolved.read
200
+ if (
201
+ read_timeout is None
202
+ or not math.isfinite(read_timeout)
203
+ or read_timeout <= 0
204
+ or read_timeout > MAX_READ_TIMEOUT_SECONDS
205
+ ):
206
+ raise ConfigurationError(
207
+ "The read timeout must be greater than 0 and at most "
208
+ f"{MAX_READ_TIMEOUT_SECONDS:g} seconds, the server's own limit; got {read_timeout!r}."
209
+ )
210
+ return resolved
211
+
212
+
213
+ def _first_present(*candidates: str | None) -> str | None:
214
+ """The first candidate that is not ``None`` or blank, stripped."""
215
+ for candidate in candidates:
216
+ if candidate is not None and candidate.strip():
217
+ return candidate.strip()
218
+ return None
219
+
220
+
221
+ def _validate_api_key(api_key: str) -> SecretStr:
222
+ """Refuse a key that cannot travel in an HTTP header. The key itself is never in the message."""
223
+ if not (api_key.isascii() and api_key.isprintable()) or " " in api_key:
224
+ raise ConfigurationError(
225
+ "The API key contains characters that cannot be sent in an HTTP header: only "
226
+ "printable ASCII without spaces is accepted. Check for invisible characters or "
227
+ "line breaks copied along with the key."
228
+ )
229
+ return SecretStr(api_key)
230
+
231
+
232
+ def _validate_max_retries(max_retries: int) -> int:
233
+ if not _is_integer(max_retries) or max_retries < 0:
234
+ raise ConfigurationError(
235
+ f"max_retries must be an integer of at least 0, got {max_retries!r}."
236
+ )
237
+ return max_retries
238
+
239
+
240
+ def _validate_max_units_per_request(max_units: int | None) -> int | None:
241
+ if max_units is None:
242
+ return None
243
+ if not _is_integer(max_units) or max_units < 1:
244
+ raise ConfigurationError(
245
+ f"max_units_per_request must be an integer of at least 1 or None, got {max_units!r}."
246
+ )
247
+ return max_units
248
+
249
+
250
+ def _is_integer(value: object) -> bool:
251
+ """An ``int`` that is not a ``bool``; ``True`` would otherwise pass as 1."""
252
+ return isinstance(value, int) and not isinstance(value, bool)
253
+
254
+
255
+ def _is_number(value: object) -> bool:
256
+ """An ``int`` or ``float`` that is not a ``bool``; numeric strings do not count."""
257
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
@@ -0,0 +1,159 @@
1
+ """Conversion of list responses to pandas DataFrames. pandas is imported only when used."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING, Any, ClassVar, cast
7
+
8
+ from .models import APIResponse
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Mapping, Sequence
12
+
13
+ import pandas
14
+
15
+ ENVELOPE_FIELDS_SKIPPED = frozenset({"meta"})
16
+ NESTED_ROWS_SEPARATOR = "."
17
+ _CAMEL_CASE_BOUNDARY = re.compile(r"(?<!^)(?=[A-Z])")
18
+
19
+
20
+ class TabularResponse(APIResponse):
21
+ """A response whose main content is a list of rows.
22
+
23
+ Subclasses name the list in ``rows_field`` when it is not ``data``. A
24
+ dotted name such as ``"groups.members"`` points at a list nested in each
25
+ item of another list; the parent's own fields are repeated on each row.
26
+ """
27
+
28
+ rows_field: ClassVar[str] = "data"
29
+
30
+ def to_dataframe(self, *, include_envelope: bool = True) -> pandas.DataFrame:
31
+ """One row per item of the list, as a pandas DataFrame.
32
+
33
+ Nested objects are flattened with ``_``: ``stats.median_price`` becomes
34
+ ``stats_median_price``. With ``include_envelope``, the scalar fields
35
+ around the list, such as the queried entity, the summary and the
36
+ ``usage`` block, are repeated on every row, prefixed by their name. An
37
+ envelope field that shares its name with a row field is prefixed with
38
+ the response's name instead, as in ``product_image_url`` next to the
39
+ offer's ``image_url``, so no value is lost. Lists inside items are kept
40
+ as Python lists.
41
+
42
+ Requires pandas, available through the SDK's ``pandas`` extra.
43
+ """
44
+ return build_dataframe(self, rows_field=self.rows_field, include_envelope=include_envelope)
45
+
46
+
47
+ def build_dataframe(
48
+ response: APIResponse, *, rows_field: str, include_envelope: bool
49
+ ) -> pandas.DataFrame:
50
+ """Build the DataFrame of ``response`` from the list in ``rows_field``.
51
+
52
+ Only the named list is serialised; sibling lists are left out of the dump
53
+ since the envelope never includes them.
54
+ """
55
+ parent_field, _, child_field = rows_field.partition(NESTED_ROWS_SEPARATOR)
56
+ payload = response.model_dump(mode="python", exclude=_other_lists(response, parent_field))
57
+ parents = [row for row in payload.pop(parent_field, None) or [] if isinstance(row, dict)]
58
+ if child_field:
59
+ rows = [
60
+ {**flatten(_without(parent, child_field)), **flatten(child)}
61
+ for parent in parents
62
+ for child in parent.get(child_field) or []
63
+ if isinstance(child, dict)
64
+ ]
65
+ else:
66
+ rows = [flatten(row) for row in parents]
67
+ envelope: dict[str, Any] = {}
68
+ if include_envelope:
69
+ envelope = disambiguate_envelope(
70
+ flatten_envelope(payload), rows, prefix=response_prefix(response)
71
+ )
72
+ records = [{**envelope, **row} for row in rows]
73
+ return records_to_dataframe(records, columns=column_union(records) or list(envelope))
74
+
75
+
76
+ def disambiguate_envelope(
77
+ envelope: Mapping[str, Any], rows: Sequence[Mapping[str, Any]], *, prefix: str
78
+ ) -> dict[str, Any]:
79
+ """Prefix the envelope columns that a row also uses, so both values get a column."""
80
+ row_keys = {key for row in rows for key in row}
81
+ return {
82
+ (f"{prefix}_{key}" if key in row_keys else key): value for key, value in envelope.items()
83
+ }
84
+
85
+
86
+ def response_prefix(response: APIResponse) -> str:
87
+ """The response's class name in snake case: ``PriceHistory`` gives ``price_history``."""
88
+ return _CAMEL_CASE_BOUNDARY.sub("_", type(response).__name__).lower()
89
+
90
+
91
+ def column_union(records: Sequence[Mapping[str, Any]]) -> list[str]:
92
+ """Every key seen across the records, in order of first appearance.
93
+
94
+ Rows need not share the same keys: free-form objects and fields the API
95
+ adds to some rows only still get a column.
96
+ """
97
+ seen: dict[str, None] = {}
98
+ for record in records:
99
+ for key in record:
100
+ seen.setdefault(key, None)
101
+ return list(seen)
102
+
103
+
104
+ def records_to_dataframe(
105
+ records: Sequence[Mapping[str, Any]], *, columns: Sequence[str]
106
+ ) -> pandas.DataFrame:
107
+ """Build a DataFrame from flat records, importing pandas on first use."""
108
+ try:
109
+ import pandas as pandas_module
110
+ except ImportError as exc:
111
+ raise ImportError(
112
+ "pandas is not installed. Install it, or install the SDK with its 'pandas' extra."
113
+ ) from exc
114
+ # pandas ships partial inline types, so the constructor resolves to Any.
115
+ return cast("pandas.DataFrame", pandas_module.DataFrame(list(records), columns=list(columns)))
116
+
117
+
118
+ def flatten(mapping: Mapping[str, Any], prefix: str = "") -> dict[str, Any]:
119
+ """Flatten nested mappings into one level, joining keys with ``_``."""
120
+ flat: dict[str, Any] = {}
121
+ for key, value in mapping.items():
122
+ name = f"{prefix}{key}"
123
+ if isinstance(value, dict):
124
+ flat.update(flatten(value, f"{name}_"))
125
+ else:
126
+ flat[name] = value
127
+ return flat
128
+
129
+
130
+ def flatten_envelope(payload: Mapping[str, Any]) -> dict[str, Any]:
131
+ """Flatten what surrounds the rows: scalars as they are, objects prefixed by their name.
132
+
133
+ A field of an object that already starts with the object's name keeps it:
134
+ ``store.store_id`` becomes ``store_id``, ``store.city`` becomes
135
+ ``store_city``. Lists and pagination metadata are left out.
136
+ """
137
+ flat: dict[str, Any] = {}
138
+ for key, value in payload.items():
139
+ if key in ENVELOPE_FIELDS_SKIPPED or isinstance(value, list):
140
+ continue
141
+ if isinstance(value, dict):
142
+ for inner_key, inner_value in flatten(value).items():
143
+ name = inner_key if inner_key.startswith(f"{key}_") else f"{key}_{inner_key}"
144
+ flat[name] = inner_value
145
+ else:
146
+ flat[key] = value
147
+ return flat
148
+
149
+
150
+ def _other_lists(response: APIResponse, keep: str) -> set[str]:
151
+ return {
152
+ name
153
+ for name in type(response).model_fields
154
+ if name != keep and isinstance(getattr(response, name, None), list)
155
+ }
156
+
157
+
158
+ def _without(mapping: Mapping[str, Any], key: str) -> dict[str, Any]:
159
+ return {name: value for name, value in mapping.items() if name != key}