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 ADDED
@@ -0,0 +1,6 @@
1
+ """NetBox CLI package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
6
+
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