Skip to content

Commit 0a925a7

Browse files
authored
feat: Make Python version configurable (#592)
#### Relevant issue or PR n/a #### Description of changes This makes the Python version a Tesseract uses configurable. Since we already use UV for dependency installation, bootstrapping an arbitrary Python version into the image is trivial. Default is `python_version: null` which still resolves to whatever the base image provides, so this is fully backwards compatible. #### Testing done CI, new e2e test
1 parent 240e9f2 commit 0a925a7

9 files changed

Lines changed: 138 additions & 3 deletions

File tree

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: "py310"
22
version: "0.1.0"
33
description: |
4-
Empty Tesseract that requires Python 3.10 (set through a custom Docker image).
4+
Empty Tesseract that requires Python 3.10 (set through build_config.python_version).
55
66
build_config:
7-
base_image: "python:3.10-slim-bookworm"
7+
python_version: "3.10"

tesseract_core/sdk/api_parse.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
Field,
1616
Strict,
1717
field_validator,
18+
model_validator,
1819
)
1920
from pydantic import ValidationError as PydanticValidationError
2021

@@ -132,11 +133,31 @@ class TesseractBuildConfig(BaseModel, validate_assignment=True):
132133
"Example: ``[\"RUN echo 'Hello, world!'\"]``"
133134
),
134135
)
136+
python_version: StrictStr | None = Field(
137+
None,
138+
description=(
139+
"Python version to use inside the Tesseract (e.g., '3.12'). "
140+
"When set, ``uv python install`` is used to install the specified version, "
141+
"decoupling the Python version from the base image. "
142+
"When unset, the system Python from the base image is used."
143+
),
144+
)
135145

136146
requirements: PythonRequirements = PipRequirements(provider="python-pip")
137147

138148
model_config = ConfigDict(extra="forbid")
139149

150+
@model_validator(mode="after")
151+
def _validate_python_version_provider(self):
152+
if self.python_version is not None and isinstance(
153+
self.requirements, CondaRequirements
154+
):
155+
raise ValueError(
156+
"python_version cannot be used with conda requirements. "
157+
"Set the Python version in tesseract_environment.yaml instead."
158+
)
159+
return self
160+
140161
skip_checks: bool = Field(
141162
False,
142163
description=(

tesseract_core/sdk/templates/Dockerfile.base

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ COPY {{ tesseract_source_directory }}/{{ config.build_config.requirements._filen
5252
COPY {{ config.build_config.requirements._build_script }} ./
5353
COPY local_requirements/ ./local_requirements
5454

55+
{% if config.build_config.python_version %}
56+
ENV TESSERACT_PYTHON_VERSION="{{ config.build_config.python_version }}"
57+
{% endif %}
58+
5559
# Build a python venv from python provider build scripts.
5660
# The build script has to create a venv at /python-env
5761
RUN --mount=type=cache,target=/root/.cache/uv {% if use_ssh_mount %}--mount=type=ssh{% endif %} bash {{ config.build_config.requirements._build_script }}
@@ -69,7 +73,9 @@ USER root
6973

7074
RUN apt-get update && apt-get install -y --no-install-recommends \
7175
libnss-wrapper \
76+
{%- if not config.build_config.python_version %}
7277
&& { [ -x "$(command -v python3)" ] || apt-get install -y --no-install-recommends python3; } \
78+
{%- endif %}
7379
&& rm -rf /var/lib/apt/lists/*
7480

7581
{% if config.build_config.extra_packages %}

tesseract_core/sdk/templates/base/tesseract_config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ build_config:
1717
# Base image to use for the container, must be Ubuntu or Debian-based
1818
# base_image: "debian:bookworm-slim"
1919

20+
# Python version to use inside the container (e.g. "3.12"). When set, the given
21+
# version is installed via `uv python install`, decoupling it from the base
22+
# image. When unset, the base image's system Python is used. Cannot be combined
23+
# with conda requirements; set the version in tesseract_environment.yaml instead.
24+
# python_version: "3.12"
25+
2026
# Platform to build the container for. In general, images can only be executed
2127
# on the platform they were built for.
2228
target_platform: "native"

tesseract_core/sdk/templates/build_pip_venv.sh

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55

66
set -e # Exit immediately if a command exits with a non-zero status
77

8-
uv venv /python-env
8+
if [ -n "${TESSERACT_PYTHON_VERSION:-}" ]; then
9+
uv python install "$TESSERACT_PYTHON_VERSION"
10+
uv venv --python "$TESSERACT_PYTHON_VERSION" /python-env
11+
else
12+
uv venv /python-env
13+
fi
914
source /python-env/bin/activate
1015

1116
# Collect dependencies
@@ -28,3 +33,20 @@ uv -v pip install --compile-bytecode ./tesseract_runtime
2833

2934
# Install pip itself into the virtual environment for use by any custom build steps
3035
uv pip install pip
36+
37+
if [ -n "${TESSERACT_PYTHON_VERSION:-}" ]; then
38+
# The venv's python binary is a symlink into the uv-managed installation
39+
# (e.g. /root/.local/share/uv/python/cpython-3.12-.../). Merge that
40+
# installation (stdlib, binary) into /python-env so the venv is fully
41+
# self-contained after Docker multi-stage COPY (which preserves symlinks).
42+
UV_PYTHON_DIR=$(dirname "$(dirname "$(readlink -f /python-env/bin/python)")")
43+
rm /python-env/bin/python /python-env/bin/python3 /python-env/bin/python3.*
44+
cp -a "$UV_PYTHON_DIR"/bin/* /python-env/bin/
45+
cp -a "$UV_PYTHON_DIR"/lib/* /python-env/lib/
46+
cp -a "$UV_PYTHON_DIR"/include/* /python-env/include/
47+
48+
# pyvenv.cfg still points `home` at the uv-managed installation, which does
49+
# not exist after the Docker multi-stage COPY. Point it at the now-local
50+
# binaries so the interpreter can locate its stdlib.
51+
sed -i "s|^home = .*|home = /python-env/bin|" /python-env/pyvenv.cfg
52+
fi

tesseract_core/sdk/templates/jax/tesseract_config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ build_config:
1717
# Base image to use for the container, must be Ubuntu or Debian-based
1818
# base_image: "debian:bookworm-slim"
1919

20+
# Python version to use inside the container (e.g. "3.12"). When set, the given
21+
# version is installed via `uv python install`, decoupling it from the base
22+
# image. When unset, the base image's system Python is used. Cannot be combined
23+
# with conda requirements; set the version in tesseract_environment.yaml instead.
24+
# python_version: "3.12"
25+
2026
# Platform to build the container for. In general, images can only be executed
2127
# on the platform they were built for.
2228
target_platform: "native"

tesseract_core/sdk/templates/pytorch/tesseract_config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ build_config:
1717
# Base image to use for the container, must be Ubuntu or Debian-based
1818
# base_image: "debian:bookworm-slim"
1919

20+
# Python version to use inside the container (e.g. "3.12"). When set, the given
21+
# version is installed via `uv python install`, decoupling it from the base
22+
# image. When unset, the base image's system Python is used. Cannot be combined
23+
# with conda requirements; set the version in tesseract_environment.yaml instead.
24+
# python_version: "3.12"
25+
2026
# Platform to build the container for. In general, images can only be executed
2127
# on the platform they were built for.
2228
target_platform: "native"

tests/sdk_tests/test_api_parse.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,50 @@ def test_optional_signature_check(
205205
validate_tesseract_api(tmp_path)
206206

207207

208+
def test_config_with_python_version(
209+
tmp_path, valid_tesseract_api, valid_tesseract_config
210+
):
211+
_write_tesseract_api_to_file(valid_tesseract_api, tmp_path)
212+
213+
config_with_python_version = yaml.safe_load(valid_tesseract_config)
214+
config_with_python_version["build_config"]["python_version"] = "3.12"
215+
_write_tesseract_config_to_file(yaml.dump(config_with_python_version), tmp_path)
216+
validate_tesseract_api(tmp_path)
217+
218+
from tesseract_core.sdk.api_parse import get_config
219+
220+
config = get_config(tmp_path)
221+
assert config.build_config.python_version == "3.12"
222+
223+
224+
def test_config_python_version_rejects_conda(
225+
tmp_path, valid_tesseract_api, valid_tesseract_config
226+
):
227+
_write_tesseract_api_to_file(valid_tesseract_api, tmp_path)
228+
229+
config = yaml.safe_load(valid_tesseract_config)
230+
config["build_config"]["python_version"] = "3.12"
231+
config["build_config"]["requirements"] = {"provider": "conda"}
232+
_write_tesseract_config_to_file(yaml.dump(config), tmp_path)
233+
234+
with pytest.raises(
235+
ValidationError, match="python_version cannot be used with conda"
236+
):
237+
validate_tesseract_api(tmp_path)
238+
239+
240+
def test_config_python_version_defaults_to_none(
241+
tmp_path, valid_tesseract_api, valid_tesseract_config
242+
):
243+
_write_tesseract_api_to_file(valid_tesseract_api, tmp_path)
244+
_write_tesseract_config_to_file(valid_tesseract_config, tmp_path)
245+
246+
from tesseract_core.sdk.api_parse import get_config
247+
248+
config = get_config(tmp_path)
249+
assert config.build_config.python_version is None
250+
251+
208252
def test_schema_parent_class_is_checked(
209253
tmp_path, valid_tesseract_api, valid_tesseract_config
210254
):

tests/sdk_tests/test_engine.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,30 @@ def test_prepare_build_context(tmp_path_factory):
3333
assert (build_dir / "Dockerfile").exists()
3434

3535

36+
def test_prepare_build_context_python_version(tmp_path_factory):
37+
"""Test that python_version is rendered as ENV in the Dockerfile."""
38+
src_dir = tmp_path_factory.mktemp("src")
39+
(src_dir / "foo").touch()
40+
build_dir = tmp_path_factory.mktemp("build")
41+
42+
config = TesseractConfig(
43+
name="foobar",
44+
build_config=TesseractBuildConfig(python_version="3.12"),
45+
)
46+
engine.prepare_build_context(src_dir, build_dir, config)
47+
48+
dockerfile = (build_dir / "Dockerfile").read_text()
49+
assert 'TESSERACT_PYTHON_VERSION="3.12"' in dockerfile
50+
51+
# Without python_version, the env var should not appear
52+
build_dir2 = tmp_path_factory.mktemp("build2")
53+
config_default = TesseractConfig(name="foobar")
54+
engine.prepare_build_context(src_dir, build_dir2, config_default)
55+
56+
dockerfile_default = (build_dir2 / "Dockerfile").read_text()
57+
assert "TESSERACT_PYTHON_VERSION" not in dockerfile_default
58+
59+
3660
def test_prepare_build_context_env(tmp_path_factory):
3761
"""Test that env variables are rendered as ENV lines in the Dockerfile."""
3862
src_dir = tmp_path_factory.mktemp("src")

0 commit comments

Comments
 (0)