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,11 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
|
|
3
|
+
from .inputs import router as inputs_router
|
|
4
|
+
from .output import router as output_router
|
|
5
|
+
from .runner import router as runner_router
|
|
6
|
+
|
|
7
|
+
router = APIRouter()
|
|
8
|
+
|
|
9
|
+
router.include_router(inputs_router)
|
|
10
|
+
router.include_router(output_router)
|
|
11
|
+
router.include_router(runner_router)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Annotated, Any, Literal, Self, Union
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter
|
|
5
|
+
from fastapi.responses import JSONResponse
|
|
6
|
+
from pipelex.core.pipes.inputs.exceptions import NoInputsRequiredError
|
|
7
|
+
from pipelex.pipe_machinery.rendering.input_renderer import InputsTemplateFormat, render_inputs, render_inputs_toml
|
|
8
|
+
from pipelex.pipeline.exceptions import ValidateBundleError
|
|
9
|
+
from pydantic import BaseModel, Field, model_validator
|
|
10
|
+
|
|
11
|
+
from pipelex_api.json_body import JsonBodyRoute
|
|
12
|
+
from pipelex_api.openapi_responses import PROBLEM_404_METHOD_PACKAGE, PROBLEM_501_METHOD_REF
|
|
13
|
+
from pipelex_api.routes.pipelex.crate_ops import (
|
|
14
|
+
CrateInvalidReport,
|
|
15
|
+
RequestedPipe,
|
|
16
|
+
invalid_crate_report_response,
|
|
17
|
+
resolve_requested_crate,
|
|
18
|
+
resolve_requested_pipe,
|
|
19
|
+
teardown_current_library,
|
|
20
|
+
)
|
|
21
|
+
from pipelex_api.schemas.models import MthdsPipeRequest
|
|
22
|
+
|
|
23
|
+
router = APIRouter(tags=["build"], route_class=JsonBodyRoute)
|
|
24
|
+
|
|
25
|
+
INPUTS_GENERATED_MESSAGE = "Inputs template generated successfully"
|
|
26
|
+
NO_INPUTS_MESSAGE = "This pipe declares no inputs — the template is empty."
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BuildInputsRequest(MthdsPipeRequest):
|
|
30
|
+
"""The inputs-template request: the shared closure + pipe selectors, plus the two rendering axes.
|
|
31
|
+
|
|
32
|
+
Both axes mirror `pipelex codegen inputs` exactly — `--format` and `--explicit` — and every
|
|
33
|
+
combination of them is served, as on the CLI.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
format: InputsTemplateFormat = Field(
|
|
37
|
+
default=InputsTemplateFormat.JSON,
|
|
38
|
+
description="Template encoding. `json` returns the parsed template in `inputs`; `toml` returns the raw text in `inputs_toml`.",
|
|
39
|
+
)
|
|
40
|
+
explicit: bool = Field(
|
|
41
|
+
default=False,
|
|
42
|
+
description=(
|
|
43
|
+
"When true, emit the ceremonial `{concept, content}` envelope for every input. Defaults to false — the light, "
|
|
44
|
+
"signature-driven shape that smart inputs accepts (a bare string for a Text input, and so on)."
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BuildInputsValidReport(BaseModel):
|
|
50
|
+
"""The 200 **valid** arm: the example inputs template for the requested pipe.
|
|
51
|
+
|
|
52
|
+
The template rides **one of two fields, chosen by `format`** — `inputs` (parsed object) for
|
|
53
|
+
`json`, `inputs_toml` (raw text) for `toml`. TOML cannot be carried as a parsed object without
|
|
54
|
+
losing what makes it worth asking for (its concept comments and key order), and the JSON case
|
|
55
|
+
must stay a real object, since that is what the deploy dialog and the SDKs consume. So the two
|
|
56
|
+
are separate, honestly-typed fields, and the unused one is omitted from the body entirely.
|
|
57
|
+
|
|
58
|
+
A pipe that declares no inputs is a *valid* verdict, not an error (the CLI likewise exits 0):
|
|
59
|
+
the template is simply empty, and `message` says so.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
is_valid: Literal[True] = True
|
|
63
|
+
pipe_ref: str = Field(..., description="The qualified pipe the template was generated for — the resolved selector.")
|
|
64
|
+
requested_pipe_ref: str | None = Field(
|
|
65
|
+
default=None,
|
|
66
|
+
description=(
|
|
67
|
+
"The `pipe_ref` as submitted. Absent when it was omitted and defaulted — to the fetched package manifest's "
|
|
68
|
+
"`main_pipe` on a `method_ref` request, else to the closure's declared `main_pipe`."
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
format: InputsTemplateFormat = Field(..., description="The template encoding (echo of the request).")
|
|
72
|
+
explicit: bool = Field(..., description="Whether the ceremonial envelope shape was emitted (echo of the request).")
|
|
73
|
+
inputs: dict[str, Any] | None = Field(
|
|
74
|
+
default=None,
|
|
75
|
+
description="The parsed inputs template — present exactly when `format` is `json`.",
|
|
76
|
+
)
|
|
77
|
+
inputs_toml: str | None = Field(
|
|
78
|
+
default=None,
|
|
79
|
+
description="The inputs template as TOML text — present exactly when `format` is `toml`.",
|
|
80
|
+
)
|
|
81
|
+
message: str = Field(default=INPUTS_GENERATED_MESSAGE, description="Status message")
|
|
82
|
+
|
|
83
|
+
@model_validator(mode="after")
|
|
84
|
+
def _template_field_matches_format(self) -> Self:
|
|
85
|
+
# The invariant the two-field shape exists to keep: the caller can read the field its own
|
|
86
|
+
# `format` names, without probing. A route bug that set the other one would be a silent
|
|
87
|
+
# contract break, so it fails loudly here instead.
|
|
88
|
+
match self.format:
|
|
89
|
+
case InputsTemplateFormat.JSON:
|
|
90
|
+
if self.inputs is None or self.inputs_toml is not None:
|
|
91
|
+
msg = "format='json' must carry `inputs` and no `inputs_toml`"
|
|
92
|
+
raise ValueError(msg)
|
|
93
|
+
case InputsTemplateFormat.TOML:
|
|
94
|
+
if self.inputs_toml is None or self.inputs is not None:
|
|
95
|
+
msg = "format='toml' must carry `inputs_toml` and no `inputs`"
|
|
96
|
+
raise ValueError(msg)
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Discriminated 200 response union: the `/validate` discipline — the verdict rides `is_valid`, never
|
|
101
|
+
# the HTTP status.
|
|
102
|
+
BuildInputsResponse = Annotated[Union[BuildInputsValidReport, CrateInvalidReport], Field(discriminator="is_valid")]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _render_report(*, requested: BuildInputsRequest, requested_pipe: RequestedPipe) -> BuildInputsValidReport:
|
|
106
|
+
"""Render the requested pipe's declared inputs into the valid arm, on the `format` axis.
|
|
107
|
+
|
|
108
|
+
A pipe with no inputs raises `NoInputsRequiredError` out of the engine renderers. That is not a
|
|
109
|
+
failure — it is the honest answer "run this with nothing" — so it lands on the valid arm as an
|
|
110
|
+
empty template, mirroring the CLI's exit-0.
|
|
111
|
+
"""
|
|
112
|
+
inputs: dict[str, Any] | None = None
|
|
113
|
+
inputs_toml: str | None = None
|
|
114
|
+
message: str = INPUTS_GENERATED_MESSAGE
|
|
115
|
+
match requested.format:
|
|
116
|
+
case InputsTemplateFormat.JSON:
|
|
117
|
+
try:
|
|
118
|
+
inputs = json.loads(render_inputs(requested_pipe.pipe, explicit=requested.explicit))
|
|
119
|
+
except NoInputsRequiredError:
|
|
120
|
+
inputs = {}
|
|
121
|
+
message = NO_INPUTS_MESSAGE
|
|
122
|
+
case InputsTemplateFormat.TOML:
|
|
123
|
+
try:
|
|
124
|
+
inputs_toml = render_inputs_toml(requested_pipe.pipe, explicit=requested.explicit)
|
|
125
|
+
except NoInputsRequiredError:
|
|
126
|
+
inputs_toml = ""
|
|
127
|
+
message = NO_INPUTS_MESSAGE
|
|
128
|
+
return BuildInputsValidReport(
|
|
129
|
+
pipe_ref=requested_pipe.ref,
|
|
130
|
+
requested_pipe_ref=requested.pipe_ref,
|
|
131
|
+
format=requested.format,
|
|
132
|
+
explicit=requested.explicit,
|
|
133
|
+
inputs=inputs,
|
|
134
|
+
inputs_toml=inputs_toml,
|
|
135
|
+
message=message,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@router.post(
|
|
140
|
+
"/build/inputs",
|
|
141
|
+
response_model=BuildInputsResponse,
|
|
142
|
+
# On top of the composite router's shared 401/413/422/500: the `method_ref` closure selector the
|
|
143
|
+
# envelope accepts but no server-side method registry resolves yet (shared with /resolve, /codegen).
|
|
144
|
+
responses={404: PROBLEM_404_METHOD_PACKAGE, 501: PROBLEM_501_METHOD_REF},
|
|
145
|
+
)
|
|
146
|
+
async def build_inputs(request_data: BuildInputsRequest) -> JSONResponse:
|
|
147
|
+
"""Generate an example inputs template for a pipe (the inputs projection, per pipe).
|
|
148
|
+
|
|
149
|
+
Rides the **same static core** as `POST /resolve` and `POST /codegen`: the closure is resolved to
|
|
150
|
+
its normalized crate, the requested pipe is read live from the library that leaves loaded, and its
|
|
151
|
+
*declared* inputs are rendered — the exact projection `pipelex codegen inputs` writes, on both of
|
|
152
|
+
its axes (`format`, `explicit`).
|
|
153
|
+
|
|
154
|
+
Static is the point: a template is a read of the pipe's declared IO, so there is **no dry-run
|
|
155
|
+
sweep** here (that is `/validate`'s vocabulary, and `/build/runner`'s need). A valid verdict says
|
|
156
|
+
the closure is structurally sound and the template matches what the pipe declares — it is *not* a
|
|
157
|
+
promise the pipe runs. Ask `/validate` for that.
|
|
158
|
+
|
|
159
|
+
This projection is deliberately **not** a `/codegen` kind: the templates are user-editable
|
|
160
|
+
scaffolds, never stamped or locked, so they cannot ride the trust chain `/codegen`'s valid arm
|
|
161
|
+
promises (see `CodegenRouteKind`).
|
|
162
|
+
|
|
163
|
+
Response contract (the `/validate` discipline):
|
|
164
|
+
|
|
165
|
+
- **Valid verdict (200, `is_valid: true`):** the template, in `inputs` or `inputs_toml` per `format`.
|
|
166
|
+
- **Invalid verdict (200, `is_valid: false`):** the closure could not be parsed, loaded, or
|
|
167
|
+
validated — `validation_errors[]` from pipelex's one shared builder; no template exists.
|
|
168
|
+
- **No verdict (non-2xx):** a selection refusal — an unknown pipe ref, or an omitted `pipe_ref` that
|
|
169
|
+
nothing defaults (no fetched-manifest `main_pipe`, and a closure declaring no — or several —
|
|
170
|
+
`main_pipe`) — is an input 422 whose `error_type` is `EntryPipeNotFoundError` or
|
|
171
|
+
`EntryPipeAmbiguousError`; a malformed closure selector is a request-shape 422 problem+json; a
|
|
172
|
+
registry-form `method_ref` is a 501 until server-side method-registry resolution exists.
|
|
173
|
+
|
|
174
|
+
An omitted `pipe_ref` defaults the way a run by address does: to the fetched package manifest's
|
|
175
|
+
`main_pipe` on a `method_ref` request, else to the closure's own declared `main_pipe`.
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
resolved = resolve_requested_crate(request_data)
|
|
179
|
+
except ValidateBundleError as validate_error:
|
|
180
|
+
return invalid_crate_report_response(validate_error.to_error_report())
|
|
181
|
+
try:
|
|
182
|
+
requested_pipe = resolve_requested_pipe(
|
|
183
|
+
resolved.crate,
|
|
184
|
+
pipe_ref=request_data.pipe_ref,
|
|
185
|
+
manifest_main_pipe=resolved.manifest_main_pipe,
|
|
186
|
+
)
|
|
187
|
+
report = _render_report(requested=request_data, requested_pipe=requested_pipe)
|
|
188
|
+
# exclude_none drops the template field the `format` did not select (and an absent
|
|
189
|
+
# `requested_pipe_ref`), so exactly the fields the caller's own request implies are present.
|
|
190
|
+
return JSONResponse(content=report.model_dump(mode="json", by_alias=True, exclude_none=True))
|
|
191
|
+
finally:
|
|
192
|
+
teardown_current_library()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Annotated, Any, Literal, Self, Union
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter
|
|
5
|
+
from fastapi.responses import JSONResponse
|
|
6
|
+
from pipelex.core.concepts.concept_representation_generator import ConceptRepresentationFormat
|
|
7
|
+
from pipelex.pipe_machinery.rendering.output_renderer import render_output
|
|
8
|
+
from pipelex.pipeline.exceptions import ValidateBundleError
|
|
9
|
+
from pydantic import BaseModel, Field, model_validator
|
|
10
|
+
|
|
11
|
+
from pipelex_api.errors import raise_validation_error
|
|
12
|
+
from pipelex_api.json_body import JsonBodyRoute
|
|
13
|
+
from pipelex_api.openapi_responses import PROBLEM_404_METHOD_PACKAGE, PROBLEM_501_METHOD_REF
|
|
14
|
+
from pipelex_api.routes.pipelex.crate_ops import (
|
|
15
|
+
CrateInvalidReport,
|
|
16
|
+
RequestedPipe,
|
|
17
|
+
invalid_crate_report_response,
|
|
18
|
+
resolve_requested_crate,
|
|
19
|
+
resolve_requested_pipe,
|
|
20
|
+
teardown_current_library,
|
|
21
|
+
)
|
|
22
|
+
from pipelex_api.schemas.models import MthdsPipeRequest
|
|
23
|
+
|
|
24
|
+
router = APIRouter(tags=["build"], route_class=JsonBodyRoute)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BuildOutputRequest(MthdsPipeRequest):
|
|
28
|
+
"""The output-representation request: the shared closure + pipe selectors, plus the format axis."""
|
|
29
|
+
|
|
30
|
+
format: ConceptRepresentationFormat = Field(
|
|
31
|
+
default=ConceptRepresentationFormat.SCHEMA,
|
|
32
|
+
description=(
|
|
33
|
+
"Representation to render. `schema` (JSON Schema) and `json` (example value) return a parsed object in "
|
|
34
|
+
"`output`; `python` returns Python source in `output_python`."
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BuildOutputValidReport(BaseModel):
|
|
40
|
+
"""The 200 **valid** arm: the example output representation for the requested pipe.
|
|
41
|
+
|
|
42
|
+
The representation rides **one of two fields, chosen by `format`**, for the same reason
|
|
43
|
+
`/build/inputs` splits `inputs` / `inputs_toml`: `schema` and `json` are objects, `python` is
|
|
44
|
+
source text. (Before this split the route parsed *every* format as JSON, so `format=python` was a
|
|
45
|
+
hard 500 — it fed Python source to `json.loads`.)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
is_valid: Literal[True] = True
|
|
49
|
+
pipe_ref: str = Field(..., description="The qualified pipe the representation was generated for — the resolved selector.")
|
|
50
|
+
requested_pipe_ref: str | None = Field(
|
|
51
|
+
default=None,
|
|
52
|
+
description=(
|
|
53
|
+
"The `pipe_ref` as submitted. Absent when it was omitted and defaulted — to the fetched package manifest's "
|
|
54
|
+
"`main_pipe` on a `method_ref` request, else to the closure's declared `main_pipe`."
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
format: ConceptRepresentationFormat = Field(..., description="The representation format (echo of the request).")
|
|
58
|
+
output: dict[str, Any] | None = Field(
|
|
59
|
+
default=None,
|
|
60
|
+
description="The parsed output representation — present exactly when `format` is `schema` or `json`.",
|
|
61
|
+
)
|
|
62
|
+
output_python: str | None = Field(
|
|
63
|
+
default=None,
|
|
64
|
+
description="The output representation as Python source — present exactly when `format` is `python`.",
|
|
65
|
+
)
|
|
66
|
+
message: str = Field(default="Output representation generated successfully", description="Status message")
|
|
67
|
+
|
|
68
|
+
@model_validator(mode="after")
|
|
69
|
+
def _representation_field_matches_format(self) -> Self:
|
|
70
|
+
# Same invariant as `/build/inputs`: the caller reads the field its own `format` names.
|
|
71
|
+
match self.format:
|
|
72
|
+
case ConceptRepresentationFormat.SCHEMA | ConceptRepresentationFormat.JSON:
|
|
73
|
+
if self.output is None or self.output_python is not None:
|
|
74
|
+
msg = "format='schema'/'json' must carry `output` and no `output_python`"
|
|
75
|
+
raise ValueError(msg)
|
|
76
|
+
case ConceptRepresentationFormat.PYTHON:
|
|
77
|
+
if self.output_python is None or self.output is not None:
|
|
78
|
+
msg = "format='python' must carry `output_python` and no `output`"
|
|
79
|
+
raise ValueError(msg)
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Discriminated 200 response union: the `/validate` discipline — the verdict rides `is_valid`, never
|
|
84
|
+
# the HTTP status.
|
|
85
|
+
BuildOutputResponse = Annotated[Union[BuildOutputValidReport, CrateInvalidReport], Field(discriminator="is_valid")]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _render_report(*, requested: BuildOutputRequest, requested_pipe: RequestedPipe) -> BuildOutputValidReport:
|
|
89
|
+
"""Render the requested pipe's declared output into the valid arm, on the `format` axis.
|
|
90
|
+
|
|
91
|
+
`render_output` raises a bare `ValueError` (documented in its docstring) when the pipe's output is
|
|
92
|
+
`native.Anything` and no concrete option can be determined — a fact about the *requested pipe*,
|
|
93
|
+
not about the closure, so it is a no-verdict 422 rather than an invalid-crate verdict.
|
|
94
|
+
"""
|
|
95
|
+
output: dict[str, Any] | None = None
|
|
96
|
+
output_python: str | None = None
|
|
97
|
+
try:
|
|
98
|
+
rendered = render_output(requested_pipe.pipe, output_format=requested.format)
|
|
99
|
+
except ValueError as exc:
|
|
100
|
+
raise_validation_error(f"Cannot render the output representation of pipe '{requested_pipe.ref}': {exc}")
|
|
101
|
+
match requested.format:
|
|
102
|
+
case ConceptRepresentationFormat.SCHEMA | ConceptRepresentationFormat.JSON:
|
|
103
|
+
output = json.loads(rendered)
|
|
104
|
+
case ConceptRepresentationFormat.PYTHON:
|
|
105
|
+
output_python = rendered
|
|
106
|
+
return BuildOutputValidReport(
|
|
107
|
+
pipe_ref=requested_pipe.ref,
|
|
108
|
+
requested_pipe_ref=requested.pipe_ref,
|
|
109
|
+
format=requested.format,
|
|
110
|
+
output=output,
|
|
111
|
+
output_python=output_python,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@router.post(
|
|
116
|
+
"/build/output",
|
|
117
|
+
response_model=BuildOutputResponse,
|
|
118
|
+
# On top of the composite router's shared 401/413/422/500: the `method_ref` closure selector the
|
|
119
|
+
# envelope accepts but no server-side method registry resolves yet (shared with /resolve, /codegen).
|
|
120
|
+
responses={404: PROBLEM_404_METHOD_PACKAGE, 501: PROBLEM_501_METHOD_REF},
|
|
121
|
+
)
|
|
122
|
+
async def build_output(request_data: BuildOutputRequest) -> JSONResponse:
|
|
123
|
+
"""Generate an example output representation for a pipe (the output projection, per pipe).
|
|
124
|
+
|
|
125
|
+
Rides the **same static core** as `POST /resolve`, `POST /codegen` and `POST /build/inputs`: the
|
|
126
|
+
closure is resolved to its normalized crate, the requested pipe is read live from the library that
|
|
127
|
+
leaves loaded, and its *declared* output is rendered.
|
|
128
|
+
|
|
129
|
+
Static is the point: the representation is a read of the pipe's declared IO, so there is **no
|
|
130
|
+
dry-run sweep** here. A valid verdict says the closure is structurally sound and the shape matches
|
|
131
|
+
what the pipe declares — it is *not* a promise the pipe runs. Ask `/validate` for that.
|
|
132
|
+
|
|
133
|
+
Response contract (the `/validate` discipline):
|
|
134
|
+
|
|
135
|
+
- **Valid verdict (200, `is_valid: true`):** the representation, in `output` or `output_python` per `format`.
|
|
136
|
+
- **Invalid verdict (200, `is_valid: false`):** the closure could not be parsed, loaded, or
|
|
137
|
+
validated — `validation_errors[]`; no representation exists.
|
|
138
|
+
- **No verdict (non-2xx):** a selection refusal — an unknown pipe ref, or an omitted `pipe_ref` that
|
|
139
|
+
nothing defaults (no fetched-manifest `main_pipe`, and a closure declaring no — or several —
|
|
140
|
+
`main_pipe`) — is an input 422 whose `error_type` is `EntryPipeNotFoundError` or
|
|
141
|
+
`EntryPipeAmbiguousError`; a pipe whose `native.Anything` output has no determinable shape, or a
|
|
142
|
+
malformed closure selector, is a request-shape 422 problem+json; a registry-form `method_ref` is a 501 until server-side
|
|
143
|
+
method-registry resolution exists.
|
|
144
|
+
|
|
145
|
+
An omitted `pipe_ref` defaults the way a run by address does: to the fetched package manifest's
|
|
146
|
+
`main_pipe` on a `method_ref` request, else to the closure's own declared `main_pipe`.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
resolved = resolve_requested_crate(request_data)
|
|
150
|
+
except ValidateBundleError as validate_error:
|
|
151
|
+
return invalid_crate_report_response(validate_error.to_error_report())
|
|
152
|
+
try:
|
|
153
|
+
requested_pipe = resolve_requested_pipe(
|
|
154
|
+
resolved.crate,
|
|
155
|
+
pipe_ref=request_data.pipe_ref,
|
|
156
|
+
manifest_main_pipe=resolved.manifest_main_pipe,
|
|
157
|
+
)
|
|
158
|
+
report = _render_report(requested=request_data, requested_pipe=requested_pipe)
|
|
159
|
+
# exclude_none drops the representation field the `format` did not select (and an absent
|
|
160
|
+
# `requested_pipe_ref`), so exactly the fields the caller's own request implies are present.
|
|
161
|
+
return JSONResponse(content=report.model_dump(mode="json", by_alias=True, exclude_none=True))
|
|
162
|
+
finally:
|
|
163
|
+
teardown_current_library()
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from typing import Annotated, Literal, Union
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Request
|
|
4
|
+
from fastapi.responses import JSONResponse
|
|
5
|
+
from mthds.package.manifest.schema import MTHDS_STANDARD_VERSION
|
|
6
|
+
from pipelex.base_exceptions import PipelexUnexpectedError
|
|
7
|
+
from pipelex.builder.runner_code import generate_runner_code
|
|
8
|
+
from pipelex.codegen.emission import build_stamped_projection
|
|
9
|
+
from pipelex.codegen.emitters.naming import runtime_to_emitted_class_names
|
|
10
|
+
from pipelex.codegen.emitters.target import CodegenKind, CodegenTarget
|
|
11
|
+
from pipelex.codegen.emitters.types_emitter import emit_types
|
|
12
|
+
from pipelex.codegen.lock import CODEGEN_LOCK_FILENAME
|
|
13
|
+
from pipelex.codegen.resolved_concepts import resolve_concepts_from_crate
|
|
14
|
+
from pipelex.core.pipes.variable_multiplicity import parse_concept_with_multiplicity
|
|
15
|
+
from pipelex.interpreter_hub import get_current_library_id_or_none, get_library_manager
|
|
16
|
+
from pipelex.libraries.crate_normalization import normalize_crate
|
|
17
|
+
from pipelex.libraries.pipe.exceptions import EntryPipeNotFoundError, PipeNotFoundError
|
|
18
|
+
from pipelex.mthds_parsing.pipelex_bundle_blueprint import PipelexBundleBlueprint
|
|
19
|
+
from pipelex.pipeline.bundle_validator import DryRunOutput, DryRunStatus
|
|
20
|
+
from pipelex.pipeline.exceptions import ValidateBundleError
|
|
21
|
+
from pipelex.pipeline.validate_bundle import validate_bundle
|
|
22
|
+
from pipelex.system.caller_identity import CallerIdentity
|
|
23
|
+
from pipelex.tools.misc.package_utils import get_package_version
|
|
24
|
+
from pipelex.tools.typing.pydantic_utils import empty_list_factory_of
|
|
25
|
+
from pydantic import BaseModel, Field
|
|
26
|
+
|
|
27
|
+
from pipelex_api.errors import raise_validation_error
|
|
28
|
+
from pipelex_api.json_body import JsonBodyRoute
|
|
29
|
+
from pipelex_api.openapi_responses import PROBLEM_404_METHOD_PACKAGE, PROBLEM_501_METHOD_REF
|
|
30
|
+
from pipelex_api.routes.pipelex.crate_ops import (
|
|
31
|
+
CrateInvalidReport,
|
|
32
|
+
GeneratedArtifact,
|
|
33
|
+
invalid_crate_report_response,
|
|
34
|
+
resolve_requested_pipe,
|
|
35
|
+
selected_files,
|
|
36
|
+
teardown_current_library,
|
|
37
|
+
)
|
|
38
|
+
from pipelex_api.routes.pipelex.pipeline import get_request_user_id
|
|
39
|
+
from pipelex_api.schemas.models import ALLOW_SIGNATURES_DESCRIPTION, CallerAnalyticsGroupsMixin, MthdsPipeRequest
|
|
40
|
+
|
|
41
|
+
router = APIRouter(tags=["build"], route_class=JsonBodyRoute)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BuildRunnerRequest(MthdsPipeRequest, CallerAnalyticsGroupsMixin):
|
|
45
|
+
"""The runner-script request: the shared closure + pipe selectors, plus the sweep's `allow_signatures`.
|
|
46
|
+
|
|
47
|
+
Alone among the `/build/*` projections this route keeps `allow_signatures`, because alone among
|
|
48
|
+
them it still runs the dry-run sweep — the flag only ever parameterized that sweep.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
allow_signatures: bool = Field(default=False, description=ALLOW_SIGNATURES_DESCRIPTION)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RunnerStructures(BaseModel):
|
|
55
|
+
"""The typed-structures projection the runner script imports from (`from structures.structures import ...`).
|
|
56
|
+
|
|
57
|
+
The same stamped `python-structures` artifacts + `codegen.lock` a local `pipelex build runner`
|
|
58
|
+
scaffolds into `<output>/structures/` — write `artifacts` and the lock under `directory` and
|
|
59
|
+
the returned `python_code` runs against them, with the offline `codegen check` passing there.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
directory: str = Field(default="structures", description="Directory (relative to the runner script) to write the artifacts and lock into.")
|
|
63
|
+
artifacts: list[GeneratedArtifact] = Field(
|
|
64
|
+
default_factory=empty_list_factory_of(GeneratedArtifact),
|
|
65
|
+
description="The stamped generated files (paths relative to `directory`).",
|
|
66
|
+
)
|
|
67
|
+
lock: str = Field(..., description="The lock content (TOML) tracking the artifact set — write verbatim inside `directory`.")
|
|
68
|
+
lock_filename: str = Field(default=CODEGEN_LOCK_FILENAME, description="Filename the lock content must be written as.")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BuildRunnerValidReport(BaseModel):
|
|
72
|
+
"""The 200 **valid** arm: the runner script plus the structures projection it imports from."""
|
|
73
|
+
|
|
74
|
+
is_valid: Literal[True] = True
|
|
75
|
+
pipe_ref: str = Field(..., description="The qualified pipe the runner was generated for — the resolved selector.")
|
|
76
|
+
requested_pipe_ref: str | None = Field(
|
|
77
|
+
default=None,
|
|
78
|
+
description=(
|
|
79
|
+
"The `pipe_ref` as submitted. Absent when it was omitted and defaulted — to the fetched package manifest's "
|
|
80
|
+
"`main_pipe` on a `method_ref` request, else to the closure's declared `main_pipe`."
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
python_code: str = Field(..., description="Generated Python script for running the pipeline, imports spelled with the emitted class names.")
|
|
84
|
+
structures: RunnerStructures = Field(..., description="The typed-structures projection the script imports from.")
|
|
85
|
+
message: str = Field(default="Runner code generated successfully", description="Status message")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Discriminated 200 response union: the `/validate` discipline — the verdict rides `is_valid`,
|
|
89
|
+
# never the HTTP status (breaking change from the previous `success`-bool body + 422-on-invalid).
|
|
90
|
+
BuildRunnerResponse = Annotated[Union[BuildRunnerValidReport, CrateInvalidReport], Field(discriminator="is_valid")]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _reject_if_requested_pipe_skipped(sweep_result: dict[str, DryRunOutput], *, pipe_ref: str) -> None:
|
|
94
|
+
"""Reject when the *requested* pipe was SKIPPED (its cross-package dependency is unresolved).
|
|
95
|
+
|
|
96
|
+
`validate_pipes` records a pipe SKIPPED — rather than failing the whole sweep — when a controller
|
|
97
|
+
references a sub-pipe in a package not included in the request (the cross-package tolerance shared
|
|
98
|
+
with `/validate`). For a code-generation endpoint that is a footgun: `generate_runner_code` reads
|
|
99
|
+
only the pipe's own inputs/output and never resolves sub-pipes, so it would happily emit runner
|
|
100
|
+
code for a pipeline that cannot actually run. A SKIPPED requested pipe is a no-verdict condition
|
|
101
|
+
(its dependency closure is absent from the request, so nothing could be diagnosed) → request-shape
|
|
102
|
+
422; unrelated SKIPPED pipes elsewhere in the bundle stay tolerated.
|
|
103
|
+
"""
|
|
104
|
+
for output in sweep_result.values():
|
|
105
|
+
if pipe_ref != output.pipe_ref:
|
|
106
|
+
continue
|
|
107
|
+
match output.status:
|
|
108
|
+
case DryRunStatus.SKIPPED:
|
|
109
|
+
detail = output.error_message or "its dependencies could not be resolved"
|
|
110
|
+
raise_validation_error(f"Cannot generate runner code for pipe '{pipe_ref}': {detail}")
|
|
111
|
+
case DryRunStatus.SUCCESS | DryRunStatus.FAILURE:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _output_is_list(blueprints: list[PipelexBundleBlueprint], *, pipe_ref: str) -> bool:
|
|
116
|
+
"""Whether the requested pipe's declared output carries a list multiplicity marker (mirrors the CLI).
|
|
117
|
+
|
|
118
|
+
Matched on the **qualified** ref. A blueprint's `pipe` map is keyed by bare code, so scanning by
|
|
119
|
+
bare code alone would return the first blueprint declaring that code — which in a multi-domain
|
|
120
|
+
closure can be a *different* pipe that merely shares the name, and whose multiplicity may differ.
|
|
121
|
+
The runner would then be generated with the wrong output handling. Compare the owning domain too.
|
|
122
|
+
"""
|
|
123
|
+
domain_code, _, pipe_code = pipe_ref.rpartition(".")
|
|
124
|
+
for blueprint in blueprints:
|
|
125
|
+
if blueprint.domain != domain_code or not blueprint.pipe or pipe_code not in blueprint.pipe:
|
|
126
|
+
continue
|
|
127
|
+
output_parse = parse_concept_with_multiplicity(blueprint.pipe[pipe_code].output)
|
|
128
|
+
return output_parse.multiplicity is not None
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@router.post(
|
|
133
|
+
"/build/runner",
|
|
134
|
+
response_model=BuildRunnerResponse,
|
|
135
|
+
# On top of the composite router's shared 401/413/422/500: the `method_ref` closure selector the
|
|
136
|
+
# envelope accepts but no server-side method registry resolves yet (shared with /resolve, /codegen).
|
|
137
|
+
responses={404: PROBLEM_404_METHOD_PACKAGE, 501: PROBLEM_501_METHOD_REF},
|
|
138
|
+
)
|
|
139
|
+
async def build_runner(request: Request, request_data: BuildRunnerRequest) -> JSONResponse:
|
|
140
|
+
"""Generate a Python runner script for a pipe, riding the codegen types projection.
|
|
141
|
+
|
|
142
|
+
The one `/build/*` projection that is **not** static: a runner script is a promise the pipe can
|
|
143
|
+
actually run, so this route keeps the dry-run sweep its siblings dropped (and with it
|
|
144
|
+
`allow_signatures`, which only ever parameterized that sweep). `validate_bundle` opens a single
|
|
145
|
+
library, loads the closure, sweeps, and on success leaves the library loaded + current; on failure
|
|
146
|
+
it tears it down itself. On the success path the crate is read from that library, the
|
|
147
|
+
`python-structures` projection is emitted and stamped, and the runner script is generated with the
|
|
148
|
+
**emitted** class names — the same flow as a local `pipelex build runner`.
|
|
149
|
+
|
|
150
|
+
The sweep is scoped to the requested pipe, so unrelated broken siblings do not block a good pipe.
|
|
151
|
+
When `pipe_ref` is omitted the scope is not settled before the closure loads, so the whole closure
|
|
152
|
+
is swept and the pipe then defaults the way a run by address does — to the fetched package
|
|
153
|
+
manifest's `main_pipe` on a `method_ref` request, else to the closure's own declared `main_pipe` —
|
|
154
|
+
a stricter verdict, and the honest one for a caller who did not say which pipe they meant.
|
|
155
|
+
|
|
156
|
+
Response contract (the `/validate` discipline): an invalid closure — including a failed dry-run of
|
|
157
|
+
the requested pipe — is a produced verdict: a **200** `is_valid: false` with the structured
|
|
158
|
+
`validation_errors[]`. Non-2xx is reserved for no-verdict conditions: a selection refusal (an
|
|
159
|
+
unknown pipe ref, an omitted `pipe_ref` that nothing defaults — no fetched-manifest `main_pipe`,
|
|
160
|
+
and a closure declaring no, or several, `main_pipe`), an input 422 whose `error_type` is
|
|
161
|
+
`EntryPipeNotFoundError` or `EntryPipeAmbiguousError`; a request-shape 422 for a requested pipe
|
|
162
|
+
whose cross-package dependencies are absent from the request; a 501 for a registry-form `method_ref`, auth, server
|
|
163
|
+
fault — RFC 7807 via the global handlers.
|
|
164
|
+
"""
|
|
165
|
+
library_manager = get_library_manager()
|
|
166
|
+
selection = selected_files(request_data)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
# If this raises, validate_bundle has already torn down its own library — nothing to clean up here.
|
|
170
|
+
validate_result = await validate_bundle(
|
|
171
|
+
mthds_contents=[item.content for item in selection.files],
|
|
172
|
+
mthds_sources=[item.source for item in selection.files],
|
|
173
|
+
allow_signatures=request_data.allow_signatures,
|
|
174
|
+
dry_run_pipe_codes=[request_data.pipe_ref] if request_data.pipe_ref else None,
|
|
175
|
+
# The sweep is done for the caller, so its dry runs and `pipe_dry_run` event name them.
|
|
176
|
+
caller_identity=CallerIdentity.make_from_host(user_id=get_request_user_id(request), extras=request_data.analytics_groups),
|
|
177
|
+
)
|
|
178
|
+
except ValidateBundleError as validate_error:
|
|
179
|
+
return invalid_crate_report_response(validate_error.to_error_report())
|
|
180
|
+
except PipeNotFoundError as exc:
|
|
181
|
+
# The engine deliberately lets this one through untranslated (see `translate_to_validate_bundle_error`)
|
|
182
|
+
# so the caller can own it: a pipe ref naming nothing in the closure is an input 422, not an
|
|
183
|
+
# invalid-closure verdict — nothing about the closure is wrong. It is re-raised as the entry-lookup
|
|
184
|
+
# class the slice miss already is, so its `error_type` matches `resolve_requested_pipe`'s refusals.
|
|
185
|
+
msg = f"Pipe '{request_data.pipe_ref}' not found in the submitted closure: {exc}"
|
|
186
|
+
raise EntryPipeNotFoundError(msg) from exc
|
|
187
|
+
|
|
188
|
+
# Success: validate_bundle left its library loaded + current. Build everything from it, then own its teardown.
|
|
189
|
+
library_id = get_current_library_id_or_none()
|
|
190
|
+
try:
|
|
191
|
+
crate = library_manager.get_crate(library_id) if library_id else None
|
|
192
|
+
if crate is None:
|
|
193
|
+
# Unreachable after a successful in-memory validate (the blueprints were accumulated),
|
|
194
|
+
# so a None crate is an internal invariant break — a server fault (5xx), never a
|
|
195
|
+
# caller-facing verdict (mirrors resolve_crate_from_contents's identical guard).
|
|
196
|
+
msg = "library crate unavailable after a successful bundle load"
|
|
197
|
+
raise PipelexUnexpectedError(msg)
|
|
198
|
+
normalized_crate = normalize_crate(crate, mthds_version=MTHDS_STANDARD_VERSION)
|
|
199
|
+
requested_pipe = resolve_requested_pipe(
|
|
200
|
+
normalized_crate,
|
|
201
|
+
pipe_ref=request_data.pipe_ref,
|
|
202
|
+
manifest_main_pipe=selection.manifest_main_pipe,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# The sweep tolerates a cross-package unresolved dependency by recording the pipe SKIPPED
|
|
206
|
+
# instead of failing. Don't hand back runner code for the *requested* pipe in that state.
|
|
207
|
+
_reject_if_requested_pipe_skipped(validate_result.dry_run_result, pipe_ref=requested_pipe.ref)
|
|
208
|
+
|
|
209
|
+
emitted = emit_types(normalized_crate, target=CodegenTarget.PYTHON_STRUCTURES)
|
|
210
|
+
projection = build_stamped_projection(
|
|
211
|
+
emitted,
|
|
212
|
+
crate_fingerprint=normalized_crate.fingerprint,
|
|
213
|
+
engine_version=get_package_version(),
|
|
214
|
+
kind=CodegenKind.TYPES,
|
|
215
|
+
target=CodegenTarget.PYTHON_STRUCTURES,
|
|
216
|
+
)
|
|
217
|
+
class_name_overrides = runtime_to_emitted_class_names(resolve_concepts_from_crate(normalized_crate))
|
|
218
|
+
|
|
219
|
+
python_code = generate_runner_code(
|
|
220
|
+
pipe=requested_pipe.pipe,
|
|
221
|
+
output_multiplicity=_output_is_list(validate_result.blueprints, pipe_ref=requested_pipe.ref),
|
|
222
|
+
class_name_overrides=class_name_overrides,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
report = BuildRunnerValidReport(
|
|
226
|
+
pipe_ref=requested_pipe.ref,
|
|
227
|
+
requested_pipe_ref=request_data.pipe_ref,
|
|
228
|
+
python_code=python_code,
|
|
229
|
+
structures=RunnerStructures(
|
|
230
|
+
artifacts=[GeneratedArtifact(path=stamped.filename, content=stamped.content) for stamped in projection.files],
|
|
231
|
+
lock=projection.lock_content,
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
return JSONResponse(content=report.model_dump(mode="json", by_alias=True, exclude_none=True))
|
|
235
|
+
finally:
|
|
236
|
+
teardown_current_library()
|