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/runtime.py ADDED
@@ -0,0 +1,290 @@
1
+ """Runtime context, profile resolution, exit-code mapping.
2
+
3
+ `RuntimeContext` carries the live `NetBoxClient`, command model, config, and
4
+ output preferences for a single invocation. It is populated by the bootstrap
5
+ pipeline in the root Typer callback (Task 12) and consumed by the dynamic
6
+ read handlers.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ import uuid
13
+ from collections.abc import Iterable, Iterator, Mapping
14
+ from typing import Any, Literal
15
+
16
+ from pydantic import BaseModel, ConfigDict, HttpUrl, SkipValidation
17
+
18
+ from nsc.cache.store import ADHOC_PROFILE
19
+ from nsc.config.models import Config, OutputFormat, Profile
20
+ from nsc.config.settings import default_paths
21
+ from nsc.http.client import NetBoxClient
22
+ from nsc.http.errors import NetBoxAPIError, NetBoxClientError
23
+ from nsc.model.command_model import CommandModel, Operation
24
+ from nsc.output.errors import (
25
+ EXIT_CODES,
26
+ ErrorEnvelope,
27
+ ErrorType,
28
+ RenderTarget,
29
+ render_to_json,
30
+ render_to_rich_stderr,
31
+ select_render_target,
32
+ )
33
+
34
+ # Named constants to satisfy PLR2004 (no magic numbers in comparisons).
35
+ _STATUS_UNAUTHORIZED = 401
36
+ _STATUS_FORBIDDEN = 403
37
+ _STATUS_NOT_FOUND = 404
38
+ _STATUS_CONFLICT = 409
39
+ _STATUS_TOO_MANY = 429
40
+ _STATUS_4XX_MIN = 400
41
+ _STATUS_5XX_MIN = 500
42
+
43
+
44
+ class _Frozen(BaseModel):
45
+ model_config = ConfigDict(frozen=True, extra="forbid")
46
+
47
+
48
+ class CLIOverrides(_Frozen):
49
+ profile: str | None = None
50
+ url: str | None = None
51
+ token: str | None = None
52
+ insecure: bool | None = None
53
+ schema_override: str | None = None
54
+ output: str | None = None
55
+
56
+
57
+ class ResolvedProfile(_Frozen):
58
+ name: str
59
+ url: HttpUrl
60
+ token: str
61
+ verify_ssl: bool
62
+ timeout: float
63
+ schema_url: HttpUrl | None
64
+
65
+
66
+ class NoProfileError(Exception):
67
+ """No URL/token available from any source."""
68
+
69
+
70
+ class UnknownProfileError(Exception):
71
+ """A profile was requested by name but is not in the config."""
72
+
73
+
74
+ def resolve_profile(
75
+ config: Config,
76
+ overrides: CLIOverrides,
77
+ env: Mapping[str, str],
78
+ ) -> ResolvedProfile:
79
+ base, base_name = _select_base_profile(config, overrides, env)
80
+
81
+ url = _first_set(overrides.url, env.get("NSC_URL"), str(base.url) if base else None)
82
+ token = _first_set(overrides.token, env.get("NSC_TOKEN"), base.token if base else None)
83
+ if url is None or token is None:
84
+ raise NoProfileError(
85
+ "no NetBox URL/token configured (set NSC_URL+NSC_TOKEN, "
86
+ "pass --url and --token, or configure a profile in ~/.nsc/config.yaml)"
87
+ )
88
+
89
+ insecure_env = _bool_env(env.get("NSC_INSECURE"))
90
+ if overrides.insecure is not None:
91
+ verify_ssl = not overrides.insecure
92
+ elif insecure_env is not None:
93
+ verify_ssl = not insecure_env
94
+ elif base is not None:
95
+ verify_ssl = base.verify_ssl
96
+ else:
97
+ verify_ssl = True
98
+
99
+ timeout = base.timeout if (base and base.timeout is not None) else config.defaults.timeout
100
+
101
+ schema_url_raw = _first_set(
102
+ _url_only(overrides.schema_override),
103
+ _url_only(env.get("NSC_SCHEMA")),
104
+ str(base.schema_url) if base and base.schema_url else None,
105
+ )
106
+
107
+ return ResolvedProfile(
108
+ name=base_name,
109
+ url=HttpUrl(url),
110
+ token=token,
111
+ verify_ssl=verify_ssl,
112
+ timeout=timeout,
113
+ schema_url=HttpUrl(schema_url_raw) if schema_url_raw else None,
114
+ )
115
+
116
+
117
+ def _select_base_profile(
118
+ config: Config, overrides: CLIOverrides, env: Mapping[str, str]
119
+ ) -> tuple[Profile | None, str]:
120
+ name = overrides.profile or env.get("NSC_PROFILE") or config.default_profile
121
+ if name is None:
122
+ return None, ADHOC_PROFILE
123
+ if name not in config.profiles:
124
+ raise UnknownProfileError(f"profile {name!r} is not defined in ~/.nsc/config.yaml")
125
+ return config.profiles[name], name
126
+
127
+
128
+ def _first_set(*values: str | None) -> str | None:
129
+ for v in values:
130
+ if v is not None and v != "":
131
+ return v
132
+ return None
133
+
134
+
135
+ def _bool_env(raw: str | None) -> bool | None:
136
+ if raw is None:
137
+ return None
138
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
139
+
140
+
141
+ def _url_only(value: str | None) -> str | None:
142
+ """Return the value only if it looks like an HTTP(S) URL; else None.
143
+
144
+ `--schema`/`NSC_SCHEMA` may be a local path, in which case it should not
145
+ populate the profile's `schema_url` (which is HttpUrl-validated). The
146
+ schema_override flow consumes the raw value directly from `CLIOverrides`.
147
+ """
148
+ if value is None or not value:
149
+ return None
150
+ if value.startswith(("http://", "https://")):
151
+ return value
152
+ return None
153
+
154
+
155
+ class RuntimeContext(BaseModel):
156
+ """Per-invocation runtime state.
157
+
158
+ Not frozen because `client` (a NetBoxClient wrapping httpx.Client) is mutable.
159
+ """
160
+
161
+ model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
162
+
163
+ resolved_profile: ResolvedProfile
164
+ config: Config
165
+ command_model: SkipValidation[CommandModel]
166
+ client: SkipValidation[NetBoxClient]
167
+ output_format: OutputFormat
168
+ debug: bool = False
169
+ page_size: int = 50
170
+ columns_override: list[str] | None = None
171
+ filters: list[tuple[str, str]] = []
172
+ limit: int | None = None
173
+ fetch_all: bool = False
174
+ compact: bool = False
175
+ apply: bool = False
176
+ explain: bool = False
177
+ strict: bool = False
178
+ file: str | None = None
179
+ fields: list[str] = []
180
+ file_format: str | None = None
181
+ bulk: bool | None = None
182
+ no_bulk: bool | None = None
183
+ on_error: Literal["stop", "continue"] = "stop"
184
+
185
+ def resolve_columns(self, tag: str, resource: str, operation: Operation) -> list[str] | None:
186
+ if self.columns_override is not None:
187
+ return self.columns_override
188
+ per_tag = self.config.columns.get(tag, {})
189
+ configured = per_tag.get(resource)
190
+ if configured is not None:
191
+ return configured
192
+ return operation.default_columns
193
+
194
+
195
+ def apply_limit(
196
+ iterator: Iterable[dict[str, Any]],
197
+ *,
198
+ limit: int | None,
199
+ fetch_all: bool,
200
+ page_size: int,
201
+ ) -> Iterator[dict[str, Any]]:
202
+ cap: int | None
203
+ if limit is not None:
204
+ cap = limit
205
+ elif fetch_all:
206
+ cap = None
207
+ else:
208
+ cap = page_size
209
+ for n, record in enumerate(iterator):
210
+ if cap is not None and n >= cap:
211
+ return
212
+ yield record
213
+
214
+
215
+ def _audit_log_path() -> str | None:
216
+ p = default_paths().logs_dir / "audit.jsonl"
217
+ return str(p) if p.exists() else None
218
+
219
+
220
+ def _api_error_type(status_code: int) -> ErrorType:
221
+ if status_code in (_STATUS_UNAUTHORIZED, _STATUS_FORBIDDEN):
222
+ return ErrorType.AUTH
223
+ if status_code == _STATUS_NOT_FOUND:
224
+ return ErrorType.NOT_FOUND
225
+ if status_code == _STATUS_CONFLICT:
226
+ return ErrorType.CONFLICT
227
+ if status_code == _STATUS_TOO_MANY:
228
+ return ErrorType.RATE_LIMITED
229
+ if _STATUS_4XX_MIN <= status_code < _STATUS_5XX_MIN:
230
+ return ErrorType.VALIDATION
231
+ return ErrorType.SERVER
232
+
233
+
234
+ def map_error(
235
+ exc: BaseException,
236
+ *,
237
+ operation_id: str | None = None,
238
+ attempt_n: int | None = None,
239
+ ) -> ErrorEnvelope:
240
+ """Translate a known nsc exception into an ErrorEnvelope. Unknown → internal."""
241
+ if isinstance(exc, NetBoxAPIError):
242
+ et = _api_error_type(exc.status_code)
243
+ details: dict[str, Any] = {}
244
+ if et is ErrorType.SERVER:
245
+ details = {"body_excerpt": exc.body_snippet, "retry_safe": False}
246
+ elif et is ErrorType.AUTH:
247
+ details = {"reason": "rejected"}
248
+ elif et is ErrorType.VALIDATION:
249
+ details = {"source": "server", "body_excerpt": exc.body_snippet}
250
+ return ErrorEnvelope(
251
+ error=str(exc),
252
+ type=et,
253
+ endpoint=exc.url,
254
+ status_code=exc.status_code,
255
+ attempt_n=attempt_n,
256
+ audit_log_path=_audit_log_path(),
257
+ operation_id=operation_id,
258
+ details=details,
259
+ )
260
+ if isinstance(exc, NetBoxClientError):
261
+ return ErrorEnvelope(
262
+ error=str(exc),
263
+ type=ErrorType.TRANSPORT,
264
+ endpoint=exc.url,
265
+ attempt_n=attempt_n,
266
+ audit_log_path=_audit_log_path(),
267
+ operation_id=operation_id,
268
+ details={"cause": "connect", "retry_safe": True},
269
+ )
270
+ if isinstance(exc, NoProfileError):
271
+ return ErrorEnvelope(error=str(exc), type=ErrorType.CONFIG)
272
+ if isinstance(exc, UnknownProfileError):
273
+ return ErrorEnvelope(error=str(exc), type=ErrorType.CONFIG)
274
+ return ErrorEnvelope(
275
+ error=f"internal error: {exc}",
276
+ type=ErrorType.INTERNAL,
277
+ details={"traceback_id": str(uuid.uuid4())},
278
+ )
279
+
280
+
281
+ def emit_envelope(env: ErrorEnvelope, *, output_format: OutputFormat) -> int:
282
+ """Write the envelope to the right target and return the exit code."""
283
+ target = select_render_target(output_format=output_format, stdout_is_tty=sys.stdout.isatty())
284
+ if target is RenderTarget.JSON_STDOUT:
285
+ print(render_to_json(env), file=sys.stdout)
286
+ elif target is RenderTarget.JSON_STDERR:
287
+ print(render_to_json(env), file=sys.stderr)
288
+ else:
289
+ render_to_rich_stderr(env, stream=sys.stderr)
290
+ return EXIT_CODES.get(env.type, 1)
@@ -0,0 +1,186 @@
1
+ """`nsc skill` — install the bundled portable Skill into agent harnesses.
2
+
3
+ Phase 5c ships `install`. Default behavior is dry-run; `--apply` copies the
4
+ bundled `SKILL.md` to each target's documented Skills directory. Targets
5
+ whose convention is unknown print actionable manual instructions instead of
6
+ guessing a path silently.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import shutil
14
+ from dataclasses import dataclass
15
+ from enum import StrEnum
16
+ from pathlib import Path
17
+ from typing import Annotated
18
+
19
+ import typer
20
+
21
+ from nsc.skill import BUNDLE_NAME, bundle_path
22
+
23
+
24
+ class _Target(StrEnum):
25
+ CLAUDE_CODE = "claude-code"
26
+ CODEX = "codex"
27
+ GEMINI = "gemini"
28
+ COPILOT = "copilot"
29
+
30
+
31
+ class _OutputFormat(StrEnum):
32
+ TABLE = "table"
33
+ JSON = "json"
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class _Resolution:
38
+ target: _Target
39
+ path: Path | None
40
+ manual_instructions: str | None
41
+
42
+
43
+ def _home() -> Path:
44
+ return Path(os.environ.get("HOME") or os.path.expanduser("~"))
45
+
46
+
47
+ def _resolve_claude_code() -> _Resolution:
48
+ return _Resolution(
49
+ target=_Target.CLAUDE_CODE,
50
+ path=_home() / ".claude" / "skills" / BUNDLE_NAME / "SKILL.md",
51
+ manual_instructions=None,
52
+ )
53
+
54
+
55
+ def _resolve_codex() -> _Resolution:
56
+ # Per T1 research (developers.openai.com/codex/skills): Codex CLI loads
57
+ # user-scoped skills from $HOME/.agents/skills/<name>/SKILL.md. The
58
+ # `.agents/` prefix is agent-tool-neutral, not codex-specific.
59
+ return _Resolution(
60
+ target=_Target.CODEX,
61
+ path=_home() / ".agents" / "skills" / BUNDLE_NAME / "SKILL.md",
62
+ manual_instructions=None,
63
+ )
64
+
65
+
66
+ def _resolve_gemini() -> _Resolution:
67
+ return _Resolution(
68
+ target=_Target.GEMINI,
69
+ path=None,
70
+ manual_instructions=(
71
+ "Gemini CLI does not document a programmatic Skill install path "
72
+ "as of this nsc release. To use the bundled Skill: paste its "
73
+ "content into a project-scoped GEMINI.md or your Gemini system "
74
+ "prompt."
75
+ ),
76
+ )
77
+
78
+
79
+ def _resolve_copilot() -> _Resolution:
80
+ return _Resolution(
81
+ target=_Target.COPILOT,
82
+ path=None,
83
+ manual_instructions=(
84
+ "GitHub Copilot CLI does not document a stable user-scoped Skill "
85
+ "install path as of this nsc release. To use the bundled Skill: "
86
+ "paste its content into .github/copilot-instructions.md or your "
87
+ "team's Copilot configuration."
88
+ ),
89
+ )
90
+
91
+
92
+ _RESOLVERS = {
93
+ _Target.CLAUDE_CODE: _resolve_claude_code,
94
+ _Target.CODEX: _resolve_codex,
95
+ _Target.GEMINI: _resolve_gemini,
96
+ _Target.COPILOT: _resolve_copilot,
97
+ }
98
+
99
+
100
+ def _resolve(target: _Target) -> _Resolution:
101
+ return _RESOLVERS[target]()
102
+
103
+
104
+ def _render_table(resolution: _Resolution, mode: str, written: bool, source: Path) -> str:
105
+ lines: list[str] = []
106
+ if resolution.path is None:
107
+ lines.append(f"nsc skill install --target {resolution.target.value}")
108
+ lines.append("")
109
+ assert resolution.manual_instructions is not None
110
+ lines.append(resolution.manual_instructions)
111
+ lines.append("")
112
+ lines.append(f" source SKILL.md: {source}")
113
+ return "\n".join(lines)
114
+
115
+ if mode == "dry-run":
116
+ lines.append(
117
+ f"nsc skill install --target {resolution.target.value} (dry-run) "
118
+ "— pass --apply to install"
119
+ )
120
+ lines.append(f" would write to {resolution.path}")
121
+ lines.append(f" source: {source}")
122
+ elif written:
123
+ lines.append(f"✓ installed netbox-super-cli skill at {resolution.path}")
124
+ else:
125
+ lines.append(f"nsc skill install --target {resolution.target.value} (no-op)")
126
+ return "\n".join(lines)
127
+
128
+
129
+ def _render_json(resolution: _Resolution, mode: str, written: bool, source: Path) -> str:
130
+ payload: dict[str, object] = {
131
+ "mode": mode,
132
+ "target": resolution.target.value,
133
+ "source": str(source),
134
+ "destination": str(resolution.path) if resolution.path else None,
135
+ "manual": resolution.path is None,
136
+ }
137
+ if resolution.manual_instructions is not None:
138
+ payload["instructions"] = resolution.manual_instructions
139
+ if mode == "apply":
140
+ payload["written"] = written
141
+ return json.dumps(payload)
142
+
143
+
144
+ def register(app: typer.Typer) -> None:
145
+ skill_app = typer.Typer(
146
+ name="skill",
147
+ help="Manage the bundled portable Skill for AI agent harnesses.",
148
+ no_args_is_help=True,
149
+ )
150
+
151
+ @skill_app.command("install")
152
+ def install_cmd(
153
+ target: Annotated[
154
+ _Target,
155
+ typer.Option(
156
+ "--target",
157
+ "-t",
158
+ help="Which agent harness to install the Skill into.",
159
+ case_sensitive=False,
160
+ ),
161
+ ],
162
+ apply_: Annotated[
163
+ bool,
164
+ typer.Option("--apply", help="Actually copy the file (default: dry-run)."),
165
+ ] = False,
166
+ output: Annotated[
167
+ _OutputFormat,
168
+ typer.Option("--output", "-o", help="table|json"),
169
+ ] = _OutputFormat.TABLE,
170
+ ) -> None:
171
+ resolution = _resolve(target)
172
+ mode = "apply" if apply_ else "dry-run"
173
+ written = False
174
+
175
+ with bundle_path() as source:
176
+ if apply_ and resolution.path is not None:
177
+ resolution.path.parent.mkdir(parents=True, exist_ok=True)
178
+ shutil.copy2(source, resolution.path)
179
+ written = True
180
+
181
+ if output is _OutputFormat.JSON:
182
+ typer.echo(_render_json(resolution, mode, written, source))
183
+ else:
184
+ typer.echo(_render_table(resolution, mode, written, source))
185
+
186
+ app.add_typer(skill_app, name="skill")
@@ -0,0 +1,10 @@
1
+ """Write pipeline (Phase 3b/3c).
2
+
3
+ Stages:
4
+ input.collect() → RawWriteInput
5
+ preflight.check() → PreflightResult
6
+ apply.resolve() → ResolvedRequest
7
+ confirmation.* → flag-conflict gating helpers
8
+ """
9
+
10
+ from __future__ import annotations
@@ -0,0 +1,177 @@
1
+ """Stage 3 of the write pipeline: build the wire-shape ResolvedRequest.
2
+
3
+ 3b emits one ResolvedRequest per input record (single-record loop shape).
4
+ 3c adds a bulk variant gated by ``mode=RoutingMode.BULK`` that produces a
5
+ single request with a list-shaped body and ``record_indices=[0..N)``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Literal
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+ from nsc.cli.writes.bulk import RoutingMode
15
+ from nsc.cli.writes.coercion import FALSY as _FALSY
16
+ from nsc.cli.writes.coercion import TRUTHY as _TRUTHY
17
+ from nsc.cli.writes.input import RawWriteInput
18
+ from nsc.model.command_model import FieldShape, HttpMethod, Operation, PrimitiveType
19
+ from nsc.output.headers import SENSITIVE_HEADERS
20
+
21
+ _REDACTED_AUTH = "Token <redacted>"
22
+
23
+
24
+ class _Frozen(BaseModel):
25
+ model_config = ConfigDict(frozen=True, extra="forbid")
26
+
27
+
28
+ class ResolvedRequest(_Frozen):
29
+ method: HttpMethod
30
+ url: str
31
+ headers: dict[str, str] = Field(default_factory=dict)
32
+ query: dict[str, Any] = Field(default_factory=dict)
33
+ body: dict[str, Any] | list[dict[str, Any]] | None = None
34
+ path_vars: dict[str, str] = Field(default_factory=dict)
35
+ operation_id: str
36
+ record_indices: list[int] = Field(default_factory=list)
37
+
38
+
39
+ def resolve(
40
+ raw: RawWriteInput,
41
+ operation: Operation,
42
+ *,
43
+ path_vars: dict[str, str],
44
+ base_url: str,
45
+ headers: dict[str, str] | None = None,
46
+ mode: RoutingMode = RoutingMode.SINGLE,
47
+ ) -> list[ResolvedRequest]:
48
+ """Build wire-shape requests.
49
+
50
+ Mode drives the body shape:
51
+ SINGLE / LOOP -> one ResolvedRequest per record, body=dict, record_indices=[i].
52
+ BULK -> one ResolvedRequest, body=list[dict], record_indices=[0..N).
53
+
54
+ SINGLE and LOOP differ only in caller intent (single record vs. multiple
55
+ records that the user opted out of bulk for). The wire shape is identical
56
+ per request.
57
+ """
58
+ base = base_url.rstrip("/")
59
+ url = base + operation.path.format(**path_vars)
60
+ visible_headers = _redact(headers or {})
61
+ records = raw.records or [{}]
62
+
63
+ if mode is RoutingMode.BULK:
64
+ body_list: list[dict[str, Any]] = []
65
+ for record in records:
66
+ shaped = _shape_body(record, operation)
67
+ assert shaped is not None, "bulk path requires non-None body shape"
68
+ body_list.append(shaped)
69
+ return [
70
+ ResolvedRequest(
71
+ method=operation.http_method,
72
+ url=url,
73
+ headers=visible_headers,
74
+ query={},
75
+ body=body_list,
76
+ path_vars=dict(path_vars),
77
+ operation_id=operation.operation_id,
78
+ record_indices=list(range(len(records))),
79
+ )
80
+ ]
81
+
82
+ out: list[ResolvedRequest] = []
83
+ for index, record in enumerate(records):
84
+ body = _shape_body(record, operation)
85
+ out.append(
86
+ ResolvedRequest(
87
+ method=operation.http_method,
88
+ url=url,
89
+ headers=visible_headers,
90
+ query={},
91
+ body=body,
92
+ path_vars=dict(path_vars),
93
+ operation_id=operation.operation_id,
94
+ record_indices=[index],
95
+ )
96
+ )
97
+ return out
98
+
99
+
100
+ def _shape_body(record: dict[str, Any], operation: Operation) -> dict[str, Any] | None:
101
+ if operation.http_method is HttpMethod.DELETE:
102
+ return None
103
+ if operation.request_body is None:
104
+ return record or None
105
+ cast_record: dict[str, Any] = {}
106
+ for key, value in record.items():
107
+ shape = operation.request_body.fields.get(key)
108
+ cast_record[key] = _cast(value, shape)
109
+ return cast_record
110
+
111
+
112
+ def _cast(value: Any, shape: FieldShape | None) -> Any:
113
+ if shape is None or value is None:
114
+ return value
115
+ match shape.primitive:
116
+ case PrimitiveType.INTEGER:
117
+ return _cast_int(value)
118
+ case PrimitiveType.NUMBER:
119
+ return _cast_number(value)
120
+ case PrimitiveType.BOOLEAN:
121
+ return _cast_bool(value)
122
+ case _:
123
+ return value
124
+
125
+
126
+ def _cast_int(value: Any) -> Any:
127
+ if isinstance(value, bool):
128
+ return value
129
+ if isinstance(value, str):
130
+ try:
131
+ return int(value)
132
+ except ValueError:
133
+ return value
134
+ return value
135
+
136
+
137
+ def _cast_number(value: Any) -> Any:
138
+ if isinstance(value, str):
139
+ try:
140
+ return float(value)
141
+ except ValueError:
142
+ return value
143
+ return value
144
+
145
+
146
+ def _cast_bool(value: Any) -> Any:
147
+ if isinstance(value, bool):
148
+ return value
149
+ if isinstance(value, str):
150
+ lowered = value.strip().lower()
151
+ if lowered in _TRUTHY:
152
+ return True
153
+ if lowered in _FALSY:
154
+ return False
155
+ return value
156
+
157
+
158
+ _OTHER_SENSITIVE_HEADERS = SENSITIVE_HEADERS - {"authorization"}
159
+
160
+
161
+ def _redact(headers: dict[str, str]) -> dict[str, str]:
162
+ out: dict[str, str] = {}
163
+ for k, v in headers.items():
164
+ if k.lower() == "authorization":
165
+ out[k] = _REDACTED_AUTH
166
+ elif k.lower() in _OTHER_SENSITIVE_HEADERS:
167
+ out[k] = "<redacted>"
168
+ else:
169
+ out[k] = v
170
+ return out
171
+
172
+
173
+ CastSource = Literal["file", "field_flag", "default", "schema_cast"]
174
+ """Reserved for ExplainTrace consumers. Defined here so apply.py owns the type."""
175
+
176
+
177
+ __all__ = ["CastSource", "ResolvedRequest", "resolve"]