pipelex-api 0.71.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 (46) hide show
  1. pipelex_api/__init__.py +0 -0
  2. pipelex_api/api.toml +21 -0
  3. pipelex_api/api_config.py +144 -0
  4. pipelex_api/bundle.py +243 -0
  5. pipelex_api/disclosure.py +43 -0
  6. pipelex_api/error_types.py +80 -0
  7. pipelex_api/error_uri.py +45 -0
  8. pipelex_api/errors.py +130 -0
  9. pipelex_api/exception_handlers.py +693 -0
  10. pipelex_api/json_body.py +182 -0
  11. pipelex_api/limits.py +67 -0
  12. pipelex_api/main.py +221 -0
  13. pipelex_api/method_cache.py +241 -0
  14. pipelex_api/method_source.py +215 -0
  15. pipelex_api/middleware.py +209 -0
  16. pipelex_api/openapi_responses.py +186 -0
  17. pipelex_api/openapi_schema.py +83 -0
  18. pipelex_api/problem_document.py +134 -0
  19. pipelex_api/py.typed +0 -0
  20. pipelex_api/routes/__init__.py +23 -0
  21. pipelex_api/routes/health.py +24 -0
  22. pipelex_api/routes/pipelex/__init__.py +21 -0
  23. pipelex_api/routes/pipelex/agent/__init__.py +11 -0
  24. pipelex_api/routes/pipelex/agent/concept.py +60 -0
  25. pipelex_api/routes/pipelex/agent/models.py +49 -0
  26. pipelex_api/routes/pipelex/agent/pipe_spec.py +59 -0
  27. pipelex_api/routes/pipelex/build/__init__.py +11 -0
  28. pipelex_api/routes/pipelex/build/inputs.py +192 -0
  29. pipelex_api/routes/pipelex/build/output.py +163 -0
  30. pipelex_api/routes/pipelex/build/runner.py +236 -0
  31. pipelex_api/routes/pipelex/codegen.py +164 -0
  32. pipelex_api/routes/pipelex/crate_ops.py +331 -0
  33. pipelex_api/routes/pipelex/pipe_io.py +186 -0
  34. pipelex_api/routes/pipelex/pipeline.py +938 -0
  35. pipelex_api/routes/pipelex/resolve.py +81 -0
  36. pipelex_api/routes/pipelex/tools.py +111 -0
  37. pipelex_api/routes/pipelex/utils.py +6 -0
  38. pipelex_api/routes/pipelex/validate.py +473 -0
  39. pipelex_api/routes/version.py +51 -0
  40. pipelex_api/schemas/__init__.py +0 -0
  41. pipelex_api/schemas/models.py +653 -0
  42. pipelex_api/security.py +284 -0
  43. pipelex_api-0.71.0.dist-info/METADATA +188 -0
  44. pipelex_api-0.71.0.dist-info/RECORD +46 -0
  45. pipelex_api-0.71.0.dist-info/WHEEL +4 -0
  46. pipelex_api-0.71.0.dist-info/licenses/LICENSE +95 -0
@@ -0,0 +1,164 @@
1
+ from enum import StrEnum
2
+ from typing import Annotated, Literal, Self, Union
3
+
4
+ from fastapi import APIRouter
5
+ from fastapi.responses import JSONResponse
6
+ from pipelex.codegen.emission import build_stamped_projection
7
+ from pipelex.codegen.emitters.target import CodegenKind, CodegenTarget
8
+ from pipelex.codegen.emitters.types_emitter import emit_types
9
+ from pipelex.codegen.lock import CODEGEN_LOCK_FILENAME
10
+ from pipelex.pipeline.exceptions import ValidateBundleError
11
+ from pipelex.tools.misc.package_utils import get_package_version
12
+ from pipelex.tools.typing.pydantic_utils import empty_list_factory_of
13
+ from pydantic import BaseModel, Field, model_validator
14
+
15
+ from pipelex_api.json_body import JsonBodyRoute
16
+ from pipelex_api.openapi_responses import PROBLEM_404_METHOD_PACKAGE, PROBLEM_501_METHOD_REF
17
+ from pipelex_api.routes.pipelex.crate_ops import (
18
+ CrateInvalidReport,
19
+ GeneratedArtifact,
20
+ invalid_crate_report_response,
21
+ resolve_requested_crate,
22
+ teardown_current_library,
23
+ )
24
+ from pipelex_api.schemas.models import MthdsFilesRequest
25
+
26
+ router = APIRouter(tags=["codegen"], route_class=JsonBodyRoute)
27
+
28
+
29
+ class CodegenRouteKind(StrEnum):
30
+ """The projection kinds this route serves — the `kind` axis of the codegen request.
31
+
32
+ Membership follows the **trust chain**: a kind is served here exactly when its artifacts are
33
+ stamped and locked, so a client can write them verbatim and pass the offline `codegen check` —
34
+ the promise this route's valid arm makes by carrying a `lock`. Input templates are user-editable
35
+ scaffolds, never stamped or locked, so they cannot make that promise; they ride
36
+ `POST /build/inputs` instead and `inputs` is therefore not a kind here. Future per-pipe kinds
37
+ (`docs`, `tools`, `tests`) do emit tracked artifacts, so they join this enum and select their
38
+ pipe via `pipe_ref`. An unknown kind is a request-shape 422 listing the served set.
39
+ """
40
+
41
+ TYPES = "types"
42
+
43
+ @property
44
+ def engine_kind(self) -> CodegenKind:
45
+ """The engine-side `CodegenKind` this route kind projects."""
46
+ match self:
47
+ case CodegenRouteKind.TYPES:
48
+ return CodegenKind.TYPES
49
+
50
+
51
+ class CodegenRequest(MthdsFilesRequest):
52
+ """The codegen request: the shared closure selector plus the two explicit projection axes."""
53
+
54
+ kind: CodegenRouteKind = Field(..., description="What to project. `types` projects the crate's concept set into typed models.")
55
+ target: CodegenTarget = Field(
56
+ ...,
57
+ description=(
58
+ "For whom: `ts-zod` (zod schemas + inferred types), `python-pydantic` (self-contained BaseModels), or "
59
+ "`python-structures` (runtime StructuredContent classes, for a Pipelex host)."
60
+ ),
61
+ )
62
+ pipe_ref: str | None = Field(
63
+ default=None,
64
+ max_length=512,
65
+ description="Pipe selector for per-pipe projection kinds. Not accepted for `types` (a concept-set-wide projection).",
66
+ )
67
+
68
+ @model_validator(mode="after")
69
+ def _pipe_ref_only_for_per_pipe_kinds(self) -> Self:
70
+ # `types` is concept-set-wide: silently ignoring a pipe_ref would mislead the caller into
71
+ # believing the artifacts were narrowed to one pipe. Request-shape error → 422. A future
72
+ # per-pipe kind adds its arm here and accepts the selector. The `return self` is
73
+ # unconditional so a non-raising future arm can never make pydantic take None as the model.
74
+ match self.kind:
75
+ case CodegenRouteKind.TYPES:
76
+ if self.pipe_ref is not None:
77
+ msg = "pipe_ref is not accepted for kind='types' (a concept-set-wide projection)"
78
+ raise ValueError(msg)
79
+ return self
80
+
81
+
82
+ class CodegenValidReport(BaseModel):
83
+ """The 200 **valid** arm: the stamped artifact set plus its lock.
84
+
85
+ A client that writes each artifact and the lock verbatim reproduces a local
86
+ `pipelex codegen types` run byte-for-byte — the same stamps, the same `codegen.lock` — so the
87
+ offline `codegen check` passes on the written tree exactly as it would locally.
88
+ """
89
+
90
+ is_valid: Literal[True] = True
91
+ kind: CodegenRouteKind = Field(..., description="The projected kind (echo of the request).")
92
+ target: CodegenTarget = Field(..., description="The projection target (echo of the request).")
93
+ crate_fingerprint: str = Field(..., description="Fingerprint of the normalized crate the artifacts were generated from.")
94
+ engine_version: str = Field(..., description="The pipelex engine version that generated the artifacts.")
95
+ artifacts: list[GeneratedArtifact] = Field(
96
+ default_factory=empty_list_factory_of(GeneratedArtifact),
97
+ description="The stamped generated files.",
98
+ )
99
+ lock: str = Field(
100
+ ..., description=f"The `{CODEGEN_LOCK_FILENAME}` content (TOML) tracking the artifact set — write verbatim beside the artifacts."
101
+ )
102
+ lock_filename: str = Field(default=CODEGEN_LOCK_FILENAME, description="Filename the lock content must be written as.")
103
+ message: str = Field(default="Codegen artifacts generated successfully", description="Status message")
104
+
105
+
106
+ # Discriminated 200 response union: the `/validate` discipline (see `POST /resolve`).
107
+ CodegenResponse = Annotated[Union[CodegenValidReport, CrateInvalidReport], Field(discriminator="is_valid")]
108
+
109
+
110
+ @router.post(
111
+ "/codegen",
112
+ response_model=CodegenResponse,
113
+ # On top of the composite router's shared 401/413/422/500: the `method_ref` closure selector
114
+ # the envelope accepts but no server-side method registry resolves yet (shared with `/resolve`).
115
+ responses={404: PROBLEM_404_METHOD_PACKAGE, 501: PROBLEM_501_METHOD_REF},
116
+ # NOT tagged `x-mthds-protocol` — a Pipelex API extension, like `/resolve`. The MTHDS standard
117
+ # specifies the crate this reads (the Library Crate Format); it specifies no type projection, so
118
+ # every `target` here — `ts-zod` and `python-pydantic` no less than `python-structures` — is ours.
119
+ )
120
+ async def codegen_mthds(request_data: CodegenRequest) -> JSONResponse:
121
+ """Generate typed artifacts from a library closure (Pipelex API extension).
122
+
123
+ Resolves the closure to its normalized crate (exactly like `POST /resolve`), then projects it
124
+ through the requested `kind`/`target` axes and returns the **stamped** artifact set plus its
125
+ `codegen.lock` — everything a client needs to materialize a byte-identical local projection
126
+ and run the offline drift check. There is deliberately **no** server-side check route: the
127
+ check is offline by design.
128
+
129
+ Response contract (the `/validate` discipline):
130
+
131
+ - **Valid verdict (200, `is_valid: true`):** the artifacts + lock on the valid arm.
132
+ - **Invalid verdict (200, `is_valid: false`):** the library could not be parsed, loaded, or
133
+ validated — `validation_errors[]` from pipelex's one shared builder; no artifacts exist.
134
+ - **No verdict (non-2xx):** an unknown projection `kind`/`target`, a `pipe_ref` on a
135
+ concept-set-wide kind, or a malformed closure selector is a request-shape 422 problem+json;
136
+ `method_ref` is a 501 until server-side method registry resolution exists; auth is 401/403;
137
+ server fault is 5xx.
138
+ """
139
+ try:
140
+ # `types` is concept-set-wide: no pipe is selected, so the manifest's `main_pipe` beside the crate is unused.
141
+ # A future per-pipe kind takes it from the `ResolvedClosure` and hands it to `resolve_requested_pipe`.
142
+ crate = resolve_requested_crate(request_data).crate
143
+ except ValidateBundleError as validate_error:
144
+ return invalid_crate_report_response(validate_error.to_error_report())
145
+ try:
146
+ emitted = emit_types(crate, target=request_data.target)
147
+ projection = build_stamped_projection(
148
+ emitted,
149
+ crate_fingerprint=crate.fingerprint,
150
+ engine_version=get_package_version(),
151
+ kind=request_data.kind.engine_kind,
152
+ target=request_data.target,
153
+ )
154
+ report = CodegenValidReport(
155
+ kind=request_data.kind,
156
+ target=request_data.target,
157
+ crate_fingerprint=crate.fingerprint,
158
+ engine_version=get_package_version(),
159
+ artifacts=[GeneratedArtifact(path=stamped.filename, content=stamped.content) for stamped in projection.files],
160
+ lock=projection.lock_content,
161
+ )
162
+ return JSONResponse(content=report.model_dump(mode="json", by_alias=True))
163
+ finally:
164
+ teardown_current_library()
@@ -0,0 +1,331 @@
1
+ """Shared crate-resolution plumbing for the `/resolve`, `/codegen`, `/pipe-io` and `/build/*` routes.
2
+
3
+ They all select a closure the same way (inline `files[]` XOR `method_ref`), resolve it through the
4
+ same engine core (`pipelex.pipeline.resolve_bundle.resolve_crate_from_contents`), and speak the same
5
+ verdict vocabulary as `POST /validate`: a produced verdict is a 200 discriminated on `is_valid`, with
6
+ the invalid arm carrying the structured `validation_errors[]` built by pipelex's one shared builder.
7
+ This module holds the pieces they share so the envelopes cannot drift.
8
+
9
+ The invalid arm here is the *crate* verdict: it deliberately omits `/validate`'s runnability facts
10
+ (`pending_signatures`, `is_runnable`) — resolution is static (no dry-run sweep, matching
11
+ `pipelex resolve`), so runnability is not part of its vocabulary. The per-pipe projections
12
+ (`/build/{inputs,output}`) ride that same static core: a template is a read of the pipe's *declared*
13
+ IO, so a valid verdict there says the closure is structurally sound, never that the pipe runs.
14
+ `/build/runner` is the exception — it needs the dry-run sweep, so it keeps `validate_bundle`.
15
+ """
16
+
17
+ from enum import StrEnum
18
+ from typing import Literal, NamedTuple, NoReturn
19
+
20
+ from fastapi.responses import JSONResponse
21
+ from pipelex.base_exceptions import ErrorReport, ValidationErrorItem
22
+ from pipelex.cogt.inference.error_classification import UserAction, UserActionKind
23
+ from pipelex.interpreter_hub import clear_current_library, get_current_library_id_or_none, get_library_manager, get_required_entry_pipe
24
+ from pipelex.libraries.library_crate import LibraryCrate
25
+ from pipelex.libraries.pipe.exceptions import EntryPipeAmbiguousError, EntryPipeNotFoundError, PipeNotFoundError
26
+ from pipelex.methods.method_ref import looks_like_method_ref
27
+ from pipelex.pipe_machinery.pipe_abstract import PipeAbstract
28
+ from pipelex.pipeline.resolve_bundle import resolve_crate_from_contents
29
+ from pipelex.tools.typing.pydantic_utils import empty_list_factory_of
30
+ from pydantic import BaseModel, Field
31
+
32
+ from pipelex_api.error_types import ErrorType
33
+ from pipelex_api.errors import raise_not_implemented
34
+ from pipelex_api.method_source import fetch_method_mthds_files
35
+ from pipelex_api.schemas.models import MthdsFileItem, MthdsFilesRequest
36
+
37
+
38
+ class GeneratedArtifact(BaseModel):
39
+ """One generated file: its path relative to the client's chosen output root, and its full content."""
40
+
41
+ path: str = Field(..., description="Artifact path, relative to the output root the client writes into.")
42
+ content: str = Field(..., description="Complete file content, stamp header included — write verbatim.")
43
+
44
+
45
+ class CrateInvalidReport(BaseModel):
46
+ """The 200 **invalid** arm shared by `/resolve` and `/codegen` — the crate-verdict vocabulary.
47
+
48
+ Same discipline as `/validate`'s `InvalidReport`: an invalid library is the *successful
49
+ product* of a diagnostic call (the request was well-formed; the library was not), so it rides
50
+ a 200 discriminated on `is_valid`, carrying the same structured `ValidationErrorItem`s the
51
+ local CLI and `/validate` emit for the identical failure.
52
+ """
53
+
54
+ is_valid: Literal[False] = False
55
+ """Discriminant of the invalid arm (mirrors the valid arms' `Literal[True]`)."""
56
+
57
+ validation_errors: list[ValidationErrorItem] = Field(
58
+ default_factory=empty_list_factory_of(ValidationErrorItem),
59
+ description="Per-error diagnostics, built by pipelex's one shared builder — non-empty on every invalid verdict.",
60
+ )
61
+ message: str = Field(default="MTHDS library could not be resolved", description="Human-readable summary of the verdict.")
62
+
63
+
64
+ def invalid_crate_report_response(error_report: ErrorReport) -> JSONResponse:
65
+ """Render a produced "could not resolve" verdict as a 200 `CrateInvalidReport`.
66
+
67
+ `exclude_none` drops each item's unset locators so the wire items match the agent CLI's
68
+ byte-for-byte — the "one error item, two surfaces" guarantee `/validate` already keeps.
69
+ """
70
+ invalid_report = CrateInvalidReport(
71
+ validation_errors=error_report.validation_errors or [],
72
+ message=error_report.message,
73
+ )
74
+ return JSONResponse(content=invalid_report.model_dump(mode="json", serialize_as_any=True, by_alias=True, exclude_none=True))
75
+
76
+
77
+ class SelectedFiles(NamedTuple):
78
+ """What the closure selector resolved to: the files, plus the fetched manifest's entry pipe when there is one."""
79
+
80
+ files: list[MthdsFileItem]
81
+ """The `.mthds` files forming the closure — inline `files[]` verbatim, or the fetched package's."""
82
+
83
+ manifest_main_pipe: str | None
84
+ """The fetched package manifest's `main_pipe` (a bare pipe code). Always None for inline `files[]`, which carry no manifest."""
85
+
86
+
87
+ def selected_files(request_data: MthdsFilesRequest) -> SelectedFiles:
88
+ """The files the closure selector names: inline `files[]`, or an address `method_ref`'s package.
89
+
90
+ Shared by every route on the `files[]` envelope — including `/build/runner`, which cannot use
91
+ `resolve_requested_crate` (it needs `validate_bundle`'s dry-run sweep) but owes the caller the
92
+ same answer on every selector.
93
+
94
+ An **address-form** `method_ref` (`github.com/<owner>/<repo>[/<selector>][@<tag>]`) resolves
95
+ through the same fetch path the run routes use: the package's `.mthds` files come back as
96
+ `files[]` items with their real relative paths as per-file sources, and the manifest's
97
+ `main_pipe` rides beside them so the per-pipe projections can default their selector the way
98
+ a run by address does. Only `.mthds` data travels — the package's Python (if any) never loads
99
+ on these routes. The **registry form** (any non-address reference) stays reserved and keeps
100
+ its 501 until the packaging program's registry phase.
101
+
102
+ Raises:
103
+ ApiError: 501 for a registry-form `method_ref`; 422 for a fetched package this server
104
+ cannot accept.
105
+ MethodRefError subclasses: parse, fetch, location, and bounds failures on the address
106
+ form, rendered as `problem+json` by the global handler.
107
+ """
108
+ if request_data.method_ref is not None:
109
+ if looks_like_method_ref(request_data.method_ref):
110
+ fetched = fetch_method_mthds_files(request_data.method_ref)
111
+ return SelectedFiles(files=fetched.files, manifest_main_pipe=fetched.main_pipe)
112
+ raise_not_implemented(
113
+ "Registry-form method_ref resolution is not available on this server: no method registry is wired. "
114
+ "Use an address-form reference (a `github.com/...` address, e.g. `github.com/Pipelex/methods/documents@v0.1.0`) "
115
+ "or submit inline `files[]`.",
116
+ error_type=ErrorType.METHOD_REF_NOT_SUPPORTED,
117
+ )
118
+ return SelectedFiles(files=request_data.files or [], manifest_main_pipe=None)
119
+
120
+
121
+ class ResolvedClosure(NamedTuple):
122
+ """A resolved closure: its normalized crate, the files it was resolved from, and the fetched manifest's entry pipe."""
123
+
124
+ crate: LibraryCrate
125
+ """The normalized library crate the closure resolved to."""
126
+
127
+ files: list[MthdsFileItem]
128
+ """The `.mthds` files the crate was resolved from, carried through from `selected_files` so no route fetches twice."""
129
+
130
+ manifest_main_pipe: str | None
131
+ """The fetched package manifest's `main_pipe`, carried through from `selected_files` (None for inline `files[]`)."""
132
+
133
+
134
+ def resolve_requested_crate(request_data: MthdsFilesRequest) -> ResolvedClosure:
135
+ """Resolve the request's closure selector into a normalized library crate.
136
+
137
+ Inherits the engine core's **loaded-on-success contract**: on success the freshly opened
138
+ library is loaded and current (so a route can read live pipes from it) and the route owns its
139
+ teardown — call `teardown_current_library()` in a `finally`. On failure the core has already
140
+ torn down and restored.
141
+
142
+ The fetched manifest's `main_pipe` (when the selector was a `method_ref`) rides beside the
143
+ crate so a per-pipe route can hand it to `resolve_requested_pipe` — the crate itself only
144
+ knows the domains' own `main_pipe` declarations. The selected files ride beside it too, so a
145
+ route that echoes the closure (`/pipe-io` with `include_files`) reads them here rather than
146
+ calling `selected_files` again, which would fetch a `method_ref` a second time.
147
+
148
+ Raises:
149
+ ValidateBundleError: the produced negative verdict (route maps it to the 200 invalid arm).
150
+ ApiError: 501 for a registry-form `method_ref` (see `selected_files`).
151
+ MethodRefError subclasses: address-form fetch/location failures (see `selected_files`).
152
+ """
153
+ selection = selected_files(request_data)
154
+ crate = resolve_crate_from_contents(
155
+ mthds_contents=[item.content for item in selection.files],
156
+ mthds_sources=[item.source for item in selection.files],
157
+ )
158
+ return ResolvedClosure(crate=crate, files=selection.files, manifest_main_pipe=selection.manifest_main_pipe)
159
+
160
+
161
+ class RequestedPipe(NamedTuple):
162
+ """The pipe a per-pipe projection was asked for: its resolved qualified ref and the live pipe."""
163
+
164
+ ref: str
165
+ """The qualified `domain.pipe_code` actually projected — always qualified, whatever the request spelled."""
166
+
167
+ pipe: PipeAbstract
168
+ """The live pipe, read from the library `resolve_requested_crate` left loaded + current."""
169
+
170
+
171
+ class PipeSelectionMissKind(StrEnum):
172
+ """Why a selection chain link selected no pipe: the two failures the engine's entry lookup names."""
173
+
174
+ NOT_FOUND = "not_found"
175
+ AMBIGUOUS = "ambiguous"
176
+
177
+
178
+ class PipeSelectionMiss(NamedTuple):
179
+ """A link of the pipe selection chain that selected no pipe, with the refusal a selection answers."""
180
+
181
+ kind: PipeSelectionMissKind
182
+ """Which entry-lookup failure this is, which picks the pipelex error class the refusal is raised as."""
183
+
184
+ message: str
185
+ """The detail of the `422` a selection that depends on this link raises."""
186
+
187
+ user_action: UserAction | None = None
188
+ """The fix to name instead of the error class's own, when the class's is wrong for this miss."""
189
+
190
+
191
+ class _SelectorOrigin(StrEnum):
192
+ """Where a pipe selector came from, which is what its refusal must tell the caller."""
193
+
194
+ REQUEST = "request"
195
+ MANIFEST = "manifest"
196
+ CLOSURE = "closure"
197
+
198
+
199
+ def resolve_requested_pipe(crate: LibraryCrate, *, pipe_ref: str | None, manifest_main_pipe: str | None) -> RequestedPipe:
200
+ """Select the pipe a per-pipe projection targets, defaulting to the manifest's, then the closure's, `main_pipe`.
201
+
202
+ The request's `pipe_ref` wins; omitted, the default chain (`select_default_pipe`) decides. Every
203
+ failed selection — an unknown ref, an ambiguous one, a chain that finds no entry pipe or several —
204
+ is an input-domain 422 rather than an invalid-crate verdict, since nothing about the *closure* is
205
+ wrong in any of them. It is raised as the engine's own entry-lookup error class (see
206
+ `_raise_selection_refusal`), so an unknown or ambiguous ref carries the `error_type` the run routes
207
+ answer for the same `pipe_code`. A chain finding no entry pipe or several has no run-route twin to
208
+ match: this route refuses both the way the pipe-selector design classifies them.
209
+
210
+ Must be called while the library `resolve_requested_crate` opened is still loaded + current.
211
+ """
212
+ if pipe_ref is not None:
213
+ selected = _select_entry_pipe(pipe_ref, origin=_SelectorOrigin.REQUEST)
214
+ else:
215
+ selected = select_default_pipe(crate, manifest_main_pipe=manifest_main_pipe)
216
+ if isinstance(selected, PipeSelectionMiss):
217
+ _raise_selection_refusal(selected)
218
+ return selected
219
+
220
+
221
+ def _raise_selection_refusal(miss: PipeSelectionMiss) -> NoReturn:
222
+ """Raise a selection miss as the pipelex entry-lookup error class that names its failure.
223
+
224
+ A selection refusal must not share `ValidationError` with a malformed request, or a client has no
225
+ field to tell the two apart. So it is raised as `EntryPipeNotFoundError` or
226
+ `EntryPipeAmbiguousError`, the classes the engine's entry lookup raises and the run routes answer
227
+ with for an unknown or ambiguous `pipe_code`. The global `PipelexError` handler then renders it —
228
+ the class name as `error_type`, the INPUT domain as a 422, the class's `user_action` — exactly as
229
+ it renders the run routes' refusal. Both classes declare their messages caller-facing, which every
230
+ message built here honours: it names the caller's selector, the manifest's `main_pipe`, or the
231
+ qualified refs of the closure the caller submitted, and nothing from a host library.
232
+
233
+ The no-entry-pipe miss is a not-found and the several-`main_pipe`s miss an ambiguity, the way the
234
+ pipe-selector design classifies them. Neither is a typo in a pipe code, so each carries its own
235
+ `user_action`, which replaces the class's code-typo advice for that one error.
236
+ """
237
+ match miss.kind:
238
+ case PipeSelectionMissKind.NOT_FOUND:
239
+ raise EntryPipeNotFoundError(miss.message).as_caller_fault(user_action=miss.user_action)
240
+ case PipeSelectionMissKind.AMBIGUOUS:
241
+ raise EntryPipeAmbiguousError(miss.message).as_caller_fault(user_action=miss.user_action)
242
+
243
+
244
+ def select_default_pipe(crate: LibraryCrate, *, manifest_main_pipe: str | None) -> RequestedPipe | PipeSelectionMiss:
245
+ """The selection chain without the request's `pipe_ref`: the method's own entry pipe, or why there is none.
246
+
247
+ The precedence is the run routes' (`pipeline.py`: `pipe_code or fetched.main_pipe`): a fetched
248
+ package's manifest `main_pipe` is the package author's declared entry pipe and is taken first;
249
+ only without one does the closure's own declaration decide. That last step mirrors `pipelex
250
+ codegen inputs` (`inputs_cmd.py::_default_main_pipe_ref`): the single declared `main_pipe`, with
251
+ **both** un-defaultable arms missed — a closure declaring none, and one declaring several across
252
+ domains. The chain stops at the first link present, so a manifest `main_pipe` the closure does
253
+ not declare, or declares in several domains, is a miss rather than a fall-through to the
254
+ closure's declarations. Inline `files[]` carry no manifest, so for them the chain is exactly the
255
+ closure-declared default.
256
+
257
+ It returns the miss instead of raising, so the one chain serves both `resolve_requested_pipe`
258
+ (which refuses with the miss's message) and `/pipe-io`'s `default_pipe_ref` (which states `null`),
259
+ and the selection and the reported default cannot drift apart. It is deliberately NOT
260
+ `/validate`'s `default_pipe_ref`, which is the run default and names the first of several
261
+ declaring domains where this chain refuses to choose.
262
+
263
+ Must be called while the library `resolve_requested_crate` opened is still loaded + current.
264
+ """
265
+ if manifest_main_pipe:
266
+ return _select_entry_pipe(manifest_main_pipe, origin=_SelectorOrigin.MANIFEST)
267
+ candidates = [f"{domain_code}.{domain.main_pipe}" for domain_code, domain in crate.domains.items() if domain.main_pipe]
268
+ if not candidates:
269
+ return PipeSelectionMiss(
270
+ kind=PipeSelectionMissKind.NOT_FOUND,
271
+ message="No `pipe_ref` was given and the closure declares no `main_pipe` — name the pipe explicitly.",
272
+ user_action=UserAction(
273
+ kind=UserActionKind.CHANGE_INPUT,
274
+ detail="Send a `pipe_ref` naming the pipe to select, since the closure declares no `main_pipe` to default to.",
275
+ ),
276
+ )
277
+ if len(candidates) > 1:
278
+ joined = ", ".join(sorted(candidates))
279
+ return PipeSelectionMiss(
280
+ kind=PipeSelectionMissKind.AMBIGUOUS,
281
+ message=f"No `pipe_ref` was given and the closure declares several `main_pipe`s ({joined}) — name the pipe explicitly.",
282
+ user_action=UserAction(
283
+ kind=UserActionKind.CHANGE_INPUT,
284
+ detail="Send a `pipe_ref` naming one of the declared `main_pipe`s.",
285
+ ),
286
+ )
287
+ return _select_entry_pipe(candidates[0], origin=_SelectorOrigin.CLOSURE)
288
+
289
+
290
+ def _select_entry_pipe(selector: str, *, origin: _SelectorOrigin) -> RequestedPipe | PipeSelectionMiss:
291
+ """Look one selector up through the engine's entry lookup, the way every selection chain link does.
292
+
293
+ The returned `ref` is read back off the **resolved pipe**, never echoed from the selector: the
294
+ engine's entry lookup accepts a bare code too (falling back across domains), so a caller that
295
+ submits `"echo"` must still be told `"smoke.echo"` — the valid arms promise a qualified ref, and
296
+ echoing the request back would quietly break that promise for exactly the callers who leaned on
297
+ the fallback. A manifest `main_pipe` is always a bare code, so it rides that same fallback.
298
+
299
+ A bare code declared in several domains resolves to no pipe rather than to a first match, and
300
+ the miss says so — it names the candidates the engine lists — instead of calling the pipe
301
+ missing, which would contradict the candidates in the same sentence.
302
+ """
303
+ match origin:
304
+ case _SelectorOrigin.REQUEST:
305
+ described = f"Pipe '{selector}'"
306
+ scope = "the submitted closure"
307
+ case _SelectorOrigin.MANIFEST:
308
+ # The caller never spelled this selector — say where it came from, or the 422 names a pipe out of nowhere.
309
+ described = f"Pipe '{selector}' (the fetched package manifest's `main_pipe`)"
310
+ scope = "the package's closure"
311
+ case _SelectorOrigin.CLOSURE:
312
+ described = f"Pipe '{selector}' (the closure's declared `main_pipe`)"
313
+ scope = "the submitted closure"
314
+ try:
315
+ the_pipe = get_required_entry_pipe(pipe_code=selector)
316
+ except EntryPipeAmbiguousError as exc:
317
+ return PipeSelectionMiss(
318
+ kind=PipeSelectionMissKind.AMBIGUOUS,
319
+ message=f"{described} matches pipes in several domains of {scope}, so it selects none: {exc}",
320
+ )
321
+ except PipeNotFoundError as exc:
322
+ return PipeSelectionMiss(kind=PipeSelectionMissKind.NOT_FOUND, message=f"{described} not found in {scope}: {exc}")
323
+ return RequestedPipe(ref=the_pipe.pipe_ref, pipe=the_pipe)
324
+
325
+
326
+ def teardown_current_library() -> None:
327
+ """Tear down the library `resolve_requested_crate` left loaded + current (success-path cleanup)."""
328
+ library_id = get_current_library_id_or_none()
329
+ clear_current_library()
330
+ if library_id is not None:
331
+ get_library_manager().teardown(library_id=library_id)