pydomi 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.
- pydomi/__init__.py +53 -0
- pydomi/__main__.py +10 -0
- pydomi/api.py +483 -0
- pydomi/app.py +42 -0
- pydomi/async_api.py +477 -0
- pydomi/cache.py +103 -0
- pydomi/cli.py +360 -0
- pydomi/codegen.py +512 -0
- pydomi/endpoint.py +620 -0
- pydomi/exceptions.py +62 -0
- pydomi/generated/__init__.py +28 -0
- pydomi/py.typed +0 -0
- pydomi/queryset.py +172 -0
- pydomi/record.py +403 -0
- pydomi/registry.py +107 -0
- pydomi/relations.py +269 -0
- pydomi/tokens.py +83 -0
- pydomi-0.1.0.dist-info/METADATA +478 -0
- pydomi-0.1.0.dist-info/RECORD +23 -0
- pydomi-0.1.0.dist-info/WHEEL +5 -0
- pydomi-0.1.0.dist-info/entry_points.txt +2 -0
- pydomi-0.1.0.dist-info/licenses/LICENSE +202 -0
- pydomi-0.1.0.dist-info/top_level.txt +1 -0
pydomi/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""``pydomi`` — Python SDK for DOM.Inventory CMDB.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import pydomi
|
|
6
|
+
|
|
7
|
+
api = pydomi.Api("http://127.0.0.1:8000", token="…")
|
|
8
|
+
for site in api.dcim.sites.all():
|
|
9
|
+
print(site.id, site.name)
|
|
10
|
+
|
|
11
|
+
The full client API is documented on :class:`~pydomi.api.Api`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from . import generated # cheap — pure-data module, see generated/__init__.py
|
|
17
|
+
from .api import Api
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Lazy: AsyncApi requires httpx, which is an optional extra. Don't import
|
|
21
|
+
# at top-level — pull it from ``pydomi.async_api`` when needed.
|
|
22
|
+
def __getattr__(name): # PEP 562
|
|
23
|
+
if name == "AsyncApi":
|
|
24
|
+
from .async_api import AsyncApi
|
|
25
|
+
return AsyncApi
|
|
26
|
+
raise AttributeError(name)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
from .exceptions import (
|
|
30
|
+
AuthenticationError,
|
|
31
|
+
ConfigurationError,
|
|
32
|
+
DomiError,
|
|
33
|
+
NotFoundError,
|
|
34
|
+
RequestError,
|
|
35
|
+
ThrottledError,
|
|
36
|
+
ValidationError,
|
|
37
|
+
)
|
|
38
|
+
from .record import Record
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"Api",
|
|
42
|
+
"Record",
|
|
43
|
+
# exceptions
|
|
44
|
+
"DomiError",
|
|
45
|
+
"ConfigurationError",
|
|
46
|
+
"RequestError",
|
|
47
|
+
"AuthenticationError",
|
|
48
|
+
"NotFoundError",
|
|
49
|
+
"ValidationError",
|
|
50
|
+
"ThrottledError",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
__version__ = "0.1.0"
|
pydomi/__main__.py
ADDED
pydomi/api.py
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""Top-level :class:`Api` — the entry point clients import.
|
|
2
|
+
|
|
3
|
+
Usage
|
|
4
|
+
-----
|
|
5
|
+
::
|
|
6
|
+
|
|
7
|
+
import pydomi
|
|
8
|
+
|
|
9
|
+
api = pydomi.Api("http://127.0.0.1:8000", token="abc123")
|
|
10
|
+
for device in api.dcim.devices.filter(site=1):
|
|
11
|
+
print(device.name, device.serial)
|
|
12
|
+
|
|
13
|
+
new = api.dcim.sites.create(name="DC-North", slug="dc-north")
|
|
14
|
+
new.description = "Primary RU-NORTH site"
|
|
15
|
+
new.save()
|
|
16
|
+
new.delete()
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json as _json
|
|
22
|
+
import logging
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import urljoin
|
|
25
|
+
|
|
26
|
+
import requests
|
|
27
|
+
from requests.adapters import HTTPAdapter
|
|
28
|
+
from urllib3.util.retry import Retry
|
|
29
|
+
|
|
30
|
+
from .app import App
|
|
31
|
+
from .exceptions import (
|
|
32
|
+
AuthenticationError,
|
|
33
|
+
ConfigurationError,
|
|
34
|
+
NotFoundError,
|
|
35
|
+
RequestError,
|
|
36
|
+
ThrottledError,
|
|
37
|
+
ValidationError,
|
|
38
|
+
)
|
|
39
|
+
from .registry import REGISTRY, app_names
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger("pydomi")
|
|
42
|
+
|
|
43
|
+
DEFAULT_TIMEOUT = 30 # seconds — overridable per Api instance
|
|
44
|
+
DEFAULT_RETRIES = 3 # retries on 5xx / connection errors
|
|
45
|
+
DEFAULT_BACKOFF = 0.3 # urllib3 Retry backoff_factor
|
|
46
|
+
DEFAULT_CACHE_TTL = 0.0 # seconds — 0 disables the reference-data cache
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _build_retry_adapter(retries: int, backoff: float) -> HTTPAdapter:
|
|
50
|
+
"""urllib3-based retry adapter: 5xx + connection errors + 429.
|
|
51
|
+
|
|
52
|
+
DRF returns 429 with ``Retry-After`` for throttling. urllib3 honours
|
|
53
|
+
that header automatically when 429 is in the ``status_forcelist`` —
|
|
54
|
+
so we get exponential backoff *and* server-prescribed delays from one
|
|
55
|
+
place.
|
|
56
|
+
"""
|
|
57
|
+
return HTTPAdapter(max_retries=Retry(
|
|
58
|
+
total=retries,
|
|
59
|
+
connect=retries,
|
|
60
|
+
read=retries,
|
|
61
|
+
status=retries,
|
|
62
|
+
status_forcelist=(429, 500, 502, 503, 504),
|
|
63
|
+
allowed_methods=("HEAD", "GET", "OPTIONS", "PUT", "PATCH", "POST", "DELETE"),
|
|
64
|
+
backoff_factor=backoff,
|
|
65
|
+
respect_retry_after_header=True,
|
|
66
|
+
raise_on_status=False,
|
|
67
|
+
))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Api:
|
|
71
|
+
"""Top-level handle for a DOM.Inventory server.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
url : str
|
|
76
|
+
Base URL of the server, e.g. ``http://127.0.0.1:8000``. The
|
|
77
|
+
``/api/`` prefix is appended by the SDK — do **not** include it.
|
|
78
|
+
token : str | None
|
|
79
|
+
API token (created in the DOM.Inventory ``/auth/tokens/`` UI or via
|
|
80
|
+
``apps.core.models.APIToken``). Sent as ``Authorization: Token <key>``.
|
|
81
|
+
session : requests.Session | None
|
|
82
|
+
Reuse an existing session (lets you pin custom adapters, retries,
|
|
83
|
+
TLS verification, etc.). A fresh one is created if omitted.
|
|
84
|
+
timeout : float
|
|
85
|
+
Per-request timeout in seconds (default 30).
|
|
86
|
+
verify : bool | str
|
|
87
|
+
Forwarded to ``requests`` — set to ``False`` to skip TLS check,
|
|
88
|
+
or pass a CA-bundle path.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
url: str,
|
|
94
|
+
*,
|
|
95
|
+
token: str | None = None,
|
|
96
|
+
session: requests.Session | None = None,
|
|
97
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
98
|
+
verify: bool | str = True,
|
|
99
|
+
user_agent: str = "pydomi-sdk/0.1",
|
|
100
|
+
retries: int = DEFAULT_RETRIES,
|
|
101
|
+
backoff_factor: float = DEFAULT_BACKOFF,
|
|
102
|
+
cache_ttl: float = DEFAULT_CACHE_TTL,
|
|
103
|
+
dry_run: bool = False,
|
|
104
|
+
):
|
|
105
|
+
if not url or not url.startswith(("http://", "https://")):
|
|
106
|
+
raise ConfigurationError(
|
|
107
|
+
f"`url` must start with http:// or https:// — got {url!r}"
|
|
108
|
+
)
|
|
109
|
+
self.base_url = url.rstrip("/")
|
|
110
|
+
self.token = token
|
|
111
|
+
self.timeout = timeout
|
|
112
|
+
self.verify = verify
|
|
113
|
+
|
|
114
|
+
self._session = session or requests.Session()
|
|
115
|
+
self._session.headers.update({
|
|
116
|
+
"Accept": "application/json",
|
|
117
|
+
"User-Agent": user_agent,
|
|
118
|
+
})
|
|
119
|
+
if token:
|
|
120
|
+
self._session.headers["Authorization"] = f"Token {token}"
|
|
121
|
+
|
|
122
|
+
# Mount retry adapter unless the caller supplied their own session
|
|
123
|
+
# with adapters already wired up. ``retries=0`` opts out cleanly.
|
|
124
|
+
if session is None and retries > 0:
|
|
125
|
+
adapter = _build_retry_adapter(retries, backoff_factor)
|
|
126
|
+
self._session.mount("http://", adapter)
|
|
127
|
+
self._session.mount("https://", adapter)
|
|
128
|
+
|
|
129
|
+
self._apps: dict[str, App] = {name: App(self, name) for name in app_names()}
|
|
130
|
+
# Lazy cache for /api/contenttypes/ — populated on first call.
|
|
131
|
+
self._ct_cache: dict[tuple[str, str], int] | None = None
|
|
132
|
+
# Token management namespace — ``api.tokens.create(...)`` etc.
|
|
133
|
+
from .tokens import _TokenManager
|
|
134
|
+
self.tokens = _TokenManager(self)
|
|
135
|
+
# Reference-data cache (Tag/Status/Site/AS/...). Opt-in.
|
|
136
|
+
from .cache import _TTLCache
|
|
137
|
+
self._cache = _TTLCache(ttl=cache_ttl)
|
|
138
|
+
# When True, mutating verbs (POST/PUT/PATCH/DELETE) are logged
|
|
139
|
+
# and skipped — returning a synthetic stub. Reads still go to
|
|
140
|
+
# the server so chained scripts keep working.
|
|
141
|
+
self.dry_run = dry_run
|
|
142
|
+
|
|
143
|
+
# ── public attribute access (api.dcim, api.network, …) ──────────────
|
|
144
|
+
def __getattr__(self, item: str) -> App:
|
|
145
|
+
# __getattr__ is consulted only when normal lookup fails, so
|
|
146
|
+
# internal attrs like ``base_url`` are NOT routed here.
|
|
147
|
+
try:
|
|
148
|
+
return self._apps[item]
|
|
149
|
+
except KeyError as exc:
|
|
150
|
+
raise AttributeError(
|
|
151
|
+
f"DOM.Inventory has no app {item!r}. Known apps: {sorted(self._apps)}"
|
|
152
|
+
) from exc
|
|
153
|
+
|
|
154
|
+
def __dir__(self) -> list[str]:
|
|
155
|
+
return sorted(set(self._apps) | set(super().__dir__()))
|
|
156
|
+
|
|
157
|
+
# ── meta helpers ─────────────────────────────────────────────────────
|
|
158
|
+
def apps(self) -> list[str]:
|
|
159
|
+
"""List of known app namespaces."""
|
|
160
|
+
return list(self._apps)
|
|
161
|
+
|
|
162
|
+
def endpoints(self) -> dict[str, list[str]]:
|
|
163
|
+
"""Map ``{app: [endpoint_attr, ...]}`` — useful for introspection."""
|
|
164
|
+
return {a: sorted(eps) for a, eps in REGISTRY.items()}
|
|
165
|
+
|
|
166
|
+
# ── parallel fetch / global search ─────────────────────────────────
|
|
167
|
+
def gather(self, callables, *, max_workers: int = 8) -> list:
|
|
168
|
+
"""Run *callables* concurrently in a thread pool and return their
|
|
169
|
+
results in the original order. Exceptions are re-raised in the
|
|
170
|
+
position of the failing callable when iterating the result list
|
|
171
|
+
(via ``concurrent.futures``).
|
|
172
|
+
|
|
173
|
+
Example::
|
|
174
|
+
|
|
175
|
+
sites, devices = api.gather([
|
|
176
|
+
lambda: list(api.dcim.sites.all()),
|
|
177
|
+
lambda: list(api.dcim.devices.all()),
|
|
178
|
+
])
|
|
179
|
+
"""
|
|
180
|
+
import concurrent.futures
|
|
181
|
+
results: list = [None] * len(callables)
|
|
182
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
183
|
+
future_to_idx = {pool.submit(fn): i for i, fn in enumerate(callables)}
|
|
184
|
+
for fut in concurrent.futures.as_completed(future_to_idx):
|
|
185
|
+
results[future_to_idx[fut]] = fut.result()
|
|
186
|
+
return results
|
|
187
|
+
|
|
188
|
+
def fetch_many(self, refs, *, max_workers: int = 8) -> list:
|
|
189
|
+
"""Fetch many objects in parallel.
|
|
190
|
+
|
|
191
|
+
*refs* is a list of ``(endpoint_segment, pk)`` tuples — e.g.
|
|
192
|
+
``[("sites", 1), ("devices", 7), ("tags", 4)]``. Returns Records
|
|
193
|
+
in the same order, ``None`` if any single GET fails with 404.
|
|
194
|
+
Other errors raise normally (so a 500 still surfaces).
|
|
195
|
+
"""
|
|
196
|
+
def _one(seg, pk):
|
|
197
|
+
ep = self._lookup_endpoint(seg)
|
|
198
|
+
if ep is None:
|
|
199
|
+
return None
|
|
200
|
+
try:
|
|
201
|
+
return ep.get(pk)
|
|
202
|
+
except (NotFoundError,):
|
|
203
|
+
return None
|
|
204
|
+
return self.gather([
|
|
205
|
+
(lambda s=s, p=p: _one(s, p)) for s, p in refs
|
|
206
|
+
], max_workers=max_workers)
|
|
207
|
+
|
|
208
|
+
def _lookup_endpoint(self, url_segment: str):
|
|
209
|
+
for app in self._apps.values():
|
|
210
|
+
for ep in app._endpoints.values():
|
|
211
|
+
if ep.url_segment == url_segment:
|
|
212
|
+
return ep
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
def search(
|
|
216
|
+
self,
|
|
217
|
+
query: str,
|
|
218
|
+
*,
|
|
219
|
+
endpoints: list[str] | None = None,
|
|
220
|
+
max_per: int = 25,
|
|
221
|
+
max_workers: int = 8,
|
|
222
|
+
) -> dict[str, list]:
|
|
223
|
+
"""Run DRF ``?search=<query>`` against every applicable endpoint
|
|
224
|
+
in parallel; return ``{endpoint_segment: [Record, ...]}``.
|
|
225
|
+
|
|
226
|
+
*endpoints* — restrict to specific URL segments (default: every
|
|
227
|
+
endpoint registered in :data:`REGISTRY`). *max_per* caps the
|
|
228
|
+
result count per endpoint to avoid surprising responses.
|
|
229
|
+
|
|
230
|
+
Use this for «найти 'router' где угодно» — UI search bars,
|
|
231
|
+
ad-hoc REPL exploration.
|
|
232
|
+
"""
|
|
233
|
+
from .registry import REGISTRY
|
|
234
|
+
targets = endpoints or sorted({
|
|
235
|
+
seg for app in REGISTRY.values() for seg in app.values()
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
def _run(seg: str) -> list:
|
|
239
|
+
ep = self._lookup_endpoint(seg)
|
|
240
|
+
if ep is None:
|
|
241
|
+
return []
|
|
242
|
+
try:
|
|
243
|
+
return [r for _, r in zip(range(max_per),
|
|
244
|
+
ep.search(query))]
|
|
245
|
+
except RequestError:
|
|
246
|
+
return [] # endpoint doesn't accept ?search=
|
|
247
|
+
|
|
248
|
+
results = self.gather(
|
|
249
|
+
[(lambda s=s: _run(s)) for s in targets],
|
|
250
|
+
max_workers=max_workers,
|
|
251
|
+
)
|
|
252
|
+
return {seg: hits for seg, hits in zip(targets, results) if hits}
|
|
253
|
+
|
|
254
|
+
def content_types(self, *, refresh: bool = False) -> dict[tuple[str, str], int]:
|
|
255
|
+
"""Map ``(app_label, model) → content_type_id`` for the whole project.
|
|
256
|
+
|
|
257
|
+
Cached after the first call (CT ids don't change at runtime — they
|
|
258
|
+
only shift if the DB is wiped). Pass ``refresh=True`` to force a
|
|
259
|
+
re-fetch.
|
|
260
|
+
|
|
261
|
+
Use it whenever you create an object that uses a GenericFK::
|
|
262
|
+
|
|
263
|
+
ct = api.content_types()[("dcim", "device")]
|
|
264
|
+
api.agents.agents.create(
|
|
265
|
+
content_type=ct, object_id=device.id, type=osquery_type.id,
|
|
266
|
+
name="OSQuery", config={...},
|
|
267
|
+
)
|
|
268
|
+
"""
|
|
269
|
+
if refresh or self._ct_cache is None:
|
|
270
|
+
data = self._request("GET", f"{self.base_url}/api/contenttypes/")
|
|
271
|
+
self._ct_cache = {
|
|
272
|
+
(row["app_label"], row["model"]): row["id"] for row in data
|
|
273
|
+
}
|
|
274
|
+
return self._ct_cache
|
|
275
|
+
|
|
276
|
+
def content_type_id(self, app_label: str, model: str) -> int:
|
|
277
|
+
"""Convenience: ``api.content_type_id("dcim", "device")``."""
|
|
278
|
+
try:
|
|
279
|
+
return self.content_types()[(app_label, model)]
|
|
280
|
+
except KeyError as exc:
|
|
281
|
+
raise ConfigurationError(
|
|
282
|
+
f"No ContentType for ({app_label!r}, {model!r}). "
|
|
283
|
+
f"Known: {sorted(self.content_types())[:5]}…"
|
|
284
|
+
) from exc
|
|
285
|
+
|
|
286
|
+
def login(
|
|
287
|
+
self,
|
|
288
|
+
username: str,
|
|
289
|
+
password: str,
|
|
290
|
+
*,
|
|
291
|
+
login_path: str = "/auth/login/",
|
|
292
|
+
) -> None:
|
|
293
|
+
"""Authenticate via Django's session login (CSRF + form POST).
|
|
294
|
+
|
|
295
|
+
After this call, subsequent requests carry the session cookie
|
|
296
|
+
and you can omit the API token entirely. Useful when token
|
|
297
|
+
management is locked down to admins.
|
|
298
|
+
|
|
299
|
+
The flow is the standard one for Django auth views:
|
|
300
|
+
|
|
301
|
+
1. ``GET <login_path>`` to obtain the CSRF cookie.
|
|
302
|
+
2. ``POST`` username/password/csrfmiddlewaretoken back, with
|
|
303
|
+
``Referer`` pointing at the GET URL (CSRF middleware will
|
|
304
|
+
reject otherwise).
|
|
305
|
+
|
|
306
|
+
Raises :class:`AuthenticationError` if the server returns a
|
|
307
|
+
non-redirect / non-200 (which Django does for bad credentials).
|
|
308
|
+
"""
|
|
309
|
+
login_url = f"{self.base_url}{login_path}"
|
|
310
|
+
|
|
311
|
+
# 1. GET to seed the csrftoken cookie.
|
|
312
|
+
get_resp = self._session.get(
|
|
313
|
+
login_url, timeout=self.timeout, verify=self.verify,
|
|
314
|
+
)
|
|
315
|
+
if get_resp.status_code >= 400:
|
|
316
|
+
raise AuthenticationError(
|
|
317
|
+
get_resp.status_code, login_url, get_resp.text[:200],
|
|
318
|
+
message=f"login page unreachable: HTTP {get_resp.status_code}",
|
|
319
|
+
)
|
|
320
|
+
csrf = self._session.cookies.get("csrftoken")
|
|
321
|
+
if not csrf:
|
|
322
|
+
raise AuthenticationError(
|
|
323
|
+
0, login_url, "no csrftoken cookie",
|
|
324
|
+
message="Server did not set a csrftoken cookie on the login GET.",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# 2. Form-encoded POST with Referer header (Django CSRF check).
|
|
328
|
+
post = self._session.post(
|
|
329
|
+
login_url,
|
|
330
|
+
data={
|
|
331
|
+
"username": username,
|
|
332
|
+
"password": password,
|
|
333
|
+
"csrfmiddlewaretoken": csrf,
|
|
334
|
+
},
|
|
335
|
+
headers={"Referer": login_url},
|
|
336
|
+
allow_redirects=False,
|
|
337
|
+
timeout=self.timeout, verify=self.verify,
|
|
338
|
+
)
|
|
339
|
+
# Django returns 302 on success (to LOGIN_REDIRECT_URL) and 200
|
|
340
|
+
# (re-rendered form) on failure.
|
|
341
|
+
if post.status_code not in (302, 303):
|
|
342
|
+
raise AuthenticationError(
|
|
343
|
+
post.status_code, login_url,
|
|
344
|
+
post.text[:200] if post.text else "(empty body)",
|
|
345
|
+
message=f"Login failed (HTTP {post.status_code}); "
|
|
346
|
+
f"check username/password.",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
def logout(self, *, logout_path: str = "/auth/logout/") -> None:
|
|
350
|
+
"""End the session. Best-effort — no exception on already-logged-out."""
|
|
351
|
+
try:
|
|
352
|
+
self._session.post(
|
|
353
|
+
f"{self.base_url}{logout_path}",
|
|
354
|
+
timeout=self.timeout, verify=self.verify,
|
|
355
|
+
allow_redirects=False,
|
|
356
|
+
)
|
|
357
|
+
finally:
|
|
358
|
+
self._session.cookies.clear()
|
|
359
|
+
|
|
360
|
+
def status(self) -> dict[str, Any]:
|
|
361
|
+
"""Lightweight reachability probe.
|
|
362
|
+
|
|
363
|
+
Fetches ``/api/schema/?format=json`` (drf-spectacular), since DOM.
|
|
364
|
+
Inventory does not expose a dedicated ``/api/status/`` route. If
|
|
365
|
+
you only need to confirm the server is up and the token is valid,
|
|
366
|
+
this is the cheapest call available.
|
|
367
|
+
"""
|
|
368
|
+
url = f"{self.base_url}/api/schema/?format=json"
|
|
369
|
+
data = self._request("GET", url)
|
|
370
|
+
if isinstance(data, dict):
|
|
371
|
+
info = data.get("info", {})
|
|
372
|
+
return {"title": info.get("title"), "version": info.get("version")}
|
|
373
|
+
return {"raw": data}
|
|
374
|
+
|
|
375
|
+
# ── public HTTP helper (low-level escape hatch) ──────────────────────
|
|
376
|
+
def request(
|
|
377
|
+
self,
|
|
378
|
+
method: str,
|
|
379
|
+
path: str,
|
|
380
|
+
*,
|
|
381
|
+
params: dict[str, Any] | None = None,
|
|
382
|
+
json: dict[str, Any] | None = None,
|
|
383
|
+
expect_json: bool = True,
|
|
384
|
+
) -> Any:
|
|
385
|
+
"""Public hook for non-CRUD endpoints (``/api/changelog/``, custom
|
|
386
|
+
views from third-party apps, etc.).
|
|
387
|
+
|
|
388
|
+
*path* may be absolute (``"http://..."``) or relative
|
|
389
|
+
(``"/api/changelog/"`` or even ``"changelog/"``). The token /
|
|
390
|
+
session / retry adapter / typed exceptions all apply just like
|
|
391
|
+
for built-in calls."""
|
|
392
|
+
if not path.startswith(("http://", "https://", "/")):
|
|
393
|
+
path = "/" + path
|
|
394
|
+
return self._request(method, path, params=params, json=json,
|
|
395
|
+
expect_json=expect_json)
|
|
396
|
+
|
|
397
|
+
# ── HTTP plumbing ────────────────────────────────────────────────────
|
|
398
|
+
def _request(
|
|
399
|
+
self,
|
|
400
|
+
method: str,
|
|
401
|
+
url: str,
|
|
402
|
+
*,
|
|
403
|
+
params: dict[str, Any] | None = None,
|
|
404
|
+
json: dict[str, Any] | None = None,
|
|
405
|
+
expect_json: bool = True,
|
|
406
|
+
) -> Any:
|
|
407
|
+
"""Send a single request and translate errors into typed exceptions."""
|
|
408
|
+
full_url = url if url.startswith("http") else urljoin(self.base_url + "/", url.lstrip("/"))
|
|
409
|
+
logger.debug("HTTP %s %s params=%s body=%s", method, full_url, params, json)
|
|
410
|
+
|
|
411
|
+
# Dry-run short-circuit for mutating verbs.
|
|
412
|
+
if self.dry_run and method.upper() in ("POST", "PUT", "PATCH", "DELETE"):
|
|
413
|
+
logger.info("DRY-RUN %s %s body=%s", method.upper(), full_url, json)
|
|
414
|
+
# Return something Endpoint/Record can consume without crashing.
|
|
415
|
+
stub = dict(json) if isinstance(json, dict) else {}
|
|
416
|
+
stub.setdefault("id", -1)
|
|
417
|
+
return stub
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
resp = self._session.request(
|
|
421
|
+
method, full_url,
|
|
422
|
+
params=params, json=json,
|
|
423
|
+
timeout=self.timeout, verify=self.verify,
|
|
424
|
+
)
|
|
425
|
+
except requests.RequestException as exc:
|
|
426
|
+
raise RequestError(
|
|
427
|
+
status_code=0, url=full_url, body=str(exc),
|
|
428
|
+
message=f"Network error talking to {full_url}: {exc}",
|
|
429
|
+
) from exc
|
|
430
|
+
|
|
431
|
+
return self._handle_response(resp, expect_json=expect_json)
|
|
432
|
+
|
|
433
|
+
@staticmethod
|
|
434
|
+
def _handle_response(resp: requests.Response, *, expect_json: bool) -> Any:
|
|
435
|
+
"""Lift a requests.Response into a Python value or a typed exception."""
|
|
436
|
+
if 200 <= resp.status_code < 300:
|
|
437
|
+
if resp.status_code == 204 or not resp.content:
|
|
438
|
+
return None
|
|
439
|
+
if not expect_json:
|
|
440
|
+
return None
|
|
441
|
+
try:
|
|
442
|
+
return resp.json()
|
|
443
|
+
except _json.JSONDecodeError as exc:
|
|
444
|
+
raise RequestError(
|
|
445
|
+
status_code=resp.status_code, url=resp.url,
|
|
446
|
+
body=resp.text[:400],
|
|
447
|
+
message=f"Server returned non-JSON: {exc}",
|
|
448
|
+
) from exc
|
|
449
|
+
|
|
450
|
+
# Error path. DRF replies with JSON about 99% of the time;
|
|
451
|
+
# fall back to text otherwise.
|
|
452
|
+
try:
|
|
453
|
+
body: Any = resp.json()
|
|
454
|
+
except _json.JSONDecodeError:
|
|
455
|
+
body = resp.text
|
|
456
|
+
|
|
457
|
+
if resp.status_code in (401, 403):
|
|
458
|
+
raise AuthenticationError(resp.status_code, resp.url, body)
|
|
459
|
+
if resp.status_code == 404:
|
|
460
|
+
raise NotFoundError(resp.status_code, resp.url, body)
|
|
461
|
+
if resp.status_code == 400:
|
|
462
|
+
raise ValidationError(resp.status_code, resp.url, body)
|
|
463
|
+
if resp.status_code == 429:
|
|
464
|
+
retry = resp.headers.get("Retry-After")
|
|
465
|
+
try:
|
|
466
|
+
retry_f = float(retry) if retry is not None else None
|
|
467
|
+
except ValueError:
|
|
468
|
+
retry_f = None
|
|
469
|
+
raise ThrottledError(resp.status_code, resp.url, body, retry_after=retry_f)
|
|
470
|
+
raise RequestError(resp.status_code, resp.url, body)
|
|
471
|
+
|
|
472
|
+
def close(self) -> None:
|
|
473
|
+
self._session.close()
|
|
474
|
+
|
|
475
|
+
def __enter__(self) -> "Api":
|
|
476
|
+
return self
|
|
477
|
+
|
|
478
|
+
def __exit__(self, *_exc: object) -> None:
|
|
479
|
+
self.close()
|
|
480
|
+
|
|
481
|
+
def __repr__(self) -> str:
|
|
482
|
+
masked = "set" if self.token else "none"
|
|
483
|
+
return f"<Api {self.base_url} token={masked}>"
|
pydomi/app.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Domain-app namespace — groups endpoints under ``api.<app>.<endpoint>``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .endpoint import Endpoint
|
|
8
|
+
from .registry import READ_ONLY_ENDPOINTS, endpoints_for
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
11
|
+
from .api import Api
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class App:
|
|
15
|
+
"""Lazy container for endpoints belonging to one DOM.Inventory app."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, api: "Api", name: str):
|
|
18
|
+
self._api = api
|
|
19
|
+
self.name = name
|
|
20
|
+
self._endpoints: dict[str, Endpoint] = {}
|
|
21
|
+
for attr, segment in endpoints_for(name).items():
|
|
22
|
+
self._endpoints[attr] = Endpoint(
|
|
23
|
+
api,
|
|
24
|
+
segment,
|
|
25
|
+
read_only=(name, attr) in READ_ONLY_ENDPOINTS,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Attribute access exposes endpoints by their python identifier.
|
|
29
|
+
def __getattr__(self, item: str) -> Endpoint:
|
|
30
|
+
try:
|
|
31
|
+
return self._endpoints[item]
|
|
32
|
+
except KeyError as exc:
|
|
33
|
+
raise AttributeError(
|
|
34
|
+
f"App {self.name!r} has no endpoint {item!r}. "
|
|
35
|
+
f"Available: {sorted(self._endpoints)}"
|
|
36
|
+
) from exc
|
|
37
|
+
|
|
38
|
+
def __dir__(self) -> list[str]:
|
|
39
|
+
return sorted(set(list(self._endpoints)) | set(super().__dir__()))
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
return f"<App {self.name} endpoints={sorted(self._endpoints)}>"
|