pipelex-api 0.84.0__tar.gz → 0.85.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 (43) hide show
  1. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/PKG-INFO +2 -2
  2. pipelex_api-0.85.0/pipelex_api/bundle_resources.py +148 -0
  3. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/error_types.py +4 -0
  4. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/exception_handlers.py +5 -0
  5. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/limits.py +7 -0
  6. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/crate_ops.py +3 -0
  7. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/pipeline.py +42 -9
  8. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/validate.py +12 -2
  9. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/schemas/models.py +38 -0
  10. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/.gitignore +0 -0
  11. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/LICENSE +0 -0
  12. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/README.md +0 -0
  13. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/hatch_build.py +0 -0
  14. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/__init__.py +0 -0
  15. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/api.toml +0 -0
  16. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/api_config.py +0 -0
  17. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/bundle.py +0 -0
  18. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/disclosure.py +0 -0
  19. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/error_uri.py +0 -0
  20. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/errors.py +0 -0
  21. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/json_body.py +0 -0
  22. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/main.py +0 -0
  23. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/method_cache.py +0 -0
  24. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/method_source.py +0 -0
  25. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/middleware.py +0 -0
  26. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/openapi_responses.py +0 -0
  27. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/openapi_schema.py +0 -0
  28. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/problem_document.py +0 -0
  29. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/py.typed +0 -0
  30. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/__init__.py +0 -0
  31. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/health.py +0 -0
  32. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/__init__.py +0 -0
  33. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/agent/__init__.py +0 -0
  34. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/agent/models.py +0 -0
  35. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/codegen.py +0 -0
  36. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/pipe_io.py +0 -0
  37. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/resolve.py +0 -0
  38. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/tools.py +0 -0
  39. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/utils.py +0 -0
  40. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/version.py +0 -0
  41. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/schemas/__init__.py +0 -0
  42. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/security.py +0 -0
  43. {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pipelex-api
3
- Version: 0.84.0
3
+ Version: 0.85.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.84.0
19
+ Requires-Dist: pipelex[anthropic,bedrock,fal,google,google-genai,mistralai]==0.85.0
20
20
  Requires-Dist: pyjwt>=2.10.1
21
21
  Requires-Dist: uvicorn>=0.37.0
22
22
  Provides-Extra: dev
@@ -0,0 +1,148 @@
1
+ """Decode the files a request submits with its bundle that its steps read, such as a template file.
2
+
3
+ They come in two ways. `resources` is a JSON object mapping each file's bundle-relative path, the exact path a step
4
+ names (a `PipeDocGen` step's `template_file`), to the file's bytes in standard base64, beside `mthds_contents` or a
5
+ method bundle. A method bundle (`bundle_b64` or `files`) may also ship the file beside its `.mthds` file, which
6
+ `files_its_steps_read` picks out. Every bundle of the request is read as sitting in one directory, so one path holds
7
+ one file. The decoded files go to the runtime as `BundleResources`, which refuses a file none of the bundle's steps
8
+ names once the bundle is parsed.
9
+
10
+ Refusals, in the order they are checked:
11
+
12
+ - more `resources` than `MAX_RESOURCES_PER_REQUEST` → 413 `PayloadTooLarge`;
13
+ - a path that cannot name a file inside the bundle (absolute, a `..` segment, a backslash, a colon, empty, or a
14
+ second spelling of a path already submitted) → 422 `InvalidResourcePath`, before any value is decoded;
15
+ - more files than `MAX_RESOURCES_PER_REQUEST`, counting those the method bundle ships → 413 `PayloadTooLarge`;
16
+ - a file whose encoded length already exceeds the per-file ceiling → 413 `PayloadTooLarge`, before decoding;
17
+ - a value that is not valid base64 → 400 `InvalidBase64`, as `bundle_b64` is;
18
+ - two different files under one path, sent in `resources` and shipped in the bundle, or shipped beside two of its
19
+ `.mthds` files → 422 `InvalidResourcePath`;
20
+ - a decoded or shipped file over `MAX_RESOURCE_FILE_BYTES`, or a running total over `MAX_RESOURCES_TOTAL_BYTES` → 413.
21
+ """
22
+
23
+ import base64
24
+ import binascii
25
+ from collections.abc import Iterable, Mapping
26
+ from pathlib import PurePosixPath
27
+ from typing import NoReturn
28
+
29
+ from pipelex import log
30
+ from pipelex.core.bundle_resources.bundle_resource import (
31
+ BundleResources,
32
+ canonical_resource_path,
33
+ canonical_resource_path_or_none,
34
+ make_bundle_resources,
35
+ )
36
+ from pipelex.core.bundle_resources.exceptions import BundleResourcePathError
37
+ from pipelex.mthds_parsing.exceptions import MthdsParserError
38
+ from pipelex.mthds_parsing.parser import MthdsParser
39
+ from pipelex.tools.log.error_fields import error_fields
40
+
41
+ from pipelex_api.error_types import ErrorType
42
+ from pipelex_api.errors import raise_bad_request, raise_payload_too_large, raise_validation_error
43
+ from pipelex_api.limits import MAX_RESOURCE_FILE_BYTES, MAX_RESOURCES_PER_REQUEST, MAX_RESOURCES_TOTAL_BYTES
44
+
45
+ # Base64 inflates by 4/3, so a file within the ceiling encodes to at most this many characters (+4 covers padding).
46
+ # Checking the encoded length first refuses an oversized file before it is decoded into memory.
47
+ _MAX_RESOURCE_B64_CHARS = MAX_RESOURCE_FILE_BYTES * 4 // 3 + 4
48
+
49
+
50
+ def _refuse_two_files_under_one_path(*, path: str, where: str) -> NoReturn:
51
+ msg = (
52
+ f"Two different files are named '{path}', {where}. Every bundle of a request reads its files as if it sat in one "
53
+ "directory, so a path names one file: send one of them, or rename one and the step that reads it."
54
+ )
55
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_RESOURCE_PATH)
56
+
57
+
58
+ def files_its_steps_read(
59
+ *, mthds_entries: Iterable[tuple[PurePosixPath, str]], other_entries: Iterable[tuple[PurePosixPath, bytes]]
60
+ ) -> dict[str, bytes]:
61
+ """The files a method bundle ships beside its `.mthds` files that their steps read, keyed by the path a step names.
62
+
63
+ Each `.mthds` file is parsed for the files its steps name, such as a `PipeDocGen` step's `template_file`, which is
64
+ looked up relative to that file's own directory in the bundle. The bundle's `.mthds` files load as content strings,
65
+ so these files travel with them as the `resources` a request sends do, wherever the run goes. A file a step names
66
+ that the bundle does not ship is left out, for `resources` to supply or the load to refuse saying so, and a `.mthds`
67
+ file that does not parse is skipped, for the load to give its verdict on.
68
+
69
+ Raises (as a 422 `InvalidResourcePath`): two steps naming one path, beside two `.mthds` files that ship different files.
70
+ """
71
+ shipped = dict(other_entries)
72
+ files: dict[str, bytes] = {}
73
+ for mthds_relpath, mthds_content in mthds_entries:
74
+ try:
75
+ blueprint = MthdsParser.make_pipelex_bundle_blueprint(mthds_content=mthds_content)
76
+ except MthdsParserError as exc:
77
+ log.debug("A method bundle's file did not parse, so the files its steps read are left to its load", fields=error_fields(exc=exc))
78
+ continue
79
+ for pipe_blueprint in (blueprint.pipe or {}).values():
80
+ for named_path in pipe_blueprint.bundle_resource_reads:
81
+ canonical_path = canonical_resource_path_or_none(path=named_path)
82
+ if canonical_path is None:
83
+ continue
84
+ data = shipped.get(mthds_relpath.parent / canonical_path)
85
+ if data is None:
86
+ continue
87
+ known = files.get(canonical_path)
88
+ if known is not None and known != data:
89
+ _refuse_two_files_under_one_path(path=canonical_path, where="shipped beside two of the bundle's .mthds files")
90
+ files[canonical_path] = data
91
+ return files
92
+
93
+
94
+ def decode_bundle_resources(*, resources: dict[str, str] | None, bundle_files: Mapping[str, bytes] | None = None) -> BundleResources | None:
95
+ """The files the bundle's steps read, decoded and keyed by canonical path, or None when there are none.
96
+
97
+ `resources` are the ones the request sends, in base64. `bundle_files` are the ones its method bundle ships, as
98
+ `files_its_steps_read` picks them out. Both are held to the same limits, together, and a file in both must be the same.
99
+ """
100
+ if not resources and not bundle_files:
101
+ return None
102
+ sent = resources or {}
103
+ shipped = bundle_files or {}
104
+ if len(sent) > MAX_RESOURCES_PER_REQUEST:
105
+ raise_payload_too_large(message=f"resources exceeds the {MAX_RESOURCES_PER_REQUEST}-file limit (got {len(sent)})")
106
+ # Every path first, so a second spelling of one file is refused as such before any value is decoded.
107
+ sent_by_path: dict[str, tuple[str, str]] = {}
108
+ for path, encoded in sent.items():
109
+ try:
110
+ canonical_path = canonical_resource_path(path=path)
111
+ except BundleResourcePathError as exc:
112
+ raise_validation_error(message=exc.message, error_type=ErrorType.INVALID_RESOURCE_PATH)
113
+ if canonical_path in sent_by_path:
114
+ msg = f"Two files submitted with the bundle name the same file '{canonical_path}'."
115
+ raise_validation_error(message=msg, error_type=ErrorType.INVALID_RESOURCE_PATH)
116
+ sent_by_path[canonical_path] = (path, encoded)
117
+ file_count = len(sent_by_path.keys() | shipped.keys())
118
+ if file_count > MAX_RESOURCES_PER_REQUEST:
119
+ msg = (
120
+ f"The files the bundle's steps read, in resources and in the bundle, exceed the {MAX_RESOURCES_PER_REQUEST}-file limit (got {file_count})"
121
+ )
122
+ raise_payload_too_large(message=msg)
123
+ files: dict[str, bytes] = {}
124
+ total_bytes = 0
125
+ for canonical_path, (path, encoded) in sent_by_path.items():
126
+ if len(encoded) > _MAX_RESOURCE_B64_CHARS:
127
+ raise_payload_too_large(message=f"The file '{path}' in resources exceeds the {MAX_RESOURCE_FILE_BYTES // 1024} KiB per-file limit")
128
+ try:
129
+ data = base64.b64decode(encoded, validate=True)
130
+ except (binascii.Error, ValueError) as decode_error:
131
+ # DEBUG: the refusal below is a caller's mistake, which the error handler already logs; this keeps the cause.
132
+ log.debug("A submitted file's base64 could not be decoded", fields=error_fields(exc=decode_error))
133
+ raise_bad_request(message=f"The file '{path}' in resources is not valid base64", error_type=ErrorType.INVALID_BASE64)
134
+ shipped_data = shipped.get(canonical_path)
135
+ if shipped_data is not None and shipped_data != data:
136
+ _refuse_two_files_under_one_path(path=canonical_path, where="one sent in resources and one shipped in the bundle")
137
+ files[canonical_path] = data
138
+ for canonical_path, data in shipped.items():
139
+ files.setdefault(canonical_path, data)
140
+ for canonical_path, data in files.items():
141
+ if len(data) > MAX_RESOURCE_FILE_BYTES:
142
+ raise_payload_too_large(
143
+ message=f"The file '{canonical_path}' a step reads exceeds the {MAX_RESOURCE_FILE_BYTES // 1024} KiB per-file limit"
144
+ )
145
+ total_bytes += len(data)
146
+ if total_bytes > MAX_RESOURCES_TOTAL_BYTES:
147
+ raise_payload_too_large(message=f"The files the bundle's steps read exceed the {MAX_RESOURCES_TOTAL_BYTES // 1024} KiB total-size limit")
148
+ return make_bundle_resources(files=files)
@@ -61,6 +61,10 @@ class ErrorType(StrEnum):
61
61
  # A run request carried a malformed method bundle (`bundle_b64` / `files`): a corrupt zip, an
62
62
  # unsafe entry name (absolute path or `..` traversal), or both transport forms supplied at once.
63
63
  INVALID_BUNDLE = "InvalidBundle"
64
+ # A request's `resources` map keys a submitted file by a path that cannot name a file inside the
65
+ # bundle: absolute, with a `..` segment, a backslash or a colon, empty, or a second spelling of a
66
+ # path already submitted. A 422: the key is the path a step names, relative to the bundle.
67
+ INVALID_RESOURCE_PATH = "InvalidResourcePath"
64
68
  # A method carrying custom Python (`.py` in a bundle, or in a fetched `method_ref` package)
65
69
  # reached a deployment that is NOT sandbox-hosted. Running customer code in-process is
66
70
  # refused (403): custom code is a sandbox-hosted capability only. Use a sandbox-hosted
@@ -341,6 +341,11 @@ _ERROR_TYPE_STATUS_OVERRIDES: dict[str, int] = {
341
341
  # sandboxed PipeFuncs, not in-process Python) → 403, the same status as the bundle
342
342
  # transport's custom-code gate.
343
343
  "MethodStructuresRefusedError": 403,
344
+ # The files submitted in `resources` that the bundle's steps read when the method loads, its HTML templates,
345
+ # total more than they may, since they travel inline with the method wherever it runs. Only the parsed bundle
346
+ # says which files those are, so the library refuses them rather than this server's decoding: the request
347
+ # entity itself is too large → 413, as the decoder's own size refusals are.
348
+ "BundleResourcesTooLargeError": 413,
344
349
  }
345
350
 
346
351
 
@@ -25,6 +25,9 @@ DEFAULT_MAX_CALLBACK_URLS = 5
25
25
  DEFAULT_MAX_CALLBACK_URL_LEN = 2048
26
26
  DEFAULT_MAX_BUNDLE_FILES = 128 # entries in a materialized method bundle (.mthds + .py + requirements.txt)
27
27
  DEFAULT_MAX_BUNDLE_TOTAL_KIB = 8 * 1024 # 8 MiB decompressed across the whole bundle (zip-bomb guard)
28
+ DEFAULT_MAX_RESOURCES_PER_REQUEST = 32 # files submitted with a bundle that its steps read (`resources`)
29
+ DEFAULT_MAX_RESOURCE_FILE_KIB = 5 * 1024 # 5 MiB decoded per submitted file, such as an office template
30
+ DEFAULT_MAX_RESOURCES_TOTAL_KIB = 10 * 1024 # 10 MiB decoded across a request's submitted files
28
31
  DEFAULT_MAX_METHOD_CACHE_CLONES = 64 # cached method-package clones (one per resolved commit SHA)
29
32
  DEFAULT_MAX_METHOD_CACHE_TOTAL_KIB = 512 * 1024 # 512 MiB across all cached clones
30
33
  DEFAULT_MAX_METHOD_CACHE_AGE_HOURS = 24 # a cached clone unused for this long is evicted
@@ -43,6 +46,10 @@ MAX_CALLBACK_URL_LEN = get_positive_int_env(env_var="MAX_CALLBACK_URL_LEN", defa
43
46
  MAX_BUNDLE_FILES = get_positive_int_env(env_var="MAX_BUNDLE_FILES", default=DEFAULT_MAX_BUNDLE_FILES)
44
47
  MAX_BUNDLE_TOTAL_BYTES = get_positive_int_env(env_var="MAX_BUNDLE_TOTAL_KIB", default=DEFAULT_MAX_BUNDLE_TOTAL_KIB) * 1024
45
48
 
49
+ MAX_RESOURCES_PER_REQUEST = get_positive_int_env(env_var="MAX_RESOURCES_PER_REQUEST", default=DEFAULT_MAX_RESOURCES_PER_REQUEST)
50
+ MAX_RESOURCE_FILE_BYTES = get_positive_int_env(env_var="MAX_RESOURCE_FILE_KIB", default=DEFAULT_MAX_RESOURCE_FILE_KIB) * 1024
51
+ MAX_RESOURCES_TOTAL_BYTES = get_positive_int_env(env_var="MAX_RESOURCES_TOTAL_KIB", default=DEFAULT_MAX_RESOURCES_TOTAL_KIB) * 1024
52
+
46
53
  MAX_METHOD_CACHE_CLONES = get_positive_int_env(env_var="MAX_METHOD_CACHE_CLONES", default=DEFAULT_MAX_METHOD_CACHE_CLONES)
47
54
  MAX_METHOD_CACHE_TOTAL_BYTES = get_positive_int_env(env_var="MAX_METHOD_CACHE_TOTAL_KIB", default=DEFAULT_MAX_METHOD_CACHE_TOTAL_KIB) * 1024
48
55
  MAX_METHOD_CACHE_AGE_SECONDS = get_positive_int_env(env_var="MAX_METHOD_CACHE_AGE_HOURS", default=DEFAULT_MAX_METHOD_CACHE_AGE_HOURS) * 3600
@@ -27,6 +27,7 @@ from pipelex.pipeline.resolve_bundle import resolve_crate_from_contents
27
27
  from pipelex.tools.typing.pydantic_utils import empty_list_factory_of
28
28
  from pydantic import BaseModel, Field
29
29
 
30
+ from pipelex_api.bundle_resources import decode_bundle_resources
30
31
  from pipelex_api.error_types import ErrorType
31
32
  from pipelex_api.errors import raise_not_implemented
32
33
  from pipelex_api.method_source import fetch_method_mthds_files
@@ -151,6 +152,8 @@ def resolve_requested_crate(request_data: MthdsFilesRequest) -> ResolvedClosure:
151
152
  crate = resolve_crate_from_contents(
152
153
  mthds_contents=[item.content for item in selection.files],
153
154
  mthds_sources=[item.source for item in selection.files],
155
+ # The files the closure's steps read, such as a PipeDocGen template file, which the load reads.
156
+ bundle_resources=decode_bundle_resources(resources=request_data.resources),
154
157
  )
155
158
  return ResolvedClosure(crate=crate, files=selection.files, manifest_main_pipe=selection.manifest_main_pipe)
156
159
 
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import hashlib
4
4
  import hmac
5
5
  from contextlib import ExitStack, contextmanager
6
- from pathlib import Path
6
+ from pathlib import Path, PurePosixPath
7
7
  from typing import TYPE_CHECKING, Annotated, Any, NamedTuple, cast
8
8
 
9
9
  from fastapi import APIRouter, Depends, Request
@@ -14,11 +14,13 @@ from pipelex.core.pipes.pipe_output import PipeOutput
14
14
  from pipelex.methods.structures_check import ensure_no_structured_content_python
15
15
  from pipelex.pipe_run.delivery_assignment import DeliveryAssignment, StorageTarget, WebhookTarget
16
16
  from pipelex.pipe_run.pipe_run_protocol import PipeRunProtocol
17
+ from pipelex.pipe_run.remote_dispatch import prepare_job_for_remote_dispatch
17
18
  from pipelex.pipeline.pipeline_response import PipelexRunResultExecute, PipelexRunResultStart, RunState
18
19
  from pipelex.pipeline.pipeline_run_setup import pipeline_run_setup
19
20
  from pipelex.pipeline.runner import PipelexMTHDSProtocol
20
21
  from pipelex.reporting.usage_records import apply_tokens_usage_wire_shape
21
22
  from pipelex.runtime_bridge.exceptions import MissingBundleValidatorError, MissingOrchestratorError
23
+ from pipelex.runtime_bridge.orchestration_mode import DIRECT_ORCHESTRATION_MODE
22
24
  from pipelex.runtime_bridge.primitives.hydration import hydrate_working_memory
23
25
  from pipelex.runtime_hub import get_bundle_validator_registry, get_orchestrator_registry
24
26
  from pipelex.system.environment import get_required_env
@@ -35,6 +37,7 @@ from pipelex_api.bundle import (
35
37
  parse_bundle,
36
38
  partition_bundle_entries,
37
39
  )
40
+ from pipelex_api.bundle_resources import decode_bundle_resources, files_its_steps_read
38
41
  from pipelex_api.error_types import ErrorType
39
42
  from pipelex_api.errors import raise_bad_request, raise_forbidden, raise_validation_error
40
43
  from pipelex_api.json_body import decode_json_body
@@ -227,6 +230,18 @@ def _pipe_output_from_run_output(run_output: PipelexPipeRunOutput) -> PipeOutput
227
230
  )
228
231
 
229
232
 
233
+ async def _prepare_job_for_dispatch(*, pipe_job: PipeJob, orchestration_mode: str) -> PipeJob:
234
+ """The job as the mode's orchestrator receives it: prepared to leave this process for every mode but `direct`.
235
+
236
+ The files submitted with a bundle travel in the job's crate. An orchestrator other than the in-process `direct`
237
+ one, such as Temporal, runs the job outside this process, so the files its steps read only when they run move to
238
+ the run's storage first, dry runs included (`prepare_job_for_remote_dispatch`). A `direct` run keeps them inline.
239
+ """
240
+ if orchestration_mode == DIRECT_ORCHESTRATION_MODE:
241
+ return pipe_job
242
+ return await prepare_job_for_remote_dispatch(pipe_job=pipe_job)
243
+
244
+
230
245
  class _OrchestratorPipeRun(PipeRunProtocol):
231
246
  """Adapts a mode-selected orchestrator to the `PipeRunProtocol` the base `execute` drives.
232
247
 
@@ -241,12 +256,14 @@ class _OrchestratorPipeRun(PipeRunProtocol):
241
256
  synchronous and always drives this arm, so there is no delivery axis to thread here.
242
257
  """
243
258
 
244
- def __init__(self, *, orchestrator: OrchestratorProtocol) -> None:
259
+ def __init__(self, *, orchestrator: OrchestratorProtocol, orchestration_mode: str) -> None:
245
260
  self._orchestrator = orchestrator
261
+ self._orchestration_mode = orchestration_mode
246
262
 
247
263
  @override
248
264
  async def run(self, pipe_job: PipeJob, *, delivery_assignment: DeliveryAssignment | None = None) -> PipeOutput:
249
- run_output = await self._orchestrator.execute(pipe_job=pipe_job, delivery_assignment=delivery_assignment)
265
+ dispatched_job = await _prepare_job_for_dispatch(pipe_job=pipe_job, orchestration_mode=self._orchestration_mode)
266
+ run_output = await self._orchestrator.execute(pipe_job=dispatched_job, delivery_assignment=delivery_assignment)
250
267
  return _pipe_output_from_run_output(run_output)
251
268
 
252
269
 
@@ -313,7 +330,7 @@ class ApiRunner(PipelexMTHDSProtocol):
313
330
  # PipeRun, then delegate to the base execute, which owns the full run lifecycle. The
314
331
  # ApiRunner is constructed per request, so mutating _pipe_run here is request-scoped.
315
332
  # `/execute` is synchronous, so it drives the orchestrator's BLOCKING `execute` arm.
316
- self._pipe_run = _OrchestratorPipeRun(orchestrator=orchestrator)
333
+ self._pipe_run = _OrchestratorPipeRun(orchestrator=orchestrator, orchestration_mode=orchestration_mode)
317
334
  return await super().execute(
318
335
  pipe_code=pipe_code,
319
336
  mthds_contents=mthds_contents,
@@ -417,6 +434,8 @@ class ApiRunner(PipelexMTHDSProtocol):
417
434
  extras=self.extras,
418
435
  # The packages the request ships, which the base `execute` threads too.
419
436
  methods_dirs=self.methods_dirs,
437
+ # The files submitted with the contents, such as a template file, which the base `execute` threads too.
438
+ bundle_resources=self.bundle_resources,
420
439
  pipeline_run_id=pipeline_run_id,
421
440
  request_id=request_id,
422
441
  )
@@ -455,7 +474,8 @@ class ApiRunner(PipelexMTHDSProtocol):
455
474
  # `PipelexPipeRunInput` (which carries no request_id / output_multiplicity /
456
475
  # dynamic_output_concept_ref and skips run registration + telemetry). `start` genuinely
457
476
  # enqueues the job and returns a `PipelexPipeDispatchAck` (ids only) immediately.
458
- dispatch_ack = await orchestrator.start(pipe_job=pipe_job, delivery_assignment=delivery_assignment)
477
+ dispatched_job = await _prepare_job_for_dispatch(pipe_job=pipe_job, orchestration_mode=orchestration_mode)
478
+ dispatch_ack = await orchestrator.start(pipe_job=dispatched_job, delivery_assignment=delivery_assignment)
459
479
 
460
480
  return PipelexRunResultStart(
461
481
  pipeline_run_id=resolved_pipeline_run_id,
@@ -517,6 +537,8 @@ class ApiRunner(PipelexMTHDSProtocol):
517
537
  # done for the caller this runner was built for — the user and groups a run would state.
518
538
  caller_identity=self.caller_identity,
519
539
  graph_pipe_code=graph_pipe_code,
540
+ # The files submitted with the contents, which the validation's dry run reads, the template check included.
541
+ bundle_resources=self.bundle_resources,
520
542
  )
521
543
  # The core seam types its valid arm at the protocol-level ValidationReport (a leaf type)
522
544
  # to stay import-acyclic in core; every registered validator in fact produces the canonical
@@ -701,6 +723,8 @@ class _BundleRunSource(NamedTuple):
701
723
  mthds_contents: list[str] | None
702
724
  library_dirs: list[str] | None
703
725
  methods_dirs: list[Path] | None
726
+ bundle_files: dict[str, bytes]
727
+ """The files a method bundle ships beside its `.mthds` files that their steps read, keyed by the path a step names."""
704
728
 
705
729
 
706
730
  def _ensure_vendored_packages_hold_no_structures(*, methods_dir: Path, packages: tuple[VendoredMethodPackage, ...]) -> None:
@@ -746,7 +770,7 @@ def _bundle_run_source(run_request: RunRequest) -> Generator[_BundleRunSource, N
746
770
  rather than executing untrusted code.
747
771
  """
748
772
  if run_request.bundle_b64 is None and run_request.files is None:
749
- yield _BundleRunSource(mthds_contents=run_request.mthds_contents, library_dirs=None, methods_dirs=None)
773
+ yield _BundleRunSource(mthds_contents=run_request.mthds_contents, library_dirs=None, methods_dirs=None, bundle_files={})
750
774
  return
751
775
  # Parse + guard in memory FIRST, then apply the sandbox-hosted gate BEFORE any disk write —
752
776
  # a bundle destined for a 403 on a non-hosted deployment never touches the filesystem.
@@ -758,12 +782,13 @@ def _bundle_run_source(run_request: RunRequest) -> Generator[_BundleRunSource, N
758
782
  # (`.py`, `requirements.txt`) → a temp `library_dirs` entry the load path source-captures; the packages it
759
783
  # ships → a temp methods directory.
760
784
  partition = partition_bundle_entries(parsed.entries)
761
- mthds_contents: list[str] = []
785
+ mthds_entries: list[tuple[PurePosixPath, str]] = []
762
786
  for relpath, content in partition.mthds_entries:
763
787
  try:
764
- mthds_contents.append(content.decode("utf-8"))
788
+ mthds_entries.append((relpath, content.decode("utf-8")))
765
789
  except UnicodeDecodeError:
766
790
  raise_validation_error(message=f"Bundle .mthds file '{relpath}' is not valid UTF-8.", error_type=ErrorType.INVALID_BUNDLE)
791
+ mthds_contents = [content for _, content in mthds_entries]
767
792
  if not mthds_contents:
768
793
  msg = "Method bundle contains no .mthds file."
769
794
  if partition.vendored_packages:
@@ -772,6 +797,8 @@ def _bundle_run_source(run_request: RunRequest) -> Generator[_BundleRunSource, N
772
797
  "packages it ships, and the bundle's own .mthds files go outside '.mthds/'."
773
798
  )
774
799
  raise_validation_error(message=msg, error_type=ErrorType.INVALID_BUNDLE)
800
+ # The `.mthds` files load as content strings, so a template file shipped beside one travels with it as a resource.
801
+ bundle_files = files_its_steps_read(mthds_entries=mthds_entries, other_entries=partition.library_entries)
775
802
  with ExitStack() as stack:
776
803
  library_dirs: list[str] | None = None
777
804
  if partition.library_entries:
@@ -783,7 +810,7 @@ def _bundle_run_source(run_request: RunRequest) -> Generator[_BundleRunSource, N
783
810
  if is_pipe_func_sandbox_hosted():
784
811
  _ensure_vendored_packages_hold_no_structures(methods_dir=methods_dir, packages=partition.vendored_packages)
785
812
  methods_dirs = [methods_dir]
786
- yield _BundleRunSource(mthds_contents=mthds_contents, library_dirs=library_dirs, methods_dirs=methods_dirs)
813
+ yield _BundleRunSource(mthds_contents=mthds_contents, library_dirs=library_dirs, methods_dirs=methods_dirs, bundle_files=bundle_files)
787
814
 
788
815
 
789
816
  class _ResolvedRunSource(NamedTuple):
@@ -794,6 +821,8 @@ class _ResolvedRunSource(NamedTuple):
794
821
  methods_dirs: list[Path] | None
795
822
  pipe_code: str | None
796
823
  method_provenance: MethodProvenance | None
824
+ bundle_files: dict[str, bytes]
825
+ """The files a method bundle ships that its steps read, which join the request's `resources`."""
797
826
 
798
827
 
799
828
  @contextmanager
@@ -817,6 +846,7 @@ def _run_source(run_request: RunRequest) -> Generator[_ResolvedRunSource, None,
817
846
  methods_dirs=fetched.methods_dirs,
818
847
  pipe_code=run_request.pipe_code or fetched.main_pipe,
819
848
  method_provenance=fetched.provenance,
849
+ bundle_files={},
820
850
  )
821
851
  return
822
852
  with _bundle_run_source(run_request) as bundle_source:
@@ -826,6 +856,7 @@ def _run_source(run_request: RunRequest) -> Generator[_ResolvedRunSource, None,
826
856
  methods_dirs=bundle_source.methods_dirs,
827
857
  pipe_code=run_request.pipe_code,
828
858
  method_provenance=None,
859
+ bundle_files=bundle_source.bundle_files,
829
860
  )
830
861
 
831
862
 
@@ -878,6 +909,7 @@ async def execute(request: Request) -> JSONResponse:
878
909
  extras=extras.analytics_groups,
879
910
  library_dirs=source.library_dirs,
880
911
  methods_dirs=source.methods_dirs,
912
+ bundle_resources=decode_bundle_resources(resources=run_request.resources, bundle_files=source.bundle_files),
881
913
  )
882
914
  response = await runner.execute(
883
915
  pipe_code=source.pipe_code,
@@ -970,6 +1002,7 @@ async def start(
970
1002
  extras=extras.analytics_groups,
971
1003
  library_dirs=source.library_dirs,
972
1004
  methods_dirs=source.methods_dirs,
1005
+ bundle_resources=decode_bundle_resources(resources=run_request.resources, bundle_files=source.bundle_files),
973
1006
  )
974
1007
  start_result = await runner.start(
975
1008
  pipe_code=source.pipe_code,
@@ -12,13 +12,14 @@ from pipelex.pipeline.validation_report import PipelexValidationReport
12
12
  from pipelex.tools.typing.pydantic_utils import empty_list_factory_of
13
13
  from pydantic import BaseModel, Field, model_validator
14
14
 
15
+ from pipelex_api.bundle_resources import decode_bundle_resources
15
16
  from pipelex_api.errors import raise_validation_error
16
17
  from pipelex_api.exception_handlers import problem_response_from_error_report
17
18
  from pipelex_api.json_body import JsonBodyRoute
18
19
  from pipelex_api.method_source import fetched_method_source
19
20
  from pipelex_api.openapi_responses import PROBLEM_403_RUN_POLICY, PROBLEM_404_METHOD_PACKAGE
20
21
  from pipelex_api.routes.pipelex.pipeline import ApiRunner, get_request_user_id
21
- from pipelex_api.schemas.models import CallerAnalyticsGroupsMixin, MthdsContentsRequest
22
+ from pipelex_api.schemas.models import RESOURCES_FIELD_DESCRIPTION, CallerAnalyticsGroupsMixin, MthdsContentsRequest
22
23
 
23
24
  router = APIRouter(tags=["validate"], route_class=JsonBodyRoute)
24
25
 
@@ -87,6 +88,7 @@ class ValidateRequest(MthdsContentsRequest, CallerAnalyticsGroupsMixin):
87
88
  "at the owning file. Must match `mthds_contents` in length when present."
88
89
  ),
89
90
  )
91
+ resources: dict[str, str] | None = Field(default=None, description=RESOURCES_FIELD_DESCRIPTION)
90
92
  render: list[str] = Field(
91
93
  default_factory=list,
92
94
  description=(
@@ -124,6 +126,10 @@ class ValidateRequest(MthdsContentsRequest, CallerAnalyticsGroupsMixin):
124
126
  if self.mthds_sources is not None and self.method_ref is not None:
125
127
  msg = "mthds_sources accompanies inline mthds_contents; a method_ref's sources are the package's own file names"
126
128
  raise ValueError(msg)
129
+ # `resources` are the files the inline contents' steps read; a `method_ref`'s files are the package's own.
130
+ if self.resources and self.method_ref is not None:
131
+ msg = "resources accompany inline mthds_contents; a method_ref's files are the package's own"
132
+ raise ValueError(msg)
127
133
  # A caller-supplied length mismatch is a request-shape bug → caught here as a 422.
128
134
  # Without this guard it reaches the runtime's `validate_bundle`, which treats the
129
135
  # mismatch as an internal host error (500) — the wrong status for caller input.
@@ -396,7 +402,11 @@ async def validate_mthds(request: Request, request_data: ValidateRequest) -> JSO
396
402
  validated_contents = request_data.mthds_contents
397
403
  # Inline contents carry no manifest, so the closure's own declaration is the whole chain.
398
404
  manifest_main_pipe = None
399
- verdict = await ApiRunner(user_id=user_id, extras=request_data.analytics_groups).validate_verdict(
405
+ verdict = await ApiRunner(
406
+ user_id=user_id,
407
+ extras=request_data.analytics_groups,
408
+ bundle_resources=decode_bundle_resources(resources=request_data.resources),
409
+ ).validate_verdict(
400
410
  mthds_contents=request_data.mthds_contents,
401
411
  mthds_sources=request_data.mthds_sources,
402
412
  allow_signatures=request_data.allow_signatures,
@@ -15,6 +15,7 @@ from urllib.parse import urlparse
15
15
  from mthds.protocol.exceptions import PipelineRequestError
16
16
  from mthds.protocol.pipe_output import VariableMultiplicity
17
17
  from mthds.protocol.pipeline_inputs import PipelineInputs
18
+ from pipelex.core.bundle_resources.bundle_resource import MAX_LOAD_STAGE_RESOURCES_BYTES
18
19
  from pipelex.core.pipes.pipe_output import PipeOutput
19
20
  from pipelex.methods.fetching import MethodProvenance
20
21
  from pipelex.pipeline.pipeline_response import PipelexRunResultExecute, PipelexRunResultStart
@@ -25,6 +26,9 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
25
26
  from pydantic.functional_validators import SkipValidation
26
27
 
27
28
  from pipelex_api.limits import (
29
+ DEFAULT_MAX_RESOURCE_FILE_KIB,
30
+ DEFAULT_MAX_RESOURCES_PER_REQUEST,
31
+ DEFAULT_MAX_RESOURCES_TOTAL_KIB,
28
32
  MAX_CALLBACK_URL_LEN,
29
33
  MAX_CALLBACK_URLS,
30
34
  MAX_METHOD_REF_LEN,
@@ -41,6 +45,21 @@ def _ensure_mthds_file_within_bytes_limit(content: str) -> None:
41
45
  raise ValueError(msg)
42
46
 
43
47
 
48
+ RESOURCES_FIELD_DESCRIPTION = (
49
+ "The files submitted with the bundle that its steps read, such as a PipeDocGen step's `template_file` (an "
50
+ "`.xlsx`, `.docx`, `.pptx` or `.html` template): a JSON object mapping each file's bundle-relative path, exactly "
51
+ "as the step names it, to the file's bytes in standard base64. Every bundle of the request is read as sitting in "
52
+ "one directory. A path must be relative and written with forward slashes, with no `..` segment, backslash or "
53
+ "colon. Every file must be named by a step, or the request is refused with a 422 `BundleResourceNotNamedError`. "
54
+ f"By default at most {DEFAULT_MAX_RESOURCES_PER_REQUEST} files, {DEFAULT_MAX_RESOURCE_FILE_KIB} KiB each and "
55
+ f"{DEFAULT_MAX_RESOURCES_TOTAL_KIB} KiB in all, decoded, counting the files a method bundle ships for its steps; a "
56
+ "deployment sets its own with `MAX_RESOURCES_PER_REQUEST`, `MAX_RESOURCE_FILE_KIB` and `MAX_RESOURCES_TOTAL_KIB`. A file "
57
+ "both sent here and shipped in a method bundle must be the same. The `.html` template files, which travel inline with the "
58
+ f"method wherever it runs, may total {MAX_LOAD_STAGE_RESOURCES_BYTES // 1024} KiB at most, a fixed bound, or the request "
59
+ "is refused with a 413 `BundleResourcesTooLargeError`."
60
+ )
61
+
62
+
44
63
  class RunRequest(BaseModel):
45
64
  """Body of `POST /execute` — this server's typed request model.
46
65
 
@@ -83,6 +102,9 @@ class RunRequest(BaseModel):
83
102
  by manifest identity); mutually exclusive with `mthds_contents` and with a
84
103
  method bundle. `pipe_code` may accompany it to override the manifest's
85
104
  `main_pipe`.
105
+ resources: The files the submitted bundle's steps read, such as a PipeDocGen
106
+ step's `template_file`, as `{bundle_relative_path: base64}`. Accompanies inline
107
+ `mthds_contents` or a method bundle, never a `method_ref`.
86
108
  """
87
109
 
88
110
  model_config = ConfigDict(extra="allow")
@@ -127,6 +149,7 @@ class RunRequest(BaseModel):
127
149
  "MTHDS concepts and sandboxed PipeFuncs, not in-process Python."
128
150
  ),
129
151
  )
152
+ resources: dict[str, str] | None = Field(default=None, description=RESOURCES_FIELD_DESCRIPTION)
130
153
 
131
154
  @model_validator(mode="before")
132
155
  @classmethod
@@ -155,6 +178,15 @@ class RunRequest(BaseModel):
155
178
  if has_method_ref and has_bundle:
156
179
  msg = "method_ref and a method bundle (bundle_b64 / files) are mutually exclusive; send one or the other."
157
180
  raise PipelineRequestError(msg)
181
+ # `resources` are the files the submitted bundle's steps read, so they accompany a bundle the request
182
+ # carries: inline contents or a method bundle. A fetched package's files are its own.
183
+ if values.get("resources"):
184
+ if has_method_ref:
185
+ msg = "resources accompany inline mthds_contents or a method bundle; a method_ref's files are the package's own."
186
+ raise PipelineRequestError(msg)
187
+ if not values.get("mthds_contents") and not values.get("mthds_content") and not has_bundle:
188
+ msg = "resources accompany the bundle whose steps read them: send them with mthds_contents or a method bundle."
189
+ raise PipelineRequestError(msg)
158
190
  if values.get("pipe_code") is None and not values.get("mthds_contents") and not has_bundle and not has_method_ref:
159
191
  msg = (
160
192
  "pipe_code and mthds_contents cannot both be empty. Either: both are provided, or if there are no mthds_contents, "
@@ -231,6 +263,7 @@ class RunRequest(BaseModel):
231
263
  bundle_b64=request_body.get("bundle_b64"),
232
264
  files=request_body.get("files"),
233
265
  method_ref=request_body.get("method_ref"),
266
+ resources=request_body.get("resources"),
234
267
  )
235
268
 
236
269
 
@@ -565,11 +598,16 @@ class MthdsFilesRequest(BaseModel):
565
598
  ),
566
599
  )
567
600
 
601
+ resources: dict[str, str] | None = Field(default=None, description=RESOURCES_FIELD_DESCRIPTION)
602
+
568
603
  @model_validator(mode="after")
569
604
  def _exactly_one_selector(self) -> Self:
570
605
  if (self.files is None) == (self.method_ref is None):
571
606
  msg = "provide exactly one of `files` or `method_ref`"
572
607
  raise ValueError(msg)
608
+ if self.resources and self.method_ref is not None:
609
+ msg = "resources accompany inline `files`; a method_ref's files are the package's own"
610
+ raise ValueError(msg)
573
611
  return self
574
612
 
575
613
 
File without changes
File without changes
File without changes