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
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
+ ]
@@ -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"]