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.
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/PKG-INFO +2 -2
- pipelex_api-0.85.0/pipelex_api/bundle_resources.py +148 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/error_types.py +4 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/exception_handlers.py +5 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/limits.py +7 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/crate_ops.py +3 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/pipeline.py +42 -9
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/validate.py +12 -2
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/schemas/models.py +38 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/.gitignore +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/LICENSE +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/README.md +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/hatch_build.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/__init__.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/api.toml +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/api_config.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/bundle.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/disclosure.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/error_uri.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/errors.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/json_body.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/main.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/method_cache.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/method_source.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/middleware.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/openapi_responses.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/openapi_schema.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/problem_document.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/py.typed +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/__init__.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/health.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/__init__.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/agent/__init__.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/agent/models.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/codegen.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/pipe_io.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/resolve.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/tools.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/pipelex/utils.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/routes/version.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/schemas/__init__.py +0 -0
- {pipelex_api-0.84.0 → pipelex_api-0.85.0}/pipelex_api/security.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
785
|
+
mthds_entries: list[tuple[PurePosixPath, str]] = []
|
|
762
786
|
for relpath, content in partition.mthds_entries:
|
|
763
787
|
try:
|
|
764
|
-
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|