netbox-explorer 0.1.2__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 +6 -0
- netbox_cli/app.py +468 -0
- netbox_cli/cache.py +112 -0
- netbox_cli/client.py +429 -0
- netbox_cli/config.py +266 -0
- netbox_cli/discovery.py +300 -0
- netbox_cli/errors.py +57 -0
- netbox_cli/parsing.py +102 -0
- netbox_cli/profiles.py +77 -0
- netbox_cli/query.py +205 -0
- netbox_cli/render.py +493 -0
- netbox_cli/repl/__init__.py +7 -0
- netbox_cli/repl/commands.py +486 -0
- netbox_cli/repl/completer.py +336 -0
- netbox_cli/repl/help.py +41 -0
- netbox_cli/repl/metadata.py +469 -0
- netbox_cli/repl/shell.py +248 -0
- netbox_cli/repl/state.py +167 -0
- netbox_cli/search.py +302 -0
- netbox_cli/settings.py +51 -0
- netbox_explorer-0.1.2.dist-info/METADATA +588 -0
- netbox_explorer-0.1.2.dist-info/RECORD +26 -0
- netbox_explorer-0.1.2.dist-info/WHEEL +5 -0
- netbox_explorer-0.1.2.dist-info/entry_points.txt +2 -0
- netbox_explorer-0.1.2.dist-info/licenses/LICENSE +21 -0
- netbox_explorer-0.1.2.dist-info/top_level.txt +1 -0
netbox_cli/__init__.py
ADDED
netbox_cli/app.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""Typer entrypoint for the NetBox CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .cache import MetadataCache, clear_metadata_cache
|
|
12
|
+
from .client import NetBoxClient
|
|
13
|
+
from .config import init_config, load_settings, resolve_app_paths
|
|
14
|
+
from .discovery import list_apps, list_endpoints, list_filters, resolve_list_path
|
|
15
|
+
from .errors import ConfigError, NetBoxCLIError
|
|
16
|
+
from .parsing import (
|
|
17
|
+
ColumnParseError,
|
|
18
|
+
FilterParseError,
|
|
19
|
+
parse_column_tokens,
|
|
20
|
+
parse_get_filter_tokens,
|
|
21
|
+
parse_list_filter_tokens,
|
|
22
|
+
)
|
|
23
|
+
from .query import get_record, list_records
|
|
24
|
+
from .repl.shell import launch_shell
|
|
25
|
+
from .repl.state import ShellState
|
|
26
|
+
from .render import (
|
|
27
|
+
print_error,
|
|
28
|
+
print_success,
|
|
29
|
+
render_apps,
|
|
30
|
+
render_config_created,
|
|
31
|
+
render_config_test,
|
|
32
|
+
render_endpoints,
|
|
33
|
+
render_filters,
|
|
34
|
+
render_paths,
|
|
35
|
+
render_query_result,
|
|
36
|
+
render_record_result,
|
|
37
|
+
render_search_groups,
|
|
38
|
+
)
|
|
39
|
+
from .search import global_search
|
|
40
|
+
from .settings import AppPaths, LoadedSettings, NetBoxSettings, OutputFormat
|
|
41
|
+
|
|
42
|
+
cli = typer.Typer(
|
|
43
|
+
name="netbox",
|
|
44
|
+
add_completion=False,
|
|
45
|
+
help="Read-only NetBox CLI for discovery, endpoint queries, grouped search, and an interactive shell.",
|
|
46
|
+
no_args_is_help=True,
|
|
47
|
+
rich_markup_mode="rich",
|
|
48
|
+
)
|
|
49
|
+
config_app = typer.Typer(help="Create, inspect, and validate explicit CLI configuration.")
|
|
50
|
+
cache_app = typer.Typer(help="Inspect and clear local metadata cache.")
|
|
51
|
+
cli.add_typer(config_app, name="config")
|
|
52
|
+
cli.add_typer(cache_app, name="cache")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CLIOutputFormat(str, Enum):
|
|
56
|
+
TABLE = "table"
|
|
57
|
+
JSON = "json"
|
|
58
|
+
CSV = "csv"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def version_callback(value: bool) -> None:
|
|
62
|
+
if value:
|
|
63
|
+
typer.echo(__version__)
|
|
64
|
+
raise typer.Exit()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@cli.callback()
|
|
68
|
+
def main_callback(
|
|
69
|
+
version: Annotated[
|
|
70
|
+
bool | None,
|
|
71
|
+
typer.Option(
|
|
72
|
+
"--version",
|
|
73
|
+
help="Show the package version and exit.",
|
|
74
|
+
callback=version_callback,
|
|
75
|
+
is_eager=True,
|
|
76
|
+
),
|
|
77
|
+
] = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
del version
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@cli.command("init")
|
|
83
|
+
def init_command(
|
|
84
|
+
url: Annotated[
|
|
85
|
+
str,
|
|
86
|
+
typer.Option(
|
|
87
|
+
"--url",
|
|
88
|
+
prompt=True,
|
|
89
|
+
help="Base URL for your NetBox instance.",
|
|
90
|
+
),
|
|
91
|
+
],
|
|
92
|
+
token: Annotated[
|
|
93
|
+
str,
|
|
94
|
+
typer.Option(
|
|
95
|
+
"--token",
|
|
96
|
+
prompt=True,
|
|
97
|
+
hide_input=True,
|
|
98
|
+
help="NetBox API token.",
|
|
99
|
+
),
|
|
100
|
+
],
|
|
101
|
+
default_format: Annotated[
|
|
102
|
+
str,
|
|
103
|
+
typer.Option("--default-format", help="Default output format."),
|
|
104
|
+
] = "table",
|
|
105
|
+
default_limit: Annotated[
|
|
106
|
+
int,
|
|
107
|
+
typer.Option("--default-limit", help="Default row limit."),
|
|
108
|
+
] = 15,
|
|
109
|
+
timeout_seconds: Annotated[
|
|
110
|
+
float,
|
|
111
|
+
typer.Option("--timeout", help="HTTP timeout in seconds."),
|
|
112
|
+
] = 10.0,
|
|
113
|
+
verify_tls: Annotated[
|
|
114
|
+
bool,
|
|
115
|
+
typer.Option("--verify-tls/--no-verify-tls", help="Enable or disable TLS verification."),
|
|
116
|
+
] = True,
|
|
117
|
+
force: Annotated[
|
|
118
|
+
bool,
|
|
119
|
+
typer.Option("--force", help="Overwrite an existing config file."),
|
|
120
|
+
] = False,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Create the explicit user config file."""
|
|
123
|
+
|
|
124
|
+
paths = resolve_app_paths()
|
|
125
|
+
try:
|
|
126
|
+
config_path = init_config(
|
|
127
|
+
url=url,
|
|
128
|
+
token=token,
|
|
129
|
+
default_format=default_format,
|
|
130
|
+
default_limit=default_limit,
|
|
131
|
+
timeout_seconds=timeout_seconds,
|
|
132
|
+
verify_tls=verify_tls,
|
|
133
|
+
force=force,
|
|
134
|
+
app_paths=paths,
|
|
135
|
+
)
|
|
136
|
+
except NetBoxCLIError as exc:
|
|
137
|
+
_exit_with_error(exc)
|
|
138
|
+
|
|
139
|
+
settings = NetBoxSettings(
|
|
140
|
+
url=url,
|
|
141
|
+
token=token,
|
|
142
|
+
default_format=default_format, # type: ignore[arg-type]
|
|
143
|
+
default_limit=default_limit,
|
|
144
|
+
timeout_seconds=timeout_seconds,
|
|
145
|
+
verify_tls=verify_tls,
|
|
146
|
+
)
|
|
147
|
+
render_config_created(paths, settings)
|
|
148
|
+
print_success(f"Config written to {config_path}.")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@config_app.command("test")
|
|
152
|
+
def config_test_command() -> None:
|
|
153
|
+
"""Validate config loading, token, and API connectivity."""
|
|
154
|
+
|
|
155
|
+
paths = resolve_app_paths()
|
|
156
|
+
try:
|
|
157
|
+
loaded = load_settings(app_paths=paths)
|
|
158
|
+
client = NetBoxClient(
|
|
159
|
+
loaded.settings,
|
|
160
|
+
metadata_cache=MetadataCache(paths.cache_dir),
|
|
161
|
+
)
|
|
162
|
+
api_root = client.test_connection()
|
|
163
|
+
except NetBoxCLIError as exc:
|
|
164
|
+
_exit_with_error(exc)
|
|
165
|
+
|
|
166
|
+
render_config_test(loaded, api_root)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@config_app.command("paths")
|
|
170
|
+
def config_paths_command() -> None:
|
|
171
|
+
"""Show the user-specific config, cache, and history locations."""
|
|
172
|
+
|
|
173
|
+
render_paths(resolve_app_paths())
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@cache_app.command("clear")
|
|
177
|
+
def cache_clear_command() -> None:
|
|
178
|
+
"""Clear local metadata cache files."""
|
|
179
|
+
|
|
180
|
+
paths = resolve_app_paths()
|
|
181
|
+
removed_files = clear_metadata_cache(paths.cache_dir)
|
|
182
|
+
if removed_files == 0:
|
|
183
|
+
print_success(f"Cache is already empty at {paths.cache_dir}.")
|
|
184
|
+
return
|
|
185
|
+
print_success(f"Cleared {removed_files} cache file(s) from {paths.cache_dir}.")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@cli.command("apps")
|
|
189
|
+
def apps_command(
|
|
190
|
+
output_format: Annotated[
|
|
191
|
+
CLIOutputFormat | None,
|
|
192
|
+
typer.Option("--format", "-f", help="Output format."),
|
|
193
|
+
] = None,
|
|
194
|
+
) -> None:
|
|
195
|
+
"""List top-level NetBox apps. `netbox list` is the preferred exploration command."""
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
_, loaded, client = _build_runtime()
|
|
199
|
+
apps = list_apps(client)
|
|
200
|
+
render_apps(
|
|
201
|
+
apps,
|
|
202
|
+
_resolve_output_format(output_format, loaded.settings.default_format),
|
|
203
|
+
)
|
|
204
|
+
except NetBoxCLIError as exc:
|
|
205
|
+
_exit_with_error(exc)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@cli.command("endpoints")
|
|
209
|
+
def endpoints_command(
|
|
210
|
+
app_name: Annotated[str, typer.Argument(help="NetBox app name, for example dcim.")],
|
|
211
|
+
output_format: Annotated[
|
|
212
|
+
CLIOutputFormat | None,
|
|
213
|
+
typer.Option("--format", "-f", help="Output format."),
|
|
214
|
+
] = None,
|
|
215
|
+
) -> None:
|
|
216
|
+
"""List endpoints for a NetBox app. `netbox list <app>` is the preferred exploration command."""
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
_, loaded, client = _build_runtime()
|
|
220
|
+
endpoints = list_endpoints(client, app_name)
|
|
221
|
+
render_endpoints(
|
|
222
|
+
endpoints,
|
|
223
|
+
_resolve_output_format(output_format, loaded.settings.default_format),
|
|
224
|
+
)
|
|
225
|
+
except NetBoxCLIError as exc:
|
|
226
|
+
_exit_with_error(exc)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@cli.command("filters")
|
|
230
|
+
def filters_command(
|
|
231
|
+
endpoint_path: Annotated[
|
|
232
|
+
str,
|
|
233
|
+
typer.Argument(help="Endpoint path in app/endpoint form, for example dcim/devices."),
|
|
234
|
+
],
|
|
235
|
+
output_format: Annotated[
|
|
236
|
+
CLIOutputFormat | None,
|
|
237
|
+
typer.Option("--format", "-f", help="Output format."),
|
|
238
|
+
] = None,
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Show available filters and known choices for an endpoint."""
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
_, loaded, client = _build_runtime()
|
|
244
|
+
filters = list_filters(client, endpoint_path)
|
|
245
|
+
render_filters(
|
|
246
|
+
filters,
|
|
247
|
+
_resolve_output_format(output_format, loaded.settings.default_format),
|
|
248
|
+
)
|
|
249
|
+
except NetBoxCLIError as exc:
|
|
250
|
+
_exit_with_error(exc)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@cli.command("list")
|
|
254
|
+
def list_command(
|
|
255
|
+
path_and_filters: Annotated[
|
|
256
|
+
list[str] | None,
|
|
257
|
+
typer.Argument(
|
|
258
|
+
help="Optional app or endpoint path followed by free-text terms and/or key=value filters.",
|
|
259
|
+
),
|
|
260
|
+
] = None,
|
|
261
|
+
output_format: Annotated[
|
|
262
|
+
CLIOutputFormat | None,
|
|
263
|
+
typer.Option("--format", "-f", help="Output format."),
|
|
264
|
+
] = None,
|
|
265
|
+
cols: Annotated[
|
|
266
|
+
str | None,
|
|
267
|
+
typer.Option("--cols", help="Comma-separated output columns, for example name,site,status."),
|
|
268
|
+
] = None,
|
|
269
|
+
limit: Annotated[
|
|
270
|
+
int | None,
|
|
271
|
+
typer.Option("--limit", min=1, help="Maximum rows to display."),
|
|
272
|
+
] = None,
|
|
273
|
+
) -> None:
|
|
274
|
+
"""List apps, app endpoints, or endpoint records depending on the provided path."""
|
|
275
|
+
|
|
276
|
+
selected_columns = parse_column_args(cols)
|
|
277
|
+
raw_args = path_and_filters or []
|
|
278
|
+
try:
|
|
279
|
+
_, loaded, client = _build_runtime()
|
|
280
|
+
resolved_target = resolve_list_path(client, raw_args[0] if raw_args else None)
|
|
281
|
+
output = _resolve_output_format(output_format, loaded.settings.default_format)
|
|
282
|
+
|
|
283
|
+
if resolved_target.kind == "root":
|
|
284
|
+
_validate_context_list_usage([], cols=cols, limit=limit)
|
|
285
|
+
render_apps(list_apps(client), output)
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
if resolved_target.kind == "app":
|
|
289
|
+
_validate_context_list_usage(
|
|
290
|
+
raw_args[1:],
|
|
291
|
+
cols=cols,
|
|
292
|
+
limit=limit,
|
|
293
|
+
)
|
|
294
|
+
render_endpoints(list_endpoints(client, resolved_target.path or ""), output)
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
result = list_records(
|
|
298
|
+
client,
|
|
299
|
+
resolved_target.path or "",
|
|
300
|
+
parse_list_filter_args(raw_args[1:]),
|
|
301
|
+
limit=limit or loaded.settings.default_limit,
|
|
302
|
+
)
|
|
303
|
+
render_query_result(
|
|
304
|
+
result,
|
|
305
|
+
output,
|
|
306
|
+
columns=selected_columns,
|
|
307
|
+
project_columns=cols is not None,
|
|
308
|
+
)
|
|
309
|
+
except NetBoxCLIError as exc:
|
|
310
|
+
_exit_with_error(exc)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@cli.command("get")
|
|
314
|
+
def get_command(
|
|
315
|
+
endpoint_path: Annotated[
|
|
316
|
+
str,
|
|
317
|
+
typer.Argument(help="Endpoint path in app/endpoint form."),
|
|
318
|
+
],
|
|
319
|
+
filters: Annotated[
|
|
320
|
+
list[str] | None,
|
|
321
|
+
typer.Argument(help="Lookup filters such as id=123 or name=router01."),
|
|
322
|
+
] = None,
|
|
323
|
+
output_format: Annotated[
|
|
324
|
+
CLIOutputFormat | None,
|
|
325
|
+
typer.Option("--format", "-f", help="Output format."),
|
|
326
|
+
] = None,
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Fetch one object from an endpoint using lookup filters."""
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
_, loaded, client = _build_runtime()
|
|
332
|
+
result = get_record(
|
|
333
|
+
client,
|
|
334
|
+
endpoint_path,
|
|
335
|
+
parse_get_filter_args(filters or []),
|
|
336
|
+
)
|
|
337
|
+
render_record_result(
|
|
338
|
+
result,
|
|
339
|
+
_resolve_output_format(output_format, loaded.settings.default_format),
|
|
340
|
+
)
|
|
341
|
+
except NetBoxCLIError as exc:
|
|
342
|
+
_exit_with_error(exc)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@cli.command("search")
|
|
346
|
+
def search_command(
|
|
347
|
+
term: Annotated[str, typer.Argument(help="Search term.")],
|
|
348
|
+
output_format: Annotated[
|
|
349
|
+
CLIOutputFormat | None,
|
|
350
|
+
typer.Option("--format", "-f", help="Output format."),
|
|
351
|
+
] = None,
|
|
352
|
+
cols: Annotated[
|
|
353
|
+
str | None,
|
|
354
|
+
typer.Option("--cols", help="Comma-separated output columns, for example id,name,site,status."),
|
|
355
|
+
] = None,
|
|
356
|
+
limit: Annotated[
|
|
357
|
+
int | None,
|
|
358
|
+
typer.Option("--limit", min=1, help="Maximum rows to display per group."),
|
|
359
|
+
] = None,
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Search curated endpoints and group results by object type."""
|
|
362
|
+
|
|
363
|
+
selected_columns = parse_column_args(cols)
|
|
364
|
+
try:
|
|
365
|
+
_, loaded, client = _build_runtime()
|
|
366
|
+
groups = global_search(
|
|
367
|
+
client,
|
|
368
|
+
term,
|
|
369
|
+
limit_per_group=limit or loaded.settings.default_limit,
|
|
370
|
+
)
|
|
371
|
+
render_search_groups(
|
|
372
|
+
groups,
|
|
373
|
+
_resolve_output_format(output_format, loaded.settings.default_format),
|
|
374
|
+
columns=selected_columns,
|
|
375
|
+
project_columns=cols is not None,
|
|
376
|
+
)
|
|
377
|
+
except NetBoxCLIError as exc:
|
|
378
|
+
_exit_with_error(exc)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@cli.command("shell")
|
|
382
|
+
def shell_command() -> None:
|
|
383
|
+
"""Launch the interactive shell with contextual autocomplete."""
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
paths, loaded, client = _build_runtime()
|
|
387
|
+
launch_shell(
|
|
388
|
+
client,
|
|
389
|
+
history_path=paths.history_path,
|
|
390
|
+
initial_state=ShellState.from_settings(loaded.settings),
|
|
391
|
+
)
|
|
392
|
+
except NetBoxCLIError as exc:
|
|
393
|
+
_exit_with_error(exc)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def main() -> None:
|
|
397
|
+
cli()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def parse_list_filter_args(raw_filters: list[str]) -> list[tuple[str, str]]:
|
|
401
|
+
try:
|
|
402
|
+
return parse_list_filter_tokens(raw_filters)
|
|
403
|
+
except FilterParseError as exc:
|
|
404
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def parse_get_filter_args(raw_filters: list[str]) -> dict[str, str]:
|
|
408
|
+
try:
|
|
409
|
+
return parse_get_filter_tokens(raw_filters)
|
|
410
|
+
except FilterParseError as exc:
|
|
411
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def parse_column_args(raw_columns: str | None) -> tuple[str, ...] | None:
|
|
415
|
+
if raw_columns is None:
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
return parse_column_tokens(raw_columns)
|
|
420
|
+
except ColumnParseError as exc:
|
|
421
|
+
raise typer.BadParameter(str(exc), param_hint="--cols") from exc
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _validate_context_list_usage(
|
|
425
|
+
extra_args: list[str],
|
|
426
|
+
*,
|
|
427
|
+
cols: str | None,
|
|
428
|
+
limit: int | None,
|
|
429
|
+
) -> None:
|
|
430
|
+
if extra_args:
|
|
431
|
+
raise typer.BadParameter(
|
|
432
|
+
"Free-text terms and filters are only valid when listing an endpoint path."
|
|
433
|
+
)
|
|
434
|
+
if cols is not None:
|
|
435
|
+
raise typer.BadParameter(
|
|
436
|
+
"`--cols` is only supported when listing endpoint records.",
|
|
437
|
+
param_hint="--cols",
|
|
438
|
+
)
|
|
439
|
+
if limit is not None:
|
|
440
|
+
raise typer.BadParameter(
|
|
441
|
+
"`--limit` is only supported when listing endpoint records.",
|
|
442
|
+
param_hint="--limit",
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _build_runtime() -> tuple[AppPaths, LoadedSettings, NetBoxClient]:
|
|
447
|
+
paths = resolve_app_paths()
|
|
448
|
+
loaded = load_settings(app_paths=paths)
|
|
449
|
+
client = NetBoxClient(
|
|
450
|
+
loaded.settings,
|
|
451
|
+
metadata_cache=MetadataCache(paths.cache_dir),
|
|
452
|
+
)
|
|
453
|
+
return paths, loaded, client
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _resolve_output_format(
|
|
457
|
+
explicit_format: CLIOutputFormat | None,
|
|
458
|
+
default_format: str,
|
|
459
|
+
) -> OutputFormat:
|
|
460
|
+
return explicit_format.value if explicit_format is not None else default_format
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _exit_with_error(error: NetBoxCLIError) -> None:
|
|
464
|
+
if isinstance(error, ConfigError):
|
|
465
|
+
print_error(str(error))
|
|
466
|
+
else:
|
|
467
|
+
print_error(str(error))
|
|
468
|
+
raise typer.Exit(code=1)
|
netbox_cli/cache.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Helpers for lightweight metadata caching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Mapping
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def read_json_cache(path: Path, *, max_age_seconds: int | None = None) -> dict[str, Any] | None:
|
|
16
|
+
"""Read a JSON cache file if it exists and is still fresh."""
|
|
17
|
+
|
|
18
|
+
if not path.exists():
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
if max_age_seconds is not None:
|
|
22
|
+
age_seconds = time.time() - path.stat().st_mtime
|
|
23
|
+
if age_seconds >= max_age_seconds:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
28
|
+
payload = json.load(handle)
|
|
29
|
+
except (OSError, json.JSONDecodeError):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
if not isinstance(payload, dict):
|
|
33
|
+
return None
|
|
34
|
+
return payload
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_json_cache(path: Path, payload: Mapping[str, Any]) -> None:
|
|
38
|
+
"""Write JSON cache data, creating the parent directory if needed."""
|
|
39
|
+
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
with path.open("w", encoding="utf-8") as handle:
|
|
42
|
+
json.dump(payload, handle, indent=2, sort_keys=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cache_key_to_path(cache_dir: Path, cache_key: str) -> Path:
|
|
46
|
+
"""Map a logical cache key to a stable JSON file path."""
|
|
47
|
+
|
|
48
|
+
safe_key = cache_key.replace("/", "__")
|
|
49
|
+
return cache_dir / f"{safe_key}.json"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class MetadataCacheTTL:
|
|
54
|
+
"""TTL values for cached NetBox metadata."""
|
|
55
|
+
|
|
56
|
+
api_root_seconds: int = 300
|
|
57
|
+
schema_seconds: int = 3600
|
|
58
|
+
options_seconds: int = 1800
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(slots=True)
|
|
62
|
+
class MetadataCache:
|
|
63
|
+
"""Small file-backed cache for discovery metadata."""
|
|
64
|
+
|
|
65
|
+
cache_dir: Path
|
|
66
|
+
ttl: MetadataCacheTTL = field(default_factory=MetadataCacheTTL)
|
|
67
|
+
|
|
68
|
+
def read_api_root(self) -> dict[str, Any] | None:
|
|
69
|
+
return read_json_cache(
|
|
70
|
+
self.cache_dir / "api_root.json",
|
|
71
|
+
max_age_seconds=self.ttl.api_root_seconds,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def write_api_root(self, payload: Mapping[str, Any]) -> None:
|
|
75
|
+
write_json_cache(self.cache_dir / "api_root.json", payload)
|
|
76
|
+
|
|
77
|
+
def read_schema(self) -> dict[str, Any] | None:
|
|
78
|
+
return read_json_cache(
|
|
79
|
+
self.cache_dir / "schema.json",
|
|
80
|
+
max_age_seconds=self.ttl.schema_seconds,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def write_schema(self, payload: Mapping[str, Any]) -> None:
|
|
84
|
+
write_json_cache(self.cache_dir / "schema.json", payload)
|
|
85
|
+
|
|
86
|
+
def read_options(self, endpoint_path: str) -> dict[str, Any] | None:
|
|
87
|
+
return read_json_cache(
|
|
88
|
+
self._options_path(endpoint_path),
|
|
89
|
+
max_age_seconds=self.ttl.options_seconds,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def write_options(self, endpoint_path: str, payload: Mapping[str, Any]) -> None:
|
|
93
|
+
write_json_cache(self._options_path(endpoint_path), payload)
|
|
94
|
+
|
|
95
|
+
def _options_path(self, endpoint_path: str) -> Path:
|
|
96
|
+
normalized = endpoint_path.strip("/") or "root"
|
|
97
|
+
slug = re.sub(r"[^A-Za-z0-9._-]+", "_", normalized).strip("_") or "endpoint"
|
|
98
|
+
digest = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:10]
|
|
99
|
+
return self.cache_dir / f"options__{slug}__{digest}.json"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def clear_metadata_cache(cache_dir: Path) -> int:
|
|
103
|
+
"""Remove cached metadata files and recreate the cache directory."""
|
|
104
|
+
|
|
105
|
+
if not cache_dir.exists():
|
|
106
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
removed_files = sum(1 for path in cache_dir.rglob("*") if path.is_file())
|
|
110
|
+
shutil.rmtree(cache_dir)
|
|
111
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
return removed_files
|