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
|
@@ -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
|