Source code for tesseract_core.sdk.api_parse

# Copyright 2025 Pasteur Labs. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0

import ast
import logging
import re
from pathlib import Path
from typing import Annotated, Any, Literal, NamedTuple

import yaml
from pydantic import (
    AfterValidator,
    BaseModel,
    BeforeValidator,
    ConfigDict,
    Field,
    Strict,
    ValidationInfo,
    field_validator,
    model_validator,
)
from pydantic import ValidationError as PydanticValidationError


class _ApiObject(NamedTuple):
    name: str
    expected_type: type
    num_args: int | None = None
    arg_names: tuple[str, ...] | None = None
    optional: bool = False


# Canonical location of the published JSON Schema for tesseract_config.yaml.
# The `stable` alias on Read the Docs tracks the latest tagged *release* (not the
# tip of main), so this one URL is both stable and correctly versioned. It is the
# URL scaffolded into new configs and registered with SchemaStore.
CONFIG_SCHEMA_URL = "https://docs.pasteurlabs.ai/projects/tesseract-core/stable/tesseract_config.schema.json"

ORDINALS = ["first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth"]

EXPECTED_OBJECTS = (
    _ApiObject("apply", ast.FunctionDef, 1, arg_names=("inputs",)),
    _ApiObject("InputSchema", ast.ClassDef),
    _ApiObject("OutputSchema", ast.ClassDef),
    _ApiObject(
        "jacobian",
        ast.FunctionDef,
        3,
        arg_names=("inputs", "jac_inputs", "jac_outputs"),
        optional=True,
    ),
    _ApiObject(
        "jacobian_vector_product",
        ast.FunctionDef,
        4,
        arg_names=("inputs", "jvp_inputs", "jvp_outputs", "tangent_vector"),
        optional=True,
    ),
    _ApiObject(
        "vector_jacobian_product",
        ast.FunctionDef,
        4,
        arg_names=("inputs", "vjp_inputs", "vjp_outputs", "cotangent_vector"),
        optional=True,
    ),
    _ApiObject(
        "abstract_eval",
        ast.FunctionDef,
        1,
        arg_names=("abstract_inputs",),
        optional=True,
    ),
)


def assert_relative_path(value: str) -> str:
    """Assert that a string encodes a relative path."""
    from pathlib import PurePosixPath, PureWindowsPath

    if PurePosixPath(value).is_absolute() or PureWindowsPath(value).is_absolute():
        raise ValueError(f"value must be a relative path (got {value})")
    return value


RelativePath = Annotated[str, AfterValidator(assert_relative_path)]
StrictStr = Annotated[str, Strict()]


def _normalize_provider(value: Any) -> Any:
    """Accept the deprecated ``python-pip`` provider name as an alias for ``uv-pip``."""
    if value == "python-pip":
        # Emit through the logger rather than warnings.warn: a library-emitted
        # DeprecationWarning is suppressed under Python's default filters, so a
        # real CLI user would never see it.
        # Scheduled for removal in 1.13.0; see tesseract_core/_deprecations.py.
        logging.getLogger("tesseract").warning(
            "The 'python-pip' requirements provider has been renamed to 'uv-pip' "
            "(the build has always used uv under the hood). Set `provider: uv-pip` "
            "in tesseract_config.yaml; 'python-pip' still works but will be "
            "removed in Tesseract 1.13.0."
        )
        return "uv-pip"
    return value


# Host credential fields are written verbatim into the tab-separated credentials
# file and, at build time, into netrc and git-credential entries. Each field is
# constrained to an allowlist so no crafted value -- whitespace, NUL, or any other
# control character -- can span fields or inject extra credential lines. The sets
# differ per field: ``secret_id`` also becomes a ``/run/secrets/<id>`` path, and
# ``host`` is written unencoded into the netrc ``machine`` entry.
_HOST_CREDENTIAL_ALLOWED = {
    # Valid DNS hostname characters, plus ``:`` for an optional ``host:port``.
    "host": re.compile(r"^[A-Za-z0-9.\-:]+$"),
    # BuildKit secret IDs; also safe as a path component under /run/secrets.
    "secret_id": re.compile(r"^[A-Za-z0-9._-]+$"),
    # Usernames are looser but must stay printable-ASCII and single-field.
    "username": re.compile(r"^[\x21-\x7e]+$"),
}


class HostCredential(BaseModel):
    """Credentials for authenticating HTTPS access to a host during the build.

    The credential is keyed by host and applies to everything fetched from that
    host at build time -- package indices (``--extra-index-url``), PEP 508 direct
    references (``pkg @ https://host/...whl``), conda channels, and
    ``git+https://host/...`` dependencies alike. The token is supplied
    out-of-band via a build secret and assembled into netrc and git-credential
    entries inside the build stage; it never lands in the config or an image layer.
    """

    host: StrictStr = Field(
        ...,
        description=(
            "Host the credential authenticates against (e.g. ``pkgs.dev.azure.com`` "
            "or ``github.com``). Just the host, not a full URL."
        ),
    )
    secret_id: StrictStr = Field(
        ...,
        description=(
            "ID of the build secret carrying the token/password for this host, "
            "matching the ``id`` of a ``tesseract build --secret "
            "id=<id>,env=<VAR>`` (or ``,src=<file>``)."
        ),
    )
    username: StrictStr = Field(
        "__token__",
        description=(
            "Username paired with the secret. Defaults to ``__token__``, which "
            "suits PAT-style tokens; set it for hosts that require a real username."
        ),
    )
    model_config: ConfigDict = ConfigDict(extra="forbid")

    @field_validator("host", "secret_id", "username")
    @classmethod
    def _allowed_characters(cls, value: str, info: "ValidationInfo") -> str:
        pattern = _HOST_CREDENTIAL_ALLOWED[info.field_name]
        if not pattern.match(value):
            raise ValueError(
                f"{info.field_name} contains disallowed characters "
                f"(must match {pattern.pattern}, got {value!r})"
            )
        return value


class PipRequirements(BaseModel):
    """Configuration options for Python environments built via uv."""

    provider: Annotated[Literal["uv-pip"], BeforeValidator(_normalize_provider)]
    python_version: StrictStr | None = Field(
        None,
        description=(
            "Python version to use inside the Tesseract (e.g., '3.12'). "
            "When set, ``uv python install`` is used to install the specified version, "
            "decoupling the Python version from the base image. "
            "When unset, the system Python from the base image is used."
        ),
    )
    _filename: Literal["tesseract_requirements.txt"] = "tesseract_requirements.txt"
    _build_script: Literal["build_pip_venv.sh"] = "build_pip_venv.sh"
    model_config: ConfigDict = ConfigDict(extra="forbid")


class CondaRequirements(BaseModel):
    """Configuration options for Python environments built via conda."""

    provider: Literal["conda"]
    _filename: Literal["tesseract_environment.yaml"] = "tesseract_environment.yaml"
    _build_script: Literal["build_conda_venv.sh"] = "build_conda_venv.sh"
    model_config: ConfigDict = ConfigDict(extra="forbid")


PythonRequirements = PipRequirements | CondaRequirements


[docs] class TesseractBuildConfig(BaseModel, validate_assignment=True): """Configuration options for building a Tesseract.""" base_image: StrictStr = Field( "debian:bookworm-slim", description="Base Docker image for the build. Must be Debian-based.", ) target_platform: StrictStr = Field( "native", description=( "Target platform for the Docker build. Must be a valid Docker platform, " "or 'native' to build for the host platform. " "In general, images built for one platform will not run on another." ), ) extra_packages: tuple[StrictStr, ...] = Field( (), description="Extra packages to install during build via apt-get." ) package_data: tuple[tuple[RelativePath, StrictStr], ...] | None = Field( (), description=( "Additional files to copy into the Docker image, in the format ``(source, destination)``. " "Source paths are relative to the directory containing `tesseract_api.py`. " "Destination paths are relative or absolute paths within the Docker image (e.g., ``/app/shared_code.py``) " "and must be unique." ), ) custom_build_steps: tuple[StrictStr, ...] | None = Field( (), description=( "Custom steps to run during ``docker build`` (after everything else is installed). " "Example: ``[\"RUN echo 'Hello, world!'\"]``" ), ) python_version: StrictStr | None = Field( None, description=( "Deprecated alias for ``build_config.requirements.python_version``. " "Kept for backwards compatibility; set the version under the provider " "settings instead. Removed in Tesseract 1.13.0." ), ) inherit_base_image_packages: bool = Field( False, description=( "If True, create the Python virtual environment with --system-site-packages " "so it inherits Python packages pre-installed in the base image " "(e.g. Firedrake, FEniCS, OpenFOAM). Cannot be combined with python_version." ), ) requirements: PythonRequirements = PipRequirements(provider="uv-pip") host_credentials: tuple[HostCredential, ...] = Field( (), description=( "Credentials for authenticated hosts accessed during the build. Each " "entry maps a host to a build secret supplied out-of-band at build time " "(see ``HostCredential`` and ``tesseract build --secret``). Applies to " "package indices, direct-reference wheels, conda channels, and " "``git+https`` dependencies on that host." ), ) build_env: dict[StrictStr, StrictStr] = Field( default_factory=dict, description=( "Environment variables to set during the build stage only (not in the " "final image). Useful for configuring the package resolver, e.g. " "``{UV_INDEX_STRATEGY: unsafe-best-match}``. " "Do not put secrets here: values are written into the build context. " "Use ``tesseract build --secret`` for credentials instead." ), ) model_config = ConfigDict(extra="forbid") @property def effective_python_version(self) -> str | None: """Python version requested for the build, or None to use the base image's. Only the uv-pip provider supports pinning the version; conda pins it via ``tesseract_environment.yaml`` instead. """ if isinstance(self.requirements, PipRequirements): return self.requirements.python_version return None @model_validator(mode="after") def _validate_python_version_provider(self): # Forward the deprecated build_config.python_version onto the provider so # the rest of the code only reads requirements.python_version (via # effective_python_version). Scheduled for removal in 1.13.0; see # tesseract_core/_deprecations.py. if self.python_version is not None: # Emit through the logger rather than relying on the Field's # deprecated= warning: a library-emitted DeprecationWarning is # suppressed under Python's default filters, so a real CLI user would # never see it (mirrors _normalize_provider). logging.getLogger("tesseract").warning( "build_config.python_version has moved to the uv-pip provider " "settings. Set `build_config.requirements.python_version` in " "tesseract_config.yaml; the old location still works but will be " "removed in Tesseract 1.13.0." ) if not isinstance(self.requirements, PipRequirements): raise ValueError( "python_version cannot be used with conda requirements. " "Set the Python version in tesseract_environment.yaml instead." ) if ( self.requirements.python_version is not None and self.requirements.python_version != self.python_version ): raise ValueError( "python_version is set both on build_config and on " "build_config.requirements. Set it only once, under " "build_config.requirements.python_version." ) self.requirements.python_version = self.python_version if ( self.effective_python_version is not None and self.inherit_base_image_packages ): raise ValueError( "python_version cannot be used with inherit_base_image_packages. " "inherit_base_image_packages exposes the base image's system Python " "packages, which belong to a different interpreter than the one " "installed by python_version. Set only one of the two." ) return self skip_checks: bool = Field( False, description=( "If True, skip build-time checks of Tesseract API module. " "This can be useful when such a check cannot succeed (e.g. when building for a " "different platform), but may lead to runtime errors if the Tesseract API is not valid." ), )
# Allow None to be passed as a valid value for build_config, for example in YAML that comments out all options. OptionalBuildConfig = Annotated[ TesseractBuildConfig, BeforeValidator(lambda v: TesseractBuildConfig() if v is None else v), ]
[docs] class TesseractConfig(BaseModel, validate_assignment=True): """Configuration options for Tesseracts. Defines valid options in ``tesseract_config.yaml``.""" name: StrictStr = Field(..., description="Name of the Tesseract.", min_length=1) version: StrictStr = Field( "unknown", description="Version of the Tesseract.", min_length=1, max_length=128 ) description: StrictStr = Field( "", description="Free-text description of what the Tesseract does.", ) build_config: OptionalBuildConfig = Field( default_factory=TesseractBuildConfig, description="Configuration options for building the Tesseract.", ) env: dict[StrictStr, StrictStr] = Field( default_factory=dict, description=( "Environment variables to set in the Docker image. " "Rendered as ``ENV`` lines in the Dockerfile. " "Example: ``{XLA_PYTHON_CLIENT_PREALLOCATE: 'false'}``" ), ) metadata: dict[str, Any] = Field( default_factory=dict, description="Arbitrary user-defined metadata. " "This will be stored as a Docker label (ai.pasteurlabs.tesseract.metadata).", ) model_config = ConfigDict(extra="forbid")
[docs] @field_validator("version") @classmethod def validate_version(cls, v: str) -> str: """Validate that the version string is a valid semantic version.""" version_pattern = r"^\d+\.\d+\.\d+[a-zA-Z-0-9]*$" if (not re.match(version_pattern, v)) and v != "unknown": raise ValueError( f"Version '{v}' is not a valid version number for a Tesseract. " "You can only use three dot-separated digits (e.g. 1.2.3), to which " "optionally you can append a hyphen - and a string (e.g. 1.2.3-nightly)" ) return v
def generate_config_schema() -> dict: """Generate a JSON Schema for ``tesseract_config.yaml``. The schema is derived directly from the :class:`TesseractConfig` model, so it always matches the fields, defaults, and descriptions the SDK actually accepts. It is published to the docs site and registered with SchemaStore so IDEs can validate ``tesseract_config.yaml`` and offer inline help. A ``tesseract_config.yaml`` may carry an editor schema reference as a ``# yaml-language-server: $schema=...`` comment, which the YAML parser ignores. The schema therefore does not need to allow a ``$schema`` property (and cannot, since the model forbids extra fields). """ schema = TesseractConfig.model_json_schema() # Draft 2020-12 is what pydantic emits; declare it so validators don't guess. schema = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": CONFIG_SCHEMA_URL, "$comment": ( "Auto-generated from tesseract_core.sdk.api_parse.TesseractConfig. " "Do not edit by hand." ), "title": "Tesseract configuration", **schema, } return schema class ValidationError(Exception): """Raised when inputs needed to build a tesseract are invalid.""" pass def _get_func_argnames(func: ast.FunctionDef) -> tuple[str, ...]: """Get the names of the arguments of a function node. See: https://docs.python.org/3/library/ast.html#ast.FunctionDef https://docs.python.org/3/library/ast.html#ast.arguments """ func_args = func.args if func_args.kwonlyargs: raise ValidationError( f"Function {func.name} must not have keyword-only arguments" ) if func_args.posonlyargs: raise ValidationError( f"Function {func.name} must not have positional-only arguments" ) return tuple(arg.arg for arg in func_args.args) def validate_tesseract_api(src_dir: Path) -> None: """Check that given folder contains a Tesseract API that satisfies our constraints. This function does not return anything, but it raises `ValidationError` in case something goes wrong. In particular, we are checking that: * The mandatory endpoints needed for a tesseract are actually implemented * The implemented functions have the correct signature * Both InputSchema and OutputSchema are `pydantic.BaseModel`s. Args: src_dir (Path): Path to the directory containing tesseract_api.py and tesseract_config.yaml. """ tesseract_api_location = src_dir / "tesseract_api.py" config_location = src_dir / "tesseract_config.yaml" if not tesseract_api_location.exists(): raise ValidationError(f"No file found at {tesseract_api_location}") if not config_location.exists(): raise ValidationError(f"No file found at {config_location}") # Validate config try: get_config(src_dir) except PydanticValidationError as err: raise ValidationError( f"Invalid configuration in {config_location}: {err}" ) from err # Parse Tesseract API with open(tesseract_api_location) as f: tesseract_api_code = f.read() try: tesseract_api = ast.parse(tesseract_api_code) except SyntaxError as err: raise ValidationError( f"Syntax error in {tesseract_api_location}: {err}" ) from err # Check if expected attributes are defined toplevel_objects = { node.name: node for node in tesseract_api.body if hasattr(node, "name") } for obj in EXPECTED_OBJECTS: if obj.name not in toplevel_objects: if obj.optional: continue raise ValidationError(f"{obj.name} not defined in {tesseract_api_location}") if not isinstance(toplevel_objects[obj.name], obj.expected_type): raise ValidationError( f"{obj.name} is not a {obj.expected_type.__name__} in {tesseract_api_location}" ) if obj.num_args is not None: func_argnames = _get_func_argnames(toplevel_objects[obj.name]) func_argnums = len(func_argnames) if func_argnums != obj.num_args: raise ValidationError( f"{obj.name} must have {obj.num_args} arguments: {', '.join(obj.arg_names)}.\n" f"However, {tesseract_api_location} specifies {func_argnums} " f"arguments: {', '.join(func_argnames)}." ) msgs = [] for i in range(obj.num_args): if func_argnames[i] != obj.arg_names[i]: msgs.append( f"The {ORDINALS[i]} argument (argument {i}) of {obj.name} must be named {obj.arg_names[i]}, " f"but {tesseract_api_location} has named it {func_argnames[i]}." ) if msgs: raise ValidationError("\n".join(msgs)) # Check InputSchema and OutputSchema are pydantic BaseModels for schema in ("InputSchema", "OutputSchema"): obj = toplevel_objects[schema] if not obj.bases: raise ValidationError(f"{schema} must inherit from pydantic.BaseModel") def get_config(src_dir: Path) -> TesseractConfig: """Get configuration options from a tesseract_config.yaml file.""" config_file = src_dir / "tesseract_config.yaml" if not config_file.exists(): raise FileNotFoundError(f"No file found at {config_file}") with open(config_file) as f: config = yaml.safe_load(f) try: return TesseractConfig(**config) except PydanticValidationError as err: raise ValidationError(f"Invalid configuration: {err}") from err def get_submodel_fields_in_tesseract_config() -> list[tuple[str, type]]: """Gets fields in TesseractConfig that are Pydantic sub-models.""" non_base_fields = [] for field_name, field_info in TesseractConfig.model_fields.items(): origin = getattr(field_info.annotation, "__origin__", field_info.annotation) if isinstance(origin, type) and issubclass(origin, BaseModel): non_base_fields.append((field_name, field_info.annotation)) return non_base_fields def _submodels_in_annotation(annotation: Any) -> list[type[BaseModel]]: """Return the directly-settable ``BaseModel`` subclasses in a field annotation. Handles bare models, optionals (``Model | None``), and unions of models (e.g. the ``requirements`` field, which is ``PipRequirements | CondaRequirements``). Models nested inside collections (``tuple[HostCredential, ...]``) are ignored: their elements cannot be reached by a dotted ``--config-override`` keypath. """ import types import typing origin = typing.get_origin(annotation) # Only descend through bare annotations and unions -- not list/tuple/dict. if origin not in (None, typing.Union, types.UnionType): return [] args = typing.get_args(annotation) candidates = args if args else (annotation,) seen: dict[type[BaseModel], None] = {} for arg in candidates: # ``isinstance(arg, type)`` is not enough to guarantee ``issubclass`` won't # raise: some parameterized generics report as types on older pydantic/typing # versions but reject ``issubclass``. Guard the check so such args are simply # treated as non-models. try: is_model = isinstance(arg, type) and issubclass(arg, BaseModel) except TypeError: is_model = False if is_model: seen.setdefault(arg, None) return list(seen) def get_config_keypaths(model: type[BaseModel] = TesseractConfig) -> list[str]: """Enumerate dot-separated config keypaths a ``--config-override`` may target. Recurses through nested sub-models (including union members like the requirements providers) so nested attributes such as ``build_config.requirements.python_version`` are surfaced, not just top-level fields. Both intermediate sub-model paths and leaf paths are returned. """ # dict preserves insertion order and dedupes paths shared across union members # (e.g. `provider` on both requirements providers). keypaths: dict[str, None] = {} def _walk(current: type[BaseModel], prefix: str) -> None: for field_name, field_info in current.model_fields.items(): path = f"{prefix}{field_name}" keypaths.setdefault(path, None) for submodel in _submodels_in_annotation(field_info.annotation): _walk(submodel, f"{path}.") _walk(model, "") return list(keypaths)