pipelex-api 0.89.0__tar.gz → 0.91.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 (59) hide show
  1. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/PKG-INFO +9 -8
  2. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/README.md +7 -6
  3. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/api_config.py +13 -7
  4. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/blob_store.py +66 -16
  5. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/blob_upload.py +7 -7
  6. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/bundle.py +39 -17
  7. pipelex_api-0.91.0/pipelex_api/bundle_resources.py +95 -0
  8. pipelex_api-0.91.0/pipelex_api/exception_handlers.py +747 -0
  9. pipelex_api-0.91.0/pipelex_api/exceptions.py +289 -0
  10. pipelex_api-0.91.0/pipelex_api/json_body.py +301 -0
  11. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/limits.py +16 -0
  12. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/main.py +11 -19
  13. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/method_cache.py +9 -5
  14. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/method_source.py +18 -12
  15. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/method_tree_request.py +211 -115
  16. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/middleware.py +28 -36
  17. pipelex_api-0.91.0/pipelex_api/openapi_responses.py +225 -0
  18. pipelex_api-0.91.0/pipelex_api/openapi_schema.py +180 -0
  19. pipelex_api-0.91.0/pipelex_api/problem_document.py +81 -0
  20. pipelex_api-0.91.0/pipelex_api/problem_view.py +35 -0
  21. pipelex_api-0.91.0/pipelex_api/request_refusals.py +126 -0
  22. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/blob_upload.py +22 -15
  23. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/agent/models.py +18 -24
  24. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/blobs.py +11 -8
  25. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/codegen.py +15 -12
  26. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/crate_ops.py +138 -72
  27. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/method_selection.py +12 -9
  28. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/pipe_io.py +19 -17
  29. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/pipeline.py +229 -154
  30. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/resolve.py +17 -13
  31. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/tools.py +4 -3
  32. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/validate.py +111 -73
  33. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/version.py +3 -3
  34. pipelex_api-0.91.0/pipelex_api/run_extras_binding.py +54 -0
  35. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/schemas/models.py +119 -51
  36. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/security.py +56 -32
  37. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pyproject.toml +4 -1
  38. pipelex_api-0.89.0/pipelex_api/bundle_resources.py +0 -84
  39. pipelex_api-0.89.0/pipelex_api/disclosure.py +0 -43
  40. pipelex_api-0.89.0/pipelex_api/error_types.py +0 -116
  41. pipelex_api-0.89.0/pipelex_api/error_uri.py +0 -45
  42. pipelex_api-0.89.0/pipelex_api/errors.py +0 -144
  43. pipelex_api-0.89.0/pipelex_api/exception_handlers.py +0 -688
  44. pipelex_api-0.89.0/pipelex_api/json_body.py +0 -182
  45. pipelex_api-0.89.0/pipelex_api/openapi_responses.py +0 -207
  46. pipelex_api-0.89.0/pipelex_api/openapi_schema.py +0 -83
  47. pipelex_api-0.89.0/pipelex_api/problem_document.py +0 -134
  48. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/.gitignore +0 -0
  49. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/LICENSE +0 -0
  50. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/hatch_build.py +0 -0
  51. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/__init__.py +0 -0
  52. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/api.toml +0 -0
  53. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/py.typed +0 -0
  54. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/__init__.py +0 -0
  55. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/health.py +0 -0
  56. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/__init__.py +0 -0
  57. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/agent/__init__.py +0 -0
  58. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/routes/pipelex/utils.py +0 -0
  59. {pipelex_api-0.89.0 → pipelex_api-0.91.0}/pipelex_api/schemas/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pipelex-api
3
- Version: 0.89.0
3
+ Version: 0.91.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.89.0
19
+ Requires-Dist: pipelex[anthropic,bedrock,fal,google,google-genai,mistralai]==0.91.0
20
20
  Requires-Dist: pyjwt>=2.10.1
21
21
  Requires-Dist: uvicorn>=0.37.0
22
22
  Provides-Extra: dev
@@ -115,26 +115,27 @@ The API is now running at `http://localhost:8081`. To customize behavior (enable
115
115
 
116
116
  # 🧪 Run your first pipeline
117
117
 
118
- Once `/health` is green, send an inline pipeline definition and inputs to `/v1/execute`. The example below summarizes a string with a one-pipe MTHDS bundle — no files, no auth, copy-paste:
118
+ Once `/health` is green, send a method and its inputs to `/v1/execute`. The method travels in `files`, a map from each file's path in the method's folder to that file's content. The example below summarizes a string with a method made of a single `.mthds` file, with no auth, ready to copy and paste:
119
119
 
120
120
  ```bash
121
121
  curl -s http://localhost:8081/v1/execute \
122
122
  -H "Content-Type: application/json" \
123
123
  -d '{
124
- "pipe_code": "summarize",
125
- "mthds_contents": ["domain = \"hello\"\nmain_pipe = \"summarize\"\n\n[pipe.summarize]\ntype = \"PipeLLM\"\ndescription = \"Summarize the input text in one sentence\"\ninputs = { text = \"Text\" }\noutput = \"Text\"\nprompt = \"Summarize in one sentence:\\n@text\"\n"],
124
+ "files": {
125
+ "summarize.mthds": "domain = \"hello\"\nmain_pipe = \"summarize\"\n\n[pipe.summarize]\ntype = \"PipeLLM\"\ndescription = \"Summarize the input text in one sentence\"\ninputs = { text = \"Text\" }\noutput = \"Text\"\nprompt = \"Summarize in one sentence:\\n@text\"\n"
126
+ },
126
127
  "inputs": { "text": "Pipelex turns plain-language pipeline definitions into reproducible AI workflows that run as HTTP endpoints." }
127
128
  }'
128
129
  ```
129
130
 
130
- You'll get back a JSON response with `state: "COMPLETED"` and the summary under `pipe_output.working_memory.root.<main_stuff_name>.content`.
131
+ You'll get back a JSON response with `state: "COMPLETED"` and the summary under `pipe_output.working_memory.root.<main_stuff_name>.content`. The run starts at the method's `main_pipe`; to start at another pipe of the method, name it in `pipe_code`.
131
132
 
132
133
  **Passing files (PDFs, images) as inputs.** Use the `Document` concept and point it at any HTTP(S) URL:
133
134
 
134
135
  ```json
135
136
  {
136
137
  "pipe_code": "your_pipe",
137
- "mthds_contents": ["...your MTHDS..."],
138
+ "files": { "your_method.mthds": "...your MTHDS..." },
138
139
  "inputs": {
139
140
  "cv": { "concept": "Document", "content": { "url": "https://example.com/resume.pdf" } }
140
141
  }
@@ -143,7 +144,7 @@ You'll get back a JSON response with `state: "COMPLETED"` and the summary under
143
144
 
144
145
  `Document` accepts public HTTP/HTTPS URLs, `pipelex-storage://` URIs, or base64 data URLs. For images, use the `Image` concept with the same `{ "url": "..." }` shape.
145
146
 
146
- For inline MTHDS in the request, `mthds_contents` is a JSON array of raw `.mthds` (TOML) file contents as strings — typically `[open("my_pipe.mthds").read()]` from a client. See the [Pipe Run page](https://docs.pipelex.com/latest/api-server/pipe-run/) for every supported input shape and the full `/execute` reference.
147
+ **Sending the method.** `files` holds the whole method, as the MTHDS standard's method tree: each key is a file's path relative to the method's folder, such as `summarize.mthds` or `templates/invoice.docx`, and each value is that file's content. A text file, such as a `.mthds` bundle or a Python module, is sent as its UTF-8 text in a JSON string, and a file that is not text, such as an office template or an image, as `{"base64": "..."}`. This server also keeps a blob store, which `GET /v1/version` reports as `blob_store: true`, so a client can send a file as `{"sha256": "...", "size": n}` instead: it asks `POST /v1/blobs/missing` which of its files the server lacks, uploads each of those to the target the answer names, and then sends the method with a hash entry for each file, so a method that has not changed is not uploaded again. The older `mthds_contents` field, a JSON array of `.mthds` texts, is still accepted, but it is being retired together with `bundle_b64` and `resources`, and a later release refuses them, so new clients send `files`. See the [Pipe Run page](https://docs.pipelex.com/latest/api-server/pipe-run/) for every supported input shape, the rules of [a method sent as a tree](https://docs.pipelex.com/latest/api-server/pipe-run/#sending-a-method-as-a-tree) and of [sending files by hash](https://docs.pipelex.com/latest/api-server/pipe-run/#sending-files-by-hash), and the full `/execute` reference.
147
148
 
148
149
  # 📈 How to scale Pipelex
149
150
 
@@ -69,26 +69,27 @@ The API is now running at `http://localhost:8081`. To customize behavior (enable
69
69
 
70
70
  # 🧪 Run your first pipeline
71
71
 
72
- Once `/health` is green, send an inline pipeline definition and inputs to `/v1/execute`. The example below summarizes a string with a one-pipe MTHDS bundle — no files, no auth, copy-paste:
72
+ Once `/health` is green, send a method and its inputs to `/v1/execute`. The method travels in `files`, a map from each file's path in the method's folder to that file's content. The example below summarizes a string with a method made of a single `.mthds` file, with no auth, ready to copy and paste:
73
73
 
74
74
  ```bash
75
75
  curl -s http://localhost:8081/v1/execute \
76
76
  -H "Content-Type: application/json" \
77
77
  -d '{
78
- "pipe_code": "summarize",
79
- "mthds_contents": ["domain = \"hello\"\nmain_pipe = \"summarize\"\n\n[pipe.summarize]\ntype = \"PipeLLM\"\ndescription = \"Summarize the input text in one sentence\"\ninputs = { text = \"Text\" }\noutput = \"Text\"\nprompt = \"Summarize in one sentence:\\n@text\"\n"],
78
+ "files": {
79
+ "summarize.mthds": "domain = \"hello\"\nmain_pipe = \"summarize\"\n\n[pipe.summarize]\ntype = \"PipeLLM\"\ndescription = \"Summarize the input text in one sentence\"\ninputs = { text = \"Text\" }\noutput = \"Text\"\nprompt = \"Summarize in one sentence:\\n@text\"\n"
80
+ },
80
81
  "inputs": { "text": "Pipelex turns plain-language pipeline definitions into reproducible AI workflows that run as HTTP endpoints." }
81
82
  }'
82
83
  ```
83
84
 
84
- You'll get back a JSON response with `state: "COMPLETED"` and the summary under `pipe_output.working_memory.root.<main_stuff_name>.content`.
85
+ You'll get back a JSON response with `state: "COMPLETED"` and the summary under `pipe_output.working_memory.root.<main_stuff_name>.content`. The run starts at the method's `main_pipe`; to start at another pipe of the method, name it in `pipe_code`.
85
86
 
86
87
  **Passing files (PDFs, images) as inputs.** Use the `Document` concept and point it at any HTTP(S) URL:
87
88
 
88
89
  ```json
89
90
  {
90
91
  "pipe_code": "your_pipe",
91
- "mthds_contents": ["...your MTHDS..."],
92
+ "files": { "your_method.mthds": "...your MTHDS..." },
92
93
  "inputs": {
93
94
  "cv": { "concept": "Document", "content": { "url": "https://example.com/resume.pdf" } }
94
95
  }
@@ -97,7 +98,7 @@ You'll get back a JSON response with `state: "COMPLETED"` and the summary under
97
98
 
98
99
  `Document` accepts public HTTP/HTTPS URLs, `pipelex-storage://` URIs, or base64 data URLs. For images, use the `Image` concept with the same `{ "url": "..." }` shape.
99
100
 
100
- For inline MTHDS in the request, `mthds_contents` is a JSON array of raw `.mthds` (TOML) file contents as strings — typically `[open("my_pipe.mthds").read()]` from a client. See the [Pipe Run page](https://docs.pipelex.com/latest/api-server/pipe-run/) for every supported input shape and the full `/execute` reference.
101
+ **Sending the method.** `files` holds the whole method, as the MTHDS standard's method tree: each key is a file's path relative to the method's folder, such as `summarize.mthds` or `templates/invoice.docx`, and each value is that file's content. A text file, such as a `.mthds` bundle or a Python module, is sent as its UTF-8 text in a JSON string, and a file that is not text, such as an office template or an image, as `{"base64": "..."}`. This server also keeps a blob store, which `GET /v1/version` reports as `blob_store: true`, so a client can send a file as `{"sha256": "...", "size": n}` instead: it asks `POST /v1/blobs/missing` which of its files the server lacks, uploads each of those to the target the answer names, and then sends the method with a hash entry for each file, so a method that has not changed is not uploaded again. The older `mthds_contents` field, a JSON array of `.mthds` texts, is still accepted, but it is being retired together with `bundle_b64` and `resources`, and a later release refuses them, so new clients send `files`. See the [Pipe Run page](https://docs.pipelex.com/latest/api-server/pipe-run/) for every supported input shape, the rules of [a method sent as a tree](https://docs.pipelex.com/latest/api-server/pipe-run/#sending-a-method-as-a-tree) and of [sending files by hash](https://docs.pipelex.com/latest/api-server/pipe-run/#sending-files-by-hash), and the full `/execute` reference.
101
102
 
102
103
  # 📈 How to scale Pipelex
103
104
 
@@ -30,8 +30,7 @@ from pipelex.runtime_bridge.orchestration_mode import DIRECT_ORCHESTRATION_MODE
30
30
  from pipelex.system.configuration.config_loader import config_manager
31
31
  from pydantic import BaseModel, ConfigDict
32
32
 
33
- from pipelex_api.error_types import ErrorType
34
- from pipelex_api.errors import raise_forbidden
33
+ from pipelex_api.exceptions import ApiBootConfigError, OrchestrationOverrideForbiddenError
35
34
 
36
35
  API_CONFIG_NAME = "api"
37
36
 
@@ -79,7 +78,7 @@ def get_api_config() -> ApiConfig:
79
78
  The config is immutable for the life of the process (``PIPELEX_ENV`` is fixed
80
79
  at boot), so it is loaded once and cached. ``pipelex_api.main`` warms this at startup
81
80
  so a malformed ``api.toml`` / baked override fails the app fast — the same
82
- fail-fast posture as ``ERROR_DISCLOSURE``. Tests that need a different mode
81
+ fail-fast posture as ``AUTH_MODE``. Tests that need a different mode
83
82
  patch this getter (or call :func:`resolve_orchestration_mode` with a hand-built
84
83
  config) rather than mutating the cache.
85
84
  """
@@ -94,7 +93,7 @@ def resolve_orchestration_mode(requested: str | None, *, config: ApiConfig) -> s
94
93
  override (``allow_request_orchestration_mode_override``). A caller-supplied
95
94
  token equal to the default is always honored (it changes nothing). A caller
96
95
  trying to FORCE a different mode on a runner whose policy forbids it is refused
97
- with a 403 — so a locked-down Temporal runner can never be coerced into
96
+ with a 422 ``pipelex.deployment.orchestration_override_forbidden`` — so a locked-down Temporal runner can never be coerced into
98
97
  ``direct`` (whose whole point would be to bypass distributed execution), and
99
98
  vice versa. The token is a plain string compare; an *unregistered* token is
100
99
  not rejected here — that surfaces at dispatch as ``MissingOrchestratorError``.
@@ -107,11 +106,18 @@ def resolve_orchestration_mode(requested: str | None, *, config: ApiConfig) -> s
107
106
  f"This deployment does not allow overriding orchestration_mode per request "
108
107
  f"(configured mode '{config.orchestration_mode}', requested '{requested}')."
109
108
  )
110
- raise_forbidden(msg, error_type=ErrorType.ORCHESTRATION_MODE_OVERRIDE_FORBIDDEN)
109
+ raise OrchestrationOverrideForbiddenError(msg, requested_mode=requested)
111
110
 
112
111
 
113
- class ApiBootConfigError(ValueError):
114
- """Raised at startup when the deployment's orchestration config cannot boot coherently."""
112
+ def is_callers_override(*, orchestration_mode: str, config: ApiConfig) -> bool:
113
+ """Whether a resolved orchestration mode is a per-request override the caller named, not the deployment's default.
114
+
115
+ :func:`resolve_orchestration_mode` returns the default unless the caller named a different mode the policy
116
+ allows, so a resolved mode other than the default is always the caller's. A refusal of such a mode, such as
117
+ one no installed plugin serves, is the caller's to change (``runtime.unsupported``), where the same refusal of
118
+ the default is the operator's (``pipelex.plugin.not_found``).
119
+ """
120
+ return orchestration_mode != config.orchestration_mode
115
121
 
116
122
 
117
123
  def resolve_boot_orchestrator(config: ApiConfig) -> str | None:
@@ -36,14 +36,17 @@ From this the blob store answers without reading what it does not need:
36
36
  - `POST /v1/blobs/missing` lists a hash this runner does not hold at the size the sender states, an object held at
37
37
  another size included. The upload then tells the two cases apart, holding the bytes: bytes of the size held are the
38
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.
39
+ that does not publish writes whole could leave, a 500 `pipelex.storage.damaged`, since nothing writes over a blob
40
+ and its operator must remove the object.
41
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
- `BlobMismatch`, never `MissingBlobs`, since uploading those bytes again would change nothing: the entry is wrong.
42
+ `request.invalid`, never `request.blobs_missing`, since uploading those bytes again would change nothing: the entry
43
+ is wrong.
43
44
  Only a blob held at the size its entries state is read, so the bytes read are bounded by the sizes the request
44
45
  states, which its census already held to the tree's limits, and each is checked against its hash once read. Bytes
45
- that do not hash to their key mean the storage itself is damaged, which no caller can repair: a 500
46
- `BlobStoreCorrupted`.
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.
47
50
 
48
51
  Lookups and reads run a bounded number at a time, so one request naming many blobs never opens them all at once.
49
52
  """
@@ -54,15 +57,17 @@ from functools import partial
54
57
  from mthds.method_tree.digest import DIGEST_PREFIX, sha256_hex, tree_listing
55
58
  from mthds.method_tree.file_entry import BlobStore, InMemoryBlobStore
56
59
  from mthds.method_tree.models import MethodTree
60
+ from pipelex import log
61
+ from pipelex.config import get_config
57
62
  from pipelex.core.method_trees.exceptions import MethodFileUnavailableError
58
63
  from pipelex.core.method_trees.method_files import TreeListing
59
64
  from pipelex.runtime_hub import get_storage_provider
60
65
  from pipelex.tools.misc.async_utils import gather_bounded
61
66
  from pipelex.tools.storage.exceptions import StorageFileNotFoundError
67
+ from pipelex.tools.storage.storage_config import StorageMethod
62
68
  from pipelex.tools.storage.storage_provider_abstract import PIPELEX_STORAGE_SCHEME, StorageProviderAbstract
63
69
 
64
- from pipelex_api.error_types import ErrorType
65
- from pipelex_api.errors import raise_internal_server_error
70
+ from pipelex_api.exceptions import BlobStoreDamagedError
66
71
 
67
72
  BLOBS_PREFIX = "blobs"
68
73
  TREES_PREFIX = "trees"
@@ -72,19 +77,53 @@ _LISTING_CONTENT_TYPE = "text/plain; charset=utf-8"
72
77
  # request runs at a time.
73
78
  MAX_CONCURRENT_BLOB_LOOKUPS = 16
74
79
  MAX_CONCURRENT_BLOB_TRANSFERS = 4
80
+ # The message of the error record a blob read whose bytes do not match their hash writes, for the operator to search.
81
+ BLOB_DAMAGED_MESSAGE = "Stored blob bytes do not match their hash"
82
+
83
+
84
+ # The kind of storage a `pipelex.storage.*` problem names for a storage plugin's own backend.
85
+ PLUGIN_STORAGE_SCHEME = "plugin"
86
+
87
+
88
+ def problem_scheme_of(*, storage_method: str) -> str:
89
+ """The kind of storage a `pipelex.storage.*` problem names for a configured storage method, as the registry spells it.
90
+
91
+ `plugin` for a storage plugin's own method, whose kind the registry cannot list.
92
+ """
93
+ match storage_method:
94
+ case StorageMethod.LOCAL:
95
+ return "local"
96
+ case StorageMethod.IN_MEMORY:
97
+ return "memory"
98
+ case StorageMethod.S3:
99
+ return "s3"
100
+ case StorageMethod.GCP:
101
+ return "gcs"
102
+ case _:
103
+ # An external storage plugin's method token: the configured method is an open string.
104
+ return PLUGIN_STORAGE_SCHEME
75
105
 
76
106
 
77
107
  class RunnerBlobStore:
78
108
  """The blobs and tree listings of one caller's scope, over the deployment's storage provider."""
79
109
 
80
- def __init__(self, *, scope: str, storage: StorageProviderAbstract) -> None:
110
+ def __init__(self, *, scope: str, storage: StorageProviderAbstract, scheme: str) -> None:
111
+ """Build the blob store of one scope.
112
+
113
+ Args:
114
+ scope: The prefix of the scope's blobs and listings.
115
+ storage: The storage provider holding them.
116
+ scheme: The kind of storage the provider is, as a `pipelex.storage.*` problem names it (`problem_scheme_of`).
117
+ """
81
118
  self._scope = scope
82
119
  self._storage = storage
120
+ self._scheme = scheme
83
121
 
84
122
  @classmethod
85
123
  def make_for_scope(cls, *, scope: str) -> "RunnerBlobStore":
86
124
  """The blob store of `scope`, over the storage provider the runtime booted with."""
87
- return cls(scope=scope, storage=get_storage_provider())
125
+ scheme = problem_scheme_of(storage_method=get_config().runtime.storage.method)
126
+ return cls(scope=scope, storage=get_storage_provider(), scheme=scheme)
88
127
 
89
128
  @property
90
129
  def scope(self) -> str:
@@ -119,15 +158,21 @@ class RunnerBlobStore:
119
158
  async def _read_verified(self, *, sha256: str, size: int) -> bytes:
120
159
  """The stored bytes of `sha256`, held at `size`, checked against their hash.
121
160
 
122
- Raises (as a problem document): a 500 `BlobStoreCorrupted` for bytes that are not `size` bytes hashing to `sha256`.
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.
123
164
  """
124
165
  data = await self._storage.load(f"{PIPELEX_STORAGE_SCHEME}{self._blob_key(sha256=sha256)}")
125
166
  if len(data) != size or sha256_hex(data) != sha256:
167
+ log.error(
168
+ BLOB_DAMAGED_MESSAGE,
169
+ fields={"blob_sha256": sha256, "blob_size_bytes": size, "read_size_bytes": len(data), "storage_scheme": self._scheme},
170
+ )
126
171
  msg = (
127
- f"The bytes this runner's blob store holds under {sha256} do not hash to it: its storage is damaged, "
128
- "and its operator must remove that object for the blob to be uploaded again."
172
+ f"The bytes this runner's blob store holds under {sha256} do not hash to it: the storage holds a damaged "
173
+ "object, which its operator removes for the blob to be uploaded again."
129
174
  )
130
- raise_internal_server_error(message=msg, error_type=ErrorType.BLOB_STORE_CORRUPTED)
175
+ raise BlobStoreDamagedError(msg, scheme=self._scheme)
131
176
  return data
132
177
 
133
178
  async def read_held(self, *, sizes: Mapping[str, int]) -> BlobStore:
@@ -149,8 +194,9 @@ class RunnerBlobStore:
149
194
  free, with its hash as its checksum: a writer that created it meanwhile wrote the same bytes, and the key
150
195
  holding an object is the blob held.
151
196
 
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.
197
+ Raises (as a problem document): a 500 `pipelex.storage.damaged` naming the kind of storage when an object of
198
+ another size is held under the hash, which only damaged storage could hold and which this runner never writes
199
+ over, after the error record naming the hash that a read of damaged bytes writes.
154
200
  """
155
201
  sha256 = sha256_hex(data)
156
202
  await self._store_hashed_blob(sha256=sha256, data=data)
@@ -164,11 +210,15 @@ class RunnerBlobStore:
164
210
  data=data, key=self._blob_key(sha256=sha256), content_type=_BLOB_CONTENT_TYPE, checksum_sha256_hex=sha256
165
211
  )
166
212
  elif held_size != len(data):
213
+ log.error(
214
+ BLOB_DAMAGED_MESSAGE,
215
+ fields={"blob_sha256": sha256, "blob_size_bytes": held_size, "read_size_bytes": len(data), "storage_scheme": self._scheme},
216
+ )
167
217
  msg = (
168
218
  f"This runner's blob store holds {held_size} bytes under {sha256}, whose bytes are {len(data)} long: its "
169
219
  "storage is damaged, and its operator must remove that object for the blob to be uploaded again."
170
220
  )
171
- raise_internal_server_error(message=msg, error_type=ErrorType.BLOB_STORE_CORRUPTED)
221
+ raise BlobStoreDamagedError(msg, scheme=self._scheme)
172
222
 
173
223
  async def holds_tree(self, *, tree_digest: str) -> bool:
174
224
  """Whether the listing of the tree `tree_digest` names is held, which means every file of the tree is."""
@@ -5,8 +5,9 @@ must say that the upload is allowed, as a presigned URL does on an object store.
5
5
  names: its URL is its own `PUT /v1/blobs/{sha256}` with a query naming the blob store's scope, an expiry and an
6
6
  HMAC-SHA256 signature over the scope, the hash and the expiry. The upload route checks the signature and the expiry,
7
7
  writes under the scope the signature binds and nowhere else, and answers a missing, forged or expired signature with a
8
- 403 `BlobUploadNotAuthorized`. The upload route therefore needs no bearer token: only a caller who passed the
9
- deployment's authentication on `POST /v1/blobs/missing` holds a target, for one hash, in its own scope, for a while.
8
+ 403 `auth.forbidden` whose `requirement` is a signed upload target. The upload route therefore needs no bearer token:
9
+ only a caller who passed the deployment's authentication on `POST /v1/blobs/missing` holds a target, for one hash, in
10
+ its own scope, for a while.
10
11
 
11
12
  The key is `BLOB_UPLOAD_SIGNING_KEY` when the deployment sets it, which it must when several instances serve one
12
13
  deployment, since a target named by one instance is checked by whichever instance receives the upload; otherwise each
@@ -28,8 +29,7 @@ from urllib.parse import urlencode, urlsplit
28
29
  from fastapi import Request
29
30
  from pipelex.system.environment import get_optional_env
30
31
 
31
- from pipelex_api.error_types import ErrorType
32
- from pipelex_api.errors import raise_internal_server_error
32
+ from pipelex_api.exceptions import RunnerMisconfiguredError
33
33
  from pipelex_api.limits import BLOB_UPLOAD_URL_TTL_SECONDS
34
34
 
35
35
  PUBLIC_BASE_URL_ENV = "PUBLIC_BASE_URL"
@@ -62,8 +62,8 @@ def signed_upload_query(*, scope: str, sha256: str) -> str:
62
62
  def _public_base_url() -> str | None:
63
63
  """`PUBLIC_BASE_URL` without its trailing slash, or None when unset.
64
64
 
65
- Raises (as a problem document): a 500 `ServerMisconfigured` for a value that is not an absolute http or https URL
66
- without a query or a fragment.
65
+ Raises (as a problem document): a 500 `runtime.misconfigured` naming `PUBLIC_BASE_URL`, for a value that is not an
66
+ absolute http or https URL without a query or a fragment.
67
67
  """
68
68
  configured = get_optional_env(PUBLIC_BASE_URL_ENV)
69
69
  if not configured:
@@ -71,7 +71,7 @@ def _public_base_url() -> str | None:
71
71
  parts = urlsplit(configured)
72
72
  if parts.scheme not in {"http", "https"} or not parts.netloc or parts.query or parts.fragment:
73
73
  msg = f"Server configuration error: {PUBLIC_BASE_URL_ENV} must be an absolute http or https URL, such as https://runner.example.com."
74
- raise_internal_server_error(message=msg, error_type=ErrorType.SERVER_MISCONFIGURED)
74
+ raise RunnerMisconfiguredError(msg, component=PUBLIC_BASE_URL_ENV)
75
75
  return configured.rstrip("/")
76
76
 
77
77
 
@@ -16,30 +16,31 @@ import binascii
16
16
  import zipfile
17
17
  from io import BytesIO
18
18
 
19
+ from mthds.protocol.problem.registry import CoreCode, RequestMalformedReason
19
20
  from pipelex import log
21
+ from pipelex.methods.package_reason import MethodPackageReason
20
22
  from pipelex.tools.log.error_fields import error_fields
21
23
 
22
- from pipelex_api.error_types import ErrorType
23
- from pipelex_api.errors import raise_bad_request, raise_payload_too_large, raise_validation_error
24
- from pipelex_api.limits import MAX_BUNDLE_FILES, MAX_BUNDLE_TOTAL_BYTES
24
+ from pipelex_api.exceptions import RequestMethodError, RunnerRequestError
25
+ from pipelex_api.limits import MAX_BUNDLE_FILES, MAX_BUNDLE_TOTAL_BYTES, base64_decoded_size
25
26
 
26
- # Base64 inflates by 4/3; a zip is compressed, so a bundle whose DECOMPRESSED content is within
27
- # the ceiling encodes to well under this. Bounding the base64 string BEFORE decoding stops a
28
- # ~100 MiB request-body (the only other bound) from being expanded into ~75 MiB of heap just to
29
- # be rejected later by the decompressed-size guard. Slack (+4) covers padding.
30
- _MAX_BUNDLE_B64_CHARS = MAX_BUNDLE_TOTAL_BYTES * 4 // 3 + 4
27
+ # The request field the archive rides, which the refusals of its encoding name.
28
+ _BUNDLE_FIELD = "bundle_b64"
31
29
 
32
30
 
33
31
  def _guard_count(count: int) -> None:
34
32
  if count == 0:
35
- raise_validation_error(message="Bundle is empty (no files)", error_type=ErrorType.INVALID_BUNDLE)
33
+ msg = "Bundle is empty (no files)"
34
+ raise RunnerRequestError(msg, field=_BUNDLE_FIELD)
36
35
  if count > MAX_BUNDLE_FILES:
37
- raise_payload_too_large(message=f"Bundle exceeds the {MAX_BUNDLE_FILES}-file limit (got {count})")
36
+ msg = f"Bundle exceeds the {MAX_BUNDLE_FILES}-file limit (got {count})"
37
+ raise RequestMethodError(msg).with_params(reason=MethodPackageReason.BUNDLE_TOO_MANY_FILES, limit=MAX_BUNDLE_FILES)
38
38
 
39
39
 
40
40
  def _guard_running_total(total_bytes: int) -> None:
41
41
  if total_bytes > MAX_BUNDLE_TOTAL_BYTES:
42
- raise_payload_too_large(message=f"Bundle exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB decompressed-size limit")
42
+ msg = f"Bundle exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB decompressed-size limit"
43
+ raise RequestMethodError(msg).with_params(reason=MethodPackageReason.BUNDLE_TOO_LARGE, limit=MAX_BUNDLE_TOTAL_BYTES)
43
44
 
44
45
 
45
46
  def decode_bundle_archive(*, bundle_b64: str) -> list[tuple[str, bytes]]:
@@ -50,24 +51,45 @@ def decode_bundle_archive(*, bundle_b64: str) -> list[tuple[str, bytes]]:
50
51
  cheap length check on the still-encoded string runs FIRST, so an oversized payload is refused before it is buffered
51
52
  into memory as decoded bytes. A name the archive gives twice is returned twice, for the tree's reader to refuse.
52
53
 
53
- Raises (as problem documents): an oversized archive (413 `PayloadTooLarge`), a value that is not base64 (400
54
- `InvalidBase64`), and one that is not a zip archive or holds no file (422 `InvalidBundle`).
54
+ Raises (as problem documents): an archive past the size limit (413 `request.too_large`, its limit and its size in
55
+ bytes, the size read off the encoded length), a value that is
56
+ not base64 or not a zip archive (422 `request.malformed`), an archive holding no file (422 `request.invalid`), and
57
+ one past the limit on its files or on its decompressed size (422 `method.package_invalid`).
55
58
  """
56
- if len(bundle_b64) > _MAX_BUNDLE_B64_CHARS:
57
- raise_payload_too_large(message=f"bundle_b64 exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB compressed-size limit")
59
+ # A zip is compressed, so a bundle whose decompressed content is within the ceiling is an archive well under it. Its
60
+ # size is read off the encoded length, so a ~100 MiB request body, the only other bound, is refused before it is
61
+ # expanded into ~75 MiB of heap just to be refused later by the decompressed-size guard.
62
+ archive_bytes = base64_decoded_size(encoded=bundle_b64)
63
+ if archive_bytes > MAX_BUNDLE_TOTAL_BYTES:
64
+ msg = f"bundle_b64 exceeds the {MAX_BUNDLE_TOTAL_BYTES // 1024} KiB compressed-size limit"
65
+ raise (
66
+ RunnerRequestError(msg, field=_BUNDLE_FIELD)
67
+ .with_error_code(CoreCode.REQUEST_TOO_LARGE)
68
+ .with_params(limit=MAX_BUNDLE_TOTAL_BYTES, size=archive_bytes)
69
+ )
58
70
  try:
59
71
  raw = base64.b64decode(bundle_b64, validate=True)
60
72
  except (binascii.Error, ValueError) as decode_error:
61
73
  # DEBUG: the refusal below is a caller's mistake, which the error handler already logs; this keeps the cause.
62
74
  log.debug("A bundle's base64 could not be decoded", fields=error_fields(exc=decode_error))
63
- raise_bad_request(message="bundle_b64 is not valid base64", error_type=ErrorType.INVALID_BASE64)
75
+ msg = "bundle_b64 is not valid base64"
76
+ raise (
77
+ RunnerRequestError(msg, field=_BUNDLE_FIELD)
78
+ .with_error_code(CoreCode.REQUEST_MALFORMED)
79
+ .with_params(reason=RequestMalformedReason.BAD_BASE64)
80
+ ) from decode_error
64
81
 
65
82
  try:
66
83
  archive = zipfile.ZipFile(BytesIO(raw))
67
84
  except zipfile.BadZipFile as zip_error:
68
85
  # DEBUG: the refusal below is a caller's mistake, which the error handler already logs; this keeps the cause.
69
86
  log.debug("A bundle is not a valid zip archive", fields=error_fields(exc=zip_error))
70
- raise_validation_error(message="bundle_b64 is not a valid zip archive", error_type=ErrorType.INVALID_BUNDLE)
87
+ msg = "bundle_b64 is not a valid zip archive"
88
+ raise (
89
+ RunnerRequestError(msg, field=_BUNDLE_FIELD)
90
+ .with_error_code(CoreCode.REQUEST_MALFORMED)
91
+ .with_params(reason=RequestMalformedReason.BAD_ZIP)
92
+ ) from zip_error
71
93
 
72
94
  entries: list[tuple[str, bytes]] = []
73
95
  total_bytes = 0
@@ -0,0 +1,95 @@
1
+ """Decode the files a request sends in `resources`, the files its bundle's steps read, such as a template file.
2
+
3
+ `resources` is a JSON object mapping each file's bundle-relative path, the exact path a step names (a `PipeDocGen`
4
+ step's `template_file`), to the file's bytes in standard base64. Beside `mthds_contents`, every bundle of the request is
5
+ read as sitting in one directory, so one path holds one file, and the decoded files go to the runtime as its
6
+ `submitted_files`, keyed by canonical path, which refuses a file none of the bundle's steps names once the bundle is
7
+ parsed, and holds the rest as the files of the tree the bundle sits at the root of. Beside a method tree (`files` or
8
+ `bundle_b64`), the decoded files join the tree at their paths from its root (`pipelex_api.method_tree_request`), where
9
+ the tree's rule judges them as it judges the tree's own files.
10
+
11
+ Refusals, in the order they are checked:
12
+
13
+ - more `resources` than `MAX_RESOURCES_PER_REQUEST` → 422 `request.invalid`, naming the field;
14
+ - a path that leaves the bundle (absolute, a drive, a `..` segment) → 422 `security.input_path_refused`, with the rule
15
+ it breaks, before any value is decoded, which also logs a security event, as a key of a method tree does;
16
+ - a path of the wrong shape (a backslash, a colon, empty, or a second spelling of a path already submitted) → 422
17
+ `request.invalid`, naming the field;
18
+ - a file whose size, read off its encoded length, exceeds `MAX_RESOURCE_FILE_BYTES` → 413 `request.too_large`, before
19
+ decoding;
20
+ - a value that is not valid base64 → 422 `request.malformed`, as `bundle_b64` is;
21
+ - a running total of decoded files over `MAX_RESOURCES_TOTAL_BYTES` → 413 `request.too_large`.
22
+
23
+ Every `request.too_large` states its `limit` and its `size` in bytes, never in encoded characters.
24
+ """
25
+
26
+ import base64
27
+ import binascii
28
+ from typing import NoReturn
29
+
30
+ from mthds.protocol.problem.registry import CoreCode, RequestMalformedReason
31
+ from pipelex import log
32
+ from pipelex.core.bundle_resources.bundle_resource import SUBMITTED_FILES_FIELD, canonical_resource_path
33
+ from pipelex.tools.log.error_fields import error_fields
34
+
35
+ from pipelex_api.exceptions import RunnerRequestError
36
+ from pipelex_api.limits import MAX_RESOURCE_FILE_BYTES, MAX_RESOURCES_PER_REQUEST, MAX_RESOURCES_TOTAL_BYTES, base64_decoded_size
37
+
38
+
39
+ def refuse_two_files_under_one_path(*, path: str, where: str) -> NoReturn:
40
+ """Refuse two different files sent under one path, naming the path, with a 422 `request.invalid` on `resources`."""
41
+ msg = (
42
+ f"Two different files are named '{path}', {where}. A path names one file of a method: send one of them, or rename "
43
+ "one and the step that reads it."
44
+ )
45
+ raise RunnerRequestError(msg, field=SUBMITTED_FILES_FIELD)
46
+
47
+
48
+ def _too_large(*, message: str, limit: int, size: int) -> RunnerRequestError:
49
+ """The refusal of submitted files past one of this runner's byte limits, a 413 `request.too_large`."""
50
+ return RunnerRequestError(message, field=SUBMITTED_FILES_FIELD).with_error_code(CoreCode.REQUEST_TOO_LARGE).with_params(limit=limit, size=size)
51
+
52
+
53
+ def decode_bundle_resources(*, resources: dict[str, str] | None) -> dict[str, bytes] | None:
54
+ """The files `resources` sends, decoded and keyed by canonical path, or None when there are none."""
55
+ if not resources:
56
+ return None
57
+ if len(resources) > MAX_RESOURCES_PER_REQUEST:
58
+ # A count, not bytes, so not `request.too_large`, whose parameters are sizes in bytes: the field is refused.
59
+ msg = f"resources exceeds the {MAX_RESOURCES_PER_REQUEST}-file limit (got {len(resources)})"
60
+ raise RunnerRequestError(msg, field=SUBMITTED_FILES_FIELD)
61
+ # Every path first, so a second spelling of one file is refused as such before any value is decoded. A path that
62
+ # leaves the bundle is a security rule's refusal (`security.input_path_refused`), whose security event the error
63
+ # handler logs; one of the wrong shape is the request's (`request.invalid`), and logs none. Both propagate as raised.
64
+ sent_by_path: dict[str, tuple[str, str]] = {}
65
+ for path, encoded in resources.items():
66
+ canonical_path = canonical_resource_path(path=path)
67
+ if canonical_path in sent_by_path:
68
+ msg = f"Two files submitted with the bundle name the same file '{canonical_path}'."
69
+ raise RunnerRequestError(msg, field=SUBMITTED_FILES_FIELD)
70
+ sent_by_path[canonical_path] = (path, encoded)
71
+ files: dict[str, bytes] = {}
72
+ total_bytes = 0
73
+ for canonical_path, (path, encoded) in sent_by_path.items():
74
+ # Read off the encoded length, so an oversized file is refused before it is decoded into memory.
75
+ file_bytes = base64_decoded_size(encoded=encoded)
76
+ if file_bytes > MAX_RESOURCE_FILE_BYTES:
77
+ msg = f"The file '{path}' in resources exceeds the {MAX_RESOURCE_FILE_BYTES // 1024} KiB per-file limit"
78
+ raise _too_large(message=msg, limit=MAX_RESOURCE_FILE_BYTES, size=file_bytes)
79
+ try:
80
+ data = base64.b64decode(encoded, validate=True)
81
+ except (binascii.Error, ValueError) as decode_error:
82
+ # DEBUG: the refusal below is a caller's mistake, which the error handler already logs; this keeps the cause.
83
+ log.debug("A submitted file's base64 could not be decoded", fields=error_fields(exc=decode_error))
84
+ msg = f"The file '{path}' in resources is not valid base64"
85
+ raise (
86
+ RunnerRequestError(msg, field=SUBMITTED_FILES_FIELD)
87
+ .with_error_code(CoreCode.REQUEST_MALFORMED)
88
+ .with_params(reason=RequestMalformedReason.BAD_BASE64)
89
+ ) from decode_error
90
+ total_bytes += len(data)
91
+ if total_bytes > MAX_RESOURCES_TOTAL_BYTES:
92
+ msg = f"The files the bundle's steps read exceed the {MAX_RESOURCES_TOTAL_BYTES // 1024} KiB total-size limit"
93
+ raise _too_large(message=msg, limit=MAX_RESOURCES_TOTAL_BYTES, size=total_bytes)
94
+ files[canonical_path] = data
95
+ return files