Skip to content

Commit be45a81

Browse files
feat: Add pydantic type making other Tesseracts callable (needed for HOTs) (#343)
#### Relevant issue or PR Extracted from: pasteurlabs/AutoPhysics#31 Needed for HOTs in optimization etc. #### Description of changes Joint work with @zmheiko. Enables passing references to target Tesseracts as input, which we may then call. Example: ``` class InputSchema(BaseModel): target: TesseractReference = Field(description="Tesseract to call.") def apply(inputs: InputSchema) -> OutputSchema: result = inputs.target.apply({"name": "Alice"})["greeting"] ... ``` Corresponding payload for an apply call: ``` {"inputs": {"target": {"type": "url", "url": "http://helloworld:8000"}}} ``` Instead of a URL, we may pass a `tesseract_api.py` path to a `tesseract_api.py` file. The TesseractArg spins up the target in that case. We'll likely deprecate this once Autophysics workflows / P4D cover all local development purposes. #### TODO - [x] Is the Generic type (`TesseractArg[lazy]` vs `TesseractArg[eager]`) desirable? Should we separate entirely into `TesseractArg` and `TesseractReference`, where the former always validates to a `Tesseract` and the latter is just two strings? - [x] Is there a better way than lazy loading the Tesseract class? Problem is: Like all custom Pydantic types, `TesseractArg` lives in runtime. However, it validates to `Tesseract`, which is part of the SDK, making the SDK a dependency of the runtime. In container images, we currently only install the runtime though. The current approach requires adding `tesseract_core` to the requirements only in cases where the `TesseractArg` is actually used. #### Testing done CI end-to-end test included. --------- Co-authored-by: Alessandro Angioi <alessandro.angioi@simulation.science>
1 parent f7e80c0 commit be45a81

File tree

6 files changed

+256
-1
lines changed

6 files changed

+256
-1
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright 2025 Pasteur Labs. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from pydantic import BaseModel, Field
5+
6+
from tesseract_core.runtime.experimental import TesseractReference
7+
8+
9+
class InputSchema(BaseModel):
10+
target: TesseractReference = Field(description="Tesseract to call.")
11+
12+
13+
class OutputSchema(BaseModel):
14+
result: str = Field(description="Result of the Tesseract calls.")
15+
16+
17+
def apply(inputs: InputSchema) -> OutputSchema:
18+
result = inputs.target.apply({"name": "Alice"})["greeting"]
19+
result += " " + inputs.target.apply({"name": "Bob"})["greeting"]
20+
21+
return OutputSchema(result=result)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
name: "tesseractreference"
2+
version: "1.0.0"
3+
description: "Demonstrates calling other Tesseracts."
4+
5+
build_config:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
tesseract_core

tesseract_core/runtime/experimental.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
TypeAdapter,
2121
)
2222
from pydantic.json_schema import JsonSchemaValue
23-
from pydantic_core import SchemaSerializer, SchemaValidator, core_schema
23+
from pydantic_core import CoreSchema, SchemaSerializer, SchemaValidator, core_schema
2424

2525
from tesseract_core.runtime.file_interactions import PathLike, parent_path
2626
from tesseract_core.runtime.mpa import (
@@ -248,11 +248,95 @@ def require_file(file_path: PathLike) -> Path:
248248
return file_path
249249

250250

251+
class TesseractReference:
252+
"""Allows passing a reference to another Tesseract as input."""
253+
254+
def __init__(self, tesseract: Any) -> None:
255+
self._tesseract = tesseract
256+
257+
def __getattr__(self, name: str) -> Any:
258+
"""Delegate attribute access to the underlying Tesseract instance."""
259+
return getattr(self._tesseract, name)
260+
261+
@classmethod
262+
def _get_tesseract_class(cls) -> type:
263+
"""Lazy import of Tesseract class. Avoids hard dependency of Tesseract runtime on Tesseract SDK."""
264+
try:
265+
from tesseract_core import Tesseract
266+
267+
return Tesseract
268+
except ImportError:
269+
raise ImportError(
270+
"Tesseract class not found. Ensure tesseract_core is installed and configured correctly."
271+
) from ImportError
272+
273+
@classmethod
274+
def __get_pydantic_core_schema__(
275+
cls, source_type: Any, handler: GetCoreSchemaHandler
276+
) -> CoreSchema:
277+
"""Generate Pydantic core schema for TesseractReference."""
278+
279+
def validate_tesseract_reference(v: Any) -> "TesseractReference":
280+
if isinstance(v, cls):
281+
return v
282+
283+
if not (isinstance(v, dict) and "type" in v and "ref" in v):
284+
raise ValueError(
285+
f"Expected dict with 'type' and 'ref' keys, got {type(v)}"
286+
)
287+
288+
tesseract_type = v["type"]
289+
ref = v["ref"]
290+
291+
if tesseract_type not in ("api_path", "image", "url"):
292+
raise ValueError(
293+
f"Invalid tesseract type '{tesseract_type}'. Expected 'api_path', 'image' or 'url'."
294+
)
295+
296+
Tesseract = cls._get_tesseract_class()
297+
if tesseract_type == "api_path":
298+
tesseract = Tesseract.from_tesseract_api(ref)
299+
elif tesseract_type == "image":
300+
tesseract = Tesseract.from_image(ref)
301+
tesseract.serve()
302+
elif tesseract_type == "url":
303+
tesseract = Tesseract.from_url(ref)
304+
305+
return cls(tesseract)
306+
307+
return core_schema.no_info_plain_validator_function(
308+
validate_tesseract_reference
309+
)
310+
311+
@classmethod
312+
def __get_pydantic_json_schema__(
313+
cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler
314+
) -> JsonSchemaValue:
315+
"""Generate JSON schema for OpenAPI."""
316+
return {
317+
"type": "object",
318+
"properties": {
319+
"type": {
320+
"type": "string",
321+
"enum": ["api_path", "image", "url"],
322+
"description": "Type of tesseract reference",
323+
},
324+
"ref": {
325+
"type": "string",
326+
"description": "URL or file path to the tesseract",
327+
},
328+
},
329+
"required": ["type", "ref"],
330+
"additionalProperties": False,
331+
}
332+
333+
251334
__all__ = [
252335
"InputFileReference",
253336
"LazySequence",
254337
"OutputFileReference",
255338
"PydanticLazySequenceAnnotation",
339+
"TesseractReference",
256340
"log_artifact",
257341
"log_metric",
258342
"log_parameter",

tests/endtoend_tests/test_endtoend.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,3 +1150,144 @@ def test_multi_helloworld_endtoend(
11501150
)
11511151
assert result.exit_code == 0, result.output
11521152
assert "The helloworld Tesseract says: Hello you!" in result.output
1153+
1154+
1155+
def test_tesseractreference_endtoend(
1156+
docker_client,
1157+
unit_tesseracts_parent_dir,
1158+
dummy_image_name,
1159+
dummy_network_name,
1160+
docker_cleanup,
1161+
):
1162+
"""Test that tesseractreference example can be built and executed, calling helloworld tesseract."""
1163+
cli_runner = CliRunner(mix_stderr=False)
1164+
1165+
# Build Tesseract images
1166+
img_names = []
1167+
for tess_name in ("tesseractreference", "helloworld"):
1168+
img_name = build_tesseract(
1169+
docker_client,
1170+
unit_tesseracts_parent_dir / tess_name,
1171+
dummy_image_name + f"_{tess_name}",
1172+
tag="sometag",
1173+
)
1174+
img_names.append(img_name)
1175+
assert image_exists(docker_client, img_name)
1176+
docker_cleanup["images"].append(img_name)
1177+
1178+
tesseractreference_img_name, helloworld_img_name = img_names
1179+
1180+
# Create Docker network
1181+
config = get_config()
1182+
docker = config.docker_executable
1183+
1184+
result = subprocess.run(
1185+
[*docker, "network", "create", dummy_network_name],
1186+
capture_output=True,
1187+
text=True,
1188+
check=True,
1189+
)
1190+
assert result.returncode == 0, result.stderr
1191+
docker_cleanup["networks"].append(dummy_network_name)
1192+
1193+
# Serve helloworld Tesseract on the shared network
1194+
result = cli_runner.invoke(
1195+
app,
1196+
[
1197+
"serve",
1198+
helloworld_img_name,
1199+
"--network",
1200+
dummy_network_name,
1201+
"--network-alias",
1202+
"helloworld",
1203+
"--port",
1204+
"8000",
1205+
],
1206+
catch_exceptions=True,
1207+
)
1208+
assert result.exit_code == 0, result.output
1209+
serve_meta = json.loads(result.output)
1210+
docker_cleanup["containers"].append(serve_meta["container_name"])
1211+
1212+
# Test url type
1213+
url_payload = (
1214+
'{"inputs": {"target": {"type": "url", "ref": "http://helloworld:8000"}}}'
1215+
)
1216+
result = cli_runner.invoke(
1217+
app,
1218+
[
1219+
"run",
1220+
tesseractreference_img_name,
1221+
"apply",
1222+
url_payload,
1223+
"--network",
1224+
dummy_network_name,
1225+
],
1226+
catch_exceptions=True,
1227+
)
1228+
assert result.exit_code == 0, result.output
1229+
output_data = json.loads(result.output)
1230+
expected_result = "Hello Alice! Hello Bob!"
1231+
assert output_data["result"] == expected_result
1232+
1233+
# Test image type
1234+
image_payload = json.dumps(
1235+
{
1236+
"inputs": {
1237+
"target": {
1238+
"type": "image",
1239+
"ref": helloworld_img_name,
1240+
}
1241+
}
1242+
}
1243+
)
1244+
result = subprocess.run(
1245+
[
1246+
"tesseract-runtime",
1247+
"apply",
1248+
image_payload,
1249+
],
1250+
capture_output=True,
1251+
text=True,
1252+
env={
1253+
**os.environ,
1254+
"TESSERACT_API_PATH": str(
1255+
unit_tesseracts_parent_dir / "tesseractreference/tesseract_api.py"
1256+
),
1257+
},
1258+
)
1259+
assert result.returncode == 0, result.stderr
1260+
output_data = json.loads(result.stdout)
1261+
assert output_data["result"] == expected_result
1262+
1263+
# Test api_path type
1264+
path_payload = json.dumps(
1265+
{
1266+
"inputs": {
1267+
"target": {
1268+
"type": "api_path",
1269+
"ref": str(
1270+
unit_tesseracts_parent_dir / "helloworld/tesseract_api.py"
1271+
),
1272+
}
1273+
}
1274+
}
1275+
)
1276+
result = subprocess.run(
1277+
[
1278+
"tesseract-runtime",
1279+
"apply",
1280+
path_payload,
1281+
],
1282+
capture_output=True,
1283+
text=True,
1284+
env={
1285+
**os.environ,
1286+
"TESSERACT_API_PATH": str(
1287+
unit_tesseracts_parent_dir / "tesseractreference/tesseract_api.py"
1288+
),
1289+
},
1290+
)
1291+
assert result.returncode == 0, result.stderr
1292+
output_data = json.loads(result.stdout)
1293+
assert output_data["result"] == expected_result

tests/endtoend_tests/test_examples.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,9 @@ class Config:
760760
),
761761
],
762762
),
763+
"tesseractreference": Config( # Can't test requests standalone; needs target Tesseract. Covered in separate test.
764+
test_with_random_inputs=False, sample_requests=[]
765+
),
763766
}
764767

765768

0 commit comments

Comments
 (0)