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.
Files changed (48) hide show
  1. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/PKG-INFO +2 -2
  2. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/blob_store.py +54 -19
  3. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/blob_upload.py +7 -5
  4. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/blobs.py +4 -2
  5. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/pipeline.py +2 -0
  6. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/validate.py +6 -2
  7. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/.gitignore +0 -0
  8. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/LICENSE +0 -0
  9. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/README.md +0 -0
  10. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/hatch_build.py +0 -0
  11. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/__init__.py +0 -0
  12. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/api.toml +0 -0
  13. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/api_config.py +0 -0
  14. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/blob_upload.py +0 -0
  15. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/bundle.py +0 -0
  16. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/bundle_resources.py +0 -0
  17. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/disclosure.py +0 -0
  18. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/error_types.py +0 -0
  19. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/error_uri.py +0 -0
  20. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/errors.py +0 -0
  21. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/exception_handlers.py +0 -0
  22. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/json_body.py +0 -0
  23. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/limits.py +0 -0
  24. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/main.py +0 -0
  25. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/method_cache.py +0 -0
  26. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/method_source.py +0 -0
  27. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/method_tree_request.py +0 -0
  28. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/middleware.py +0 -0
  29. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/openapi_responses.py +0 -0
  30. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/openapi_schema.py +0 -0
  31. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/problem_document.py +0 -0
  32. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/py.typed +0 -0
  33. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/__init__.py +0 -0
  34. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/health.py +0 -0
  35. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/__init__.py +0 -0
  36. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/agent/__init__.py +0 -0
  37. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/agent/models.py +0 -0
  38. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/codegen.py +0 -0
  39. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/crate_ops.py +0 -0
  40. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/pipe_io.py +0 -0
  41. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/resolve.py +0 -0
  42. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/tools.py +0 -0
  43. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/pipelex/utils.py +0 -0
  44. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/routes/version.py +0 -0
  45. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/schemas/__init__.py +0 -0
  46. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/schemas/models.py +0 -0
  47. {pipelex_api-0.87.0 → pipelex_api-0.88.0}/pipelex_api/security.py +0 -0
  48. {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.87.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.87.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 (the local one through a
23
- temporary file renamed onto the key). So an object's stored size, read from its metadata without reading its bytes, is
24
- the size of that hash's bytes, and nothing deletes or rewrites a blob. A listing, written once each of its files was
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, which only a storage that does not publish writes whole could leave short, and which the
31
- upload then writes over.
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, writing it unless it is held at its size already, and return the hash.
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
- An object under the hash at another size, which only a storage that does not publish writes whole could leave,
144
- is written over.
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
- if not await self.holds_blob(sha256=sha256, size=len(data)):
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
- await gather_bounded([partial(self.store_blob, data=data) for data in contents.values()], max_concurrency=MAX_CONCURRENT_BLOB_TRANSFERS)
185
- await self._storage.store(tree_listing(tree.files), key=self._tree_key(tree_digest=tree.digest), content_type=_LISTING_CONTENT_TYPE)
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 changed, so uploading bytes the runner
7
- already holds at their size succeeds and changes nothing, and only bytes held short, which a storage that does not
8
- publish writes whole could leave, are written over. Only `blobs/` is ever written here, never a tree's listing under `trees/`,
9
- so an uploaded file that happens to read as a listing proves nothing.
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; bytes held
109
- under a hash at another size are listed, for the upload to write over. When the request names the tree the blobs
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
- await record_request_method(request_method=request_method, blob_store=blob_store)
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