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.
- pipelex_api/__init__.py +0 -0
- pipelex_api/api.toml +21 -0
- pipelex_api/api_config.py +144 -0
- pipelex_api/bundle.py +243 -0
- pipelex_api/disclosure.py +43 -0
- pipelex_api/error_types.py +80 -0
- pipelex_api/error_uri.py +45 -0
- pipelex_api/errors.py +130 -0
- pipelex_api/exception_handlers.py +693 -0
- pipelex_api/json_body.py +182 -0
- pipelex_api/limits.py +67 -0
- pipelex_api/main.py +221 -0
- pipelex_api/method_cache.py +241 -0
- pipelex_api/method_source.py +215 -0
- pipelex_api/middleware.py +209 -0
- pipelex_api/openapi_responses.py +186 -0
- pipelex_api/openapi_schema.py +83 -0
- pipelex_api/problem_document.py +134 -0
- pipelex_api/py.typed +0 -0
- pipelex_api/routes/__init__.py +23 -0
- pipelex_api/routes/health.py +24 -0
- pipelex_api/routes/pipelex/__init__.py +21 -0
- pipelex_api/routes/pipelex/agent/__init__.py +11 -0
- pipelex_api/routes/pipelex/agent/concept.py +60 -0
- pipelex_api/routes/pipelex/agent/models.py +49 -0
- pipelex_api/routes/pipelex/agent/pipe_spec.py +59 -0
- pipelex_api/routes/pipelex/build/__init__.py +11 -0
- pipelex_api/routes/pipelex/build/inputs.py +192 -0
- pipelex_api/routes/pipelex/build/output.py +163 -0
- pipelex_api/routes/pipelex/build/runner.py +236 -0
- pipelex_api/routes/pipelex/codegen.py +164 -0
- pipelex_api/routes/pipelex/crate_ops.py +331 -0
- pipelex_api/routes/pipelex/pipe_io.py +186 -0
- pipelex_api/routes/pipelex/pipeline.py +938 -0
- pipelex_api/routes/pipelex/resolve.py +81 -0
- pipelex_api/routes/pipelex/tools.py +111 -0
- pipelex_api/routes/pipelex/utils.py +6 -0
- pipelex_api/routes/pipelex/validate.py +473 -0
- pipelex_api/routes/version.py +51 -0
- pipelex_api/schemas/__init__.py +0 -0
- pipelex_api/schemas/models.py +653 -0
- pipelex_api/security.py +284 -0
- pipelex_api-0.71.0.dist-info/METADATA +188 -0
- pipelex_api-0.71.0.dist-info/RECORD +46 -0
- pipelex_api-0.71.0.dist-info/WHEEL +4 -0
- 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)
|