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.
Files changed (71) hide show
  1. netbox_super_cli-1.0.0.dist-info/METADATA +182 -0
  2. netbox_super_cli-1.0.0.dist-info/RECORD +71 -0
  3. netbox_super_cli-1.0.0.dist-info/WHEEL +4 -0
  4. netbox_super_cli-1.0.0.dist-info/entry_points.txt +3 -0
  5. netbox_super_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
  6. nsc/__init__.py +5 -0
  7. nsc/__main__.py +6 -0
  8. nsc/_version.py +3 -0
  9. nsc/aliases/__init__.py +24 -0
  10. nsc/aliases/resolver.py +112 -0
  11. nsc/auth/__init__.py +5 -0
  12. nsc/auth/verify.py +143 -0
  13. nsc/builder/__init__.py +5 -0
  14. nsc/builder/build.py +514 -0
  15. nsc/cache/__init__.py +5 -0
  16. nsc/cache/store.py +295 -0
  17. nsc/cli/__init__.py +1 -0
  18. nsc/cli/aliases_commands.py +264 -0
  19. nsc/cli/app.py +291 -0
  20. nsc/cli/cache_commands.py +159 -0
  21. nsc/cli/commands_dump.py +57 -0
  22. nsc/cli/config_commands.py +156 -0
  23. nsc/cli/globals.py +65 -0
  24. nsc/cli/handlers.py +660 -0
  25. nsc/cli/init_commands.py +95 -0
  26. nsc/cli/login_commands.py +265 -0
  27. nsc/cli/profiles_commands.py +256 -0
  28. nsc/cli/registration.py +465 -0
  29. nsc/cli/runtime.py +290 -0
  30. nsc/cli/skill_commands.py +186 -0
  31. nsc/cli/writes/__init__.py +10 -0
  32. nsc/cli/writes/apply.py +177 -0
  33. nsc/cli/writes/bulk.py +231 -0
  34. nsc/cli/writes/coercion.py +9 -0
  35. nsc/cli/writes/confirmation.py +96 -0
  36. nsc/cli/writes/input.py +358 -0
  37. nsc/cli/writes/preflight.py +182 -0
  38. nsc/config/__init__.py +23 -0
  39. nsc/config/loader.py +69 -0
  40. nsc/config/models.py +54 -0
  41. nsc/config/settings.py +36 -0
  42. nsc/config/writer.py +207 -0
  43. nsc/http/__init__.py +6 -0
  44. nsc/http/audit.py +183 -0
  45. nsc/http/client.py +365 -0
  46. nsc/http/errors.py +35 -0
  47. nsc/http/retry.py +90 -0
  48. nsc/model/__init__.py +23 -0
  49. nsc/model/command_model.py +125 -0
  50. nsc/output/__init__.py +1 -0
  51. nsc/output/csv_.py +34 -0
  52. nsc/output/errors.py +346 -0
  53. nsc/output/explain.py +194 -0
  54. nsc/output/flatten.py +25 -0
  55. nsc/output/headers.py +9 -0
  56. nsc/output/json_.py +21 -0
  57. nsc/output/jsonl.py +21 -0
  58. nsc/output/render.py +47 -0
  59. nsc/output/table.py +50 -0
  60. nsc/output/yaml_.py +28 -0
  61. nsc/schema/__init__.py +1 -0
  62. nsc/schema/hashing.py +24 -0
  63. nsc/schema/loader.py +66 -0
  64. nsc/schema/models.py +109 -0
  65. nsc/schema/source.py +120 -0
  66. nsc/schemas/__init__.py +1 -0
  67. nsc/schemas/bundled/__init__.py +1 -0
  68. nsc/schemas/bundled/manifest.yaml +5 -0
  69. nsc/schemas/bundled/netbox-4.6.0-beta2.json.gz +0 -0
  70. nsc/skill/__init__.py +35 -0
  71. skills/netbox-super-cli/SKILL.md +127 -0
@@ -0,0 +1,182 @@
1
+ """Stage 2 of the write pipeline: best-effort preflight validation.
2
+
3
+ Three checks per record (spec §4.6):
4
+ 1. required fields present (top-level only),
5
+ 2. primitive type matches,
6
+ 3. enum value is in the allowed set.
7
+
8
+ Explicitly NOT validated: oneOf/anyOf/allOf, pattern/format/minLength/maxLength,
9
+ foreign-key existence, cross-field rules. NetBox is the source of truth for
10
+ those — server-side 400s surface as ErrorType.VALIDATION with source="server".
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Callable
16
+ from typing import Any, Literal
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field
19
+
20
+ from nsc.cli.writes.coercion import BOOL_STRINGS as _BOOL_STRINGS
21
+ from nsc.cli.writes.input import RawWriteInput
22
+ from nsc.model.command_model import (
23
+ FieldShape,
24
+ Operation,
25
+ PrimitiveType,
26
+ RequestBodyShape,
27
+ )
28
+
29
+
30
+ class _Frozen(BaseModel):
31
+ model_config = ConfigDict(frozen=True, extra="forbid")
32
+
33
+
34
+ class Issue(_Frozen):
35
+ record_index: int
36
+ field_path: str
37
+ kind: Literal["missing_required", "type_mismatch", "enum_invalid"]
38
+ message: str
39
+ expected: str | None = None
40
+
41
+
42
+ class PreflightResult(_Frozen):
43
+ ok: bool
44
+ issues: list[Issue] = Field(default_factory=list)
45
+
46
+
47
+ def check(raw: RawWriteInput, operation: Operation) -> PreflightResult:
48
+ body = operation.request_body
49
+ if body is None:
50
+ return PreflightResult(ok=True)
51
+ issues: list[Issue] = []
52
+ for index, record in enumerate(raw.records):
53
+ issues.extend(_check_required(index, record, body))
54
+ issues.extend(_check_fields(index, record, body))
55
+ return PreflightResult(ok=not issues, issues=issues)
56
+
57
+
58
+ def _check_required(index: int, record: dict[str, Any], body: RequestBodyShape) -> list[Issue]:
59
+ return [
60
+ Issue(
61
+ record_index=index,
62
+ field_path=name,
63
+ kind="missing_required",
64
+ message=f"required field {name!r} is missing",
65
+ expected=None,
66
+ )
67
+ for name in body.required
68
+ if name not in record
69
+ ]
70
+
71
+
72
+ def _check_fields(index: int, record: dict[str, Any], body: RequestBodyShape) -> list[Issue]:
73
+ out: list[Issue] = []
74
+ for name, value in record.items():
75
+ shape = body.fields.get(name)
76
+ if shape is None:
77
+ continue
78
+ type_issue = _check_primitive(index, name, value, shape)
79
+ if type_issue is not None:
80
+ out.append(type_issue)
81
+ continue
82
+ enum_issue = _check_enum(index, name, value, shape)
83
+ if enum_issue is not None:
84
+ out.append(enum_issue)
85
+ return out
86
+
87
+
88
+ def _check_primitive(index: int, name: str, value: Any, shape: FieldShape) -> Issue | None:
89
+ if value is None and shape.nullable:
90
+ return None
91
+ if _value_matches_primitive(value, shape.primitive):
92
+ return None
93
+ return Issue(
94
+ record_index=index,
95
+ field_path=name,
96
+ kind="type_mismatch",
97
+ message=(f"field {name!r} expected {shape.primitive.value}, got {type(value).__name__}"),
98
+ expected=shape.primitive.value,
99
+ )
100
+
101
+
102
+ def _matches_string(value: Any) -> bool:
103
+ return isinstance(value, str)
104
+
105
+
106
+ def _matches_integer(value: Any) -> bool:
107
+ if isinstance(value, bool):
108
+ return False
109
+ if isinstance(value, int):
110
+ return True
111
+ return isinstance(value, str) and _is_int_string(value)
112
+
113
+
114
+ def _matches_number(value: Any) -> bool:
115
+ if isinstance(value, bool):
116
+ return False
117
+ if isinstance(value, (int, float)):
118
+ return True
119
+ return isinstance(value, str) and _is_float_string(value)
120
+
121
+
122
+ def _matches_boolean(value: Any) -> bool:
123
+ if isinstance(value, bool):
124
+ return True
125
+ return isinstance(value, str) and value.strip().lower() in _BOOL_STRINGS
126
+
127
+
128
+ def _matches_array(value: Any) -> bool:
129
+ return isinstance(value, list)
130
+
131
+
132
+ def _matches_object(value: Any) -> bool:
133
+ return isinstance(value, dict)
134
+
135
+
136
+ _PRIMITIVE_MATCHERS: dict[PrimitiveType, Callable[[Any], bool]] = {
137
+ PrimitiveType.STRING: _matches_string,
138
+ PrimitiveType.INTEGER: _matches_integer,
139
+ PrimitiveType.NUMBER: _matches_number,
140
+ PrimitiveType.BOOLEAN: _matches_boolean,
141
+ PrimitiveType.ARRAY: _matches_array,
142
+ PrimitiveType.OBJECT: _matches_object,
143
+ }
144
+
145
+
146
+ def _value_matches_primitive(value: Any, primitive: PrimitiveType) -> bool:
147
+ matcher = _PRIMITIVE_MATCHERS.get(primitive)
148
+ if matcher is None:
149
+ return True # UNKNOWN — let the server arbitrate
150
+ return matcher(value)
151
+
152
+
153
+ def _is_int_string(value: str) -> bool:
154
+ s = value.strip()
155
+ if s.startswith(("-", "+")):
156
+ s = s[1:]
157
+ return s.isdigit()
158
+
159
+
160
+ def _is_float_string(value: str) -> bool:
161
+ try:
162
+ float(value)
163
+ except ValueError:
164
+ return False
165
+ return True
166
+
167
+
168
+ def _check_enum(index: int, name: str, value: Any, shape: FieldShape) -> Issue | None:
169
+ if not shape.enum:
170
+ return None
171
+ if str(value) in shape.enum:
172
+ return None
173
+ return Issue(
174
+ record_index=index,
175
+ field_path=name,
176
+ kind="enum_invalid",
177
+ message=f"field {name!r}={value!r} not in allowed set",
178
+ expected=", ".join(shape.enum),
179
+ )
180
+
181
+
182
+ __all__ = ["Issue", "PreflightResult", "check"]
nsc/config/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """Configuration and on-disk paths."""
2
+
3
+ from nsc.config.loader import ConfigParseError, load_config
4
+ from nsc.config.models import (
5
+ Config,
6
+ Defaults,
7
+ OutputFormat,
8
+ Profile,
9
+ SchemaRefresh,
10
+ )
11
+ from nsc.config.settings import Paths, default_paths
12
+
13
+ __all__ = [
14
+ "Config",
15
+ "ConfigParseError",
16
+ "Defaults",
17
+ "OutputFormat",
18
+ "Paths",
19
+ "Profile",
20
+ "SchemaRefresh",
21
+ "default_paths",
22
+ "load_config",
23
+ ]
nsc/config/loader.py ADDED
@@ -0,0 +1,69 @@
1
+ """Config loader for `~/.nsc/config.yaml`, backed by ruamel.yaml round-trip mode.
2
+
3
+ ruamel.yaml's `YAML(typ="rt")` preserves comments, key order, anchors, and
4
+ custom tags through subsequent writes (see `nsc/config/writer.py`). The parsed
5
+ document is a `CommentedMap` (dict-compatible); we hand it to
6
+ `Config.model_validate` for the structural validation gate.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from pydantic import ValidationError
17
+ from ruamel.yaml import YAML
18
+ from ruamel.yaml.constructor import BaseConstructor
19
+ from ruamel.yaml.error import YAMLError
20
+ from ruamel.yaml.nodes import ScalarNode
21
+
22
+ from nsc.config.models import Config
23
+
24
+
25
+ class ConfigParseError(Exception):
26
+ """Raised when ~/.nsc/config.yaml cannot be parsed or is structurally invalid."""
27
+
28
+
29
+ def _construct_env(_loader: BaseConstructor, node: ScalarNode) -> str | None:
30
+ raw = str(node.value)
31
+ parts = raw.strip().split(maxsplit=1)
32
+ var = parts[0]
33
+ parts_count = 2
34
+ default = parts[1] if len(parts) == parts_count else None
35
+ return os.environ.get(var, default)
36
+
37
+
38
+ def _round_trip_yaml() -> YAML:
39
+ """Build a fresh round-trip YAML parser shared in shape between loader and writer."""
40
+ yaml = YAML(typ="rt")
41
+ yaml.preserve_quotes = True
42
+ yaml.constructor.add_constructor("!env", _construct_env)
43
+ return yaml
44
+
45
+
46
+ def load_config(path: Path) -> Config:
47
+ if not path.exists():
48
+ return Config()
49
+ try:
50
+ text = path.read_text(encoding="utf-8")
51
+ except OSError as exc:
52
+ raise ConfigParseError(f"could not read {path}: {exc}") from exc
53
+ try:
54
+ data: Any = _round_trip_yaml().load(io.StringIO(text))
55
+ except YAMLError as exc:
56
+ raise ConfigParseError(f"YAML parse error in {path}: {exc}") from exc
57
+ if data is None:
58
+ return Config()
59
+ if not isinstance(data, dict):
60
+ raise ConfigParseError(f"{path}: top-level value must be a mapping")
61
+ profiles = data.get("profiles") or {}
62
+ if isinstance(profiles, dict):
63
+ for pname, pbody in profiles.items():
64
+ if isinstance(pbody, dict):
65
+ pbody.setdefault("name", pname)
66
+ try:
67
+ return Config.model_validate(dict(data))
68
+ except ValidationError as exc:
69
+ raise ConfigParseError(f"{path}: {exc}") from exc
nsc/config/models.py ADDED
@@ -0,0 +1,54 @@
1
+ """Pydantic models for ~/.nsc/config.yaml.
2
+
3
+ Phase 2 is read-only; these models describe the on-disk shape. The runtime view
4
+ (`ResolvedProfile`) lives in `nsc/cli/runtime.py` and is built by overlaying
5
+ flags + env vars on top of a selected `Profile`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import StrEnum
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field, HttpUrl
13
+
14
+
15
+ class _Frozen(BaseModel):
16
+ model_config = ConfigDict(frozen=True, extra="forbid")
17
+
18
+
19
+ class OutputFormat(StrEnum):
20
+ TABLE = "table"
21
+ JSON = "json"
22
+ JSONL = "jsonl"
23
+ YAML = "yaml"
24
+ CSV = "csv"
25
+
26
+
27
+ class SchemaRefresh(StrEnum):
28
+ MANUAL = "manual"
29
+ ON_HASH_CHANGE = "on-hash-change"
30
+ DAILY = "daily"
31
+ WEEKLY = "weekly"
32
+
33
+
34
+ class Defaults(_Frozen):
35
+ output: OutputFormat = OutputFormat.TABLE
36
+ page_size: int = 50
37
+ timeout: float = 30.0
38
+ schema_refresh: SchemaRefresh = SchemaRefresh.ON_HASH_CHANGE
39
+
40
+
41
+ class Profile(_Frozen):
42
+ name: str
43
+ url: HttpUrl
44
+ token: str | None = None
45
+ verify_ssl: bool = True
46
+ schema_url: HttpUrl | None = None
47
+ timeout: float | None = None
48
+
49
+
50
+ class Config(_Frozen):
51
+ default_profile: str | None = None
52
+ profiles: dict[str, Profile] = Field(default_factory=dict)
53
+ defaults: Defaults = Field(default_factory=Defaults)
54
+ columns: dict[str, dict[str, list[str]]] = Field(default_factory=dict)
nsc/config/settings.py ADDED
@@ -0,0 +1,36 @@
1
+ """On-disk locations.
2
+
3
+ Phase 1 only models the paths under `~/.nsc/`. Profiles, tokens, defaults, and
4
+ the YAML config file are added in Phase 4.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class Paths:
16
+ root: Path
17
+
18
+ @property
19
+ def config_file(self) -> Path:
20
+ return self.root / "config.yaml"
21
+
22
+ @property
23
+ def cache_dir(self) -> Path:
24
+ return self.root / "cache"
25
+
26
+ @property
27
+ def logs_dir(self) -> Path:
28
+ return self.root / "logs"
29
+
30
+
31
+ def default_paths() -> Paths:
32
+ """Return the default `~/.nsc/` Paths, honoring `NSC_HOME` if set."""
33
+ override = os.environ.get("NSC_HOME")
34
+ if override:
35
+ return Paths(root=Path(override).expanduser().resolve())
36
+ return Paths(root=Path.home() / ".nsc")
nsc/config/writer.py ADDED
@@ -0,0 +1,207 @@
1
+ """Round-trip-preserving writes for `~/.nsc/config.yaml`.
2
+
3
+ This module is the write counterpart to `nsc/config/loader.py`. It exposes:
4
+
5
+ * `load_round_trip(path)` / `dump_round_trip(doc)` — parse and serialize using
6
+ `YAML(typ="rt")`. Unlike the loader, the writer's YAML does NOT register an
7
+ `!env` constructor, so tagged scalars round-trip through unchanged.
8
+ * `set_path(doc, dotted, value)` / `unset_path(doc, dotted)` — dotted-path
9
+ mutators that preserve comments and key order.
10
+ * `atomic_write(path, text)` — tempfile + fsync + os.replace, 0600 mode.
11
+ * `acquire_lock(path)` — best-effort `flock` context manager.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import io
18
+ import logging
19
+ import os
20
+ import tempfile
21
+ from collections.abc import Iterator
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from ruamel.yaml import YAML
26
+ from ruamel.yaml.comments import CommentedMap, TaggedScalar
27
+ from ruamel.yaml.constructor import BaseConstructor
28
+ from ruamel.yaml.nodes import ScalarNode
29
+
30
+ _log = logging.getLogger(__name__)
31
+ _FILE_MODE = 0o600
32
+
33
+
34
+ def atomic_write(path: Path, text: str) -> None:
35
+ """Write `text` to `path` atomically with 0600 permissions.
36
+
37
+ Strategy: create a sibling temp file in the same directory, fsync it,
38
+ chmod 0600, then `os.replace` it onto the target. On any failure
39
+ before or during `os.replace`, the original file is untouched and
40
+ the temp file is cleaned up.
41
+ """
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+ fd, tmp_path = tempfile.mkstemp(
44
+ prefix=f".{path.name}.",
45
+ suffix=".tmp",
46
+ dir=str(path.parent),
47
+ )
48
+ tmp = Path(tmp_path)
49
+ try:
50
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
51
+ fh.write(text)
52
+ fh.flush()
53
+ os.fsync(fh.fileno())
54
+ os.chmod(tmp, _FILE_MODE)
55
+ os.replace(tmp, path)
56
+ except BaseException:
57
+ with contextlib.suppress(FileNotFoundError):
58
+ tmp.unlink()
59
+ raise
60
+
61
+
62
+ @contextlib.contextmanager
63
+ def acquire_lock(path: Path) -> Iterator[None]:
64
+ """Best-effort exclusive lock on `path`'s sidecar lock file.
65
+
66
+ Uses `fcntl.flock` on POSIX; on platforms without it (or on NFS-style
67
+ filesystems where it is a no-op), logs a debug note and yields without
68
+ blocking. The lock is sidecar-on-purpose (`<name>.lock`) so we never
69
+ truncate the target file via locking.
70
+ """
71
+ lock_path = path.with_suffix(path.suffix + ".lock")
72
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
73
+ try:
74
+ import fcntl # noqa: PLC0415 # platform-conditional: absent on Windows.
75
+ except ImportError:
76
+ _log.debug("fcntl unavailable; proceeding without flock on %s", path)
77
+ yield
78
+ return
79
+
80
+ fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o600)
81
+ locked = False
82
+ try:
83
+ try:
84
+ fcntl.flock(fd, fcntl.LOCK_EX)
85
+ locked = True
86
+ except OSError as exc:
87
+ _log.debug("flock failed on %s (%s); proceeding without it", lock_path, exc)
88
+ yield
89
+ finally:
90
+ if locked:
91
+ with contextlib.suppress(OSError):
92
+ fcntl.flock(fd, fcntl.LOCK_UN)
93
+ os.close(fd)
94
+
95
+
96
+ class ConfigWriteError(Exception):
97
+ """Raised when a config write violates the dotted-path contract."""
98
+
99
+
100
+ def _construct_env_tag(_loader: BaseConstructor, node: ScalarNode) -> TaggedScalar:
101
+ return TaggedScalar(value=str(node.value), style=None, tag="!env")
102
+
103
+
104
+ def _writer_yaml() -> YAML:
105
+ """Round-trip YAML for the writer.
106
+
107
+ The writer round-trips the *file* surface, so a `!env FOO` scalar must
108
+ come back out as `!env FOO`, not as the resolved string. We override the
109
+ `!env` constructor with one that returns a `TaggedScalar`, preserving the
110
+ tag through dump.
111
+
112
+ ruamel.yaml's `add_constructor` registers at the class level on
113
+ `RoundTripConstructor`, so the most recent registration wins for every
114
+ later `YAML(typ="rt")` instance in the same process. Both this factory and
115
+ `nsc.config.loader._round_trip_yaml` register fresh on every call to
116
+ ensure the local intent wins, but tests that interleave loader and writer
117
+ parsers can still observe the cross-instance side effect — keep that in
118
+ mind when adding tests that depend on `!env` resolution semantics.
119
+ """
120
+ yaml = YAML(typ="rt")
121
+ yaml.preserve_quotes = True
122
+ yaml.constructor.add_constructor("!env", _construct_env_tag)
123
+ return yaml
124
+
125
+
126
+ def load_round_trip(path: Path) -> CommentedMap:
127
+ """Parse `path` into a `CommentedMap` ready for in-place mutation.
128
+
129
+ Returns an empty `CommentedMap` if the file is missing or empty so callers
130
+ can `set_path` into a fresh doc.
131
+ """
132
+ if not path.exists():
133
+ return CommentedMap()
134
+ text = path.read_text(encoding="utf-8")
135
+ if not text.strip():
136
+ return CommentedMap()
137
+ doc = _writer_yaml().load(io.StringIO(text))
138
+ if doc is None:
139
+ return CommentedMap()
140
+ if not isinstance(doc, CommentedMap):
141
+ raise ConfigWriteError(f"{path}: top-level value must be a mapping")
142
+ return doc
143
+
144
+
145
+ def dump_round_trip(doc: CommentedMap) -> str:
146
+ """Serialize `doc` back to YAML, preserving comments, order, and tags."""
147
+ buf = io.StringIO()
148
+ _writer_yaml().dump(doc, buf)
149
+ return buf.getvalue()
150
+
151
+
152
+ def _split(dotted: str) -> list[str]:
153
+ parts = [p for p in dotted.split(".") if p]
154
+ if not parts:
155
+ raise ConfigWriteError("path must be non-empty")
156
+ return parts
157
+
158
+
159
+ def set_path(doc: CommentedMap, dotted: str, value: Any) -> None:
160
+ """Set `doc[a][b]...[leaf] = value`, creating intermediate maps as needed.
161
+
162
+ Refuses to descend past a scalar (would discard data) or to overwrite a
163
+ map with a scalar at the leaf (likewise). Both raise `ConfigWriteError`.
164
+ """
165
+ parts = _split(dotted)
166
+ cursor: Any = doc
167
+ for i, key in enumerate(parts[:-1]):
168
+ nxt = cursor.get(key) if isinstance(cursor, CommentedMap) else None
169
+ if nxt is None:
170
+ nxt = CommentedMap()
171
+ cursor[key] = nxt
172
+ elif not isinstance(nxt, CommentedMap):
173
+ joined = ".".join(parts[: i + 1])
174
+ raise ConfigWriteError(
175
+ f"cannot descend into scalar at {joined!r}; "
176
+ f"refusing to overwrite a value with a map"
177
+ )
178
+ cursor = nxt
179
+ leaf_key = parts[-1]
180
+ existing = cursor.get(leaf_key) if isinstance(cursor, CommentedMap) else None
181
+ if isinstance(existing, CommentedMap):
182
+ raise ConfigWriteError(f"refusing to overwrite map at {dotted!r} with a scalar value")
183
+ cursor[leaf_key] = value
184
+
185
+
186
+ def unset_path(doc: CommentedMap, dotted: str) -> None:
187
+ """Remove `doc[a][b]...[leaf]` and prune empty parent maps.
188
+
189
+ No-op if any segment is missing. Pruning stops at non-empty parents.
190
+ """
191
+ parts = _split(dotted)
192
+ chain: list[tuple[CommentedMap, str]] = []
193
+ cursor: Any = doc
194
+ for key in parts[:-1]:
195
+ if not isinstance(cursor, CommentedMap) or key not in cursor:
196
+ return
197
+ chain.append((cursor, key))
198
+ cursor = cursor[key]
199
+ leaf_key = parts[-1]
200
+ if not isinstance(cursor, CommentedMap) or leaf_key not in cursor:
201
+ return
202
+ del cursor[leaf_key]
203
+ for parent, key in reversed(chain):
204
+ if isinstance(parent[key], CommentedMap) and len(parent[key]) == 0:
205
+ del parent[key]
206
+ else:
207
+ break
nsc/http/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Sync HTTP client for NetBox."""
2
+
3
+ from nsc.http.client import NetBoxClient
4
+ from nsc.http.errors import NetBoxAPIError, NetBoxClientError
5
+
6
+ __all__ = ["NetBoxAPIError", "NetBoxClient", "NetBoxClientError"]