netbox-sdk 0.0.3__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.
Files changed (71) hide show
  1. netbox_cli/__init__.py +4 -0
  2. netbox_cli/api.py +356 -0
  3. netbox_cli/cli/__init__.py +509 -0
  4. netbox_cli/cli/demo.py +543 -0
  5. netbox_cli/cli/dev.py +694 -0
  6. netbox_cli/cli/django_model.py +178 -0
  7. netbox_cli/cli/dynamic.py +485 -0
  8. netbox_cli/cli/runtime.py +178 -0
  9. netbox_cli/cli/support.py +380 -0
  10. netbox_cli/config.py +236 -0
  11. netbox_cli/demo_auth.py +219 -0
  12. netbox_cli/dev_tui.py +17 -0
  13. netbox_cli/dev_tui.tcss +664 -0
  14. netbox_cli/django_model_tui.tcss +165 -0
  15. netbox_cli/django_models/__init__.py +8 -0
  16. netbox_cli/django_models/diagram.py +199 -0
  17. netbox_cli/django_models/fetcher.py +239 -0
  18. netbox_cli/django_models/parser.py +284 -0
  19. netbox_cli/django_models/rich_rendering.py +571 -0
  20. netbox_cli/django_models/store.py +124 -0
  21. netbox_cli/docgen/__init__.py +16 -0
  22. netbox_cli/docgen/engine.py +410 -0
  23. netbox_cli/docgen/format.py +63 -0
  24. netbox_cli/docgen/models.py +156 -0
  25. netbox_cli/docgen/specs.py +27 -0
  26. netbox_cli/docgen_capture.py +277 -0
  27. netbox_cli/docgen_specs.py +394 -0
  28. netbox_cli/http_cache.py +139 -0
  29. netbox_cli/logging_runtime.py +170 -0
  30. netbox_cli/logs_tui.tcss +99 -0
  31. netbox_cli/markdown_output.py +113 -0
  32. netbox_cli/output_safety.py +32 -0
  33. netbox_cli/reference/openapi/netbox-openapi.json +297606 -0
  34. netbox_cli/schema.py +343 -0
  35. netbox_cli/services.py +127 -0
  36. netbox_cli/theme_registry.py +258 -0
  37. netbox_cli/themes/dracula.json +39 -0
  38. netbox_cli/themes/netbox-dark.json +39 -0
  39. netbox_cli/themes/netbox-light.json +39 -0
  40. netbox_cli/themes/onedark-pro.json +39 -0
  41. netbox_cli/themes/tokyo-night.json +39 -0
  42. netbox_cli/trace_ascii.py +175 -0
  43. netbox_cli/tui.py +7 -0
  44. netbox_cli/tui.tcss +1006 -0
  45. netbox_cli/ui/__init__.py +22 -0
  46. netbox_cli/ui/app.py +1181 -0
  47. netbox_cli/ui/chrome.py +171 -0
  48. netbox_cli/ui/cli_completions.py +178 -0
  49. netbox_cli/ui/cli_tui.py +1194 -0
  50. netbox_cli/ui/dev_app.py +923 -0
  51. netbox_cli/ui/dev_rendering.py +133 -0
  52. netbox_cli/ui/dev_state.py +106 -0
  53. netbox_cli/ui/django_model_app.py +752 -0
  54. netbox_cli/ui/django_model_state.py +40 -0
  55. netbox_cli/ui/filter_overlay.py +324 -0
  56. netbox_cli/ui/formatting.py +356 -0
  57. netbox_cli/ui/logo_render.py +30 -0
  58. netbox_cli/ui/logs_app.py +219 -0
  59. netbox_cli/ui/nav_blueprint.py +383 -0
  60. netbox_cli/ui/navigation.py +99 -0
  61. netbox_cli/ui/panels.py +126 -0
  62. netbox_cli/ui/plugin_discovery.py +99 -0
  63. netbox_cli/ui/state.py +90 -0
  64. netbox_cli/ui/widgets.py +362 -0
  65. netbox_cli/ui_common.tcss +1444 -0
  66. netbox_sdk-0.0.3.dist-info/METADATA +230 -0
  67. netbox_sdk-0.0.3.dist-info/RECORD +71 -0
  68. netbox_sdk-0.0.3.dist-info/WHEEL +5 -0
  69. netbox_sdk-0.0.3.dist-info/entry_points.txt +2 -0
  70. netbox_sdk-0.0.3.dist-info/licenses/LICENSE.txt +177 -0
  71. netbox_sdk-0.0.3.dist-info/top_level.txt +1 -0
netbox_cli/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """netbox-cli package."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.1.0"
netbox_cli/api.py ADDED
@@ -0,0 +1,356 @@
1
+ """Data models and HTTP client logic for authenticated NetBox API requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from typing import Any
8
+ from urllib.parse import urljoin, urlsplit
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ from .config import (
13
+ DEMO_BASE_URL,
14
+ DEMO_PROFILE,
15
+ Config,
16
+ authorization_header_value,
17
+ cache_dir,
18
+ load_profile_config,
19
+ save_profile_config,
20
+ )
21
+ from .http_cache import CachePolicy, HttpCacheStore, build_cache_key
22
+ from .logging_runtime import get_logger
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ class ApiResponse(BaseModel):
28
+ model_config = ConfigDict(arbitrary_types_allowed=True)
29
+
30
+ status: int
31
+ text: str
32
+ headers: dict[str, str] = Field(default_factory=dict)
33
+
34
+ def json(self) -> Any:
35
+ return json.loads(self.text)
36
+
37
+
38
+ class RequestError(RuntimeError):
39
+ def __init__(self, response: ApiResponse):
40
+ self.response = response
41
+ super().__init__(f"Request failed with status {response.status}")
42
+
43
+
44
+ class ConnectionProbe(BaseModel):
45
+ status: int
46
+ version: str
47
+ ok: bool
48
+ error: str | None = None
49
+
50
+
51
+ class NetBoxApiClient:
52
+ def __init__(self, config: Config):
53
+ self.config = config
54
+ self._cache = HttpCacheStore(cache_dir())
55
+ logger.debug("initialized api client for %s", self.config.base_url or "<unset>")
56
+
57
+ def build_url(self, path: str) -> str:
58
+ if not self.config.base_url:
59
+ raise RuntimeError("NetBox base URL is not configured")
60
+ normalized = self._normalize_request_path(path)
61
+ return urljoin(f"{self.config.base_url.rstrip('/')}/", normalized.lstrip("/"))
62
+
63
+ def _normalize_request_path(self, path: str) -> str:
64
+ raw = path.strip()
65
+ if not raw:
66
+ raise ValueError("Request path cannot be empty")
67
+ parsed = urlsplit(raw)
68
+ if parsed.scheme or parsed.netloc:
69
+ raise ValueError("Request path must be relative to the configured NetBox base URL")
70
+ if parsed.query or parsed.fragment:
71
+ raise ValueError("Request path must not include query parameters or fragments")
72
+ normalized = parsed.path if parsed.path.startswith("/") else f"/{parsed.path}"
73
+ return normalized
74
+
75
+ async def request(
76
+ self,
77
+ method: str,
78
+ path: str,
79
+ *,
80
+ query: dict[str, str] | None = None,
81
+ payload: dict[str, Any] | list[Any] | None = None,
82
+ headers: dict[str, str] | None = None,
83
+ ) -> ApiResponse:
84
+ try:
85
+ import aiohttp
86
+ except ModuleNotFoundError as exc:
87
+ raise RuntimeError(
88
+ "aiohttp is required for HTTP requests. Install project dependencies first."
89
+ ) from exc
90
+
91
+ authorization = authorization_header_value(self.config)
92
+ cache_policy = self._cache_policy(
93
+ method=method,
94
+ path=path,
95
+ query=query,
96
+ payload=payload,
97
+ )
98
+ logger.info(
99
+ "api request starting",
100
+ extra={
101
+ "http_method": method.upper(),
102
+ "request_path": path,
103
+ "query_keys": sorted((query or {}).keys()),
104
+ "has_payload": payload is not None,
105
+ },
106
+ )
107
+ cache_key: str | None = None
108
+ cache_entry = None
109
+ req_headers = dict(headers or {})
110
+ if cache_policy is not None and self.config.base_url:
111
+ cache_key = build_cache_key(
112
+ base_url=self.config.base_url,
113
+ method=method,
114
+ path=path,
115
+ query=query,
116
+ authorization=authorization,
117
+ )
118
+ cache_entry = self._cache.load(cache_key)
119
+ if cache_entry is not None and cache_entry.is_fresh(self._now()):
120
+ return self._cached_response(cache_entry, cache_status="HIT")
121
+ if cache_entry is not None:
122
+ if cache_entry.etag:
123
+ req_headers.setdefault("If-None-Match", cache_entry.etag)
124
+ if cache_entry.last_modified:
125
+ req_headers.setdefault("If-Modified-Since", cache_entry.last_modified)
126
+
127
+ timeout = aiohttp.ClientTimeout(total=self.config.timeout)
128
+ async with aiohttp.ClientSession(timeout=timeout) as session:
129
+ try:
130
+ response = await self._request_once(
131
+ session,
132
+ method=method,
133
+ path=path,
134
+ query=query,
135
+ payload=payload,
136
+ headers=req_headers,
137
+ authorization=authorization,
138
+ )
139
+ except Exception:
140
+ logger.exception(
141
+ "api request failed",
142
+ extra={"http_method": method.upper(), "request_path": path},
143
+ )
144
+ if cache_entry is not None and cache_entry.can_serve_stale(self._now()):
145
+ return self._cached_response(cache_entry, cache_status="STALE")
146
+ raise
147
+ if self._should_retry_with_v1(response):
148
+ response = await self._request_once(
149
+ session,
150
+ method=method,
151
+ path=path,
152
+ query=query,
153
+ payload=payload,
154
+ headers=req_headers,
155
+ authorization=self._v1_fallback_header(),
156
+ )
157
+ elif self._should_refresh_demo_v1_token(response):
158
+ authorization = self._refresh_demo_v1_authorization()
159
+ if authorization:
160
+ response = await self._request_once(
161
+ session,
162
+ method=method,
163
+ path=path,
164
+ query=query,
165
+ payload=payload,
166
+ headers=req_headers,
167
+ authorization=authorization,
168
+ )
169
+ logger.info(
170
+ "api request completed",
171
+ extra={
172
+ "http_method": method.upper(),
173
+ "request_path": path,
174
+ "status": response.status,
175
+ },
176
+ )
177
+ return self._finalize_cached_response(
178
+ response=response,
179
+ cache_key=cache_key,
180
+ cache_entry=cache_entry,
181
+ cache_policy=cache_policy,
182
+ )
183
+
184
+ async def _request_once(
185
+ self,
186
+ session: Any,
187
+ *,
188
+ method: str,
189
+ path: str,
190
+ query: dict[str, str] | None,
191
+ payload: dict[str, Any] | list[Any] | None,
192
+ headers: dict[str, str] | None,
193
+ authorization: str | None,
194
+ ) -> ApiResponse:
195
+ req_headers = dict(headers or {})
196
+ req_headers.setdefault("Accept", "application/json")
197
+ if authorization:
198
+ req_headers["Authorization"] = authorization
199
+
200
+ async with session.request(
201
+ method=method.upper(),
202
+ url=self.build_url(path),
203
+ params=query,
204
+ json=payload,
205
+ headers=req_headers,
206
+ ) as response:
207
+ text = await response.text()
208
+ logger.debug(
209
+ "received raw api response",
210
+ extra={
211
+ "http_method": method.upper(),
212
+ "request_path": path,
213
+ "status": response.status,
214
+ },
215
+ )
216
+ return ApiResponse(status=response.status, text=text, headers=dict(response.headers))
217
+
218
+ def _v1_fallback_header(self) -> str | None:
219
+ if not self.config.token_secret:
220
+ return None
221
+ return f"Token {self.config.token_secret}"
222
+
223
+ def _should_retry_with_v1(self, response: ApiResponse) -> bool:
224
+ if self.config.token_version != "v2" or not self.config.token_secret:
225
+ return False
226
+ if response.status not in {401, 403}:
227
+ return False
228
+ return "invalid v2 token" in response.text.lower()
229
+
230
+ def _should_refresh_demo_v1_token(self, response: ApiResponse) -> bool:
231
+ if self.config.base_url != DEMO_BASE_URL:
232
+ return False
233
+ if self.config.token_version != "v1":
234
+ return False
235
+ if response.status not in {401, 403}:
236
+ return False
237
+ if "invalid v1 token" not in response.text.lower():
238
+ return False
239
+ if self.config.demo_username and self.config.demo_password:
240
+ return True
241
+ refreshed_profile = load_profile_config(DEMO_PROFILE)
242
+ if refreshed_profile.demo_username and refreshed_profile.demo_password:
243
+ self.config.demo_username = refreshed_profile.demo_username
244
+ self.config.demo_password = refreshed_profile.demo_password
245
+ if refreshed_profile.timeout:
246
+ self.config.timeout = refreshed_profile.timeout
247
+ return True
248
+ return False
249
+
250
+ def _refresh_demo_v1_authorization(self) -> str | None:
251
+ try:
252
+ from .demo_auth import refresh_demo_profile # noqa: PLC0415
253
+
254
+ refreshed = refresh_demo_profile(self.config, headless=True)
255
+ except Exception: # noqa: BLE001
256
+ logger.exception("failed to refresh demo v1 token")
257
+ return None
258
+ save_profile_config(DEMO_PROFILE, refreshed)
259
+ try:
260
+ from .cli.runtime import _cache_profile # noqa: PLC0415
261
+
262
+ _cache_profile(DEMO_PROFILE, refreshed)
263
+ except Exception: # noqa: BLE001
264
+ pass
265
+ self.config = refreshed
266
+ return authorization_header_value(refreshed)
267
+
268
+ def _cache_policy(
269
+ self,
270
+ *,
271
+ method: str,
272
+ path: str,
273
+ query: dict[str, str] | None,
274
+ payload: dict[str, Any] | list[Any] | None,
275
+ ) -> CachePolicy | None:
276
+ if method.upper() != "GET" or payload is not None:
277
+ return None
278
+ if not path.startswith("/api/"):
279
+ return None
280
+ if path == "/api/status/":
281
+ return None
282
+ if self._is_list_request(path):
283
+ return CachePolicy(fresh_ttl_seconds=60.0, stale_if_error_seconds=300.0)
284
+ if query:
285
+ return CachePolicy(fresh_ttl_seconds=30.0, stale_if_error_seconds=120.0)
286
+ return CachePolicy(fresh_ttl_seconds=15.0, stale_if_error_seconds=60.0)
287
+
288
+ def _is_list_request(self, path: str) -> bool:
289
+ parts = [part for part in path.split("/") if part]
290
+ if len(parts) != 3:
291
+ return False
292
+ return parts[0] == "api"
293
+
294
+ def _cached_response(self, entry: Any, *, cache_status: str) -> ApiResponse:
295
+ status, text, headers = entry.response_parts(cache_status=cache_status)
296
+ return ApiResponse(status=status, text=text, headers=headers)
297
+
298
+ def _finalize_cached_response(
299
+ self,
300
+ *,
301
+ response: ApiResponse,
302
+ cache_key: str | None,
303
+ cache_entry: Any,
304
+ cache_policy: CachePolicy | None,
305
+ ) -> ApiResponse:
306
+ if cache_policy is None or cache_key is None:
307
+ return response
308
+ if response.status == 304 and cache_entry is not None:
309
+ refreshed = self._cache.refresh(cache_key, cache_entry, cache_policy)
310
+ return self._cached_response(refreshed, cache_status="REVALIDATED")
311
+ if 200 <= response.status < 300:
312
+ stored = self._cache.save(cache_key, response, cache_policy)
313
+ return self._cached_response(stored, cache_status="MISS")
314
+ if (
315
+ cache_entry is not None
316
+ and cache_entry.can_serve_stale(self._now())
317
+ and response.status >= 500
318
+ ):
319
+ return self._cached_response(cache_entry, cache_status="STALE")
320
+ return response
321
+
322
+ def _now(self) -> float:
323
+ return time.time()
324
+
325
+ async def probe_connection(self) -> ConnectionProbe:
326
+ headers = {"Content-Type": "application/json"}
327
+ try:
328
+ response = await self.request("GET", "/", headers=headers)
329
+ except Exception as exc: # noqa: BLE001
330
+ logger.warning("connection probe failed: %s", exc)
331
+ return ConnectionProbe(status=0, version="", ok=False, error=str(exc))
332
+
333
+ version = response.headers.get("API-Version", "")
334
+ if response.status < 400 or response.status == 403:
335
+ return ConnectionProbe(status=response.status, version=version, ok=True)
336
+
337
+ return ConnectionProbe(
338
+ status=response.status,
339
+ version=version,
340
+ ok=False,
341
+ error=response.text[:500] if response.text else None,
342
+ )
343
+
344
+ async def get_version(self) -> str:
345
+ """Gets the API version of NetBox via GET base URL and API-Version response header."""
346
+ probe = await self.probe_connection()
347
+ if probe.ok:
348
+ return probe.version
349
+ raise RequestError(ApiResponse(status=probe.status, text=probe.error or "", headers={}))
350
+
351
+ async def graphql(self, query: str, variables: dict[str, Any] | None = None) -> ApiResponse:
352
+ """Execute a GraphQL query against the NetBox API."""
353
+ payload: dict[str, Any] = {"query": query}
354
+ if variables:
355
+ payload["variables"] = variables
356
+ return await self.request("POST", "/api/graphql/", payload=payload)