pipelex-api 0.91.0__tar.gz → 0.92.1__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 (49) hide show
  1. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/PKG-INFO +2 -2
  2. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/blob_store.py +79 -33
  3. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/method_tree_request.py +13 -12
  4. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/pipelex/pipeline.py +12 -6
  5. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/.gitignore +0 -0
  6. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/LICENSE +0 -0
  7. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/README.md +0 -0
  8. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/hatch_build.py +0 -0
  9. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/__init__.py +0 -0
  10. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/api.toml +0 -0
  11. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/api_config.py +0 -0
  12. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/blob_upload.py +0 -0
  13. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/bundle.py +0 -0
  14. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/bundle_resources.py +0 -0
  15. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/exception_handlers.py +0 -0
  16. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/exceptions.py +0 -0
  17. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/json_body.py +0 -0
  18. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/limits.py +0 -0
  19. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/main.py +0 -0
  20. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/method_cache.py +0 -0
  21. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/method_source.py +0 -0
  22. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/middleware.py +0 -0
  23. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/openapi_responses.py +0 -0
  24. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/openapi_schema.py +0 -0
  25. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/problem_document.py +0 -0
  26. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/problem_view.py +0 -0
  27. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/py.typed +0 -0
  28. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/request_refusals.py +0 -0
  29. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/__init__.py +0 -0
  30. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/blob_upload.py +0 -0
  31. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/health.py +0 -0
  32. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/pipelex/__init__.py +0 -0
  33. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/pipelex/agent/__init__.py +0 -0
  34. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/pipelex/agent/models.py +0 -0
  35. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/pipelex/blobs.py +0 -0
  36. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/pipelex/codegen.py +0 -0
  37. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/pipelex/crate_ops.py +0 -0
  38. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/pipelex/method_selection.py +0 -0
  39. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/pipelex/pipe_io.py +0 -0
  40. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/pipelex/resolve.py +0 -0
  41. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/pipelex/tools.py +0 -0
  42. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/pipelex/utils.py +0 -0
  43. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/pipelex/validate.py +0 -0
  44. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/routes/version.py +0 -0
  45. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/run_extras_binding.py +0 -0
  46. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/schemas/__init__.py +0 -0
  47. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/schemas/models.py +0 -0
  48. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pipelex_api/security.py +0 -0
  49. {pipelex_api-0.91.0 → pipelex_api-0.92.1}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pipelex-api
3
- Version: 0.91.0
3
+ Version: 0.92.1
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.91.0
19
+ Requires-Dist: pipelex[anthropic,bedrock,fal,google,google-genai,mistralai]==0.92.1
20
20
  Requires-Dist: pyjwt>=2.10.1
21
21
  Requires-Dist: uvicorn>=0.37.0
22
22
  Provides-Extra: dev
@@ -38,21 +38,24 @@ From this the blob store answers without reading what it does not need:
38
38
  ones held, and nothing is written, while bytes of another size mean the object held is damaged, which only a storage
39
39
  that does not publish writes whole could leave, a 500 `pipelex.storage.damaged`, since nothing writes over a blob
40
40
  and its operator must remove the object.
41
- - A run looks up each hash's stored size first. A hash held at another size than an entry states is a 422
42
- `request.invalid`, never `request.blobs_missing`, since uploading those bytes again would change nothing: the entry
43
- is wrong.
44
- Only a blob held at the size its entries state is read, so the bytes read are bounded by the sizes the request
45
- states, which its census already held to the tree's limits, and each is checked against its hash once read. Bytes
46
- that do not hash to their key mean the object stored under it is damaged, which no caller can repair and no retry
47
- cures: a 500 `pipelex.storage.damaged` naming the kind of storage, whose verdict is not to retry, and an error
48
- record, "Stored blob bytes do not match their hash", naming the hash for the operator to remove the object, after
49
- which the blob is uploaded again.
41
+ - A run reads each hash's bytes in one request, a read of at most one byte more than the largest size its entries
42
+ state, so the bytes read are bounded by the sizes the request states, which its census already held to the tree's
43
+ limits, whatever size the object is held at. A hash held at another size than an entry states, which the length read
44
+ shows, is a 422 `request.invalid`, never `request.blobs_missing`, since uploading those bytes again would change
45
+ nothing: the entry is wrong. Only that refusal looks the size held up, for the operator's message, when the read
46
+ stopped short of the object's end.
47
+ Bytes read at the size their entries state are checked against their hash. Bytes that do not hash to their key mean
48
+ the object stored under it is damaged, which no caller can repair and no retry cures: a 500
49
+ `pipelex.storage.damaged` naming the kind of storage, whose verdict is not to retry, and an error record, "Stored blob
50
+ bytes do not match their hash", naming the hash for the operator to remove the object, after which the blob is
51
+ uploaded again.
50
52
 
51
53
  Lookups and reads run a bounded number at a time, so one request naming many blobs never opens them all at once.
52
54
  """
53
55
 
54
56
  from collections.abc import Iterable, Mapping
55
57
  from functools import partial
58
+ from typing import NamedTuple
56
59
 
57
60
  from mthds.method_tree.digest import DIGEST_PREFIX, sha256_hex, tree_listing
58
61
  from mthds.method_tree.file_entry import BlobStore, InMemoryBlobStore
@@ -73,9 +76,11 @@ BLOBS_PREFIX = "blobs"
73
76
  TREES_PREFIX = "trees"
74
77
  _BLOB_CONTENT_TYPE = "application/octet-stream"
75
78
  _LISTING_CONTENT_TYPE = "text/plain; charset=utf-8"
76
- # How many lookups of a blob's size, which read metadata alone, and how many reads or writes of a blob's bytes, one
77
- # request runs at a time.
79
+ # How many lookups of a blob's size, which read metadata alone, how many reads of the blobs a request names, each
80
+ # bounded by the size the request states for it, and how many writes of a blob's bytes one request runs at a time. The
81
+ # S3 provider's pool of connections holds at least as many as the first two (`S3_MAX_POOL_CONNECTIONS`).
78
82
  MAX_CONCURRENT_BLOB_LOOKUPS = 16
83
+ MAX_CONCURRENT_BLOB_READS = 16
79
84
  MAX_CONCURRENT_BLOB_TRANSFERS = 4
80
85
  # The message of the error record a blob read whose bytes do not match their hash writes, for the operator to search.
81
86
  BLOB_DAMAGED_MESSAGE = "Stored blob bytes do not match their hash"
@@ -104,6 +109,16 @@ def problem_scheme_of(*, storage_method: str) -> str:
104
109
  return PLUGIN_STORAGE_SCHEME
105
110
 
106
111
 
112
+ class StatedBlobs(NamedTuple):
113
+ """The blobs a map of file entries names by hash, as the blob store holds them."""
114
+
115
+ held: BlobStore
116
+ """The bytes of each hash held at the size its entries state, checked against the hash."""
117
+
118
+ mismatched: dict[str, int]
119
+ """Each hash held at another size than an entry states, with the size it is held at, in hash order."""
120
+
121
+
107
122
  class RunnerBlobStore:
108
123
  """The blobs and tree listings of one caller's scope, over the deployment's storage provider."""
109
124
 
@@ -155,37 +170,68 @@ class RunnerBlobStore:
155
170
  sizes = await gather_bounded([partial(self.stored_size, sha256=sha256) for sha256 in hashes], max_concurrency=MAX_CONCURRENT_BLOB_LOOKUPS)
156
171
  return dict(zip(hashes, sizes, strict=True))
157
172
 
158
- async def _read_verified(self, *, sha256: str, size: int) -> bytes:
159
- """The stored bytes of `sha256`, held at `size`, checked against their hash.
173
+ async def _read_bounded(self, *, sha256: str, nb_bytes: int) -> bytes | None:
174
+ """At most `nb_bytes` leading bytes of the blob `sha256`, read in one request, or None when the hash is not held."""
175
+ try:
176
+ return await self._storage.load_head(f"{PIPELEX_STORAGE_SCHEME}{self._blob_key(sha256=sha256)}", nb_bytes=nb_bytes)
177
+ except StorageFileNotFoundError:
178
+ return None
179
+
180
+ async def read_stated(self, *, stated_sizes: Mapping[str, set[int]]) -> StatedBlobs:
181
+ """Read the blobs a map of file entries names, each in one request bounded by the sizes its entries state.
182
+
183
+ Each hash is read for at most one byte more than the largest size its entries state, so a hash held at a larger
184
+ size shows it without its bytes being read, and the bytes read stay bounded by the sizes stated. A hash not
185
+ held is left out, for the reader to report among the blobs this runner does not hold.
160
186
 
161
- Raises (as a problem document): a 500 `pipelex.storage.damaged` naming the kind of storage, for bytes that
162
- are not `size` bytes hashing to `sha256`, after an error record naming the hash, the one place the operator
163
- reads which damaged object to remove.
187
+ Args:
188
+ stated_sizes: Each hash the map names, with every size its entries state for it, which the map's census
189
+ held to the tree's limits.
190
+
191
+ Raises (as a problem document): a 500 `pipelex.storage.damaged` naming the kind of storage, when no hash is
192
+ held at another size and bytes read at the size their entries state do not hash to their key, after an error
193
+ record naming each such hash, the one place the operator reads which damaged object to remove.
164
194
  """
165
- data = await self._storage.load(f"{PIPELEX_STORAGE_SCHEME}{self._blob_key(sha256=sha256)}")
166
- if len(data) != size or sha256_hex(data) != sha256:
195
+ hashes = sorted(stated_sizes)
196
+ reads = await gather_bounded(
197
+ [partial(self._read_bounded, sha256=sha256, nb_bytes=max(stated_sizes[sha256]) + 1) for sha256 in hashes],
198
+ max_concurrency=MAX_CONCURRENT_BLOB_READS,
199
+ )
200
+ blobs: dict[str, bytes] = {}
201
+ mismatched: dict[str, int] = {}
202
+ for sha256, data in zip(hashes, reads, strict=True):
203
+ if data is None:
204
+ continue
205
+ sizes = stated_sizes[sha256]
206
+ if sizes == {len(data)}:
207
+ blobs[sha256] = data
208
+ elif len(data) <= max(sizes):
209
+ mismatched[sha256] = len(data)
210
+ else:
211
+ # The read stopped a byte past the largest size stated: the object is larger, and its size is looked up.
212
+ held_size = await self.stored_size(sha256=sha256)
213
+ if held_size is not None:
214
+ mismatched[sha256] = held_size
215
+ if mismatched:
216
+ return StatedBlobs(held=InMemoryBlobStore(), mismatched=mismatched)
217
+ damaged = [sha256 for sha256, data in blobs.items() if sha256_hex(data) != sha256]
218
+ for sha256 in damaged:
167
219
  log.error(
168
220
  BLOB_DAMAGED_MESSAGE,
169
- fields={"blob_sha256": sha256, "blob_size_bytes": size, "read_size_bytes": len(data), "storage_scheme": self._scheme},
221
+ fields={
222
+ "blob_sha256": sha256,
223
+ "blob_size_bytes": len(blobs[sha256]),
224
+ "read_size_bytes": len(blobs[sha256]),
225
+ "storage_scheme": self._scheme,
226
+ },
170
227
  )
228
+ if damaged:
171
229
  msg = (
172
- f"The bytes this runner's blob store holds under {sha256} do not hash to it: the storage holds a damaged "
230
+ f"The bytes this runner's blob store holds under {damaged[0]} do not hash to it: the storage holds a damaged "
173
231
  "object, which its operator removes for the blob to be uploaded again."
174
232
  )
175
233
  raise BlobStoreDamagedError(msg, scheme=self._scheme)
176
- return data
177
-
178
- async def read_held(self, *, sizes: Mapping[str, int]) -> BlobStore:
179
- """Read these held blobs, each at its stored size, as the synchronous blob store a map of file entries is read with.
180
-
181
- Args:
182
- sizes: Each hash to read, with the size it is held at, which the caller bounded before asking.
183
- """
184
- blobs = await gather_bounded(
185
- [partial(self._read_verified, sha256=sha256, size=size) for sha256, size in sizes.items()],
186
- max_concurrency=MAX_CONCURRENT_BLOB_TRANSFERS,
187
- )
188
- return InMemoryBlobStore(blobs)
234
+ return StatedBlobs(held=InMemoryBlobStore(list(blobs.values())), mismatched={})
189
235
 
190
236
  async def store_blob(self, *, data: bytes) -> str:
191
237
  """Hold `data` under its SHA-256, creating the blob unless it is held already, and return the hash.
@@ -419,26 +419,27 @@ def _refuse_map(*, refusal_error: MethodTreeRefusedError, label: str, field: str
419
419
 
420
420
 
421
421
  async def _held_blobs(*, blob_store: RunnerBlobStore, stated_sizes: dict[str, set[int]], label: str, field: str) -> BlobStore:
422
- """The bytes a map's hash entries name that the blob store holds, looked up by size before any is read.
422
+ """The bytes a map's hash entries name that the blob store holds, each read in one request bounded by its stated size.
423
423
 
424
424
  A hash held at another size than one of its entries states is refused, since uploading its bytes again would not
425
- change the size the store holds them at. Every other held hash is held at the one size its entries state, which the
426
- census bounded, so reading them reads no more than the census allowed. A hash not held is left out, for the reader
427
- to report among the blobs this runner does not hold.
428
-
429
- Raises (as a problem document): a 422 `request.invalid` naming the field the map rides; its message, in the operator's
430
- diagnostics, lists each hash held at another size, with that size, which no parameter of the code carries.
425
+ change the size the store holds them at. Every hash is read for at most one byte past the largest size its entries
426
+ state, which the census bounded, so reading them reads no more than the census allowed. A hash not held is left
427
+ out, for the reader to report among the blobs this runner does not hold.
428
+
429
+ Raises (as problem documents): a 422 `request.invalid` naming the field the map rides, whose message, in the
430
+ operator's diagnostics, lists each hash held at another size, with that size, which no parameter of the code
431
+ carries; and, when no hash is, the 500 `pipelex.storage.damaged` of the blob store for bytes that do not hash to
432
+ their key.
431
433
  """
432
- stored = await blob_store.stored_sizes(sha256s=sorted(stated_sizes))
433
- mismatched = {sha256: size for sha256, size in stored.items() if size is not None and stated_sizes[sha256] != {size}}
434
- if mismatched:
435
- described = ", ".join(f"{sha256} (held at {size} bytes)" for sha256, size in mismatched.items())
434
+ stated = await blob_store.read_stated(stated_sizes=stated_sizes)
435
+ if stated.mismatched:
436
+ described = ", ".join(f"{sha256} (held at {size} bytes)" for sha256, size in stated.mismatched.items())
436
437
  msg = (
437
438
  f"{label} states a size for a hash other than the size of the bytes this runner holds under it: {described}. "
438
439
  "A hash names one set of bytes, so state the size of those bytes."
439
440
  )
440
441
  raise RunnerRequestError(msg, field=field)
441
- return await blob_store.read_held(sizes={sha256: size for sha256, size in stored.items() if size is not None})
442
+ return stated.held
442
443
 
443
444
 
444
445
  async def read_request_tree(
@@ -590,9 +590,14 @@ class ApiRunner(PipelexMTHDSProtocol):
590
590
  and it is made through `tree_listing`, which the caller hands to the request's load, so the load reads the trees
591
591
  checked here without listing them again.
592
592
 
593
+ A refusal of the method is the invalid verdict the load of the same directories gives, through the one translation
594
+ the run path's load uses (`translate_to_validate_bundle_error`): a `method.invalid` problem whose items each keep
595
+ their own code, a `method.dependency_missing` or a `method.package_invalid` located at `requirements.txt` and at the
596
+ line refused. A run answers it as a 422, and a validation as its invalid verdict.
597
+
593
598
  Raises:
594
- MethodRequirementsFileError: The method's requirements.txt holds a line the standard refuses.
595
- MethodRequirementsNotMetError: The PipeFunc environment does not meet the method's requirements.
599
+ ValidateBundleError: The method's requirements.txt holds a line the standard refuses, or the PipeFunc
600
+ environment does not meet the method's requirements, or the deployment refuses them with its own reason.
596
601
  HostLibraryTreeError: A directory of the request could not be listed as a method tree.
597
602
  PipeFuncPackageSetUnstatedError: The deployment states no package set for its PipeFunc sandbox.
598
603
  """
@@ -601,7 +606,9 @@ class ApiRunner(PipelexMTHDSProtocol):
601
606
  library_dirs = [Path(library_dir) for library_dir in self.library_dirs]
602
607
  # In the method-load pool, whose cancellation waits for the check to end: the directories it lists are removed
603
608
  # when the request's scope exits, which a client disconnect can make happen while the check still hashes them.
604
- await run_method_load(ensure_served_method_requirements_met, library_dirs=library_dirs, tree_listing=tree_listing)
609
+ # The directories are the caller's (`library_dirs_are_callers`), so their refusal is translated as their load's is.
610
+ with translate_to_validate_bundle_error():
611
+ await run_method_load(ensure_served_method_requirements_met, library_dirs=library_dirs, tree_listing=tree_listing)
605
612
 
606
613
  async def validate_verdict(
607
614
  self,
@@ -668,11 +675,10 @@ class ApiRunner(PipelexMTHDSProtocol):
668
675
  # its PipeFunc steps run with, before any dispatch, so a refusal reaches the caller whichever validator runs. A
669
676
  # worker a method tree's validation is dispatched to never reads these directories: it receives the method's
670
677
  # trees through its method file store and judges their requirements again as it loads them. A refusal is the
671
- # method's invalid verdict, as a run of it is refused before its files load.
678
+ # method's invalid verdict, the same one a run of it is refused with before its files load.
672
679
  tree_listing = MethodTreeListing()
673
680
  try:
674
- with translate_to_validate_bundle_error():
675
- await self._ensure_served_method_requirements_met(tree_listing=tree_listing)
681
+ await self._ensure_served_method_requirements_met(tree_listing=tree_listing)
676
682
  except ValidateBundleError as invalid_method:
677
683
  return BundleValidationFailure(
678
684
  problem=invalid_method.to_problem(view=ProblemView.OPERATOR),
File without changes
File without changes
File without changes