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,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()