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/cli/writes/bulk.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Bulk routing brain (Phase 3c).
|
|
2
|
+
|
|
3
|
+
Pure logic. No I/O, no Typer, no httpx. Three responsibilities:
|
|
4
|
+
|
|
5
|
+
1. `detect_bulk_capability(operation)` — classify a parsed Operation as
|
|
6
|
+
bulk-capable, single-only, or ambiguous (spec §4.5).
|
|
7
|
+
2. `route_to_bulk_or_loop(...)` — combine record count, capability, and
|
|
8
|
+
--bulk/--no-bulk to pick a transport mode (Task 2).
|
|
9
|
+
3. `run_loop(...)` — sequential, deterministic loop with --on-error
|
|
10
|
+
stop|continue semantics (Task 6).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Literal, assert_never
|
|
19
|
+
|
|
20
|
+
from nsc.model.command_model import Operation
|
|
21
|
+
from nsc.output.errors import ErrorEnvelope
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from nsc.cli.writes.apply import ResolvedRequest
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BulkCapability(StrEnum):
|
|
28
|
+
BULK = "bulk"
|
|
29
|
+
SINGLE = "single"
|
|
30
|
+
AMBIGUOUS = "ambiguous"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def detect_bulk_capability(operation: Operation) -> BulkCapability:
|
|
34
|
+
"""Classify an operation by its request_body.top_level (spec §4.5).
|
|
35
|
+
|
|
36
|
+
The builder is the upstream classifier; this function never re-parses
|
|
37
|
+
raw schema. AMBIGUOUS means the builder couldn't determine a shape
|
|
38
|
+
(unparseable, unresolved $ref, or no requestBody) — the handler treats
|
|
39
|
+
it as single + emits a stderr warning.
|
|
40
|
+
"""
|
|
41
|
+
body = operation.request_body
|
|
42
|
+
if body is None:
|
|
43
|
+
return BulkCapability.AMBIGUOUS
|
|
44
|
+
match body.top_level:
|
|
45
|
+
case "array" | "object_or_array":
|
|
46
|
+
return BulkCapability.BULK
|
|
47
|
+
case "object":
|
|
48
|
+
return BulkCapability.SINGLE
|
|
49
|
+
case _:
|
|
50
|
+
assert_never(body.top_level)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RoutingMode(StrEnum):
|
|
54
|
+
BULK = "bulk"
|
|
55
|
+
LOOP = "loop"
|
|
56
|
+
SINGLE = "single"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class RoutingDecision:
|
|
61
|
+
mode: RoutingMode
|
|
62
|
+
records_count: int
|
|
63
|
+
capability: BulkCapability
|
|
64
|
+
reasoning: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class UnsupportedBulkError(ValueError):
|
|
68
|
+
"""`--bulk` was passed for an endpoint whose request body is not bulk-capable."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def route_to_bulk_or_loop(
|
|
72
|
+
*,
|
|
73
|
+
record_count: int,
|
|
74
|
+
capability: BulkCapability,
|
|
75
|
+
bulk_flag: bool | None,
|
|
76
|
+
) -> RoutingDecision:
|
|
77
|
+
"""Pick a transport mode per spec §4.5.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
record_count: Number of records produced by the input layer (>=1).
|
|
81
|
+
capability: Result of `detect_bulk_capability(operation)`.
|
|
82
|
+
bulk_flag: True for `--bulk`, False for `--no-bulk`, None for default.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
UnsupportedBulkError: `--bulk` on a non-bulk-capable endpoint
|
|
86
|
+
(capability == SINGLE).
|
|
87
|
+
ValueError: record_count < 1 (caller bug).
|
|
88
|
+
"""
|
|
89
|
+
if record_count < 1:
|
|
90
|
+
raise ValueError(f"record_count must be >= 1, got {record_count}")
|
|
91
|
+
|
|
92
|
+
if bulk_flag is True and capability is BulkCapability.SINGLE:
|
|
93
|
+
raise UnsupportedBulkError(
|
|
94
|
+
"--bulk requested but this endpoint does not support a list-shaped "
|
|
95
|
+
"request body; rerun without --bulk (or with --no-bulk to be explicit)"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if record_count == 1:
|
|
99
|
+
if bulk_flag is True:
|
|
100
|
+
mode = RoutingMode.BULK
|
|
101
|
+
reasoning = "explicit --bulk on a single record (sent as a 1-element array)"
|
|
102
|
+
else:
|
|
103
|
+
mode = RoutingMode.SINGLE
|
|
104
|
+
reasoning = "single record — sent as a single object regardless of capability"
|
|
105
|
+
return RoutingDecision(
|
|
106
|
+
mode=mode,
|
|
107
|
+
records_count=record_count,
|
|
108
|
+
capability=capability,
|
|
109
|
+
reasoning=reasoning,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if bulk_flag is True:
|
|
113
|
+
return RoutingDecision(
|
|
114
|
+
mode=RoutingMode.BULK,
|
|
115
|
+
records_count=record_count,
|
|
116
|
+
capability=capability,
|
|
117
|
+
reasoning=(
|
|
118
|
+
f"explicit --bulk: sending {record_count} records as a single "
|
|
119
|
+
f"array body (capability={capability.value})"
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
if bulk_flag is False:
|
|
123
|
+
return RoutingDecision(
|
|
124
|
+
mode=RoutingMode.LOOP,
|
|
125
|
+
records_count=record_count,
|
|
126
|
+
capability=capability,
|
|
127
|
+
reasoning=(
|
|
128
|
+
f"explicit --no-bulk: looping {record_count} sequential single-record requests"
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if capability is BulkCapability.BULK:
|
|
133
|
+
return RoutingDecision(
|
|
134
|
+
mode=RoutingMode.BULK,
|
|
135
|
+
records_count=record_count,
|
|
136
|
+
capability=capability,
|
|
137
|
+
reasoning=(
|
|
138
|
+
f"endpoint is bulk-capable: sending {record_count} records as a single array body"
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
return RoutingDecision(
|
|
142
|
+
mode=RoutingMode.LOOP,
|
|
143
|
+
records_count=record_count,
|
|
144
|
+
capability=capability,
|
|
145
|
+
reasoning=(
|
|
146
|
+
f"capability={capability.value}: looping {record_count} sequential "
|
|
147
|
+
"single-record requests"
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class LoopAttempt:
|
|
154
|
+
request: ResolvedRequest
|
|
155
|
+
response: dict[str, Any] | None
|
|
156
|
+
failure: ErrorEnvelope | None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class LoopResult:
|
|
161
|
+
attempts: list[LoopAttempt]
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def attempted(self) -> int:
|
|
165
|
+
return len(self.attempts)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def successes(self) -> int:
|
|
169
|
+
return sum(1 for a in self.attempts if a.failure is None)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def failures(self) -> list[ErrorEnvelope]:
|
|
173
|
+
return [a.failure for a in self.attempts if a.failure is not None]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
SendOne = Callable[[Operation, "ResolvedRequest"], dict[str, Any]]
|
|
177
|
+
AuditAttempt = Callable[["ResolvedRequest", dict[str, Any] | None, Exception | None], None]
|
|
178
|
+
ToEnvelope = Callable[[Exception], ErrorEnvelope]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run_loop(
|
|
182
|
+
requests: list[ResolvedRequest],
|
|
183
|
+
*,
|
|
184
|
+
operation: Operation,
|
|
185
|
+
on_error: Literal["stop", "continue"],
|
|
186
|
+
send_one: SendOne,
|
|
187
|
+
audit_attempt: AuditAttempt,
|
|
188
|
+
to_envelope: ToEnvelope,
|
|
189
|
+
) -> LoopResult:
|
|
190
|
+
"""Sequentially send N requests; collect successes and failures (spec §4.5).
|
|
191
|
+
|
|
192
|
+
The loop never spawns concurrency. Audit fires once per attempted request
|
|
193
|
+
(success and failure). On `on_error="stop"` the loop aborts on the first
|
|
194
|
+
failure; on `"continue"` it attempts every request.
|
|
195
|
+
|
|
196
|
+
`send_one` does the wire send; `audit_attempt` writes the audit entry;
|
|
197
|
+
`to_envelope` converts a raised exception into an ErrorEnvelope. Callers
|
|
198
|
+
inject these so this loop has no dependency on httpx or the audit module.
|
|
199
|
+
"""
|
|
200
|
+
attempts: list[LoopAttempt] = []
|
|
201
|
+
for request in requests:
|
|
202
|
+
try:
|
|
203
|
+
response = send_one(operation, request)
|
|
204
|
+
except (KeyboardInterrupt, SystemExit):
|
|
205
|
+
raise
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
failure = to_envelope(exc)
|
|
208
|
+
audit_attempt(request, None, exc)
|
|
209
|
+
attempts.append(LoopAttempt(request=request, response=None, failure=failure))
|
|
210
|
+
if on_error == "stop":
|
|
211
|
+
break
|
|
212
|
+
continue
|
|
213
|
+
audit_attempt(request, response, None)
|
|
214
|
+
attempts.append(LoopAttempt(request=request, response=response, failure=None))
|
|
215
|
+
return LoopResult(attempts=attempts)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
__all__ = [
|
|
219
|
+
"AuditAttempt",
|
|
220
|
+
"BulkCapability",
|
|
221
|
+
"LoopAttempt",
|
|
222
|
+
"LoopResult",
|
|
223
|
+
"RoutingDecision",
|
|
224
|
+
"RoutingMode",
|
|
225
|
+
"SendOne",
|
|
226
|
+
"ToEnvelope",
|
|
227
|
+
"UnsupportedBulkError",
|
|
228
|
+
"detect_bulk_capability",
|
|
229
|
+
"route_to_bulk_or_loop",
|
|
230
|
+
"run_loop",
|
|
231
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Shared boolean-string coercion sets (preflight + apply must agree)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
TRUTHY: frozenset[str] = frozenset({"true", "1", "yes"})
|
|
6
|
+
FALSY: frozenset[str] = frozenset({"false", "0", "no"})
|
|
7
|
+
BOOL_STRINGS: frozenset[str] = TRUTHY | FALSY
|
|
8
|
+
|
|
9
|
+
__all__ = ["BOOL_STRINGS", "FALSY", "TRUTHY"]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Write-time refusal helpers.
|
|
2
|
+
|
|
3
|
+
Each helper raises ClientError with a fully-shaped envelope. The CLI layer
|
|
4
|
+
catches ClientError, emits the envelope, and exits with EXIT_CODES[CLIENT].
|
|
5
|
+
|
|
6
|
+
Spec §4.7, §4.8.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from nsc.cli.writes.bulk import UnsupportedBulkError
|
|
12
|
+
from nsc.output.errors import ClientError, client_envelope
|
|
13
|
+
|
|
14
|
+
_SUPPORTED_FORMATS = {"yaml", "yml", "json"}
|
|
15
|
+
_SUPPORTED_ON_ERROR = {"stop", "continue"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def refuse_all_on_writes(*, operation_id: str) -> None:
|
|
19
|
+
raise ClientError(
|
|
20
|
+
client_envelope(
|
|
21
|
+
"--all is not supported on write commands (it is list-only)",
|
|
22
|
+
operation_id=operation_id,
|
|
23
|
+
flag="--all",
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def refuse_delete_without_id(*, operation_id: str) -> None:
|
|
29
|
+
raise ClientError(
|
|
30
|
+
client_envelope(
|
|
31
|
+
"delete requires a positional id (e.g. `nsc <tag> <resource> delete <id> --apply`)",
|
|
32
|
+
operation_id=operation_id,
|
|
33
|
+
flag="<id>",
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def refuse_unknown_format_for_writes(value: str | None) -> None:
|
|
39
|
+
if value is None:
|
|
40
|
+
return
|
|
41
|
+
if value.lower() not in _SUPPORTED_FORMATS:
|
|
42
|
+
raise ClientError(
|
|
43
|
+
client_envelope(
|
|
44
|
+
f"--format {value!r} is not supported; expected one of: yaml, yml, json",
|
|
45
|
+
flag="--format",
|
|
46
|
+
value=value,
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def refuse_bulk_and_no_bulk_together(
|
|
52
|
+
*,
|
|
53
|
+
bulk: bool,
|
|
54
|
+
no_bulk: bool,
|
|
55
|
+
operation_id: str,
|
|
56
|
+
) -> None:
|
|
57
|
+
if bulk and no_bulk:
|
|
58
|
+
raise ClientError(
|
|
59
|
+
client_envelope(
|
|
60
|
+
"--bulk and --no-bulk are mutually exclusive",
|
|
61
|
+
operation_id=operation_id,
|
|
62
|
+
flag="--bulk/--no-bulk",
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def refuse_unsupported_bulk(err: UnsupportedBulkError, *, operation_id: str) -> None:
|
|
68
|
+
raise ClientError(
|
|
69
|
+
client_envelope(
|
|
70
|
+
str(err),
|
|
71
|
+
operation_id=operation_id,
|
|
72
|
+
flag="--bulk",
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def refuse_unknown_on_error(value: str) -> None:
|
|
78
|
+
if value in _SUPPORTED_ON_ERROR:
|
|
79
|
+
return
|
|
80
|
+
raise ClientError(
|
|
81
|
+
client_envelope(
|
|
82
|
+
f"--on-error {value!r} is not supported; expected one of: stop, continue",
|
|
83
|
+
flag="--on-error",
|
|
84
|
+
value=value,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = [
|
|
90
|
+
"refuse_all_on_writes",
|
|
91
|
+
"refuse_bulk_and_no_bulk_together",
|
|
92
|
+
"refuse_delete_without_id",
|
|
93
|
+
"refuse_unknown_format_for_writes",
|
|
94
|
+
"refuse_unknown_on_error",
|
|
95
|
+
"refuse_unsupported_bulk",
|
|
96
|
+
]
|
nsc/cli/writes/input.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Stage 1 of the write pipeline: collect raw input.
|
|
2
|
+
|
|
3
|
+
Reads `-f file` (yaml/yml/json), `-f -` (stdin sniffed), and `--field k=v`
|
|
4
|
+
flag values; merges them into a single `RawWriteInput`.
|
|
5
|
+
|
|
6
|
+
Spec §4.7.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Literal, TextIO
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
17
|
+
from ruamel.yaml import YAML
|
|
18
|
+
from ruamel.yaml.error import YAMLError
|
|
19
|
+
|
|
20
|
+
_FILE_SIZE_CAP_BYTES = 10 * 1024 * 1024
|
|
21
|
+
_SUPPORTED_EXTENSIONS = frozenset({".yaml", ".yml", ".json", ".ndjson", ".jsonl"})
|
|
22
|
+
_NDJSON_BAD_LINE_CAP = 20
|
|
23
|
+
_BOM = ""
|
|
24
|
+
_STDIN_SNIFF_BYTES = 512
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _safe_load(text: str) -> Any:
|
|
28
|
+
"""Parse a YAML/JSON document with ruamel's safe loader.
|
|
29
|
+
|
|
30
|
+
Returns plain Python primitives (`dict` / `list` / scalar), not the
|
|
31
|
+
round-trip CommentedMap/CommentedSeq — downstream `_normalize_top_level`
|
|
32
|
+
expects `dict` / `list`.
|
|
33
|
+
"""
|
|
34
|
+
return YAML(typ="safe", pure=True).load(io.StringIO(text))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InputError(ValueError):
|
|
38
|
+
"""A user-facing error during input collection. Maps to ErrorType.CLIENT."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NDJSONParseError(InputError):
|
|
42
|
+
"""One or more NDJSON lines failed to parse. Carries a bounded `bad_lines` list."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, bad_lines: list[dict[str, Any]], *, total_lines: int) -> None:
|
|
45
|
+
self.bad_lines = bad_lines
|
|
46
|
+
self.total_lines = total_lines
|
|
47
|
+
message = (
|
|
48
|
+
f"{len(bad_lines)} of {total_lines} NDJSON line(s) failed to parse; "
|
|
49
|
+
f"see details.bad_lines"
|
|
50
|
+
)
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _Frozen(BaseModel):
|
|
55
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RawWriteInput(_Frozen):
|
|
59
|
+
records: list[dict[str, Any]] = Field(default_factory=list)
|
|
60
|
+
source: Literal["file", "stdin", "fields_only", "file_plus_fields"]
|
|
61
|
+
file_path: Path | None = None
|
|
62
|
+
is_explicit_list: bool = False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def collect(
|
|
66
|
+
*,
|
|
67
|
+
file: Path | None,
|
|
68
|
+
fields: list[str],
|
|
69
|
+
stdin: TextIO | None,
|
|
70
|
+
) -> RawWriteInput:
|
|
71
|
+
"""Read a file or stdin and merge --field overrides.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
file: A `Path` to a yaml/yml/json file, or `Path("-")` for stdin, or None.
|
|
75
|
+
fields: A list of `key=value` strings (key may be a dotted path).
|
|
76
|
+
stdin: A text stream to read from when `file == Path("-")`.
|
|
77
|
+
"""
|
|
78
|
+
if file is None and not fields and stdin is None:
|
|
79
|
+
raise InputError("no input: pass -f FILE, --field k=v, or pipe to stdin")
|
|
80
|
+
|
|
81
|
+
file_records: list[dict[str, Any]] = []
|
|
82
|
+
is_list = False
|
|
83
|
+
file_path: Path | None = None
|
|
84
|
+
used_file = False
|
|
85
|
+
used_stdin = False
|
|
86
|
+
if file is not None and str(file) == "-":
|
|
87
|
+
if stdin is None:
|
|
88
|
+
raise InputError("`-f -` requires stdin to be readable")
|
|
89
|
+
file_records, is_list = _parse_stdin(stdin)
|
|
90
|
+
used_stdin = True
|
|
91
|
+
elif file is not None:
|
|
92
|
+
file_records, is_list = _parse_file(file)
|
|
93
|
+
file_path = file
|
|
94
|
+
used_file = True
|
|
95
|
+
|
|
96
|
+
field_overlay = _parse_fields(fields)
|
|
97
|
+
|
|
98
|
+
records = _merge(file_records, field_overlay, used_file=used_file)
|
|
99
|
+
source: Literal["file", "stdin", "fields_only", "file_plus_fields"]
|
|
100
|
+
if used_stdin:
|
|
101
|
+
source = "stdin"
|
|
102
|
+
elif used_file and field_overlay:
|
|
103
|
+
source = "file_plus_fields"
|
|
104
|
+
elif used_file:
|
|
105
|
+
source = "file"
|
|
106
|
+
else:
|
|
107
|
+
source = "fields_only"
|
|
108
|
+
return RawWriteInput(
|
|
109
|
+
records=records,
|
|
110
|
+
source=source,
|
|
111
|
+
file_path=file_path,
|
|
112
|
+
is_explicit_list=is_list,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_file(path: Path) -> tuple[list[dict[str, Any]], bool]:
|
|
117
|
+
if not path.exists():
|
|
118
|
+
raise InputError(f"input file does not exist: {path}")
|
|
119
|
+
if path.suffix.lower() not in _SUPPORTED_EXTENSIONS:
|
|
120
|
+
raise InputError(
|
|
121
|
+
f"unsupported file extension {path.suffix!r}; "
|
|
122
|
+
f"expected one of: {sorted(_SUPPORTED_EXTENSIONS)}"
|
|
123
|
+
)
|
|
124
|
+
if path.stat().st_size > _FILE_SIZE_CAP_BYTES:
|
|
125
|
+
raise InputError(f"input file exceeds 10 MB cap: {path}")
|
|
126
|
+
try:
|
|
127
|
+
raw = path.read_bytes()
|
|
128
|
+
except OSError as exc:
|
|
129
|
+
raise InputError(f"could not read input file {path}: {exc}") from exc
|
|
130
|
+
text = _decode_utf8(raw, path)
|
|
131
|
+
suffix = path.suffix.lower()
|
|
132
|
+
if suffix in {".ndjson", ".jsonl"}:
|
|
133
|
+
return _parse_ndjson(text), True
|
|
134
|
+
return _parse_text(text, hint=suffix)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _parse_ndjson(text: str) -> list[dict[str, Any]]:
|
|
138
|
+
"""Parse one JSON object per non-blank line.
|
|
139
|
+
|
|
140
|
+
All-or-nothing semantics: if ANY line fails, raise `NDJSONParseError` with
|
|
141
|
+
a `bad_lines` list capped at `_NDJSON_BAD_LINE_CAP` entries. Blank/whitespace
|
|
142
|
+
lines are skipped (not counted as bad). Empty input → InputError.
|
|
143
|
+
"""
|
|
144
|
+
records: list[dict[str, Any]] = []
|
|
145
|
+
bad_lines: list[dict[str, Any]] = []
|
|
146
|
+
total = 0
|
|
147
|
+
seen_any = False
|
|
148
|
+
|
|
149
|
+
for lineno, raw_line in enumerate(text.splitlines(), start=1):
|
|
150
|
+
stripped = raw_line.strip()
|
|
151
|
+
if not stripped:
|
|
152
|
+
continue
|
|
153
|
+
seen_any = True
|
|
154
|
+
total += 1
|
|
155
|
+
try:
|
|
156
|
+
parsed = json.loads(stripped)
|
|
157
|
+
except json.JSONDecodeError as exc:
|
|
158
|
+
if len(bad_lines) < _NDJSON_BAD_LINE_CAP:
|
|
159
|
+
bad_lines.append({"line": lineno, "reason": str(exc)})
|
|
160
|
+
continue
|
|
161
|
+
if not isinstance(parsed, dict):
|
|
162
|
+
if len(bad_lines) < _NDJSON_BAD_LINE_CAP:
|
|
163
|
+
bad_lines.append(
|
|
164
|
+
{
|
|
165
|
+
"line": lineno,
|
|
166
|
+
"reason": f"top-level value is not a mapping (got {type(parsed).__name__})",
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
continue
|
|
170
|
+
records.append(parsed)
|
|
171
|
+
|
|
172
|
+
if not seen_any:
|
|
173
|
+
raise InputError("input file contains no records (NDJSON file is empty or all blank)")
|
|
174
|
+
if bad_lines:
|
|
175
|
+
raise NDJSONParseError(bad_lines, total_lines=total)
|
|
176
|
+
return records
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _parse_stdin(stream: TextIO) -> tuple[list[dict[str, Any]], bool]:
|
|
180
|
+
text = stream.read()
|
|
181
|
+
if not text.strip():
|
|
182
|
+
raise InputError("stdin is empty; nothing to apply")
|
|
183
|
+
return _parse_text(text, hint=_sniff_stdin_format(text))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _sniff_stdin_format(text: str) -> str | None:
|
|
187
|
+
"""Inspect the first ~512 chars and return a parser hint.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
- "ndjson" → all-or-nothing NDJSON parser
|
|
191
|
+
- "json_or_yaml" → strict JSON first, YAML fallback (preserves flow-YAML)
|
|
192
|
+
- None → YAML (which also accepts JSON)
|
|
193
|
+
"""
|
|
194
|
+
prefix = text[:_STDIN_SNIFF_BYTES]
|
|
195
|
+
stripped = prefix.lstrip()
|
|
196
|
+
if not stripped:
|
|
197
|
+
return None
|
|
198
|
+
first = stripped[0]
|
|
199
|
+
if first == "[":
|
|
200
|
+
return "json_or_yaml"
|
|
201
|
+
if first != "{":
|
|
202
|
+
return None
|
|
203
|
+
return _classify_brace_start(stripped)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _classify_brace_start(stripped: str) -> str:
|
|
207
|
+
"""Walk the first JSON object in `stripped` to decide single vs NDJSON.
|
|
208
|
+
|
|
209
|
+
If the first complete object is followed by any non-whitespace content,
|
|
210
|
+
we treat the input as NDJSON (even if the next "line" is not valid JSON —
|
|
211
|
+
the NDJSON parser will collect those as bad_lines and raise NDJSONParseError).
|
|
212
|
+
"""
|
|
213
|
+
depth = 0
|
|
214
|
+
in_string = False
|
|
215
|
+
escape = False
|
|
216
|
+
for i, ch in enumerate(stripped):
|
|
217
|
+
if escape:
|
|
218
|
+
escape = False
|
|
219
|
+
continue
|
|
220
|
+
if ch == "\\" and in_string:
|
|
221
|
+
escape = True
|
|
222
|
+
continue
|
|
223
|
+
if ch == '"':
|
|
224
|
+
in_string = not in_string
|
|
225
|
+
continue
|
|
226
|
+
if in_string:
|
|
227
|
+
continue
|
|
228
|
+
if ch == "{":
|
|
229
|
+
depth += 1
|
|
230
|
+
elif ch == "}":
|
|
231
|
+
depth -= 1
|
|
232
|
+
if depth == 0:
|
|
233
|
+
rest = stripped[i + 1 :]
|
|
234
|
+
if rest.strip():
|
|
235
|
+
# Any non-whitespace after the first object → NDJSON.
|
|
236
|
+
return "ndjson"
|
|
237
|
+
# Nothing follows → single object.
|
|
238
|
+
return "json_or_yaml"
|
|
239
|
+
# Buffer ended mid-object; default to JSON-or-YAML.
|
|
240
|
+
return "json_or_yaml"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _decode_utf8(raw: bytes, path: Path) -> str:
|
|
244
|
+
try:
|
|
245
|
+
text = raw.decode("utf-8")
|
|
246
|
+
except UnicodeDecodeError as exc:
|
|
247
|
+
raise InputError(f"input file is not valid UTF-8: {path}: {exc}") from exc
|
|
248
|
+
if text.startswith(_BOM):
|
|
249
|
+
text = text[len(_BOM) :]
|
|
250
|
+
return text
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _parse_text(text: str, *, hint: str | None) -> tuple[list[dict[str, Any]], bool]:
|
|
254
|
+
if hint == "ndjson":
|
|
255
|
+
return _parse_ndjson(text), True
|
|
256
|
+
if hint == ".json":
|
|
257
|
+
# File-extension was .json; require strict JSON, no YAML fallback.
|
|
258
|
+
try:
|
|
259
|
+
parsed = json.loads(text)
|
|
260
|
+
except json.JSONDecodeError as exc:
|
|
261
|
+
raise InputError(f"could not parse JSON input: {exc}") from exc
|
|
262
|
+
elif hint == "json_or_yaml" or (hint is None and text.lstrip().startswith(("{", "["))):
|
|
263
|
+
# Stdin sniffer hint OR legacy fallback: try JSON first, then YAML.
|
|
264
|
+
try:
|
|
265
|
+
parsed = json.loads(text)
|
|
266
|
+
except json.JSONDecodeError:
|
|
267
|
+
try:
|
|
268
|
+
parsed = _safe_load(text)
|
|
269
|
+
except YAMLError as exc:
|
|
270
|
+
raise InputError(f"could not parse stdin as JSON or YAML: {exc}") from exc
|
|
271
|
+
else:
|
|
272
|
+
try:
|
|
273
|
+
parsed = _safe_load(text)
|
|
274
|
+
except YAMLError as exc:
|
|
275
|
+
raise InputError(f"could not parse YAML input: {exc}") from exc
|
|
276
|
+
return _normalize_top_level(parsed)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _normalize_top_level(parsed: Any) -> tuple[list[dict[str, Any]], bool]:
|
|
280
|
+
if isinstance(parsed, dict):
|
|
281
|
+
return [parsed], False
|
|
282
|
+
if isinstance(parsed, list):
|
|
283
|
+
if not parsed:
|
|
284
|
+
raise InputError("input file contains an empty list; nothing to apply")
|
|
285
|
+
for i, item in enumerate(parsed):
|
|
286
|
+
if not isinstance(item, dict):
|
|
287
|
+
raise InputError(
|
|
288
|
+
f"input file list item {i} is not a mapping (got {type(item).__name__})"
|
|
289
|
+
)
|
|
290
|
+
return list(parsed), True
|
|
291
|
+
raise InputError(
|
|
292
|
+
f"input top-level must be a mapping or list of mappings, got {type(parsed).__name__}"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _parse_fields(raw: list[str]) -> dict[str, Any]:
|
|
297
|
+
overlay: dict[str, Any] = {}
|
|
298
|
+
for item in raw:
|
|
299
|
+
if "=" not in item:
|
|
300
|
+
raise InputError(f"--field expects key=value, got: {item!r}")
|
|
301
|
+
key, _, value = item.partition("=")
|
|
302
|
+
key = key.strip()
|
|
303
|
+
if not key:
|
|
304
|
+
raise InputError(f"--field key is empty: {item!r}")
|
|
305
|
+
if "[" in key or "]" in key:
|
|
306
|
+
raise InputError(
|
|
307
|
+
f"--field {item!r} uses indexed-list syntax; use -f for nested lists in 3b"
|
|
308
|
+
)
|
|
309
|
+
_set_dotted(overlay, key.split("."), value)
|
|
310
|
+
return overlay
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _set_dotted(target: dict[str, Any], parts: list[str], value: str) -> None:
|
|
314
|
+
cursor = target
|
|
315
|
+
for part in parts[:-1]:
|
|
316
|
+
existing = cursor.get(part)
|
|
317
|
+
if not isinstance(existing, dict):
|
|
318
|
+
existing = {}
|
|
319
|
+
cursor[part] = existing
|
|
320
|
+
cursor = existing
|
|
321
|
+
cursor[parts[-1]] = value
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _merge(
|
|
325
|
+
file_records: list[dict[str, Any]],
|
|
326
|
+
overlay: dict[str, Any],
|
|
327
|
+
*,
|
|
328
|
+
used_file: bool,
|
|
329
|
+
) -> list[dict[str, Any]]:
|
|
330
|
+
if not used_file and not file_records:
|
|
331
|
+
return [_deep_copy(overlay)]
|
|
332
|
+
out: list[dict[str, Any]] = []
|
|
333
|
+
for record in file_records:
|
|
334
|
+
merged = _deep_merge(_deep_copy(record), overlay)
|
|
335
|
+
out.append(merged)
|
|
336
|
+
return out
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _deep_copy(value: Any) -> Any:
|
|
340
|
+
if isinstance(value, dict):
|
|
341
|
+
return {k: _deep_copy(v) for k, v in value.items()}
|
|
342
|
+
if isinstance(value, list):
|
|
343
|
+
return [_deep_copy(v) for v in value]
|
|
344
|
+
return value
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _deep_merge(target: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
|
|
348
|
+
"""Mutating deep merge. Overlay wins on leaves."""
|
|
349
|
+
for k, v in overlay.items():
|
|
350
|
+
existing = target.get(k)
|
|
351
|
+
if isinstance(existing, dict) and isinstance(v, dict):
|
|
352
|
+
_deep_merge(existing, v)
|
|
353
|
+
else:
|
|
354
|
+
target[k] = _deep_copy(v)
|
|
355
|
+
return target
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
__all__ = ["InputError", "NDJSONParseError", "RawWriteInput", "collect"]
|