netbox-super-cli 1.0.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.
- netbox_super_cli-1.0.0.dist-info/METADATA +182 -0
- netbox_super_cli-1.0.0.dist-info/RECORD +71 -0
- netbox_super_cli-1.0.0.dist-info/WHEEL +4 -0
- netbox_super_cli-1.0.0.dist-info/entry_points.txt +3 -0
- netbox_super_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
- nsc/__init__.py +5 -0
- nsc/__main__.py +6 -0
- nsc/_version.py +3 -0
- nsc/aliases/__init__.py +24 -0
- nsc/aliases/resolver.py +112 -0
- nsc/auth/__init__.py +5 -0
- nsc/auth/verify.py +143 -0
- nsc/builder/__init__.py +5 -0
- nsc/builder/build.py +514 -0
- nsc/cache/__init__.py +5 -0
- nsc/cache/store.py +295 -0
- nsc/cli/__init__.py +1 -0
- nsc/cli/aliases_commands.py +264 -0
- nsc/cli/app.py +291 -0
- nsc/cli/cache_commands.py +159 -0
- nsc/cli/commands_dump.py +57 -0
- nsc/cli/config_commands.py +156 -0
- nsc/cli/globals.py +65 -0
- nsc/cli/handlers.py +660 -0
- nsc/cli/init_commands.py +95 -0
- nsc/cli/login_commands.py +265 -0
- nsc/cli/profiles_commands.py +256 -0
- nsc/cli/registration.py +465 -0
- nsc/cli/runtime.py +290 -0
- nsc/cli/skill_commands.py +186 -0
- nsc/cli/writes/__init__.py +10 -0
- nsc/cli/writes/apply.py +177 -0
- nsc/cli/writes/bulk.py +231 -0
- nsc/cli/writes/coercion.py +9 -0
- nsc/cli/writes/confirmation.py +96 -0
- nsc/cli/writes/input.py +358 -0
- nsc/cli/writes/preflight.py +182 -0
- nsc/config/__init__.py +23 -0
- nsc/config/loader.py +69 -0
- nsc/config/models.py +54 -0
- nsc/config/settings.py +36 -0
- nsc/config/writer.py +207 -0
- nsc/http/__init__.py +6 -0
- nsc/http/audit.py +183 -0
- nsc/http/client.py +365 -0
- nsc/http/errors.py +35 -0
- nsc/http/retry.py +90 -0
- nsc/model/__init__.py +23 -0
- nsc/model/command_model.py +125 -0
- nsc/output/__init__.py +1 -0
- nsc/output/csv_.py +34 -0
- nsc/output/errors.py +346 -0
- nsc/output/explain.py +194 -0
- nsc/output/flatten.py +25 -0
- nsc/output/headers.py +9 -0
- nsc/output/json_.py +21 -0
- nsc/output/jsonl.py +21 -0
- nsc/output/render.py +47 -0
- nsc/output/table.py +50 -0
- nsc/output/yaml_.py +28 -0
- nsc/schema/__init__.py +1 -0
- nsc/schema/hashing.py +24 -0
- nsc/schema/loader.py +66 -0
- nsc/schema/models.py +109 -0
- nsc/schema/source.py +120 -0
- nsc/schemas/__init__.py +1 -0
- nsc/schemas/bundled/__init__.py +1 -0
- nsc/schemas/bundled/manifest.yaml +5 -0
- nsc/schemas/bundled/netbox-4.6.0-beta2.json.gz +0 -0
- nsc/skill/__init__.py +35 -0
- skills/netbox-super-cli/SKILL.md +127 -0
nsc/cache/store.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Persist generated CommandModels under `~/.nsc/cache/<profile>/<hash>.json`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from nsc.config.models import Config, Profile
|
|
15
|
+
from nsc.model.command_model import CommandModel
|
|
16
|
+
|
|
17
|
+
_LOG = logging.getLogger(__name__)
|
|
18
|
+
_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
19
|
+
_HASH_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class CacheEntry:
|
|
24
|
+
profile: str
|
|
25
|
+
schema_hash: str
|
|
26
|
+
path: Path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class PrunePlan:
|
|
31
|
+
orphan_profile_dirs: list[Path]
|
|
32
|
+
stale_hash_files: list[Path]
|
|
33
|
+
aged_files: list[Path]
|
|
34
|
+
|
|
35
|
+
def total_count(self) -> int:
|
|
36
|
+
return len(self.orphan_profile_dirs) + len(self.stale_hash_files) + len(self.aged_files)
|
|
37
|
+
|
|
38
|
+
def total_bytes(self) -> int:
|
|
39
|
+
total = 0
|
|
40
|
+
for d in self.orphan_profile_dirs:
|
|
41
|
+
for f in d.rglob("*"):
|
|
42
|
+
if f.is_file():
|
|
43
|
+
total += f.stat().st_size
|
|
44
|
+
for f in (*self.stale_hash_files, *self.aged_files):
|
|
45
|
+
if f.exists():
|
|
46
|
+
total += f.stat().st_size
|
|
47
|
+
return total
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class PruneResult:
|
|
52
|
+
deleted_dirs: int
|
|
53
|
+
deleted_files: int
|
|
54
|
+
freed_bytes: int
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class CacheStore:
|
|
59
|
+
root: Path
|
|
60
|
+
|
|
61
|
+
def load(self, profile: str, schema_hash: str) -> CommandModel | None:
|
|
62
|
+
self._validate_profile(profile)
|
|
63
|
+
if not _HASH_RE.match(schema_hash):
|
|
64
|
+
return None
|
|
65
|
+
target = self._path_for(profile, schema_hash)
|
|
66
|
+
if not target.exists():
|
|
67
|
+
return None
|
|
68
|
+
try:
|
|
69
|
+
text = target.read_text()
|
|
70
|
+
model = CommandModel.model_validate_json(text)
|
|
71
|
+
except Exception as exc: # broad: corrupt JSON, schema mismatch, etc.
|
|
72
|
+
_LOG.warning("cache: ignoring corrupt entry %s (%s)", target, exc)
|
|
73
|
+
return None
|
|
74
|
+
if model.schema_hash != schema_hash:
|
|
75
|
+
_LOG.warning("cache: hash mismatch for %s (file says %s)", target, model.schema_hash)
|
|
76
|
+
return None
|
|
77
|
+
return model
|
|
78
|
+
|
|
79
|
+
def save(self, profile: str, model: CommandModel) -> Path:
|
|
80
|
+
self._validate_profile(profile)
|
|
81
|
+
target = self._path_for(profile, model.schema_hash)
|
|
82
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
target.write_text(model.model_dump_json(indent=2))
|
|
84
|
+
return target
|
|
85
|
+
|
|
86
|
+
def clear(self, *, profile: str | None = None) -> None:
|
|
87
|
+
if profile is None:
|
|
88
|
+
if self.root.exists():
|
|
89
|
+
shutil.rmtree(self.root)
|
|
90
|
+
return
|
|
91
|
+
self._validate_profile(profile)
|
|
92
|
+
target = self.root / profile
|
|
93
|
+
if target.exists():
|
|
94
|
+
shutil.rmtree(target)
|
|
95
|
+
|
|
96
|
+
def move(self, old: str, new: str) -> None:
|
|
97
|
+
"""Rename a profile's cache directory from `old` to `new`.
|
|
98
|
+
|
|
99
|
+
No-op when `old` does not exist (the profile was never warmed). Raises
|
|
100
|
+
`FileExistsError` when `new` already exists — the caller must purge
|
|
101
|
+
the target first if that's the intent. Both names are validated to
|
|
102
|
+
prevent path-component injection (matches `_PROFILE_RE`).
|
|
103
|
+
"""
|
|
104
|
+
self._validate_profile(old)
|
|
105
|
+
self._validate_profile(new)
|
|
106
|
+
src = self.root / old
|
|
107
|
+
dst = self.root / new
|
|
108
|
+
if not src.exists():
|
|
109
|
+
return
|
|
110
|
+
if dst.exists():
|
|
111
|
+
raise FileExistsError(f"cache directory for profile {new!r} already exists at {dst}")
|
|
112
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
src.rename(dst)
|
|
114
|
+
|
|
115
|
+
def purge(self, profile: str) -> None:
|
|
116
|
+
"""Remove a profile's cache directory entirely. No-op if missing."""
|
|
117
|
+
self._validate_profile(profile)
|
|
118
|
+
target = self.root / profile
|
|
119
|
+
if target.exists():
|
|
120
|
+
shutil.rmtree(target)
|
|
121
|
+
|
|
122
|
+
def enumerate_caches(self) -> list[CacheEntry]:
|
|
123
|
+
"""Walk the cache root and return one CacheEntry per valid <profile>/<hash>.json file.
|
|
124
|
+
|
|
125
|
+
Silently skips:
|
|
126
|
+
- Entries in `self.root` that aren't directories.
|
|
127
|
+
- Directories whose names don't match `_PROFILE_RE`.
|
|
128
|
+
- Files whose stems don't match `_HASH_RE` or whose suffix isn't `.json`.
|
|
129
|
+
"""
|
|
130
|
+
if not self.root.exists():
|
|
131
|
+
return []
|
|
132
|
+
entries: list[CacheEntry] = []
|
|
133
|
+
for profile_dir in self.root.iterdir():
|
|
134
|
+
if not profile_dir.is_dir():
|
|
135
|
+
continue
|
|
136
|
+
if not _PROFILE_RE.match(profile_dir.name):
|
|
137
|
+
continue
|
|
138
|
+
for cache_file in profile_dir.iterdir():
|
|
139
|
+
if cache_file.suffix != ".json":
|
|
140
|
+
continue
|
|
141
|
+
stem = cache_file.stem
|
|
142
|
+
if not _HASH_RE.match(stem):
|
|
143
|
+
continue
|
|
144
|
+
entries.append(
|
|
145
|
+
CacheEntry(profile=profile_dir.name, schema_hash=stem, path=cache_file)
|
|
146
|
+
)
|
|
147
|
+
return entries
|
|
148
|
+
|
|
149
|
+
def _path_for(self, profile: str, schema_hash: str) -> Path:
|
|
150
|
+
return self.root / profile / f"{schema_hash}.json"
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _validate_profile(profile: str) -> None:
|
|
154
|
+
if not _PROFILE_RE.match(profile):
|
|
155
|
+
raise ValueError(f"invalid profile name {profile!r}: must match {_PROFILE_RE.pattern}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
ADHOC_PROFILE = "adhoc"
|
|
159
|
+
"""Cache subdirectory name used by `nsc/cli/runtime.py` for env-var-only
|
|
160
|
+
invocations (no profile in config). The prune logic must never delete it."""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _find_orphan_dirs(entries: list[CacheEntry], profile_names: set[str]) -> list[Path]:
|
|
164
|
+
"""Type A: return one Path per profile directory not in config (adhoc excluded)."""
|
|
165
|
+
orphan_dirs: list[Path] = []
|
|
166
|
+
seen_dirs: set[Path] = set()
|
|
167
|
+
for entry in entries:
|
|
168
|
+
if entry.profile == ADHOC_PROFILE or entry.profile in profile_names:
|
|
169
|
+
continue
|
|
170
|
+
d = entry.path.parent
|
|
171
|
+
if d not in seen_dirs:
|
|
172
|
+
seen_dirs.add(d)
|
|
173
|
+
orphan_dirs.append(d)
|
|
174
|
+
return orphan_dirs
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _find_stale_files(
|
|
178
|
+
entries: list[CacheEntry],
|
|
179
|
+
config: Config,
|
|
180
|
+
fetch_live_hash: Callable[[Profile], str],
|
|
181
|
+
) -> list[Path]:
|
|
182
|
+
"""Type B: return files whose hash doesn't match the live hash; skip on fetcher error."""
|
|
183
|
+
stale_files: list[Path] = []
|
|
184
|
+
for name in config.profiles:
|
|
185
|
+
try:
|
|
186
|
+
live_hash = fetch_live_hash(config.profiles[name])
|
|
187
|
+
except Exception: # tolerated per-profile; caller skips unreachable instances
|
|
188
|
+
continue
|
|
189
|
+
for entry in entries:
|
|
190
|
+
if entry.profile == name and entry.schema_hash != live_hash:
|
|
191
|
+
stale_files.append(entry.path)
|
|
192
|
+
return stale_files
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _find_aged_files(
|
|
196
|
+
entries: list[CacheEntry],
|
|
197
|
+
orphan_dirs: list[Path],
|
|
198
|
+
cutoff: float,
|
|
199
|
+
) -> list[Path]:
|
|
200
|
+
"""Type C: return files older than `cutoff`, excluding those inside orphan dirs."""
|
|
201
|
+
orphan_paths = set(orphan_dirs)
|
|
202
|
+
aged: list[Path] = []
|
|
203
|
+
for entry in entries:
|
|
204
|
+
if entry.path.parent in orphan_paths:
|
|
205
|
+
continue
|
|
206
|
+
try:
|
|
207
|
+
mtime = entry.path.stat().st_mtime
|
|
208
|
+
except FileNotFoundError:
|
|
209
|
+
continue
|
|
210
|
+
if mtime < cutoff:
|
|
211
|
+
aged.append(entry.path)
|
|
212
|
+
return aged
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def compute_prune_plan(
|
|
216
|
+
*,
|
|
217
|
+
config: Config,
|
|
218
|
+
store: CacheStore,
|
|
219
|
+
fetch_live_hash: Callable[[Profile], str] | None = None,
|
|
220
|
+
max_age_days: int | None = None,
|
|
221
|
+
now: float | None = None,
|
|
222
|
+
) -> PrunePlan:
|
|
223
|
+
"""Classify cache entries into the three prune categories.
|
|
224
|
+
|
|
225
|
+
Type A (orphan_profile_dirs): a profile directory whose name is not in
|
|
226
|
+
`config.profiles` (and is not the `adhoc` sentinel).
|
|
227
|
+
|
|
228
|
+
Type B (stale_hash_files): for an *active* profile, files whose
|
|
229
|
+
`<schema_hash>` does not match the live schema's current hash.
|
|
230
|
+
Requires `fetch_live_hash`. If the fetcher raises for a given profile,
|
|
231
|
+
type B is silently skipped for that profile only.
|
|
232
|
+
|
|
233
|
+
Type C (aged_files): files whose mtime is older than `max_age_days`.
|
|
234
|
+
Independent of A and B but deduplicated against A: a file inside an
|
|
235
|
+
orphan profile dir is reported under A only (the rmtree handles it).
|
|
236
|
+
"""
|
|
237
|
+
entries = store.enumerate_caches()
|
|
238
|
+
|
|
239
|
+
orphan_dirs = _find_orphan_dirs(entries, set(config.profiles.keys()))
|
|
240
|
+
|
|
241
|
+
stale_files = (
|
|
242
|
+
_find_stale_files(entries, config, fetch_live_hash) if fetch_live_hash is not None else []
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
aged = (
|
|
246
|
+
_find_aged_files(
|
|
247
|
+
entries,
|
|
248
|
+
orphan_dirs,
|
|
249
|
+
(now if now is not None else time.time()) - max_age_days * 86400,
|
|
250
|
+
)
|
|
251
|
+
if max_age_days is not None
|
|
252
|
+
else []
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return PrunePlan(
|
|
256
|
+
orphan_profile_dirs=orphan_dirs,
|
|
257
|
+
stale_hash_files=stale_files,
|
|
258
|
+
aged_files=aged,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def prune_orphans(plan: PrunePlan) -> PruneResult:
|
|
263
|
+
"""Delete every path in the plan; tolerant of paths deleted out-of-band.
|
|
264
|
+
|
|
265
|
+
Returns counts + freed bytes for output rendering. Bytes are measured
|
|
266
|
+
BEFORE deletion using `Path.stat()`; if a file vanished between
|
|
267
|
+
classification and apply, it contributes 0 bytes and 0 deletions.
|
|
268
|
+
"""
|
|
269
|
+
deleted_dirs = 0
|
|
270
|
+
deleted_files = 0
|
|
271
|
+
freed = 0
|
|
272
|
+
|
|
273
|
+
for d in plan.orphan_profile_dirs:
|
|
274
|
+
if not d.exists():
|
|
275
|
+
continue
|
|
276
|
+
for f in d.rglob("*"):
|
|
277
|
+
if f.is_file():
|
|
278
|
+
with contextlib.suppress(FileNotFoundError):
|
|
279
|
+
freed += f.stat().st_size
|
|
280
|
+
shutil.rmtree(d)
|
|
281
|
+
deleted_dirs += 1
|
|
282
|
+
|
|
283
|
+
for f in (*plan.stale_hash_files, *plan.aged_files):
|
|
284
|
+
if not f.exists():
|
|
285
|
+
continue
|
|
286
|
+
with contextlib.suppress(FileNotFoundError):
|
|
287
|
+
freed += f.stat().st_size
|
|
288
|
+
f.unlink()
|
|
289
|
+
deleted_files += 1
|
|
290
|
+
|
|
291
|
+
return PruneResult(
|
|
292
|
+
deleted_dirs=deleted_dirs,
|
|
293
|
+
deleted_files=deleted_files,
|
|
294
|
+
freed_bytes=freed,
|
|
295
|
+
)
|
nsc/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Typer-based command-line interface."""
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""`nsc ls / get / rm / search` — curated aliases over the dynamic command tree.
|
|
2
|
+
|
|
3
|
+
Each verb resolves its term to a `(tag, resource, operation)` triple via
|
|
4
|
+
`nsc.aliases.resolve` and delegates to the same handler the dynamic tree
|
|
5
|
+
uses. The audit log is written by the handler (or by `NetBoxClient` for
|
|
6
|
+
write methods), so alias and full-path invocations produce byte-identical
|
|
7
|
+
`audit.jsonl` lines (modulo timestamp and duration_ms).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from nsc.aliases import (
|
|
17
|
+
AliasVerb,
|
|
18
|
+
AmbiguousAlias,
|
|
19
|
+
ResolvedAlias,
|
|
20
|
+
UnknownAlias,
|
|
21
|
+
resolve,
|
|
22
|
+
)
|
|
23
|
+
from nsc.cli.handlers import handle_delete, handle_get, handle_list
|
|
24
|
+
from nsc.cli.runtime import RuntimeContext, emit_envelope
|
|
25
|
+
from nsc.config.models import OutputFormat
|
|
26
|
+
from nsc.http.errors import NetBoxAPIError, NetBoxClientError
|
|
27
|
+
from nsc.model.command_model import Operation
|
|
28
|
+
from nsc.output.errors import (
|
|
29
|
+
ErrorEnvelope,
|
|
30
|
+
ErrorType,
|
|
31
|
+
ambiguous_alias_envelope,
|
|
32
|
+
client_envelope,
|
|
33
|
+
unknown_alias_envelope,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _runtime_from_ctx(ctx: typer.Context) -> RuntimeContext:
|
|
38
|
+
"""Extract the bootstrapped RuntimeContext from `ctx.obj`."""
|
|
39
|
+
obj = ctx.obj
|
|
40
|
+
if isinstance(obj, tuple) and len(obj) == 2: # noqa: PLR2004
|
|
41
|
+
runtime = obj[1]
|
|
42
|
+
if isinstance(runtime, RuntimeContext):
|
|
43
|
+
return runtime
|
|
44
|
+
raise typer.Exit(2)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _emit_alias_envelope(env: ErrorEnvelope, ctx: RuntimeContext) -> int:
|
|
48
|
+
return emit_envelope(env, output_format=ctx.output_format)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _dereference_by_name(
|
|
52
|
+
runtime: RuntimeContext,
|
|
53
|
+
*,
|
|
54
|
+
list_op: Operation,
|
|
55
|
+
name: str,
|
|
56
|
+
) -> int | ErrorEnvelope:
|
|
57
|
+
"""List the resource filtered by `name=<name>` and return the single id.
|
|
58
|
+
|
|
59
|
+
Returns an `ErrorEnvelope` (already-shaped ambiguous_alias / unknown_alias)
|
|
60
|
+
if zero or >=2 records match. Caller emits and exits.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
rows = list(runtime.client.paginate(list_op.path, {"name": name}))
|
|
64
|
+
except (NetBoxAPIError, NetBoxClientError) as exc:
|
|
65
|
+
return client_envelope(
|
|
66
|
+
f"failed to dereference name {name!r}: {exc}",
|
|
67
|
+
operation_id=list_op.operation_id,
|
|
68
|
+
)
|
|
69
|
+
if len(rows) == 0:
|
|
70
|
+
return ErrorEnvelope(
|
|
71
|
+
error=f"no record matched name={name!r}",
|
|
72
|
+
type=ErrorType.UNKNOWN_ALIAS,
|
|
73
|
+
details={"verb": "rm", "term": name, "reason": "name_not_found"},
|
|
74
|
+
)
|
|
75
|
+
if len(rows) >= 2: # noqa: PLR2004
|
|
76
|
+
return ErrorEnvelope(
|
|
77
|
+
error=f"{len(rows)} records matched name={name!r}; refuse to delete",
|
|
78
|
+
type=ErrorType.AMBIGUOUS_ALIAS,
|
|
79
|
+
details={
|
|
80
|
+
"verb": "rm",
|
|
81
|
+
"term": name,
|
|
82
|
+
"reason": "name_matched_multiple",
|
|
83
|
+
"matched_ids": [row["id"] for row in rows],
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
return int(rows[0]["id"])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def register(app: typer.Typer) -> None: # noqa: PLR0915
|
|
90
|
+
@app.command("ls", help="List records on a resource (alias for `<tag> <resource> list`).")
|
|
91
|
+
def ls_cmd(
|
|
92
|
+
ctx: typer.Context,
|
|
93
|
+
term: Annotated[str, typer.Argument(help="Resource name (plural, e.g. `devices`).")],
|
|
94
|
+
output: Annotated[str | None, typer.Option("--output", "-o")] = None,
|
|
95
|
+
compact: Annotated[bool, typer.Option("--compact")] = False,
|
|
96
|
+
columns: Annotated[str | None, typer.Option("--columns")] = None,
|
|
97
|
+
limit: Annotated[int | None, typer.Option("--limit")] = None,
|
|
98
|
+
all_: Annotated[bool, typer.Option("--all")] = False,
|
|
99
|
+
filter_: Annotated[list[str] | None, typer.Option("--filter")] = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
runtime = _runtime_from_ctx(ctx)
|
|
102
|
+
update: dict[str, object] = {
|
|
103
|
+
"compact": compact,
|
|
104
|
+
"columns_override": columns.split(",") if columns else None,
|
|
105
|
+
"limit": limit,
|
|
106
|
+
"fetch_all": all_,
|
|
107
|
+
"filters": [
|
|
108
|
+
(item.split("=", 1)[0], item.split("=", 1)[1])
|
|
109
|
+
for item in (filter_ or [])
|
|
110
|
+
if "=" in item
|
|
111
|
+
],
|
|
112
|
+
}
|
|
113
|
+
if output:
|
|
114
|
+
update["output_format"] = OutputFormat(output)
|
|
115
|
+
runtime = runtime.model_copy(update=update)
|
|
116
|
+
|
|
117
|
+
result = resolve(AliasVerb.LS, term, runtime.command_model)
|
|
118
|
+
if isinstance(result, AmbiguousAlias):
|
|
119
|
+
env = ambiguous_alias_envelope(verb="ls", term=term, candidates=result.candidates)
|
|
120
|
+
raise typer.Exit(_emit_alias_envelope(env, runtime))
|
|
121
|
+
if isinstance(result, UnknownAlias):
|
|
122
|
+
env = unknown_alias_envelope(verb="ls", term=term, reason=result.reason)
|
|
123
|
+
raise typer.Exit(_emit_alias_envelope(env, runtime))
|
|
124
|
+
assert isinstance(result, ResolvedAlias)
|
|
125
|
+
handle_list(
|
|
126
|
+
result.operation,
|
|
127
|
+
op_tag=result.tag,
|
|
128
|
+
op_resource=result.resource_name,
|
|
129
|
+
ctx=runtime,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@app.command("rm", help="Delete one record (alias for `<tag> <resource> delete`).")
|
|
133
|
+
def rm_cmd(
|
|
134
|
+
ctx: typer.Context,
|
|
135
|
+
term: Annotated[str, typer.Argument(help="Resource name (plural).")],
|
|
136
|
+
id_or_name: Annotated[str, typer.Argument(help="Numeric id or unique name.")],
|
|
137
|
+
apply: Annotated[bool, typer.Option("--apply", "-a")] = False,
|
|
138
|
+
explain: Annotated[bool, typer.Option("--explain")] = False,
|
|
139
|
+
strict: Annotated[bool, typer.Option("--strict")] = False,
|
|
140
|
+
output: Annotated[str | None, typer.Option("--output", "-o")] = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
runtime = _runtime_from_ctx(ctx)
|
|
143
|
+
update: dict[str, object] = {
|
|
144
|
+
"apply": apply,
|
|
145
|
+
"explain": explain,
|
|
146
|
+
"strict": strict,
|
|
147
|
+
}
|
|
148
|
+
if output:
|
|
149
|
+
update["output_format"] = OutputFormat(output)
|
|
150
|
+
runtime = runtime.model_copy(update=update)
|
|
151
|
+
|
|
152
|
+
result = resolve(AliasVerb.RM, term, runtime.command_model)
|
|
153
|
+
if isinstance(result, AmbiguousAlias):
|
|
154
|
+
env = ambiguous_alias_envelope(verb="rm", term=term, candidates=result.candidates)
|
|
155
|
+
raise typer.Exit(_emit_alias_envelope(env, runtime))
|
|
156
|
+
if isinstance(result, UnknownAlias):
|
|
157
|
+
env = unknown_alias_envelope(verb="rm", term=term, reason=result.reason)
|
|
158
|
+
raise typer.Exit(_emit_alias_envelope(env, runtime))
|
|
159
|
+
assert isinstance(result, ResolvedAlias)
|
|
160
|
+
|
|
161
|
+
if id_or_name.isdigit():
|
|
162
|
+
resolved_id = int(id_or_name)
|
|
163
|
+
else:
|
|
164
|
+
resource = runtime.command_model.tags[result.tag].resources[result.resource_name]
|
|
165
|
+
list_op = resource.list_op
|
|
166
|
+
if list_op is None:
|
|
167
|
+
env = unknown_alias_envelope(
|
|
168
|
+
verb="rm", term=term, reason="no_list_op_for_dereference"
|
|
169
|
+
)
|
|
170
|
+
raise typer.Exit(_emit_alias_envelope(env, runtime))
|
|
171
|
+
outcome = _dereference_by_name(runtime, list_op=list_op, name=id_or_name)
|
|
172
|
+
if isinstance(outcome, ErrorEnvelope):
|
|
173
|
+
raise typer.Exit(_emit_alias_envelope(outcome, runtime))
|
|
174
|
+
resolved_id = outcome
|
|
175
|
+
|
|
176
|
+
handle_delete(
|
|
177
|
+
result.operation,
|
|
178
|
+
op_tag=result.tag,
|
|
179
|
+
op_resource=result.resource_name,
|
|
180
|
+
ctx=runtime,
|
|
181
|
+
id=str(resolved_id),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
@app.command("get", help="Get one record (alias for `<tag> <resource> get`).")
|
|
185
|
+
def get_cmd(
|
|
186
|
+
ctx: typer.Context,
|
|
187
|
+
term: Annotated[str, typer.Argument(help="Resource name (plural).")],
|
|
188
|
+
id_or_name: Annotated[str, typer.Argument(help="Numeric id or unique name.")],
|
|
189
|
+
output: Annotated[str | None, typer.Option("--output", "-o")] = None,
|
|
190
|
+
compact: Annotated[bool, typer.Option("--compact")] = False,
|
|
191
|
+
columns: Annotated[str | None, typer.Option("--columns")] = None,
|
|
192
|
+
) -> None:
|
|
193
|
+
runtime = _runtime_from_ctx(ctx)
|
|
194
|
+
update: dict[str, object] = {
|
|
195
|
+
"compact": compact,
|
|
196
|
+
"columns_override": columns.split(",") if columns else None,
|
|
197
|
+
}
|
|
198
|
+
if output:
|
|
199
|
+
update["output_format"] = OutputFormat(output)
|
|
200
|
+
runtime = runtime.model_copy(update=update)
|
|
201
|
+
|
|
202
|
+
result = resolve(AliasVerb.GET, term, runtime.command_model)
|
|
203
|
+
if isinstance(result, AmbiguousAlias):
|
|
204
|
+
env = ambiguous_alias_envelope(verb="get", term=term, candidates=result.candidates)
|
|
205
|
+
raise typer.Exit(_emit_alias_envelope(env, runtime))
|
|
206
|
+
if isinstance(result, UnknownAlias):
|
|
207
|
+
env = unknown_alias_envelope(verb="get", term=term, reason=result.reason)
|
|
208
|
+
raise typer.Exit(_emit_alias_envelope(env, runtime))
|
|
209
|
+
assert isinstance(result, ResolvedAlias)
|
|
210
|
+
|
|
211
|
+
if id_or_name.isdigit():
|
|
212
|
+
resolved_id = int(id_or_name)
|
|
213
|
+
else:
|
|
214
|
+
list_op = runtime.command_model.tags[result.tag].resources[result.resource_name].list_op
|
|
215
|
+
if list_op is None:
|
|
216
|
+
env = unknown_alias_envelope(
|
|
217
|
+
verb="get", term=term, reason="no_list_op_for_dereference"
|
|
218
|
+
)
|
|
219
|
+
raise typer.Exit(_emit_alias_envelope(env, runtime))
|
|
220
|
+
outcome = _dereference_by_name(runtime, list_op=list_op, name=id_or_name)
|
|
221
|
+
if isinstance(outcome, ErrorEnvelope):
|
|
222
|
+
outcome = outcome.model_copy(update={"details": {**outcome.details, "verb": "get"}})
|
|
223
|
+
raise typer.Exit(_emit_alias_envelope(outcome, runtime))
|
|
224
|
+
resolved_id = outcome
|
|
225
|
+
|
|
226
|
+
handle_get(
|
|
227
|
+
result.operation,
|
|
228
|
+
op_tag=result.tag,
|
|
229
|
+
op_resource=result.resource_name,
|
|
230
|
+
ctx=runtime,
|
|
231
|
+
id=str(resolved_id),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
@app.command("search", help="Search NetBox via /api/search/?q=<query>.")
|
|
235
|
+
def search_cmd(
|
|
236
|
+
ctx: typer.Context,
|
|
237
|
+
query: Annotated[str, typer.Argument(help="Search query string.")],
|
|
238
|
+
output: Annotated[str | None, typer.Option("--output", "-o")] = None,
|
|
239
|
+
compact: Annotated[bool, typer.Option("--compact")] = False,
|
|
240
|
+
limit: Annotated[int | None, typer.Option("--limit")] = None,
|
|
241
|
+
all_: Annotated[bool, typer.Option("--all")] = False,
|
|
242
|
+
) -> None:
|
|
243
|
+
runtime = _runtime_from_ctx(ctx)
|
|
244
|
+
update: dict[str, object] = {
|
|
245
|
+
"compact": compact,
|
|
246
|
+
"limit": limit,
|
|
247
|
+
"fetch_all": all_,
|
|
248
|
+
"filters": [("q", query)],
|
|
249
|
+
}
|
|
250
|
+
if output:
|
|
251
|
+
update["output_format"] = OutputFormat(output)
|
|
252
|
+
runtime = runtime.model_copy(update=update)
|
|
253
|
+
|
|
254
|
+
result = resolve(AliasVerb.SEARCH, query, runtime.command_model)
|
|
255
|
+
if isinstance(result, UnknownAlias):
|
|
256
|
+
env = unknown_alias_envelope(verb="search", term=query, reason=result.reason)
|
|
257
|
+
raise typer.Exit(_emit_alias_envelope(env, runtime))
|
|
258
|
+
assert isinstance(result, ResolvedAlias)
|
|
259
|
+
handle_list(
|
|
260
|
+
result.operation,
|
|
261
|
+
op_tag=result.tag,
|
|
262
|
+
op_resource=result.resource_name,
|
|
263
|
+
ctx=runtime,
|
|
264
|
+
)
|