pipelex-api 0.88.0__tar.gz → 0.89.0__tar.gz

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 (50) hide show
  1. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/PKG-INFO +2 -2
  2. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/error_types.py +3 -0
  3. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/method_source.py +16 -92
  4. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/method_tree_request.py +12 -1
  5. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/openapi_responses.py +18 -3
  6. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/blobs.py +4 -1
  7. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/codegen.py +32 -30
  8. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/crate_ops.py +117 -95
  9. pipelex_api-0.89.0/pipelex_api/routes/pipelex/method_selection.py +85 -0
  10. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/pipe_io.py +98 -66
  11. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/pipeline.py +45 -14
  12. pipelex_api-0.89.0/pipelex_api/routes/pipelex/resolve.py +79 -0
  13. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/validate.py +28 -53
  14. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/schemas/models.py +56 -63
  15. pipelex_api-0.88.0/pipelex_api/routes/pipelex/resolve.py +0 -81
  16. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/.gitignore +0 -0
  17. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/LICENSE +0 -0
  18. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/README.md +0 -0
  19. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/hatch_build.py +0 -0
  20. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/__init__.py +0 -0
  21. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/api.toml +0 -0
  22. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/api_config.py +0 -0
  23. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/blob_store.py +0 -0
  24. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/blob_upload.py +0 -0
  25. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/bundle.py +0 -0
  26. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/bundle_resources.py +0 -0
  27. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/disclosure.py +0 -0
  28. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/error_uri.py +0 -0
  29. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/errors.py +0 -0
  30. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/exception_handlers.py +0 -0
  31. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/json_body.py +0 -0
  32. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/limits.py +0 -0
  33. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/main.py +0 -0
  34. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/method_cache.py +0 -0
  35. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/middleware.py +0 -0
  36. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/openapi_schema.py +0 -0
  37. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/problem_document.py +0 -0
  38. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/py.typed +0 -0
  39. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/routes/__init__.py +0 -0
  40. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/routes/blob_upload.py +0 -0
  41. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/routes/health.py +0 -0
  42. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/__init__.py +0 -0
  43. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/agent/__init__.py +0 -0
  44. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/agent/models.py +0 -0
  45. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/tools.py +0 -0
  46. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/utils.py +0 -0
  47. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/routes/version.py +0 -0
  48. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/schemas/__init__.py +0 -0
  49. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pipelex_api/security.py +0 -0
  50. {pipelex_api-0.88.0 → pipelex_api-0.89.0}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pipelex-api
3
- Version: 0.88.0
3
+ Version: 0.89.0
4
4
  Summary: Pipelex API
5
5
  Project-URL: Homepage, https://pipelex.com
6
6
  Project-URL: Repository, https://github.com/Pipelex/pipelex
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Programming Language :: Python :: 3.14
17
17
  Requires-Python: <3.15,>=3.11
18
18
  Requires-Dist: fastapi<0.142,>=0.118.0
19
- Requires-Dist: pipelex[anthropic,bedrock,fal,google,google-genai,mistralai]==0.88.0
19
+ Requires-Dist: pipelex[anthropic,bedrock,fal,google,google-genai,mistralai]==0.89.0
20
20
  Requires-Dist: pyjwt>=2.10.1
21
21
  Requires-Dist: uvicorn>=0.37.0
22
22
  Provides-Extra: dev
@@ -48,6 +48,9 @@ class ErrorType(StrEnum):
48
48
  # A run request's `read_scope` extra is not one to three path-safe segments, or the run's storage
49
49
  # scope does not lie under it, so the run could not read back what it writes.
50
50
  INVALID_READ_SCOPE = "InvalidReadScope"
51
+ # A caller authenticated by its own token (`AUTH_MODE=jwt`) sent a `read_scope` that does not lie under its own id.
52
+ # A 403: the scope is well formed, but this caller may not read under it.
53
+ READ_SCOPE_FORBIDDEN = "ReadScopeForbidden"
51
54
  # A run request's `analytics_groups` extra is not a mapping of group type to group key within
52
55
  # the runtime's charset and entry cap (`pipelex.system.run_extras`).
53
56
  INVALID_ANALYTICS_GROUPS = "InvalidAnalyticsGroups"
@@ -19,16 +19,10 @@ The packages a package ships under its own `.mthds/methods/<name>/`, the layout
19
19
  its tree (a dot directory), and each becomes a dependency tree of its own, keyed by the address its manifest declares,
20
20
  as a dependency a request sends in `dependencies` is.
21
21
 
22
- The routes use it two ways:
23
-
24
- - The run routes and `/validate` use :func:`fetched_method`: the tree and its dependency trees, read in full, which the
25
- routes write out and load as a request's `files` are (`pipelex_api.method_tree_request`), so a fetched package and a
26
- sent method run the same path, the files a step reads, such as a `PipeDocGen` template, included.
27
- - The tooling routes (`/resolve`, `/codegen`, `/pipe-io`) use :func:`fetch_method_mthds_files`: the tree's `.mthds`
28
- files, as `files[]` items labelled with their tree paths, paired with the manifest's `main_pipe` so the per-pipe route
29
- defaults its selector as a run does. It reads only what the membership rule needs to tell which `.mthds` files are
30
- the tree's, never the shipped packages, so it neither stages nor hashes the rest of the package. No Python ever loads
31
- there, so the execution-locus gate does not apply.
22
+ Every route taking a `method_ref` — the run routes, `/validate`, and the crate routes `/resolve`, `/codegen` and
23
+ `/pipe-io` — uses :func:`fetched_method`: the tree and its dependency trees, read in full, which the routes write out
24
+ and load as a request's `files` are (`pipelex_api.method_tree_request`), so a fetched package and a sent method run the
25
+ same path, the files a step reads, such as a `PipeDocGen` template, included.
32
26
 
33
27
  The security gate (packaging invariant 7 — execution locus decides) is the request tree's
34
28
  (`pipelex_api.method_tree_request.ensure_python_allowed`): on a deployment that is not sandbox-hosted, a package
@@ -44,21 +38,17 @@ import os
44
38
  import shutil
45
39
  import subprocess
46
40
  import tempfile
47
- from collections.abc import Callable, Generator
41
+ from collections.abc import Generator
48
42
  from contextlib import contextmanager
49
43
  from pathlib import Path
50
44
  from typing import NamedTuple
51
45
 
52
- from mthds.method_tree.discovery import BUNDLE_SUFFIX, GITIGNORE_NAME, MANIFEST_NAME
53
46
  from pipelex import log
54
47
  from pipelex.libraries.method_trees import list_package_method_tree
55
48
  from pipelex.methods.exceptions import MethodFetchError
56
49
  from pipelex.methods.fetching import FetchedMethodPackage, MethodProvenance
57
50
  from pipelex.methods.method_ref import parse_method_ref
58
51
 
59
- from pipelex_api.error_types import ErrorType
60
- from pipelex_api.errors import raise_validation_error
61
- from pipelex_api.limits import MAX_MTHDS_FILE_BYTES
62
52
  from pipelex_api.method_cache import get_method_clone_cache
63
53
  from pipelex_api.method_tree_request import (
64
54
  RequestMethod,
@@ -69,7 +59,6 @@ from pipelex_api.method_tree_request import (
69
59
  ensure_tree_within_limits,
70
60
  is_package_dir_name,
71
61
  )
72
- from pipelex_api.schemas.models import MthdsFileItem
73
62
 
74
63
  GIT_TIMEOUT_SECONDS = 60
75
64
  # The git modes of a tree entry this reader treats apart from a file: an executable file, a symbolic link, and a submodule's commit.
@@ -78,9 +67,6 @@ _GIT_MODE_SYMLINK = "120000"
78
67
  _GIT_MODE_SUBMODULE = "160000"
79
68
  # Where a package ships the method packages it calls, the layout of the local method cache.
80
69
  _VENDORED_METHODS_DIR = Path(".mthds") / "methods"
81
- # The files the membership rule reads to judge an entry beside the entry's own name: the patterns of a `.gitignore` file
82
- # and of the manifest, a manifest marking a nested package, and a `pyvenv.cfg` marking a virtual environment.
83
- _MEMBERSHIP_RULE_NAMES = frozenset({GITIGNORE_NAME, MANIFEST_NAME, "pyvenv.cfg"})
84
70
 
85
71
 
86
72
  class FetchedMethod(NamedTuple):
@@ -96,16 +82,6 @@ class FetchedMethod(NamedTuple):
96
82
  return f"'{self.package.full_address}'"
97
83
 
98
84
 
99
- class FetchedMthdsFiles(NamedTuple):
100
- """A fetched package shaped for the tooling path: its `.mthds` files plus the manifest's entry pipe."""
101
-
102
- files: list[MthdsFileItem]
103
- """The package's `.mthds` files as `files[]` items, each labelled with its tree path."""
104
-
105
- main_pipe: str | None
106
- """The manifest's declared `main_pipe` (a bare pipe code), or None when the manifest declares none."""
107
-
108
-
109
85
  class _GitTreeEntry(NamedTuple):
110
86
  mode: str
111
87
  object_sha: str
@@ -200,18 +176,13 @@ def _read_git_objects(*, package: FetchedMethodPackage, object_shas: list[str])
200
176
  return contents
201
177
 
202
178
 
203
- def _stage_package(*, package: FetchedMethodPackage, staging_dir: Path, keeps: Callable[[str], bool] | None = None) -> None:
179
+ def _stage_package(*, package: FetchedMethodPackage, staging_dir: Path) -> None:
204
180
  """Write the package's directory as its commit stores it into `staging_dir`, from the clone's git objects.
205
181
 
206
182
  A symbolic link is written as one, for the tree's lister to refuse unless the membership rule excludes it, and a
207
183
  submodule contributes no file.
208
-
209
- Args:
210
- package: The fetched package.
211
- staging_dir: The directory to write it into.
212
- keeps: Which paths, relative to the package's directory, to write; every path when None.
213
184
  """
214
- entries = [entry for entry in _package_tree_entries(package=package) if keeps is None or keeps(entry.path)]
185
+ entries = _package_tree_entries(package=package)
215
186
  blobs = _read_git_objects(package=package, object_shas=[entry.object_sha for entry in entries if entry.mode != _GIT_MODE_SUBMODULE])
216
187
  for entry in entries:
217
188
  if entry.mode == _GIT_MODE_SUBMODULE:
@@ -241,7 +212,15 @@ def _read_listed_tree(*, root: Path, package_address: str, label: str) -> Reques
241
212
 
242
213
 
243
214
  def _read_package_method(*, package: FetchedMethodPackage) -> RequestMethod:
244
- """The package's tree and the dependency trees it ships, read from the commit's git objects."""
215
+ """The package's tree and the dependency trees it ships, read from the commit's git objects.
216
+
217
+ The staging directory is read into memory and removed here, and a route loading the method writes the tree out a
218
+ second time (`written_method`), rather than loading the staged directory in place. The staged copy is the commit
219
+ as git holds it: files the membership rule leaves out of the tree, symlinks, and entries under `.mthds/methods/`
220
+ that are no package all stay in it, so loading it would load more than the tree. The tree is needed in memory
221
+ anyway, for the Python policy, the limits, the `include_files` echo and the blob store, and every method a route
222
+ loads, sent or fetched, is then written by the one `written_method`, which owns its teardown.
223
+ """
245
224
  staging_dir = Path(tempfile.mkdtemp(prefix="pipelex-package-"))
246
225
  try:
247
226
  package_root = staging_dir / "package"
@@ -292,27 +271,6 @@ def _fetched_package(method_ref: str) -> Generator[FetchedMethodPackage, None, N
292
271
  yield package
293
272
 
294
273
 
295
- def _keeps_for_bundle_listing(path: str) -> bool:
296
- """Whether a package's file is one the tooling read writes: a `.mthds` file, or one the membership rule reads."""
297
- name = path.rsplit("/", 1)[-1]
298
- return path.endswith(BUNDLE_SUFFIX) or name in _MEMBERSHIP_RULE_NAMES
299
-
300
-
301
- def _read_package_bundles(*, package: FetchedMethodPackage) -> dict[str, bytes]:
302
- """The `.mthds` files of the package's tree by tree path, read without staging or hashing the rest of the package.
303
-
304
- Only the `.mthds` files and the files the membership rule reads are written out, so the lister keeps exactly the
305
- `.mthds` files the whole tree would keep. The packages the package ships stay out, being under a dot directory.
306
- """
307
- staging_dir = Path(tempfile.mkdtemp(prefix="pipelex-package-bundles-"))
308
- try:
309
- _stage_package(package=package, staging_dir=staging_dir, keeps=_keeps_for_bundle_listing)
310
- tree = list_package_method_tree(root=staging_dir, package_address=package.full_address)
311
- return {tree_path: tree.file_location(tree_path).read_bytes() for tree_path in tree.tree_paths if tree_path.endswith(BUNDLE_SUFFIX)}
312
- finally:
313
- shutil.rmtree(staging_dir, ignore_errors=True)
314
-
315
-
316
274
  def fetched_method(method_ref: str) -> FetchedMethod:
317
275
  """Fetch a `method_ref`'s package and read it as a method tree, with the dependency trees it ships.
318
276
 
@@ -329,37 +287,3 @@ def fetched_method(method_ref: str) -> FetchedMethod:
329
287
  request_method = _read_package_method(package=package)
330
288
  provenance = package.provenance.model_copy(update={"tree_digest": request_method.tree.tree.digest})
331
289
  return FetchedMethod(request_method=request_method, package=package, provenance=provenance)
332
-
333
-
334
- def fetch_method_mthds_files(method_ref: str) -> FetchedMthdsFiles:
335
- """Fetch a `method_ref`'s package and return its tree's `.mthds` files as `files[]` items, plus its `main_pipe`.
336
-
337
- The tooling-route shape: each item pairs the file's content with its tree path as `source`, so crate provenance and
338
- diagnostics carry true per-file labels. Only the package's own `.mthds` files travel, never those of a package it
339
- ships under its `.mthds/methods/`, since these routes resolve no address-based dependency. The manifest's `main_pipe`
340
- rides beside the files so a per-pipe projection can default its selector the way a run does.
341
-
342
- Args:
343
- method_ref: The raw `method_ref` string from the request.
344
-
345
- Raises:
346
- MethodRefError subclasses: parse, fetch, location, and bounds failures.
347
- MethodTreeLoadError: The standard refused the package's tree.
348
- ApiError: 422 for a package with no `.mthds` file or one this server cannot accept.
349
- """
350
- with _fetched_package(method_ref) as package:
351
- bundles = _read_package_bundles(package=package)
352
- label = f"'{package.full_address}'"
353
- items: list[MthdsFileItem] = []
354
- for tree_path, data in bundles.items():
355
- try:
356
- content = data.decode("utf-8")
357
- except UnicodeDecodeError:
358
- raise_validation_error(message=f"File '{tree_path}' in method package {label} is not valid UTF-8.")
359
- if len(data) > MAX_MTHDS_FILE_BYTES:
360
- msg = f"File '{tree_path}' in method package {label} exceeds the {MAX_MTHDS_FILE_BYTES // 1024} KiB per-file limit."
361
- raise_validation_error(message=msg)
362
- items.append(MthdsFileItem(content=content, source=tree_path))
363
- if not items:
364
- raise_validation_error(message=f"Method package {label} contains no {BUNDLE_SUFFIX} file.", error_type=ErrorType.INVALID_BUNDLE)
365
- return FetchedMthdsFiles(files=items, main_pipe=package.manifest.main_pipe)
@@ -100,6 +100,17 @@ class RequestTree(NamedTuple):
100
100
  def bundle_paths(self) -> list[str]:
101
101
  return [tree_path for tree_path in self.tree.tree_paths if tree_path.endswith(BUNDLE_SUFFIX)]
102
102
 
103
+ @property
104
+ def bundle_texts(self) -> dict[str, str]:
105
+ """The text of each `.mthds` bundle of the tree by its tree path, in tree path order, as a valid verdict echoes them.
106
+
107
+ Decoded strictly, so it is read only once the bundles loaded: a bundle that is not UTF-8 never loads.
108
+
109
+ Raises:
110
+ UnicodeDecodeError: A bundle is not UTF-8 text, which a tree that loaded never holds.
111
+ """
112
+ return {tree_path: self.contents[tree_path].decode("utf-8") for tree_path in self.bundle_paths}
113
+
103
114
  @classmethod
104
115
  def make_from_contents(cls, *, contents: dict[str, bytes]) -> "RequestTree":
105
116
  """The tree these files make, each at its tree path, already judged as a tree's files."""
@@ -687,7 +698,7 @@ def select_tree_entry(*, tree: RequestTree, method_label: str) -> MethodEntry:
687
698
  raise MethodEntryError(msg) from exc
688
699
  with translate_to_validate_bundle_error():
689
700
  blueprint = MthdsParser.make_pipelex_bundle_blueprint(mthds_content=content, mthds_source=tree_path)
690
- candidates.append(EntryCandidate(tree_path=tree_path, blueprint=blueprint))
701
+ candidates.append(EntryCandidate.of_blueprint(tree_path=tree_path, blueprint=blueprint))
691
702
  manifest = manifest_of(tree=tree, label=f"the method {method_label}")
692
703
  return select_entry(candidates=candidates, manifest=manifest, method_label=method_label)
693
704
 
@@ -124,7 +124,22 @@ PROBLEM_403_RUN_POLICY: dict[str, Any] = _problem(
124
124
  "`MethodStructuresRefusedError` — the method's Python declares structure classes (`StructuredContent` "
125
125
  "subclasses), whether it arrived as a bundle (`files` or `bundle_b64`) or as a fetched `method_ref` package: "
126
126
  "a sandbox-hosted deployment imports no caller Python into its own process, so it refuses them before "
127
- "loading anything (express the types as MTHDS concepts instead).",
127
+ "loading anything (express the types as MTHDS concepts instead); or `ReadScopeForbidden` — on a `jwt` deployment, "
128
+ "the request sent a `read_scope` that does not lie under the caller's own `user_id`.",
129
+ )
130
+
131
+ PROBLEM_403_METHOD_PYTHON_POLICY: dict[str, Any] = _problem(
132
+ "The run routes' Python policy, applied to the method's tree: `CustomCodeRequiresSandbox` — the method (sent in "
133
+ "`files`, or a fetched `method_ref` package) ships custom Python and this deployment is not sandbox-hosted; or "
134
+ "`MethodStructuresRefusedError` — the method's Python declares structure classes (`StructuredContent` subclasses), "
135
+ "which a sandbox-hosted deployment never imports into its own process (express the types as MTHDS concepts instead). "
136
+ "Also `ReadScopeForbidden` — on a `jwt` deployment, the request sent a `read_scope` that does not lie under the "
137
+ "caller's own `user_id`.",
138
+ )
139
+
140
+ PROBLEM_403_READ_SCOPE: dict[str, Any] = _problem(
141
+ "`ReadScopeForbidden` — on a `jwt` deployment, the request sent a `read_scope` that does not lie under the caller's "
142
+ "own `user_id`, the only scope its token may read.",
128
143
  )
129
144
 
130
145
  PROBLEM_409_DUPLICATE_RUN: dict[str, Any] = _problem(
@@ -174,9 +189,9 @@ PROBLEM_403_BLOB_UPLOAD: dict[str, Any] = _problem(
174
189
  )
175
190
 
176
191
  PROBLEM_501_METHOD_REF: dict[str, Any] = _problem(
177
- "`MethodRefNotSupported` — the request selected its closure by a **registry-form** `method_ref` (not a "
192
+ "`MethodRefNotSupported` — the request selected its method by a **registry-form** `method_ref` (not a "
178
193
  "`github.com/...` address), and no server-side method registry resolves those yet. Use an address-form reference "
179
- "or submit inline `files[]` instead.",
194
+ "or send the method's tree in `files` instead.",
180
195
  )
181
196
 
182
197
 
@@ -4,7 +4,8 @@ A sender about to send a method by hash first asks which of its files' bytes thi
4
4
  missing ones to the targets the answer names (`pipelex_api.routes.blob_upload`), then sends the request with hash
5
5
  entries. The blobs live in the runner's blob store (`pipelex_api.blob_store`), under the caller's scope: the request's
6
6
  `read_scope` query parameter when a multi-tenant host sends one for the organization it serves, else the caller's own
7
- id, the scope a run's hash entries are resolved in.
7
+ id, the scope a run's hash entries are resolved in. A caller holding its own token (`AUTH_MODE=jwt`) may name only a
8
+ scope under its own id (`bound_read_scope`).
8
9
  """
9
10
 
10
11
  from typing import Annotated
@@ -19,6 +20,7 @@ from pipelex_api.error_types import ErrorType
19
20
  from pipelex_api.errors import raise_validation_error
20
21
  from pipelex_api.json_body import JsonBodyRoute
21
22
  from pipelex_api.method_tree_request import ensure_within_tree_limits
23
+ from pipelex_api.openapi_responses import PROBLEM_403_READ_SCOPE
22
24
  from pipelex_api.routes.pipelex.pipeline import resolve_blob_scope
23
25
 
24
26
  router = APIRouter(tags=["blobs"], route_class=JsonBodyRoute)
@@ -89,6 +91,7 @@ def _unique_blobs(*, blobs: list[BlobRef]) -> dict[str, int]:
89
91
  "/blobs/missing",
90
92
  summary="Which of these blobs the runner lacks, and where to upload each.",
91
93
  openapi_extra={"x-mthds-protocol": True},
94
+ responses={403: PROBLEM_403_READ_SCOPE},
92
95
  )
93
96
  async def find_missing_blobs(
94
97
  request: Request,
@@ -1,13 +1,13 @@
1
1
  from enum import StrEnum
2
2
  from typing import Annotated, Literal, Self, Union
3
3
 
4
- from fastapi import APIRouter
4
+ from fastapi import APIRouter, Request
5
5
  from fastapi.responses import JSONResponse
6
+ from pipelex.base_exceptions import ErrorReport
6
7
  from pipelex.codegen.emission import build_stamped_projection
7
8
  from pipelex.codegen.emitters.target import CodegenKind, CodegenTarget
8
9
  from pipelex.codegen.emitters.types_emitter import emit_types
9
10
  from pipelex.codegen.lock import CODEGEN_LOCK_FILENAME
10
- from pipelex.pipeline.exceptions import ValidateBundleError
11
11
  from pipelex.pipeline.usage_stats_reporting import CodeGenerationReport, usage_stats_codegen_target
12
12
  from pipelex.system.usage_stats.usage_stats_contract import UsageStatsCodegenArtifact
13
13
  from pipelex.tools.misc.package_utils import get_package_version
@@ -15,15 +15,15 @@ from pipelex.tools.typing.pydantic_utils import empty_list_factory_of
15
15
  from pydantic import BaseModel, Field, model_validator
16
16
 
17
17
  from pipelex_api.json_body import JsonBodyRoute
18
- from pipelex_api.openapi_responses import PROBLEM_404_METHOD_PACKAGE, PROBLEM_501_METHOD_REF
18
+ from pipelex_api.openapi_responses import PROBLEM_403_METHOD_PYTHON_POLICY, PROBLEM_404_METHOD_PACKAGE, PROBLEM_501_METHOD_REF
19
19
  from pipelex_api.routes.pipelex.crate_ops import (
20
20
  CrateInvalidReport,
21
21
  GeneratedArtifact,
22
+ ResolvedMethod,
22
23
  invalid_crate_report_response,
23
- resolve_requested_crate,
24
- teardown_current_library,
24
+ project_resolved_method,
25
25
  )
26
- from pipelex_api.schemas.models import MthdsFilesRequest
26
+ from pipelex_api.schemas.models import MethodTreeRequest
27
27
 
28
28
  router = APIRouter(tags=["codegen"], route_class=JsonBodyRoute)
29
29
 
@@ -50,8 +50,8 @@ class CodegenRouteKind(StrEnum):
50
50
  return CodegenKind.TYPES
51
51
 
52
52
 
53
- class CodegenRequest(MthdsFilesRequest):
54
- """The codegen request: the shared closure selector plus the two explicit projection axes."""
53
+ class CodegenRequest(MethodTreeRequest):
54
+ """The codegen request: the crate routes' method selector plus the two explicit projection axes."""
55
55
 
56
56
  kind: CodegenRouteKind = Field(..., description="What to project. `types` projects the crate's concept set into typed models.")
57
57
  target: CodegenTarget = Field(
@@ -112,17 +112,17 @@ CodegenResponse = Annotated[Union[CodegenValidReport, CrateInvalidReport], Field
112
112
  @router.post(
113
113
  "/codegen",
114
114
  response_model=CodegenResponse,
115
- # On top of the composite router's shared 401/413/422/500: the `method_ref` closure selector
116
- # the envelope accepts but no server-side method registry resolves yet (shared with `/resolve`).
117
- responses={404: PROBLEM_404_METHOD_PACKAGE, 501: PROBLEM_501_METHOD_REF},
115
+ # On top of the composite router's shared 401/413/422/500: the Python policy's 403s and the
116
+ # `method_ref` fetch outcomes, shared with `/resolve`.
117
+ responses={403: PROBLEM_403_METHOD_PYTHON_POLICY, 404: PROBLEM_404_METHOD_PACKAGE, 501: PROBLEM_501_METHOD_REF},
118
118
  # NOT tagged `x-mthds-protocol` — a Pipelex API extension, like `/resolve`. The MTHDS standard
119
119
  # specifies the crate this reads (the Library Crate Format); it specifies no type projection, so
120
120
  # every `target` here — `ts-zod` and `python-pydantic` no less than `python-structures` — is ours.
121
121
  )
122
- async def codegen_mthds(request_data: CodegenRequest) -> JSONResponse:
123
- """Generate typed artifacts from a library closure (Pipelex API extension).
122
+ async def codegen_mthds(request: Request, request_data: CodegenRequest) -> JSONResponse:
123
+ """Generate typed artifacts from a method (Pipelex API extension).
124
124
 
125
- Resolves the closure to its normalized crate (exactly like `POST /resolve`), then projects it
125
+ Resolves the method to its normalized crate (exactly like `POST /resolve`), then projects it
126
126
  through the requested `kind`/`target` axes and returns the **stamped** artifact set plus its
127
127
  `codegen.lock` — everything a client needs to materialize a byte-identical local projection
128
128
  and run the offline drift check. There is deliberately **no** server-side check route: the
@@ -134,27 +134,25 @@ async def codegen_mthds(request_data: CodegenRequest) -> JSONResponse:
134
134
  - **Invalid verdict (200, `is_valid: false`):** the library could not be parsed, loaded, or
135
135
  validated — `validation_errors[]` from pipelex's one shared builder; no artifacts exist.
136
136
  - **No verdict (non-2xx):** an unknown projection `kind`/`target`, a `pipe_ref` on a
137
- concept-set-wide kind, or a malformed closure selector is a request-shape 422 problem+json;
138
- `method_ref` is a 501 until server-side method registry resolution exists; auth is 401/403;
139
- server fault is 5xx.
137
+ concept-set-wide kind, or a malformed method selector is a request-shape 422 problem+json; the
138
+ method's tree is refused as `/resolve` refuses it (422 `InvalidMethodFiles` or `MissingBlobs`,
139
+ the Python policy's 403s, the `method_ref` fetch outcomes, 501 for a registry-form
140
+ `method_ref`); auth is 401/403; server fault is 5xx.
140
141
  """
141
142
  with CodeGenerationReport(
142
143
  artifact=UsageStatsCodegenArtifact.TYPES, target=usage_stats_codegen_target(target=request_data.target)
143
144
  ) as generation_report:
144
- return await _generate_codegen_response(request_data=request_data, generation_report=generation_report)
145
+ return await _generate_codegen_response(request=request, request_data=request_data, generation_report=generation_report)
145
146
 
146
147
 
147
- async def _generate_codegen_response(*, request_data: CodegenRequest, generation_report: CodeGenerationReport) -> JSONResponse:
148
+ async def _generate_codegen_response(*, request: Request, request_data: CodegenRequest, generation_report: CodeGenerationReport) -> JSONResponse:
148
149
  """The body of `codegen_mthds`, timed by `generation_report`, which an invalid verdict declares failed."""
149
- try:
150
- # `types` is concept-set-wide: no pipe is selected, so the manifest's `main_pipe` beside the crate is unused.
151
- # A future per-pipe kind takes it from the `ResolvedClosure` and hands it to `resolve_requested_pipe`.
152
- crate = (await resolve_requested_crate(request_data)).crate
153
- except ValidateBundleError as validate_error:
154
- generation_report.declare_failed()
155
- return invalid_crate_report_response(validate_error.to_error_report())
156
- generation_report.describe(crate=crate)
157
- try:
150
+
151
+ def _projection_response(resolution: ResolvedMethod) -> JSONResponse:
152
+ # `types` is concept-set-wide: no pipe is selected, so the method's manifest `main_pipe` is unused. A future
153
+ # per-pipe kind reads it off the resolution and hands it to `resolve_requested_pipe`.
154
+ crate = resolution.crate
155
+ generation_report.describe(crate=crate)
158
156
  emitted = emit_types(crate, target=request_data.target)
159
157
  projection = build_stamped_projection(
160
158
  emitted,
@@ -172,5 +170,9 @@ async def _generate_codegen_response(*, request_data: CodegenRequest, generation
172
170
  lock=projection.lock_content,
173
171
  )
174
172
  return JSONResponse(content=report.model_dump(mode="json", by_alias=True))
175
- finally:
176
- teardown_current_library()
173
+
174
+ outcome = await project_resolved_method(request=request, request_data=request_data, project=_projection_response)
175
+ if isinstance(outcome, ErrorReport):
176
+ generation_report.declare_failed()
177
+ return invalid_crate_report_response(outcome)
178
+ return outcome