pipelex-api 0.87.0__tar.gz → 0.88.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.88.0}/PKG-INFO +2 -2
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/blob_store.py +54 -19
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/blob_upload.py +7 -5
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/blobs.py +4 -2
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/pipeline.py +2 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/validate.py +6 -2
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/.gitignore +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/LICENSE +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/README.md +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/hatch_build.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/__init__.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/api.toml +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/api_config.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/blob_upload.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/bundle.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/bundle_resources.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/disclosure.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/error_types.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/error_uri.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/errors.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/exception_handlers.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/json_body.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/limits.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/main.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/method_cache.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/method_source.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/method_tree_request.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/middleware.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/openapi_responses.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/openapi_schema.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/problem_document.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/py.typed +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/__init__.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/health.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/__init__.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/agent/__init__.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/agent/models.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/codegen.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/crate_ops.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/pipe_io.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/resolve.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/tools.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/utils.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/version.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/schemas/__init__.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/schemas/models.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/security.py +0 -0
- {pipelex_api-0.87.0 → pipelex_api-0.88.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.88.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.88.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."""
|
|
@@ -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."
|
|
@@ -105,8 +105,10 @@ async def find_missing_blobs(
|
|
|
105
105
|
) -> MissingBlobsResult:
|
|
106
106
|
"""List every blob of the request this runner does not hold, each with a signed upload target (MTHDS Protocol).
|
|
107
107
|
|
|
108
|
-
A blob it holds at the stated size is left out, and an empty list means there is nothing to upload;
|
|
109
|
-
|
|
108
|
+
A blob it holds at the stated size is left out, and an empty list means there is nothing to upload; a hash held
|
|
109
|
+
at another size than the one stated is listed too, and its upload tells the two cases apart: bytes of the size held
|
|
110
|
+
change nothing, and bytes of another size mean the object held is damaged, a 500 `BlobStoreCorrupted`, since nothing
|
|
111
|
+
writes over a blob. When the request names the tree the blobs
|
|
110
112
|
make up by its `digest`, this runner has recorded that tree as held in full, which it does only after reading every
|
|
111
113
|
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
114
|
A hash given twice with different sizes is a 422 `BlobMismatch`, and a blob or a set of blobs past this runner's
|
|
@@ -489,6 +489,8 @@ class ApiRunner(PipelexMTHDSProtocol):
|
|
|
489
489
|
created_at=created_at,
|
|
490
490
|
state=RunState.STARTED,
|
|
491
491
|
workflow_id=dispatch_ack.workflow_id,
|
|
492
|
+
# The crate the job was built from, which a host records beside the run's tree digest.
|
|
493
|
+
crate_fingerprint=pipe_job.job_metadata.run_metadata.crate_fingerprint,
|
|
492
494
|
)
|
|
493
495
|
|
|
494
496
|
async def _ensure_served_method_requirements_met(self) -> None:
|
|
@@ -140,7 +140,8 @@ class ValidateRequest(MthdsContentsRequest, CallerAnalyticsGroupsMixin):
|
|
|
140
140
|
description=(
|
|
141
141
|
"PIPELEX-API EXTENSION (not part of the MTHDS Protocol) — the scope of the blob store a hash entry of `files` "
|
|
142
142
|
"resolves in, as a run's `read_scope`: a multi-tenant host sends the one of the organization it serves. Omit it "
|
|
143
|
-
"and the caller's own id is the scope."
|
|
143
|
+
"and the caller's own id is the scope. Only a validation carrying it records the tree it read in the blob "
|
|
144
|
+
"store, so a dry run without one leaves nothing stored."
|
|
144
145
|
),
|
|
145
146
|
)
|
|
146
147
|
|
|
@@ -419,7 +420,10 @@ async def validate_mthds(request: Request, request_data: ValidateRequest) -> JSO
|
|
|
419
420
|
method_label = REQUEST_METHOD_LABEL
|
|
420
421
|
ensure_method_holds_a_bundle(request_method=request_method, label="This method")
|
|
421
422
|
ensure_python_allowed(request_method=request_method, label="This method")
|
|
422
|
-
|
|
423
|
+
# A dry run records the tree only under the scope a host named: without one, the fallback scope is the
|
|
424
|
+
# caller's own id, a prefix no host lists or erases, and a validation must leave nothing stored there.
|
|
425
|
+
if request_data.read_scope is not None:
|
|
426
|
+
await record_request_method(request_method=request_method, blob_store=blob_store)
|
|
423
427
|
validated_contents = _bundle_texts(request_method=request_method)
|
|
424
428
|
# The entry pipe of the tree, the one a selector-less run executes, is the graph's pipe and the default the
|
|
425
429
|
# verdict reports; a tree with none still validates.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|