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.
- netbox_cli/__init__.py +4 -0
- netbox_cli/api.py +356 -0
- netbox_cli/cli/__init__.py +509 -0
- netbox_cli/cli/demo.py +543 -0
- netbox_cli/cli/dev.py +694 -0
- netbox_cli/cli/django_model.py +178 -0
- netbox_cli/cli/dynamic.py +485 -0
- netbox_cli/cli/runtime.py +178 -0
- netbox_cli/cli/support.py +380 -0
- netbox_cli/config.py +236 -0
- netbox_cli/demo_auth.py +219 -0
- netbox_cli/dev_tui.py +17 -0
- netbox_cli/dev_tui.tcss +664 -0
- netbox_cli/django_model_tui.tcss +165 -0
- netbox_cli/django_models/__init__.py +8 -0
- netbox_cli/django_models/diagram.py +199 -0
- netbox_cli/django_models/fetcher.py +239 -0
- netbox_cli/django_models/parser.py +284 -0
- netbox_cli/django_models/rich_rendering.py +571 -0
- netbox_cli/django_models/store.py +124 -0
- netbox_cli/docgen/__init__.py +16 -0
- netbox_cli/docgen/engine.py +410 -0
- netbox_cli/docgen/format.py +63 -0
- netbox_cli/docgen/models.py +156 -0
- netbox_cli/docgen/specs.py +27 -0
- netbox_cli/docgen_capture.py +277 -0
- netbox_cli/docgen_specs.py +394 -0
- netbox_cli/http_cache.py +139 -0
- netbox_cli/logging_runtime.py +170 -0
- netbox_cli/logs_tui.tcss +99 -0
- netbox_cli/markdown_output.py +113 -0
- netbox_cli/output_safety.py +32 -0
- netbox_cli/reference/openapi/netbox-openapi.json +297606 -0
- netbox_cli/schema.py +343 -0
- netbox_cli/services.py +127 -0
- netbox_cli/theme_registry.py +258 -0
- netbox_cli/themes/dracula.json +39 -0
- netbox_cli/themes/netbox-dark.json +39 -0
- netbox_cli/themes/netbox-light.json +39 -0
- netbox_cli/themes/onedark-pro.json +39 -0
- netbox_cli/themes/tokyo-night.json +39 -0
- netbox_cli/trace_ascii.py +175 -0
- netbox_cli/tui.py +7 -0
- netbox_cli/tui.tcss +1006 -0
- netbox_cli/ui/__init__.py +22 -0
- netbox_cli/ui/app.py +1181 -0
- netbox_cli/ui/chrome.py +171 -0
- netbox_cli/ui/cli_completions.py +178 -0
- netbox_cli/ui/cli_tui.py +1194 -0
- netbox_cli/ui/dev_app.py +923 -0
- netbox_cli/ui/dev_rendering.py +133 -0
- netbox_cli/ui/dev_state.py +106 -0
- netbox_cli/ui/django_model_app.py +752 -0
- netbox_cli/ui/django_model_state.py +40 -0
- netbox_cli/ui/filter_overlay.py +324 -0
- netbox_cli/ui/formatting.py +356 -0
- netbox_cli/ui/logo_render.py +30 -0
- netbox_cli/ui/logs_app.py +219 -0
- netbox_cli/ui/nav_blueprint.py +383 -0
- netbox_cli/ui/navigation.py +99 -0
- netbox_cli/ui/panels.py +126 -0
- netbox_cli/ui/plugin_discovery.py +99 -0
- netbox_cli/ui/state.py +90 -0
- netbox_cli/ui/widgets.py +362 -0
- netbox_cli/ui_common.tcss +1444 -0
- netbox_sdk-0.0.3.dist-info/METADATA +230 -0
- netbox_sdk-0.0.3.dist-info/RECORD +71 -0
- netbox_sdk-0.0.3.dist-info/WHEEL +5 -0
- netbox_sdk-0.0.3.dist-info/entry_points.txt +2 -0
- netbox_sdk-0.0.3.dist-info/licenses/LICENSE.txt +177 -0
- netbox_sdk-0.0.3.dist-info/top_level.txt +1 -0
netbox_cli/__init__.py
ADDED
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)
|