pipelex-api 0.87.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.
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/PKG-INFO +2 -2
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/blob_store.py +54 -19
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/error_types.py +3 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/method_source.py +16 -92
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/method_tree_request.py +12 -1
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/openapi_responses.py +18 -3
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/routes/blob_upload.py +7 -5
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/blobs.py +8 -3
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/codegen.py +32 -30
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/crate_ops.py +117 -95
- pipelex_api-0.89.0/pipelex_api/routes/pipelex/method_selection.py +85 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/pipe_io.py +98 -66
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/pipeline.py +47 -14
- pipelex_api-0.89.0/pipelex_api/routes/pipelex/resolve.py +79 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/validate.py +28 -49
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/schemas/models.py +56 -63
- pipelex_api-0.87.0/pipelex_api/routes/pipelex/resolve.py +0 -81
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/.gitignore +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/LICENSE +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/README.md +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/hatch_build.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/__init__.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/api.toml +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/api_config.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/blob_upload.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/bundle.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/bundle_resources.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/disclosure.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/error_uri.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/errors.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/exception_handlers.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/json_body.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/limits.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/main.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/method_cache.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/middleware.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/openapi_schema.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/problem_document.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/py.typed +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/routes/__init__.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/routes/health.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/__init__.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/agent/__init__.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/agent/models.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/tools.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/routes/pipelex/utils.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/routes/version.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/schemas/__init__.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.89.0}/pipelex_api/security.py +0 -0
- {pipelex_api-0.87.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.
|
|
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.
|
|
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
|
|
@@ -17,18 +17,27 @@ full, and never by an upload: `PUT /v1/blobs/{sha256}` writes under `blobs/` alo
|
|
|
17
17
|
be a well-formed listing proves nothing. A listing present means the whole tree is present, which lets
|
|
18
18
|
`POST /v1/blobs/missing` answer a tree's digest with one lookup.
|
|
19
19
|
|
|
20
|
+
Every write is create-only and checksummed, as the hosted platform writes the same keys: a blob or a listing is created
|
|
21
|
+
only when its key holds nothing (`If-None-Match: *` on S3, generation 0 on GCS, a hard link on the local filesystem),
|
|
22
|
+
so nothing written there is ever replaced, and with its SHA-256 as its checksum, which S3 checks against the body it
|
|
23
|
+
receives. A key already holding an object is the object already held, never an error. The checksum of a blob is its
|
|
24
|
+
hash, and that of a listing the hexadecimal part of its tree's digest, which is the listing's own hash, so every object
|
|
25
|
+
under `blobs/<hex>` or `trees/<hex>` holds bytes whose SHA-256 is that `<hex>`, and a bucket refusing every
|
|
26
|
+
unconditional write under those prefixes refuses none of this runner's.
|
|
27
|
+
|
|
20
28
|
Every object under `blobs/<sha256>` holds, whole, the bytes whose SHA-256 is that hash: the key is the hash this runner
|
|
21
29
|
computes from the bytes it writes, the runner's own `PUT /v1/blobs/{sha256}` writes only bytes it checked against the
|
|
22
|
-
path's hash, and every built-in storage provider publishes a write whole or not at all
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
held at its size, therefore stays true.
|
|
30
|
+
path's hash, and every built-in storage provider publishes a write whole or not at all. So an object's stored size,
|
|
31
|
+
read from its metadata without reading its bytes, is the size of that hash's bytes, and nothing deletes or rewrites a
|
|
32
|
+
blob. A listing, written once each of its files was held at its size, therefore stays true.
|
|
26
33
|
|
|
27
34
|
From this the blob store answers without reading what it does not need:
|
|
28
35
|
|
|
29
36
|
- `POST /v1/blobs/missing` lists a hash this runner does not hold at the size the sender states, an object held at
|
|
30
|
-
another size included
|
|
31
|
-
|
|
37
|
+
another size included. The upload then tells the two cases apart, holding the bytes: bytes of the size held are the
|
|
38
|
+
ones held, and nothing is written, while bytes of another size mean the object held is damaged, which only a storage
|
|
39
|
+
that does not publish writes whole could leave, a 500 `BlobStoreCorrupted`, since nothing writes over a blob and its
|
|
40
|
+
operator must remove the object.
|
|
32
41
|
- A run looks up each hash's stored size first. A hash held at another size than an entry states is a 422
|
|
33
42
|
`BlobMismatch`, never `MissingBlobs`, since uploading those bytes again would change nothing: the entry is wrong.
|
|
34
43
|
Only a blob held at the size its entries state is read, so the bytes read are bounded by the sizes the request
|
|
@@ -101,10 +110,6 @@ class RunnerBlobStore:
|
|
|
101
110
|
except StorageFileNotFoundError:
|
|
102
111
|
return None
|
|
103
112
|
|
|
104
|
-
async def holds_blob(self, *, sha256: str, size: int) -> bool:
|
|
105
|
-
"""Whether the bytes whose SHA-256 is `sha256` are held at `size`, an object of another size being no blob."""
|
|
106
|
-
return await self.stored_size(sha256=sha256) == size
|
|
107
|
-
|
|
108
113
|
async def stored_sizes(self, *, sha256s: Iterable[str]) -> dict[str, int | None]:
|
|
109
114
|
"""Each hash's stored size, None for a hash not held, looked up a bounded number at a time."""
|
|
110
115
|
hashes = list(sha256s)
|
|
@@ -138,16 +143,33 @@ class RunnerBlobStore:
|
|
|
138
143
|
return InMemoryBlobStore(blobs)
|
|
139
144
|
|
|
140
145
|
async def store_blob(self, *, data: bytes) -> str:
|
|
141
|
-
"""Hold `data` under its SHA-256,
|
|
146
|
+
"""Hold `data` under its SHA-256, creating the blob unless it is held already, and return the hash.
|
|
147
|
+
|
|
148
|
+
The blob is looked up first, so bytes already held are not sent again, then created only if its key is still
|
|
149
|
+
free, with its hash as its checksum: a writer that created it meanwhile wrote the same bytes, and the key
|
|
150
|
+
holding an object is the blob held.
|
|
142
151
|
|
|
143
|
-
|
|
144
|
-
|
|
152
|
+
Raises (as a problem document): a 500 `BlobStoreCorrupted` when an object of another size is held under the
|
|
153
|
+
hash, which only damaged storage could hold and which this runner never writes over.
|
|
145
154
|
"""
|
|
146
155
|
sha256 = sha256_hex(data)
|
|
147
|
-
|
|
148
|
-
await self._storage.store(data, key=self._blob_key(sha256=sha256), content_type=_BLOB_CONTENT_TYPE)
|
|
156
|
+
await self._store_hashed_blob(sha256=sha256, data=data)
|
|
149
157
|
return sha256
|
|
150
158
|
|
|
159
|
+
async def _store_hashed_blob(self, *, sha256: str, data: bytes) -> None:
|
|
160
|
+
"""Hold `data`, whose SHA-256 is `sha256`, as `store_blob` does, for a caller that hashed it already."""
|
|
161
|
+
held_size = await self.stored_size(sha256=sha256)
|
|
162
|
+
if held_size is None:
|
|
163
|
+
await self._storage.store_if_absent(
|
|
164
|
+
data=data, key=self._blob_key(sha256=sha256), content_type=_BLOB_CONTENT_TYPE, checksum_sha256_hex=sha256
|
|
165
|
+
)
|
|
166
|
+
elif held_size != len(data):
|
|
167
|
+
msg = (
|
|
168
|
+
f"This runner's blob store holds {held_size} bytes under {sha256}, whose bytes are {len(data)} long: its "
|
|
169
|
+
"storage is damaged, and its operator must remove that object for the blob to be uploaded again."
|
|
170
|
+
)
|
|
171
|
+
raise_internal_server_error(message=msg, error_type=ErrorType.BLOB_STORE_CORRUPTED)
|
|
172
|
+
|
|
151
173
|
async def holds_tree(self, *, tree_digest: str) -> bool:
|
|
152
174
|
"""Whether the listing of the tree `tree_digest` names is held, which means every file of the tree is."""
|
|
153
175
|
return await self._holds_key(key=self._tree_key(tree_digest=tree_digest))
|
|
@@ -173,16 +195,29 @@ class RunnerBlobStore:
|
|
|
173
195
|
"""Hold every file of a tree read in full, then its listing, so a later request can send it by hash.
|
|
174
196
|
|
|
175
197
|
The listing is written last, once every file it names is held, so a listing present always means its whole
|
|
176
|
-
tree is, and a tree whose listing is held already is left as it is, without a lookup per file.
|
|
198
|
+
tree is, and a tree whose listing is held already is left as it is, without a lookup per file. The listing is
|
|
199
|
+
created only if its key is free, with the hexadecimal part of the tree's digest as its checksum, which is the
|
|
200
|
+
listing's own hash: a listing held already, whoever wrote it, is the same bytes.
|
|
177
201
|
|
|
178
202
|
Args:
|
|
179
203
|
tree: The tree, as a map of file entries made it.
|
|
180
|
-
contents: Each file's bytes by tree path.
|
|
204
|
+
contents: Each file's bytes by tree path. Files of the same bytes are held as one blob, written once.
|
|
181
205
|
"""
|
|
182
206
|
if await self.holds_tree(tree_digest=tree.digest):
|
|
183
207
|
return
|
|
184
|
-
|
|
185
|
-
|
|
208
|
+
# One write per distinct blob: two files of the same bytes, such as two empty files, name one key, and two
|
|
209
|
+
# concurrent creates of one key would race each other for nothing.
|
|
210
|
+
blobs_by_hash = {sha256_hex(data): data for data in contents.values()}
|
|
211
|
+
await gather_bounded(
|
|
212
|
+
[partial(self._store_hashed_blob, sha256=sha256, data=data) for sha256, data in blobs_by_hash.items()],
|
|
213
|
+
max_concurrency=MAX_CONCURRENT_BLOB_TRANSFERS,
|
|
214
|
+
)
|
|
215
|
+
await self._storage.store_if_absent(
|
|
216
|
+
data=tree_listing(tree.files),
|
|
217
|
+
key=self._tree_key(tree_digest=tree.digest),
|
|
218
|
+
content_type=_LISTING_CONTENT_TYPE,
|
|
219
|
+
checksum_sha256_hex=tree.digest.removeprefix(DIGEST_PREFIX),
|
|
220
|
+
)
|
|
186
221
|
|
|
187
222
|
async def missing_blobs(self, *, sizes: Mapping[str, int]) -> list[str]:
|
|
188
223
|
"""The hashes of `sizes` not held at their size, in the mapping's order."""
|
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
194
|
+
"or send the method's tree in `files` instead.",
|
|
180
195
|
)
|
|
181
196
|
|
|
182
197
|
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
This router mounts OUTSIDE the authenticated composite `/v1` router, like `/v1/version`: the upload is authorized by
|
|
4
4
|
the signed query of its target (`pipelex_api.blob_upload`), which only a caller who passed the deployment's
|
|
5
5
|
authentication on `POST /v1/blobs/missing` holds, so a sender uploads with exactly the headers the target names. The
|
|
6
|
-
bytes are written under the scope the signature binds, whole: a blob is never
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
bytes are written under the scope the signature binds, whole and create-only (`pipelex_api.blob_store`): a blob is never
|
|
7
|
+
changed, so uploading bytes the runner already holds succeeds and changes nothing, and bytes of another size than the
|
|
8
|
+
object held under their hash, which only damaged storage could hold, are a 500 `BlobStoreCorrupted` rather than written
|
|
9
|
+
over it. Only `blobs/` is ever written here, never a tree's listing under `trees/`, so an uploaded file that happens to
|
|
10
|
+
read as a listing proves nothing.
|
|
10
11
|
"""
|
|
11
12
|
|
|
12
13
|
from typing import Annotated
|
|
@@ -55,7 +56,8 @@ async def put_blob(
|
|
|
55
56
|
403 `BlobUploadNotAuthorized`, as is a query that is not one this runner signs, such as an expiry that is not a
|
|
56
57
|
decimal integer; a body past `MAX_METHOD_TREE_FILE_KIB` a 422 `MethodTreeTooLarge`; and a body whose SHA-256 is not
|
|
57
58
|
the path's a 422 `BlobMismatch`. A blob is never changed: uploading bytes the runner already holds answers 204 and
|
|
58
|
-
changes nothing
|
|
59
|
+
changes nothing, and uploading bytes whose hash names an object of another size, which only damaged storage could
|
|
60
|
+
hold, is a 500 `BlobStoreCorrupted` that leaves the object as it is.
|
|
59
61
|
"""
|
|
60
62
|
if not is_upload_authorized(scope=scope, sha256=sha256, expires=expires, signature=signature) or scope is None:
|
|
61
63
|
msg = "This upload carries no valid target: ask POST /v1/blobs/missing for one, and upload to the URL it names before it expires."
|
|
@@ -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,
|
|
@@ -105,8 +108,10 @@ async def find_missing_blobs(
|
|
|
105
108
|
) -> MissingBlobsResult:
|
|
106
109
|
"""List every blob of the request this runner does not hold, each with a signed upload target (MTHDS Protocol).
|
|
107
110
|
|
|
108
|
-
A blob it holds at the stated size is left out, and an empty list means there is nothing to upload;
|
|
109
|
-
|
|
111
|
+
A blob it holds at the stated size is left out, and an empty list means there is nothing to upload; a hash held
|
|
112
|
+
at another size than the one stated is listed too, and its upload tells the two cases apart: bytes of the size held
|
|
113
|
+
change nothing, and bytes of another size mean the object held is damaged, a 500 `BlobStoreCorrupted`, since nothing
|
|
114
|
+
writes over a blob. When the request names the tree the blobs
|
|
110
115
|
make up by its `digest`, this runner has recorded that tree as held in full, which it does only after reading every
|
|
111
116
|
file of a tree a request sent, and every listed blob is a file of that tree, the answer is an empty list at once.
|
|
112
117
|
A hash given twice with different sizes is a 422 `BlobMismatch`, and a blob or a set of blobs past this runner's
|
|
@@ -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
|
-
|
|
24
|
-
teardown_current_library,
|
|
24
|
+
project_resolved_method,
|
|
25
25
|
)
|
|
26
|
-
from pipelex_api.schemas.models import
|
|
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(
|
|
54
|
-
"""The codegen request: the
|
|
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
|
|
116
|
-
#
|
|
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
|
|
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
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
#
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
generation_report.
|
|
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
|
-
|
|
176
|
-
|
|
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
|