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 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
@@ -0,0 +1,10 @@
1
+ """Allow ``python -m pydomi`` to work the same as ``python -m pydomi.cli``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
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)}>"