diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index cefb623a..9e6b7f0c 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -20,12 +20,12 @@ jobs: # test with oldest and latest supported Python versions # NOTE: If bumping the minimum Python version here, also do it in # ruff.toml, setup.py and other CI files as well. - python-version: ["3.10", "3.12"] + python-version: ["3.10", "3.13"] runtime-deps: ["latest"] include: - os: ubuntu-latest - python-version: "3.12" + python-version: "3.10" runtime-deps: "oldest" fail-fast: false @@ -199,7 +199,7 @@ jobs: strategy: matrix: os: [ubuntu-24.04] - python-version: ["3.12"] + python-version: ["3.13"] arch: ["x64"] docker-engine: ["docker"] @@ -214,18 +214,18 @@ jobs: # Test on arm to ensure compatibility with Apple M1 chips # (OSX runners don't have access to Docker so we use Linux ARM runners instead) - os: "ubuntu-24.04" - python-version: "3.12" + python-version: "3.13" arch: "arm" docker-engine: "docker" unit-tesseract: "base" - os: "ubuntu-24.04" - python-version: "3.12" + python-version: "3.13" arch: "arm" docker-engine: "docker" unit-tesseract: "pyvista-arm64" # Run tests using Podman - os: "ubuntu-24.04" - python-version: "3.12" + python-version: "3.13" arch: "x64" docker-engine: "podman" unit-tesseract: "base" diff --git a/examples/py39/tesseract_api.py b/examples/py310/tesseract_api.py similarity index 88% rename from examples/py39/tesseract_api.py rename to examples/py310/tesseract_api.py index b786d49e..8cf9b0c7 100644 --- a/examples/py39/tesseract_api.py +++ b/examples/py310/tesseract_api.py @@ -25,10 +25,10 @@ class OutputSchema(BaseModel): def apply(inputs: InputSchema) -> OutputSchema: - # Ensure that the Python version is what we expect (3.9) + # Ensure that the Python version is what we expect (3.10) import sys - assert sys.version_info[:2] == (3, 9) + assert sys.version_info[:2] == (3, 10) return OutputSchema(bar=0) diff --git a/examples/py310/tesseract_config.yaml b/examples/py310/tesseract_config.yaml new file mode 100644 index 00000000..7ff5dc07 --- /dev/null +++ b/examples/py310/tesseract_config.yaml @@ -0,0 +1,7 @@ +name: "py310" +version: "0.1.0" +description: | + Empty Tesseract that requires Python 3.10 (set through a custom Docker image). + +build_config: + base_image: "python:3.10-slim-bookworm" diff --git a/examples/py39/tesseract_config.yaml b/examples/py39/tesseract_config.yaml deleted file mode 100644 index 86d9a54f..00000000 --- a/examples/py39/tesseract_config.yaml +++ /dev/null @@ -1,7 +0,0 @@ -name: "py39" -version: "0.1.0" -description: | - Empty Tesseract that requires Python 3.9 (set through a custom Docker image). - -build_config: - base_image: "python:3.9-slim-bookworm" diff --git a/examples/qp_solve/tesseract_api.py b/examples/qp_solve/tesseract_api.py index 20a62234..fd157528 100644 --- a/examples/qp_solve/tesseract_api.py +++ b/examples/qp_solve/tesseract_api.py @@ -4,7 +4,6 @@ # Tesseract API module for lp_solve # Generated by tesseract 0.9.0 on 2025-06-04T13:11:17.208977 import functools -from typing import Optional import jax import jax.numpy as jnp @@ -23,29 +22,29 @@ class InputSchema(BaseModel): Q: Differentiable[Array[(None, None), Float32]] = Field( description="Quadratic cost matrix Q for the quadratic program." ) - q: Optional[Differentiable[Array[(None,), Float32]]] = Field( + q: Differentiable[Array[(None,), Float32]] | None = Field( description="Linear cost vector q for the quadratic program.", default=None ) - A: Optional[Differentiable[Array[(None, None), Float32]]] = Field( + A: Differentiable[Array[(None, None), Float32]] | None = Field( default=None, description="Linear equality constraint matrix A for the quadratic program.", ) - b: Optional[Differentiable[Array[(None,), Float32]]] = Field( + b: Differentiable[Array[(None,), Float32]] | None = Field( default=None, description="Linear equality constraint rhs b for the quadratic program.", ) - G: Optional[Differentiable[Array[(None, None), Float32]]] = Field( + G: Differentiable[Array[(None, None), Float32]] | None = Field( description="Linear inequality constraint matrix G for the quadratic program.", default=None, ) - h: Optional[Differentiable[Array[(None,), Float32]]] = Field( + h: Differentiable[Array[(None,), Float32]] | None = Field( description="Linear inequality constraint rhs h for the quadratic program.", default=None, ) - solver_tol: Optional[Float32] = Field( + solver_tol: Float32 | None = Field( description="Tolerance for the solver convergence.", default=1e-4 ) - target_kappa: Optional[Float32] = Field( + target_kappa: Float32 | None = Field( description="QPAX parameter for QP relaxation.", default=1e-3 ) diff --git a/examples/univariate/optimize.py b/examples/univariate/optimize.py index b0c4a1a2..66faf5d3 100644 --- a/examples/univariate/optimize.py +++ b/examples/univariate/optimize.py @@ -37,6 +37,6 @@ def rosenbrock_gradient(x: np.ndarray) -> np.ndarray: callback=lambda xs: trajectory.append(xs.tolist()), ) -anim = make_animation(*list(zip(*trajectory))) +anim = make_animation(*list(zip(*trajectory, strict=True))) # anim.save("rosenbrock_optimization.gif", writer="pillow", fps=2, dpi=150) plt.show() diff --git a/pyproject.toml b/pyproject.toml index 8ae79bf7..cab1dfcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ filterwarnings = [ "ignore:numpy.ufunc size changed", # sometimes, dependencies leak resources "ignore:.*socket\\.socket.*:pytest.PytestUnraisableExceptionWarning", + "ignore:.*sqlite3\\.Connection.*:pytest.PytestUnraisableExceptionWarning", ] [tool.coverage.run] diff --git a/ruff.toml b/ruff.toml index b20d25c1..dcf11c66 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,6 +1,5 @@ # Set to the lowest supported Python version. -# !! tesseract_runtime still supports Python 3.9 !! -target-version = "py39" +target-version = "py310" # Set the target line length for formatting. line-length = 88 diff --git a/tesseract_core/runtime/array_encoding.py b/tesseract_core/runtime/array_encoding.py index 460062f0..9f692e12 100644 --- a/tesseract_core/runtime/array_encoding.py +++ b/tesseract_core/runtime/array_encoding.py @@ -4,7 +4,7 @@ import re from collections.abc import Sequence from pathlib import Path -from typing import Annotated, Any, Literal, Optional, Union, get_args +from typing import Annotated, Any, Literal, get_args from uuid import uuid4 import numpy as np @@ -45,8 +45,8 @@ "complex128", ] EllipsisType = type(Ellipsis) -ArrayLike = Union[np.ndarray, np.number, np.bool_] -ShapeType = Union[tuple[Optional[int], ...], EllipsisType] +ArrayLike = np.ndarray | np.number | np.bool_ +ShapeType = tuple[int | None, ...] | EllipsisType MAX_BINREF_BUFFER_SIZE = 100 * 1024 * 1024 # 100 MB @@ -94,12 +94,12 @@ class EncodedArrayModel(BaseModel): object_type: Literal["array"] shape: tuple[PositiveInt, ...] dtype: AllowedDtypes - data: Union[BinrefArrayData, Base64ArrayData, JsonArrayData] + data: BinrefArrayData | Base64ArrayData | JsonArrayData model_config = ConfigDict(extra="forbid") def get_array_model( - expected_shape: ShapeType, expected_dtype: Optional[str], flags: Sequence[str] + expected_shape: ShapeType, expected_dtype: str | None, flags: Sequence[str] ) -> type[EncodedArrayModel]: """Create a Pydantic model for an encoded array that does validation on the given expected shape and dtype.""" if expected_dtype is None: @@ -177,7 +177,7 @@ def get_array_model( ), # Choose the appropriate data structure based on the encoding "data": ( - Union[BinrefArrayData, Base64ArrayData, JsonArrayData], + BinrefArrayData | Base64ArrayData | JsonArrayData, Field(discriminator="encoding"), ), "model_config": (ConfigDict, config), @@ -203,12 +203,12 @@ def get_array_model( def _dump_binref_arraydict( - arr: Union[np.ndarray, np.number, np.bool_], - base_dir: Union[Path, str], - subdir: Optional[Union[Path, str]], + arr: np.ndarray | np.number | np.bool_, + base_dir: Path | str, + subdir: Path | str | None, current_binref_uuid: str, max_file_size: int = MAX_BINREF_BUFFER_SIZE, -) -> tuple[dict[str, Union[str, dict[str, str]]], str]: +) -> tuple[dict[str, str | dict[str, str]], str]: """Dump array to json+binref encoded array dict. Writes a .bin file and returns json encoded data. @@ -243,8 +243,8 @@ def _dump_binref_arraydict( def _dump_base64_arraydict( - arr: Union[np.ndarray, np.number, np.bool_], -) -> dict[str, Union[str, dict[str, str]]]: + arr: np.ndarray | np.number | np.bool_, +) -> dict[str, str | dict[str, str]]: """Dump array to json+base64 encoded array dict.""" data = { "buffer": pybase64.b64encode(arr.tobytes()).decode(), @@ -260,8 +260,8 @@ def _dump_base64_arraydict( def _dump_json_arraydict( - arr: Union[np.ndarray, np.number, np.bool_], -) -> dict[str, Union[str, dict[str, str]]]: + arr: np.ndarray | np.number | np.bool_, +) -> dict[str, str | dict[str, str]]: """Dump array to json encoded array dict.""" data = { "buffer": arr.tolist(), @@ -282,7 +282,7 @@ def _load_base64_arraydict(val: dict) -> np.ndarray: return np.frombuffer(buffer, dtype=val["dtype"]).reshape(val["shape"]) -def _load_binref_arraydict(val: dict, base_dir: Union[str, Path, None]) -> np.ndarray: +def _load_binref_arraydict(val: dict, base_dir: str | Path | None) -> np.ndarray: """Load array from json+binref encoded array dict.""" path_match = re.match(r"^(?P.+?)(\:(?P\d+))?$", val["data"]["buffer"]) if not path_match: @@ -316,7 +316,7 @@ def _load_binref_arraydict(val: dict, base_dir: Union[str, Path, None]) -> np.nd def _coerce_shape_dtype( - arr: ArrayLike, expected_shape: ShapeType, expected_dtype: Optional[str] + arr: ArrayLike, expected_shape: ShapeType, expected_dtype: str | None ) -> ArrayLike: """Coerce the shape and dtype of the passed array to the expected values.""" if expected_shape is Ellipsis: @@ -363,7 +363,7 @@ def _coerce_shape_dtype( def python_to_array( - val: Any, expected_shape: ShapeType, expected_dtype: Optional[str] + val: Any, expected_shape: ShapeType, expected_dtype: str | None ) -> ArrayLike: """Convert a Python object to a NumPy array.""" val = np.asarray(val, order="C") @@ -380,7 +380,7 @@ def decode_array( val: EncodedArrayModel, info: ValidationInfo, expected_shape: ShapeType, - expected_dtype: Optional[str], + expected_dtype: str | None, ) -> ArrayLike: """Decode an EncodedArrayModel to a NumPy array.""" from tesseract_core.runtime.config import get_config @@ -422,8 +422,8 @@ def decode_array( def encode_array( - arr: ArrayLike, info: Any, expected_shape: ShapeType, expected_dtype: Optional[str] -) -> Union[EncodedArrayModel, ArrayLike]: + arr: ArrayLike, info: Any, expected_shape: ShapeType, expected_dtype: str | None +) -> EncodedArrayModel | ArrayLike: """Encode a NumPy array as an EncodedArrayModel.""" from tesseract_core.runtime.config import get_config diff --git a/tesseract_core/runtime/cli.py b/tesseract_core/runtime/cli.py index 2319834a..ae611e18 100644 --- a/tesseract_core/runtime/cli.py +++ b/tesseract_core/runtime/cli.py @@ -16,7 +16,6 @@ Annotated, Any, Literal, - Optional, get_args, get_origin, ) @@ -219,7 +218,7 @@ def check_gradients( ), ], input_paths: Annotated[ - Optional[list[str]], + list[str] | None, typer.Option( "--input-paths", help="Paths to differentiable inputs to check gradients for.", @@ -227,7 +226,7 @@ def check_gradients( ), ] = None, output_paths: Annotated[ - Optional[list[str]], + list[str] | None, typer.Option( "--output-paths", help="Paths to differentiable outputs to check gradients for.", @@ -235,7 +234,7 @@ def check_gradients( ), ] = None, endpoints: Annotated[ - Optional[list[str]], + list[str] | None, typer.Option( "--endpoints", help="Endpoints to check gradients for.", @@ -275,7 +274,7 @@ def check_gradients( ), ] = 10, seed: Annotated[ - Optional[int], + int | None, typer.Option( "--seed", help="Seed for random number generator. If not set, a random seed is used.", @@ -367,7 +366,7 @@ def serve( def _create_user_defined_cli_command( - app: typer.Typer, user_function: Callable, out_stream: Optional[io.TextIOBase] + app: typer.Typer, user_function: Callable, out_stream: io.TextIOBase | None ) -> None: """Creates a click command which sends requests to Tesseract endpoints. @@ -459,9 +458,7 @@ def command_func(): decorator(command_func) -def _add_user_commands_to_cli( - app: typer.Typer, out_stream: Optional[io.IOBase] -) -> None: +def _add_user_commands_to_cli(app: typer.Typer, out_stream: io.IOBase | None) -> None: tesseract_package = get_tesseract_api() endpoints = create_endpoints(tesseract_package) diff --git a/tesseract_core/runtime/core.py b/tesseract_core/runtime/core.py index 25827898..f357637a 100644 --- a/tesseract_core/runtime/core.py +++ b/tesseract_core/runtime/core.py @@ -9,7 +9,7 @@ from io import TextIOBase from pathlib import Path from types import ModuleType -from typing import Any, TextIO, Union +from typing import Any, TextIO from pydantic import BaseModel @@ -22,9 +22,7 @@ @contextmanager -def redirect_fd( - from_: TextIO, to_: Union[TextIO, int] -) -> Generator[TextIO, None, None]: +def redirect_fd(from_: TextIO, to_: TextIO | int) -> Generator[TextIO, None, None]: """Redirect a file descriptor at OS level. Args: @@ -49,7 +47,7 @@ def redirect_fd( orig_fd_file.close() -def load_module_from_path(path: Union[Path, str]) -> ModuleType: +def load_module_from_path(path: Path | str) -> ModuleType: """Load a module from a file path. Temporarily puts the module's parent folder on PYTHONPATH to ensure local imports work as expected. diff --git a/tesseract_core/runtime/experimental.py b/tesseract_core/runtime/experimental.py index f9c53b27..8f06912d 100644 --- a/tesseract_core/runtime/experimental.py +++ b/tesseract_core/runtime/experimental.py @@ -2,13 +2,11 @@ # SPDX-License-Identifier: Apache-2.0 import json -from collections.abc import Iterator, Sequence +from collections.abc import Callable, Iterator, Sequence from pathlib import Path from typing import ( Annotated, Any, - Callable, - Union, get_args, get_origin, ) @@ -114,7 +112,7 @@ def __get_pydantic_core_schema__( Does most of the heavy lifting for validation and serialization. """ - def create_sequence(maybe_path: Union[str, Sequence[Any]]) -> LazySequence: + def create_sequence(maybe_path: str | Sequence[Any]) -> LazySequence: """Expand a glob pattern into a LazySequence if needed.""" validator = SchemaValidator(item_schema) diff --git a/tesseract_core/runtime/file_interactions.py b/tesseract_core/runtime/file_interactions.py index 69b48235..503be554 100644 --- a/tesseract_core/runtime/file_interactions.py +++ b/tesseract_core/runtime/file_interactions.py @@ -3,12 +3,12 @@ import urllib.parse from pathlib import Path -from typing import Any, Literal, Optional, Union, get_args +from typing import Any, Literal, get_args import fsspec from pydantic import TypeAdapter -PathLike = Union[str, Path] +PathLike = str | Path supported_format_type = Literal["json", "json+base64", "json+binref"] SUPPORTED_FORMATS = get_args(supported_format_type) @@ -17,8 +17,8 @@ def output_to_bytes( obj: Any, format: supported_format_type, - base_dir: Optional[Union[str, Path]] = None, - binref_dir: Optional[Union[str, Path]] = None, + base_dir: str | Path | None = None, + binref_dir: str | Path | None = None, ) -> bytes: """Encode endpoint output to bytes in the given format. diff --git a/tesseract_core/runtime/finite_differences.py b/tesseract_core/runtime/finite_differences.py index 27c1c9e5..6230c643 100644 --- a/tesseract_core/runtime/finite_differences.py +++ b/tesseract_core/runtime/finite_differences.py @@ -2,16 +2,14 @@ # SPDX-License-Identifier: Apache-2.0 import traceback -from collections.abc import Iterator, Sequence +from collections.abc import Callable, Iterator, Sequence from functools import wraps from pathlib import Path from types import ModuleType from typing import ( Any, - Callable, Literal, NamedTuple, - Optional, get_args, ) @@ -43,9 +41,9 @@ class GradientCheckResult(NamedTuple): in_path: str out_path: str idx: tuple[int, ...] - grad_val: Optional[ArrayLike] - ref_val: Optional[ArrayLike] - exception: Optional[str] + grad_val: ArrayLike | None + ref_val: ArrayLike | None + exception: str | None def get_input_schema(endpoint_function: Callable) -> type[BaseModel]: @@ -297,7 +295,7 @@ def _sample_indices( continue n_evals = max(1, int(max_evals * np.prod(shape) / total_elements)) idx_tuple = np.unravel_index(rng.choice(int(np.prod(shape)), n_evals), shape) - idx_per_input[path] = list(zip(*idx_tuple)) + idx_per_input[path] = list(zip(*idx_tuple, strict=True)) items_to_check = [] for in_path in diff_inputs: @@ -408,14 +406,14 @@ def check_gradients( api_module: ModuleType, inputs: dict[str, Any], *, - input_paths: Optional[Sequence[str]] = None, - output_paths: Optional[Sequence[str]] = None, - base_dir: Optional[Path] = None, - endpoints: Optional[Sequence[ADEndpointName]] = None, + input_paths: Sequence[str] | None = None, + output_paths: Sequence[str] | None = None, + base_dir: Path | None = None, + endpoints: Sequence[ADEndpointName] | None = None, max_evals: int = 1000, eps: float = 1e-4, rtol: float = 0.1, - seed: Optional[int] = None, + seed: int | None = None, show_progress: bool = True, ) -> Iterator[tuple[str, list[GradientCheckResult], int]]: """Check gradients of endpoints against a finite difference approximation. diff --git a/tesseract_core/runtime/logs.py b/tesseract_core/runtime/logs.py index 4376cab2..21c6d5d2 100644 --- a/tesseract_core/runtime/logs.py +++ b/tesseract_core/runtime/logs.py @@ -3,7 +3,8 @@ import os import threading -from typing import Any, Callable +from collections.abc import Callable +from typing import Any # NOTE: This is duplicated in `tesseract_core/sdk/logs.py`. diff --git a/tesseract_core/runtime/meta/pyproject.toml b/tesseract_core/runtime/meta/pyproject.toml index 08e98b54..7c029847 100644 --- a/tesseract_core/runtime/meta/pyproject.toml +++ b/tesseract_core/runtime/meta/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "tesseract_runtime" version = "0.1.0" -requires-python = ">=3.9" +requires-python = ">=3.10" # NOTE: Upper bounds (<=) are periodically updated via .github/workflows/bump_lockfile.yml, # do not edit manually. To add constraints, use other operators (e.g. <, >=, ~=, ==) as needed. diff --git a/tesseract_core/runtime/mpa.py b/tesseract_core/runtime/mpa.py index fe3a0b5f..78fb5894 100644 --- a/tesseract_core/runtime/mpa.py +++ b/tesseract_core/runtime/mpa.py @@ -15,7 +15,7 @@ from datetime import datetime from io import UnsupportedOperation from pathlib import Path -from typing import Any, Optional, Union +from typing import Any import requests @@ -26,7 +26,7 @@ class BaseBackend(ABC): """Base class for MPA backends.""" - def __init__(self, base_dir: Optional[str] = None) -> None: + def __init__(self, base_dir: str | None = None) -> None: if base_dir is None: base_dir = get_config().output_path self.log_dir = Path(base_dir) / "logs" @@ -38,7 +38,7 @@ def log_parameter(self, key: str, value: Any) -> None: pass @abstractmethod - def log_metric(self, key: str, value: float, step: Optional[int] = None) -> None: + def log_metric(self, key: str, value: float, step: int | None = None) -> None: """Log a metric.""" pass @@ -61,7 +61,7 @@ def end_run(self) -> None: class FileBackend(BaseBackend): """MPA backend that writes to local files.""" - def __init__(self, base_dir: Optional[str] = None) -> None: + def __init__(self, base_dir: str | None = None) -> None: super().__init__(base_dir) # Initialize log files self.params_file = self.log_dir / "parameters.json" @@ -84,7 +84,7 @@ def log_parameter(self, key: str, value: Any) -> None: with open(self.params_file, "w") as f: json.dump(self.parameters, f, indent=2, default=str) - def log_metric(self, key: str, value: float, step: Optional[int] = None) -> None: + def log_metric(self, key: str, value: float, step: int | None = None) -> None: """Log a metric to CSV file.""" timestamp = datetime.now().isoformat() step_value = ( @@ -126,7 +126,7 @@ def end_run(self) -> None: class MLflowBackend(BaseBackend): """MPA backend that writes to an MLflow tracking server.""" - def __init__(self, base_dir: Optional[str] = None) -> None: + def __init__(self, base_dir: str | None = None) -> None: super().__init__(base_dir) os.environ["GIT_PYTHON_REFRESH"] = ( "quiet" # Suppress potential MLflow git warnings @@ -176,7 +176,7 @@ def log_parameter(self, key: str, value: Any) -> None: """Log a parameter to MLflow.""" self.mlflow.log_param(key, value) - def log_metric(self, key: str, value: float, step: Optional[int] = None) -> None: + def log_metric(self, key: str, value: float, step: int | None = None) -> None: """Log a metric to MLflow.""" self.mlflow.log_metric(key, value, step=step) @@ -193,7 +193,7 @@ def end_run(self) -> None: self.mlflow.end_run() -def _create_backend(base_dir: Optional[str]) -> BaseBackend: +def _create_backend(base_dir: str | None) -> BaseBackend: """Create the appropriate backend based on environment.""" config = get_config() if config.mlflow_tracking_uri: @@ -222,7 +222,7 @@ def log_parameter(key: str, value: Any) -> None: _get_current_backend().log_parameter(key, value) -def log_metric(key: str, value: float, step: Optional[int] = None) -> None: +def log_metric(key: str, value: float, step: int | None = None) -> None: """Log a metric to the current run context.""" _get_current_backend().log_metric(key, value, step) @@ -233,7 +233,7 @@ def log_artifact(local_path: str) -> None: @contextmanager -def redirect_stdio(logfile: Union[str, Path]) -> Generator[None, None, None]: +def redirect_stdio(logfile: str | Path) -> Generator[None, None, None]: """Context manager for redirecting stdout and stderr to a custom pipe. Writes messages to both the original stderr and the given logfile. @@ -270,7 +270,7 @@ def redirect_stdio(logfile: Union[str, Path]) -> Generator[None, None, None]: @contextmanager -def start_run(base_dir: Optional[str] = None) -> Generator[None, None, None]: +def start_run(base_dir: str | None = None) -> Generator[None, None, None]: """Context manager for starting and ending a run.""" backend = _create_backend(base_dir) token = _current_backend.set(backend) diff --git a/tesseract_core/runtime/schema_generation.py b/tesseract_core/runtime/schema_generation.py index 2a76d1c4..6a3d77b4 100644 --- a/tesseract_core/runtime/schema_generation.py +++ b/tesseract_core/runtime/schema_generation.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 import re -import sys import types from collections.abc import Callable, Iterable, Mapping, Sequence from copy import copy @@ -11,7 +10,6 @@ Any, ClassVar, Literal, - Optional, TypeVar, Union, get_args, @@ -46,9 +44,7 @@ # Python has funnily enough two union types now. See https://github.com/python/cpython/issues/105499 # We check against both for compatibility with older versions of Python. -UNION_TYPES = [Union] -if sys.version_info >= (3, 10): - UNION_TYPES += [types.UnionType] +UNION_TYPES = [Union, types.UnionType] def _construct_annotated(obj: Any, metadata: Iterable[Any]) -> Any: @@ -69,7 +65,7 @@ def apply_function_to_model_tree( Schema: type[BaseModel], func: Callable[[type, tuple], type], model_prefix: str = "", - default_model_config: Optional[dict[str, Any]] = None, + default_model_config: dict[str, Any] | None = None, ) -> type[BaseModel]: """Apply a function to all leaves of a Pydantic model, recursing into containers + nested models. @@ -171,7 +167,7 @@ def _recurse_over_model_tree(treeobj: Any, path: list[str]) -> Any: if not newargs: return None - return Union[tuple(newargs)] + return Union[tuple(newargs)] # noqa: UP007 elif safe_issubclass(origin_type, Mapping): # Recurse into dict-likes @@ -309,7 +305,7 @@ def create_abstract_eval_schema( fields replaced by ShapeDType objects. """ - def replace_array_with_shapedtype(obj: T, _: Any) -> Union[T, type[ShapeDType]]: + def replace_array_with_shapedtype(obj: T, _: Any) -> T | type[ShapeDType]: if safe_issubclass(obj, PydanticArrayAnnotation): return ShapeDType.from_array_type(obj) return obj @@ -363,7 +359,7 @@ def add_to_dict_if_diffable(obj: T, path: tuple) -> T: return diffable_paths -def _path_to_pattern(path: Sequence[Union[str, object]]) -> str: +def _path_to_pattern(path: Sequence[str | object]) -> str: """Return a type describing valid paths for all passed paths.""" # Check if path includes sequence or dict indexing -- in this case, we can't use the # path as a literal and need to use a regex pattern instead. @@ -434,10 +430,10 @@ def create_autodiff_schema( _path_to_pattern(path): obj for path, obj in diffable_output_paths.items() } - diffable_input_type = Union[ + diffable_input_type = Union[ # noqa: UP007 tuple(_pattern_to_type(p) for p in diffable_input_patterns) ] - diffable_output_type = Union[ + diffable_output_type = Union[ # noqa: UP007 tuple(_pattern_to_type(p) for p in diffable_output_patterns) ] @@ -520,7 +516,7 @@ def result_validator( if any( s1 != s2 - for s1, s2 in zip(got_shape, expected_shape) + for s1, s2 in zip(got_shape, expected_shape, strict=True) if s2 is not None ): raise ValueError( @@ -550,7 +546,7 @@ def result_validator( if any( s1 != s2 - for s1, s2 in zip(arr.shape, expected_shape) + for s1, s2 in zip(arr.shape, expected_shape, strict=True) if s2 is not None ): raise ValueError( @@ -577,7 +573,7 @@ def result_validator( if any( s1 != s2 - for s1, s2 in zip(arr.shape, expected_shape) + for s1, s2 in zip(arr.shape, expected_shape, strict=True) if s2 is not None ): raise ValueError( diff --git a/tesseract_core/runtime/schema_types.py b/tesseract_core/runtime/schema_types.py index e3ce5f35..0816b825 100644 --- a/tesseract_core/runtime/schema_types.py +++ b/tesseract_core/runtime/schema_types.py @@ -1,7 +1,6 @@ # Copyright 2025 Pasteur Labs. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -import sys from abc import ABCMeta from enum import IntEnum from functools import partial @@ -9,8 +8,7 @@ TYPE_CHECKING, Annotated, Any, - Optional, - Union, + TypeAlias, get_args, ) @@ -33,15 +31,8 @@ python_to_array, ) -if sys.version_info < (3, 10): - # TypeAlias is not available in Python < 3.10 - AnnotatedType = type(Annotated[Any, Any]) - EllipsisType = type(Ellipsis) -else: - from typing import TypeAlias - - AnnotatedType: TypeAlias = type(Annotated[Any, Any]) - EllipsisType: TypeAlias = type(Ellipsis) +AnnotatedType: TypeAlias = type(Annotated[Any, Any]) +EllipsisType: TypeAlias = type(Ellipsis) class ArrayFlags(IntEnum): @@ -62,7 +53,7 @@ class ArrayAnnotationType(ABCMeta): MyArray[(2, 3), 'float32'] """ - expected_shape: Union[tuple[int, ...], EllipsisType] + expected_shape: tuple[int, ...] | EllipsisType expected_dtype: str flags: tuple[ArrayFlags] @@ -95,7 +86,7 @@ class PydanticArrayAnnotation(metaclass=ArrayAnnotationType): """ # These are class attributes that must be set when the class is created - expected_shape: Union[tuple[int, ...], EllipsisType] + expected_shape: tuple[int, ...] | EllipsisType expected_dtype: str flags: tuple[ArrayFlags] @@ -235,8 +226,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: def __class_getitem__( cls, key: tuple[ - Union[tuple[Optional[int], ...], EllipsisType], - Union[ArrayAnnotationType, str, None], + tuple[int | None, ...] | EllipsisType, + ArrayAnnotationType | str | None, ], ) -> ArrayAnnotationType: """Create a new type annotation based on the given shape and dtype.""" @@ -316,8 +307,8 @@ class ShapeDType(BaseModel): def __class_getitem__( cls, key: tuple[ - Union[tuple[Optional[int], ...], EllipsisType], - Union[AnnotatedType, str, None], + tuple[int | None, ...] | EllipsisType, + AnnotatedType | str | None, ], ) -> AnnotatedType: expected_shape, _ = _ensure_valid_shapedtype(*key) @@ -329,14 +320,12 @@ def validate(shapedtype: ShapeDType) -> ShapeDType: if expected_shape is Ellipsis: return shapedtype - # TODO: replace this check with `zip(... strict=True)` - # once we stop supporting 3.9 if len(shape) != len(expected_shape): raise ValueError( f"Expected shape: {expected_shape}. Found: {shape}." ) - for actual, expected in zip(shape, expected_shape): + for actual, expected in zip(shape, expected_shape, strict=True): if expected is not None and actual != expected: raise ValueError( f"Expected shape: {expected_shape}. Found: {shape}." diff --git a/tesseract_core/runtime/serve.py b/tesseract_core/runtime/serve.py index 1b4f6f70..ce18041a 100644 --- a/tesseract_core/runtime/serve.py +++ b/tesseract_core/runtime/serve.py @@ -3,9 +3,10 @@ import inspect import uuid +from collections.abc import Callable from functools import wraps from types import ModuleType -from typing import Annotated, Any, Callable, Optional, Union +from typing import Annotated, Any import uvicorn from fastapi import FastAPI, Header, Query, Response @@ -21,7 +22,7 @@ def create_response( - model: BaseModel, accept: str, base_dir: Optional[str], binref_dir: Optional[str] + model: BaseModel, accept: str, base_dir: str | None, binref_dir: str | None ) -> Response: """Create a response of the format specified by the Accept header.""" config = get_config() @@ -62,9 +63,7 @@ def wrap_endpoint(endpoint_func: Callable): ] @wraps(endpoint_func) - async def wrapper( - *args: Any, accept: str, run_id: Optional[str], **kwargs: Any - ): + async def wrapper(*args: Any, accept: str, run_id: str | None, **kwargs: Any): if run_id is None: run_id = str(uuid.uuid4()) output_path = get_config().output_path @@ -88,13 +87,13 @@ async def wrapper( "accept", inspect.Parameter.POSITIONAL_OR_KEYWORD, default=Header(default=None), - annotation=Union[str, None], + annotation=str | None, ) run_id = inspect.Parameter( "run_id", inspect.Parameter.POSITIONAL_OR_KEYWORD, default=None, - annotation=Annotated[Optional[str], Query(include_in_schema=False)], + annotation=Annotated[str | None, Query(include_in_schema=False)], ) # Other header parameters common to computational endpoints # could be defined and appended here as well. diff --git a/tesseract_core/runtime/tree_transforms.py b/tesseract_core/runtime/tree_transforms.py index 7dd514f9..628fdf89 100644 --- a/tesseract_core/runtime/tree_transforms.py +++ b/tesseract_core/runtime/tree_transforms.py @@ -4,18 +4,18 @@ import re from collections.abc import Callable, Iterable, Mapping, Sequence from copy import deepcopy -from typing import Any, Literal, Optional, Union +from typing import Any, Literal from pydantic import BaseModel def path_to_index_op( path: str, -) -> Union[ - tuple[Literal["seq"], int], - tuple[Literal["dict"], str], - tuple[Literal["getattr"], str], -]: +) -> ( + tuple[Literal["seq"], int] + | tuple[Literal["dict"], str] + | tuple[Literal["getattr"], str] +): """Converts a path string to a tuple of operation and index.""" seq_idx_re = re.match(r"^\[(\d+)\]$", path) if seq_idx_re: @@ -115,7 +115,7 @@ def _set_recursive(tree: Any, path: list[str], value: Any) -> Any: def flatten_with_paths( - tree: Union[Mapping, Sequence, BaseModel], + tree: Mapping | Sequence | BaseModel, include_paths: Iterable[str], ) -> dict[str, Any]: """Filter and flatten a nested PyTree by extracting only the specified paths. @@ -131,8 +131,8 @@ def flatten_with_paths( def filter_func( func: Callable[[dict], dict], default_inputs: dict, - output_paths: Optional[Iterable[str]] = None, - input_paths: Optional[Sequence[str]] = None, + output_paths: Iterable[str] | None = None, + input_paths: Sequence[str] | None = None, ) -> Callable: """Modifies a function that operates on pytrees to operate on flat {path: value} or positional args instead. @@ -153,12 +153,11 @@ def filter_func( def filtered_func(*args: Any) -> dict: if input_paths: - # TODO: replace this check with `zip(... strict=True)` - # once we stop supporting 3.9 - assert len(input_paths) == len(args), ( - f"Mismatch between number of given paths {len(input_paths)} and args {len(args)}." - ) - new_inputs = dict(zip(input_paths, args)) + if len(input_paths) != len(args): + raise ValueError( + f"Mismatch between number of given paths {len(input_paths)} and args {len(args)}." + ) + new_inputs = dict(zip(input_paths, args, strict=True)) else: if len(args) != 1: raise ValueError("Expected a single dictionary argument") diff --git a/tesseract_core/sdk/api_parse.py b/tesseract_core/sdk/api_parse.py index 2d695152..073694cf 100644 --- a/tesseract_core/sdk/api_parse.py +++ b/tesseract_core/sdk/api_parse.py @@ -4,7 +4,7 @@ import ast import re from pathlib import Path -from typing import Annotated, Literal, NamedTuple, Union +from typing import Annotated, Literal, NamedTuple import yaml from pydantic import ( @@ -93,7 +93,7 @@ class CondaRequirements(BaseModel): model_config: ConfigDict = ConfigDict(extra="forbid") -PythonRequirements = Union[PipRequirements, CondaRequirements] +PythonRequirements = PipRequirements | CondaRequirements class TesseractBuildConfig(BaseModel, validate_assignment=True): diff --git a/tesseract_core/sdk/logs.py b/tesseract_core/sdk/logs.py index e22c2a43..e29ac6e1 100644 --- a/tesseract_core/sdk/logs.py +++ b/tesseract_core/sdk/logs.py @@ -7,9 +7,9 @@ import sys import threading import warnings -from collections.abc import Iterable +from collections.abc import Callable, Iterable from types import ModuleType -from typing import Any, Callable +from typing import Any import typer from rich.console import Console diff --git a/tests/endtoend_tests/test_examples.py b/tests/endtoend_tests/test_examples.py index 26194f0d..fe2670d6 100644 --- a/tests/endtoend_tests/test_examples.py +++ b/tests/endtoend_tests/test_examples.py @@ -93,7 +93,7 @@ class Config: SampleRequest(endpoint="apply", payload={"inputs": {}}), ], ), - "py39": Config( + "py310": Config( test_with_random_inputs=True, ), "helloworld": Config( diff --git a/tests/runtime_tests/test_tree_transforms.py b/tests/runtime_tests/test_tree_transforms.py index a259a573..96f7bf2c 100644 --- a/tests/runtime_tests/test_tree_transforms.py +++ b/tests/runtime_tests/test_tree_transforms.py @@ -598,7 +598,7 @@ def test_filter_func_positional_args_mismatch(self, sample_tree, sample_func): filtered_func = filter_func(sample_func, sample_tree, input_paths=input_paths) # Wrong number of arguments - with pytest.raises(AssertionError, match="Mismatch between number"): + with pytest.raises(ValueError, match="Mismatch between number"): filtered_func("hello") # Missing second argument def test_filter_func_dict_input_wrong_args(self, sample_tree, sample_func):