From a37292bd03662459965cd74aea5f2142227fb0fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dion=20H=C3=A4fner?= Date: Mon, 24 Nov 2025 11:07:11 +0100 Subject: [PATCH 1/6] ensure modules next to tesseract_api.py can always be imported --- docs/content/creating-tesseracts/advanced.md | 4 ---- .../examples/building-blocks/localdependency.md | 4 ---- tesseract_core/runtime/core.py | 14 +++++++++++++- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/content/creating-tesseracts/advanced.md b/docs/content/creating-tesseracts/advanced.md index 343e870d..c1400c4f 100644 --- a/docs/content/creating-tesseracts/advanced.md +++ b/docs/content/creating-tesseracts/advanced.md @@ -150,10 +150,6 @@ After that is done, you will be able to use the `tesseract-runtime` command in y This is the exact same command that is launched inside Tesseract containers to run their various endpoints, and its syntax mirrors the one of `tesseract run`. -```{note} -When running without containerization, the directory containing `tesseract_api.py` is automatically added to `PYTHONPATH`, allowing you to import additional Python modules from that directory. This behavior matches what happens inside Tesseract containers, where the path containing `tesseract_api.py` is also added to `PYTHONPATH`. -``` - For instance, to call the `apply` function, rather than first building a `helloworld` image and running this command: ```bash diff --git a/docs/content/examples/building-blocks/localdependency.md b/docs/content/examples/building-blocks/localdependency.md index e31a30e7..452e3336 100644 --- a/docs/content/examples/building-blocks/localdependency.md +++ b/docs/content/examples/building-blocks/localdependency.md @@ -47,10 +47,6 @@ We can then specify it as a local dependency in `tesseract_requirements.txt` by ./cowsay-6.1-py3-none-any.whl ``` -```{note} -If you have additional Python modules in the same directory as `tesseract_api.py`, you can import them directly without any special configuration. The directory containing `tesseract_api.py` is automatically added to `PYTHONPATH` in the container environment. -``` - Finally, let's build the Tesseract, and verify it works ```bash $ tesseract build . diff --git a/tesseract_core/runtime/core.py b/tesseract_core/runtime/core.py index 61a91c13..25827898 100644 --- a/tesseract_core/runtime/core.py +++ b/tesseract_core/runtime/core.py @@ -3,6 +3,7 @@ import importlib.util import os +import sys from collections.abc import Callable, Generator from contextlib import contextmanager from io import TextIOBase @@ -49,22 +50,33 @@ def redirect_fd( def load_module_from_path(path: Union[Path, str]) -> ModuleType: - """Load a module from a file path.""" + """Load a module from a file path. + + Temporarily puts the module's parent folder on PYTHONPATH to ensure local imports work as expected. + """ path = Path(path) if not path.is_file(): raise ImportError(f"Could not load module from {path} (is not a file)") module_name = path.stem + module_dir = path.parent.resolve() + spec = importlib.util.spec_from_file_location(module_name, path) if spec is None: raise ImportError(f"Could not load module from {path}") module = importlib.util.module_from_spec(spec) + try: + old_path = sys.path + sys.path = [str(module_dir), *old_path] spec.loader.exec_module(module) except Exception as exc: raise ImportError(f"Could not load module from {path}") from exc + finally: + sys.path = old_path + return module From 2c8cca013c661f3f71eb39ce073fac4208e128ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dion=20H=C3=A4fner?= Date: Tue, 25 Nov 2025 10:40:28 +0100 Subject: [PATCH 2/6] add test --- examples/localpackage/goodbyeworld.py | 6 ++++++ examples/localpackage/tesseract_api.py | 6 ++++-- examples/localpackage/tesseract_config.yaml | 4 ++++ tesseract_core/sdk/templates/Dockerfile.base | 1 - tests/endtoend_tests/test_examples.py | 2 +- 5 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 examples/localpackage/goodbyeworld.py diff --git a/examples/localpackage/goodbyeworld.py b/examples/localpackage/goodbyeworld.py new file mode 100644 index 00000000..fdc2ea5f --- /dev/null +++ b/examples/localpackage/goodbyeworld.py @@ -0,0 +1,6 @@ +# Copyright 2025 Pasteur Labs. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + + +def ungreet(name): + return f"Goodbye {name}!" diff --git a/examples/localpackage/tesseract_api.py b/examples/localpackage/tesseract_api.py index d541ff20..804e2160 100644 --- a/examples/localpackage/tesseract_api.py +++ b/examples/localpackage/tesseract_api.py @@ -1,6 +1,7 @@ # Copyright 2025 Pasteur Labs. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 +from goodbyeworld import ungreet from helloworld import greet from pydantic import BaseModel, Field @@ -10,9 +11,10 @@ class InputSchema(BaseModel): class OutputSchema(BaseModel): - greeting: str = Field(description="A greeting!") + message: str = Field(description="A message!") def apply(inputs: InputSchema) -> OutputSchema: """Greet a person whose name is given as input.""" - return OutputSchema(greeting=greet(inputs.name)) + message = f"{greet(inputs.name)}\n{ungreet(inputs.name)}" + return OutputSchema(message=message) diff --git a/examples/localpackage/tesseract_config.yaml b/examples/localpackage/tesseract_config.yaml index 7cdee4c5..af5bbb62 100644 --- a/examples/localpackage/tesseract_config.yaml +++ b/examples/localpackage/tesseract_config.yaml @@ -1,3 +1,7 @@ name: "localpackage" version: "0.0.1" description: "" + +build_config: + package_data: + - ["goodbyeworld.py", "goodbyeworld.py"] diff --git a/tesseract_core/sdk/templates/Dockerfile.base b/tesseract_core/sdk/templates/Dockerfile.base index a5af14b9..a5f061ca 100644 --- a/tesseract_core/sdk/templates/Dockerfile.base +++ b/tesseract_core/sdk/templates/Dockerfile.base @@ -83,7 +83,6 @@ COPY "{{ tesseract_source_directory }}/tesseract_api.py" ${TESSERACT_API_PATH} RUN chmod 444 ${TESSERACT_API_PATH} ENV PATH="/python-env/bin:$PATH" -ENV PYTHONPATH="/tesseract:$PYTHONPATH" {% if config.build_config.package_data %} # Copy package data to image diff --git a/tests/endtoend_tests/test_examples.py b/tests/endtoend_tests/test_examples.py index 2f08e901..93cec27b 100644 --- a/tests/endtoend_tests/test_examples.py +++ b/tests/endtoend_tests/test_examples.py @@ -138,7 +138,7 @@ class Config: SampleRequest( endpoint="apply", payload={"inputs": {"name": "Ozzy"}}, - output_contains_pattern="Hello Ozzy!", + output_contains_pattern="Hello Ozzy!\nGoodbye Ozzy!", ), ], ), From b2fd4af3f9a7b897b849202cf52843c25c3cacb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dion=20H=C3=A4fner?= Date: Tue, 25 Nov 2025 13:38:28 +0100 Subject: [PATCH 3/6] add unit test, update docs --- .../building-blocks/localdependency.md | 58 ------------------- docs/content/examples/example_gallery.md | 4 +- examples/localpackage/package/helloworld.py | 6 -- examples/localpackage/package/pyproject.toml | 7 --- .../localpackage/tesseract_requirements.txt | 2 +- tests/endtoend_tests/test_examples.py | 2 +- tests/runtime_tests/test_runtime_cli.py | 19 ++++++ 7 files changed, 23 insertions(+), 75 deletions(-) delete mode 100644 docs/content/examples/building-blocks/localdependency.md delete mode 100644 examples/localpackage/package/helloworld.py delete mode 100644 examples/localpackage/package/pyproject.toml diff --git a/docs/content/examples/building-blocks/localdependency.md b/docs/content/examples/building-blocks/localdependency.md deleted file mode 100644 index 452e3336..00000000 --- a/docs/content/examples/building-blocks/localdependency.md +++ /dev/null @@ -1,58 +0,0 @@ -# Installing local dependencies into a Tesseract - -## Context -Sometimes it might be necessary to bundle local dependencies into a Tesseract; -this can be done by simply adding their path to the `tesseract_requirements.txt` file. -Both absolute and relative paths work, but in case they are relative paths, they should be -relative to the Tesseract's root folder (i.e., the one which contains the `tesseract_api.py` file) - -## Example Tesseract -Here's an example: let's initialize an empty tesseract via -```bash -$ tesseract init --name cowsay - [i] Initializing Tesseract cowsay in directory: . - [i] Writing template tesseract_api.py to tesseract_api.py - [i] Writing template tesseract_config.yaml to tesseract_config.yaml - [i] Writing template tesseract_requirements.txt to tesseract_requirements.txt - ``` - - And let's then edit the `tesseract_api.py` file to read - -```{literalinclude} ../../../../examples/conda/tesseract_api.py -:language: python -``` - - -This Tesseract will accept a message like "Hello, world!" as an input and return -```{literalinclude} ../../../../examples/conda/expected_output.txt -:language: text -``` - -but in order to do so, it will need the dependency `cowsay` installed. We could just -add `cowsay` to the `tesseract_requirements.txt` file, but that would install it from PyPI every -time. Let's instead download it via pip download and then bundle it into a Tesseract; in order to -do the former, we can run: -```bash -$ pip download cowsay==6.1 -Collecting cowsay==6.1 - Obtaining dependency information for cowsay==6.1 from https://files.pythonhosted.org/packages/f1/13/63c0a02c44024ee16f664e0b36eefeb22d54e93531630bd99e237986f534/cowsay-6.1-py3-none-any.whl.metadata - Downloading cowsay-6.1-py3-none-any.whl.metadata (5.6 kB) -Downloading cowsay-6.1-py3-none-any.whl (25 kB) -Saved ./cowsay-6.1-py3-none-any.whl -Successfully downloaded cowsay -``` - -We can then specify it as a local dependency in `tesseract_requirements.txt` by adding the following line: -``` -./cowsay-6.1-py3-none-any.whl -``` - -Finally, let's build the Tesseract, and verify it works -```bash -$ tesseract build . - [i] Building image ... - [i] Built image sha256:7d024, ['cowsay:latest'] - -$ tesseract run install_tarball apply '{"inputs": {"message": "Hello, World!"}}' -{"out":" _____________\n| Hello, World! |\n =============\n \\\n \\\n ^__^\n (oo)\\_______\n (__)\\ )\\/\\\n ||----w |\n || ||"} -``` diff --git a/docs/content/examples/example_gallery.md b/docs/content/examples/example_gallery.md index f18e75c1..ca519b9a 100644 --- a/docs/content/examples/example_gallery.md +++ b/docs/content/examples/example_gallery.md @@ -1,4 +1,4 @@ -# Building Blocks +# Building Blocks Example Gallery ```{toctree} :name: example-gallery @@ -11,7 +11,7 @@ building-blocks/vectoradd.md building-blocks/univariate.md building-blocks/packagedata.md building-blocks/arm64.md -building-blocks/localdependency.md +building-blocks/localpackage.md building-blocks/dataloader.md building-blocks/filereference.md ``` diff --git a/examples/localpackage/package/helloworld.py b/examples/localpackage/package/helloworld.py deleted file mode 100644 index 393ba058..00000000 --- a/examples/localpackage/package/helloworld.py +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright 2025 Pasteur Labs. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0 - - -def greet(name): - return f"Hello {name}!" diff --git a/examples/localpackage/package/pyproject.toml b/examples/localpackage/package/pyproject.toml deleted file mode 100644 index 601b7962..00000000 --- a/examples/localpackage/package/pyproject.toml +++ /dev/null @@ -1,7 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "helloworld" -version = "0.1.0" diff --git a/examples/localpackage/tesseract_requirements.txt b/examples/localpackage/tesseract_requirements.txt index a6172af2..bf8441f2 100644 --- a/examples/localpackage/tesseract_requirements.txt +++ b/examples/localpackage/tesseract_requirements.txt @@ -1 +1 @@ -./package +./helloworld diff --git a/tests/endtoend_tests/test_examples.py b/tests/endtoend_tests/test_examples.py index 93cec27b..26194f0d 100644 --- a/tests/endtoend_tests/test_examples.py +++ b/tests/endtoend_tests/test_examples.py @@ -138,7 +138,7 @@ class Config: SampleRequest( endpoint="apply", payload={"inputs": {"name": "Ozzy"}}, - output_contains_pattern="Hello Ozzy!\nGoodbye Ozzy!", + output_contains_pattern="Hello Ozzy!\\nGoodbye Ozzy!", ), ], ), diff --git a/tests/runtime_tests/test_runtime_cli.py b/tests/runtime_tests/test_runtime_cli.py index 112a55d8..6c52959b 100644 --- a/tests/runtime_tests/test_runtime_cli.py +++ b/tests/runtime_tests/test_runtime_cli.py @@ -569,3 +569,22 @@ def test_check(cli, cli_runner, dummy_tesseract_package): f"{schema_name} is not a subclass of pydantic.BaseModel" in result.exception.args[0] ) + + +def test_local_module(cli, cli_runner, dummy_tesseract_package): + """Ensure that a .py file next to tesseract_api.py can be imported.""" + from tesseract_core.runtime.config import update_config + + tesseract_api_file = Path(dummy_tesseract_package) / "tesseract_api.py" + with open(tesseract_api_file, "a") as f: + f.write("\nimport foo") + + foo_file = Path(dummy_tesseract_package) / "foo.py" + with open(foo_file, "w") as f: + f.write("print('hey!')") + + update_config(api_path=tesseract_api_file) + result = cli_runner.invoke(cli, ["check"], catch_exceptions=True) + assert result.exit_code == 0, result.stderr + assert "check successful" in result.stdout + assert "hey!" in result.stdout From 91e721229a95ae79bdc212847f8c22db29ef84d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dion=20H=C3=A4fner?= Date: Tue, 25 Nov 2025 13:46:10 +0100 Subject: [PATCH 4/6] add missing files --- .../examples/building-blocks/localpackage.md | 96 +++++++++++++++++++ .../localpackage/helloworld/helloworld.py | 6 ++ .../localpackage/helloworld/pyproject.toml | 7 ++ 3 files changed, 109 insertions(+) create mode 100644 docs/content/examples/building-blocks/localpackage.md create mode 100644 examples/localpackage/helloworld/helloworld.py create mode 100644 examples/localpackage/helloworld/pyproject.toml diff --git a/docs/content/examples/building-blocks/localpackage.md b/docs/content/examples/building-blocks/localpackage.md new file mode 100644 index 00000000..41d6a48d --- /dev/null +++ b/docs/content/examples/building-blocks/localpackage.md @@ -0,0 +1,96 @@ +# Installing local Python modules into a Tesseract + +## Context +Sometimes it might be necessary to bundle local Python modules into a Tesseract. + +There are 2 ways to do this: + +1. Make them a proper Python package with `pyproject.toml` and add the local path to the `tesseract_requirements.txt` file. +Both absolute and relative paths work, but in case they are relative paths, they should be +relative to the Tesseract's root folder (i.e., the one which contains the `tesseract_api.py` file). +2. Just put them as `.py` files next to `tesseract_api.py` and add them to `build_config.package_data` (see also [packagedata.md]) in `tesseract_config.yaml` to make sure they're being included in container builds. + +## Example Tesseract + +Here is an example Tesseract that highlights both ways to include dependencies. + +```{literalinclude} ../../../../examples/localpackage/tesseract_api.py +:language: python +``` + +The custom package `helloworld` is shipped with a file `helloworld.py` and a `pyproject.toml` that ensures it can be built as a Python package. + +```{literalinclude} ../../../../examples/localpackage/helloworld/helloworld.py +:language: python +``` + +```{literalinclude} ../../../../examples/localpackage/helloworld/pyproject.toml +:language: toml +``` + +Then, it can be added as a local dependency to `tesseract_requirements.txt` by passing a relative path: + +```{literalinclude} ../../../../examples/localpackage/tesseract_requirements.txt +:language: text +``` + +The local module `goodbyeworld.py` is just a single Python file: + +```{literalinclude} ../../../../examples/localpackage/goodbyeworld.py +:language: python +``` + +To ensure it gets added to built Tesseracts, we have to register it as `package_data`: + +```{literalinclude} ../../../../examples/localpackage/tesseract_config.yaml +:language: yaml +``` + +Now we are ready to build the Tesseract. This Tesseract will accept a name like "Tessie" as an input and return a message: + +```bash +$ tesseract build examples/localpackage +$ tesseract run localpackage apply '{"inputs": {"name": "Tessie"}}' +{"message":"Hello Tessie!\nGoodbye Tessie!"} +``` + +This confirms that both custom dependencies are available to the Tesseract container. + +To run the Tesseract without containerization, we have to make sure that `helloworld` is installed into our dev environment: + +```bash +$ pip install examples/localpackage/helloworld +$ TESSERACT_API_PATH=examples/localpackage/tesseract_api.py tesseract-runtime apply '{"inputs": {"name": "Tessie"}}' +{"message":"Hello Tessie!\nGoodbye Tessie!"} +``` + +## Advanced pattern: injecting private dependencies as local wheels + +In case a Tesseract depends on private packages that are not accessible from the public Python package index (PyPI), injecting them as local files can be a useful way to side-step authentication issues. + +This is especially powerful in conjunction with `pip download` (e.g. from a private PyPI registry) to obtain a pre-built wheel: + +```bash +$ pip download cowsay==6.1 +Collecting cowsay==6.1 + Obtaining dependency information for cowsay==6.1 from https://files.pythonhosted.org/packages/f1/13/63c0a02c44024ee16f664e0b36eefeb22d54e93531630bd99e237986f534/cowsay-6.1-py3-none-any.whl.metadata + Downloading cowsay-6.1-py3-none-any.whl.metadata (5.6 kB) +Downloading cowsay-6.1-py3-none-any.whl (25 kB) +Saved ./cowsay-6.1-py3-none-any.whl +Successfully downloaded cowsay +``` + +We can then specify it as a local dependency in `tesseract_requirements.txt` by adding the following line: +``` +./cowsay-6.1-py3-none-any.whl +``` + +Finally, let's build the Tesseract, and verify it works +```bash +$ tesseract build . + [i] Building image ... + [i] Built image sha256:7d024, ['cowsay:latest'] + +$ tesseract run cowsay apply '{"inputs": {"message": "Hello, World!"}}' +{"out":" _____________\n| Hello, World! |\n =============\n \\\n \\\n ^__^\n (oo)\\_______\n (__)\\ )\\/\\\n ||----w |\n || ||"} +``` diff --git a/examples/localpackage/helloworld/helloworld.py b/examples/localpackage/helloworld/helloworld.py new file mode 100644 index 00000000..393ba058 --- /dev/null +++ b/examples/localpackage/helloworld/helloworld.py @@ -0,0 +1,6 @@ +# Copyright 2025 Pasteur Labs. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + + +def greet(name): + return f"Hello {name}!" diff --git a/examples/localpackage/helloworld/pyproject.toml b/examples/localpackage/helloworld/pyproject.toml new file mode 100644 index 00000000..601b7962 --- /dev/null +++ b/examples/localpackage/helloworld/pyproject.toml @@ -0,0 +1,7 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "helloworld" +version = "0.1.0" From 5ac0e09e31d561fb5ae46dc3cecb08bc52541e37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dion=20H=C3=A4fner?= Date: Tue, 25 Nov 2025 14:50:01 +0100 Subject: [PATCH 5/6] Apply suggestion from @dionhaefner --- examples/localpackage/tesseract_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/localpackage/tesseract_api.py b/examples/localpackage/tesseract_api.py index 804e2160..59d90a14 100644 --- a/examples/localpackage/tesseract_api.py +++ b/examples/localpackage/tesseract_api.py @@ -15,6 +15,6 @@ class OutputSchema(BaseModel): def apply(inputs: InputSchema) -> OutputSchema: - """Greet a person whose name is given as input.""" + """Greets and ungreets a person whose name is given as input.""" message = f"{greet(inputs.name)}\n{ungreet(inputs.name)}" return OutputSchema(message=message) From bb94f4cbfd574f33d62762c97206812033da8d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dion=20H=C3=A4fner?= Date: Tue, 25 Nov 2025 14:51:13 +0100 Subject: [PATCH 6/6] Apply suggestion from @dionhaefner --- docs/content/examples/building-blocks/localpackage.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/content/examples/building-blocks/localpackage.md b/docs/content/examples/building-blocks/localpackage.md index 41d6a48d..058773a5 100644 --- a/docs/content/examples/building-blocks/localpackage.md +++ b/docs/content/examples/building-blocks/localpackage.md @@ -87,10 +87,10 @@ We can then specify it as a local dependency in `tesseract_requirements.txt` by Finally, let's build the Tesseract, and verify it works ```bash -$ tesseract build . +$ tesseract build mytess [i] Building image ... - [i] Built image sha256:7d024, ['cowsay:latest'] + [i] Built image sha256:7d024, ['mytess:latest'] -$ tesseract run cowsay apply '{"inputs": {"message": "Hello, World!"}}' +$ tesseract run mytess apply '{"inputs": {"message": "Hello, World!"}}' {"out":" _____________\n| Hello, World! |\n =============\n \\\n \\\n ^__^\n (oo)\\_______\n (__)\\ )\\/\\\n ||----w |\n || ||"} ```