# Copyright 2025 Pasteur Labs. All Rights Reserved.# SPDX-License-Identifier: Apache-2.0from__future__importannotationsimportatexitimportbase64importtracebackfromcollections.abcimportCallable,Mapping,Sequencefromdataclassesimportdataclassfromfunctoolsimportcached_property,wrapsfrompathlibimportPathfromtypesimportModuleTypefromtypingimportAnyfromurllib.parseimporturlparse,urlunparseimportnumpyasnpimportrequestsfrompydanticimportBaseModel,TypeAdapter,ValidationErrorfrompydantic_coreimportInitErrorDetailsfromtesseract_core.runtime.configimportupdate_configfrom.importenginePathLike=str|Path@dataclassclassSpawnConfig:"""Configuration for spawning a Tesseract."""image:strvolumes:list[str]|Noneenvironment:dict[str,str]|Nonegpus:list[str]|Nonenum_workers:intdebug:boolno_compose:booldefrequires_client(func:Callable)->Callable:"""Decorator to require a client for a Tesseract instance."""@wraps(func)defwrapper(self:Tesseract,*args:Any,**kwargs:Any)->Any:ifnotself._client:raiseRuntimeError(f"{self.__class__.__name__} must be used as a context manager when created via `from_image`.")returnfunc(self,*args,**kwargs)returnwrapper
[docs]classTesseract:"""A Tesseract. This class represents a single Tesseract instance, either remote or local, and provides methods to run commands on it and retrieve results. Communication between a Tesseract and this class is done either via HTTP requests or directly via Python calls to the Tesseract API. """def__init__(self,url:str)->None:self._spawn_config=Noneself._serve_context=Noneself._lastlog=Noneself._client=HTTPClient(url)
[docs]@classmethoddeffrom_url(cls,url:str)->Tesseract:"""Create a Tesseract instance from a URL. This is useful for connecting to a remote Tesseract instance. Args: url: The URL of the Tesseract instance. Returns: A Tesseract instance. """obj=cls.__new__(cls)obj.__init__(url)returnobj
[docs]@classmethoddeffrom_image(cls,image:str,*,volumes:list[str]|None=None,environment:dict[str,str]|None=None,input_path:PathLike|None=None,output_path:PathLike|None=None,gpus:list[str]|None=None,num_workers:int=1,no_compose:bool=False,)->Tesseract:"""Create a Tesseract instance from a Docker image. When using this method, the Tesseract will be spawned in a Docker container, serving the Tesseract API via HTTP. To use the Tesseract, you need to call the `serve` method or use it as a context manager. Example: >>> with Tesseract.from_image("my_tesseract") as t: ... # Use tesseract here This will automatically teardown the Tesseract when exiting the context manager. Args: image: The Docker image to use. volumes: List of volumes to mount, e.g. ["/path/on/host:/path/in/container"]. environment: dictionary of environment variables to pass to the Tesseract. input_path: Path to be mounted as the input directory in the container (read only). All paths in the input payload must be relative to this path. output_path: Path to be mounted as the output directory in the container (read+write). All paths in the output result will be relative to this path. gpus: List of GPUs to use, e.g. ["0", "1"]. (default: no GPUs) num_workers: Number of worker processes to use. This determines how many requests can be handled in parallel. Higher values will increase throughput, but also increase resource usage. no_compose: if True, do not use Docker Compose to serve the Tesseracts. Returns: A Tesseract instance. """obj=cls.__new__(cls)ifenvironmentisNone:environment={}ifvolumesisNone:volumes=[]ifinput_pathisnotNone:input_path=Path(input_path).resolve()volumes.append(f"{input_path}:/tesseract/input_data:ro")ifoutput_pathisnotNone:output_path=Path(output_path).resolve()volumes.append(f"{output_path}:/tesseract/output_data:rw")obj._spawn_config=SpawnConfig(image=image,volumes=volumes,environment=environment,gpus=gpus,num_workers=num_workers,debug=True,no_compose=no_compose,)obj._serve_context=Noneobj._lastlog=Noneobj._client=Nonereturnobj
[docs]@classmethoddeffrom_tesseract_api(cls,tesseract_api:str|Path|ModuleType,input_path:Path|None=None,output_path:Path|None=None,)->Tesseract:"""Create a Tesseract instance from a Tesseract API module. Warning: This does not use a containerized Tesseract, but rather imports the Tesseract API directly. This is useful for debugging, but requires a matching runtime environment + all dependencies to be installed locally. Args: tesseract_api: Path to the `tesseract_api.py` file, or an already imported Tesseract API module. input_path: Path of input directory. All paths in the tesseract payload have to be relative to this path. output_path: Path of output directory. All paths in the tesseract result with be given relative to this path. Returns: A Tesseract instance. """ifisinstance(tesseract_api,str|Path):fromtesseract_core.runtime.coreimportload_module_from_pathtesseract_api_path=Path(tesseract_api).resolve(strict=True)ifnottesseract_api_path.is_file():raiseRuntimeError(f"Tesseract API path {tesseract_api_path} is not a file.")try:tesseract_api=load_module_from_path(tesseract_api_path)exceptImportErrorasex:raiseRuntimeError(f"Cannot load Tesseract API from {tesseract_api_path}")fromexifinput_pathisnotNone:update_config(input_path=str(input_path.resolve()))ifoutput_pathisnotNone:update_config(output_path=str(output_path.resolve()))obj=cls.__new__(cls)obj._spawn_config=Noneobj._serve_context=Noneobj._lastlog=Noneobj._client=LocalClient(tesseract_api)returnobj
def__enter__(self)->Tesseract:"""Enter the Tesseract context. This will start the Tesseract server if it is not already running. """ifself._serve_contextisnotNone:raiseRuntimeError("Cannot serve the same Tesseract multiple times.")ifself._clientisnotNone:# Tesseract is already being served -> no-opreturnselfself.serve()returnselfdef__exit__(self,*args:Any)->None:"""Exit the Tesseract context. This will stop the Tesseract server if it is running. """ifself._serve_contextisNone:# This can happen if __enter__ short-cirtuitsreturnself.teardown()
[docs]defserver_logs(self)->str:"""Get the logs of the Tesseract server. Returns: logs of the Tesseract server. """ifself._spawn_configisNone:raiseRuntimeError("Can only retrieve logs for a Tesseract created via from_image.")ifself._serve_contextisNone:returnself._lastlogreturnengine.logs(self._serve_context["container_id"])
[docs]defserve(self,port:str|None=None,host_ip:str="127.0.0.1")->None:"""Serve the Tesseract. Args: port: Port to serve the Tesseract on. host_ip: IP address of the host to bind the Tesseract to. """ifself._spawn_configisNone:raiseRuntimeError("Can only serve a Tesseract created via from_image.")ifself._serve_contextisnotNone:raiseRuntimeError("Tesseract is already being served.")project_id,container_id,served_port=self._serve(self._spawn_config.image,port=port,volumes=self._spawn_config.volumes,environment=self._spawn_config.environment,gpus=self._spawn_config.gpus,num_workers=self._spawn_config.num_workers,debug=self._spawn_config.debug,no_compose=self._spawn_config.no_compose,host_ip=host_ip,)self._serve_context=dict(project_id=project_id,container_id=container_id,port=served_port,)self._lastlog=Noneself._client=HTTPClient(f"http://{host_ip}:{served_port}")atexit.register(self.teardown)
[docs]defteardown(self)->None:"""Teardown the Tesseract. This will stop and remove the Tesseract container. """ifself._serve_contextisNone:raiseRuntimeError("Tesseract is not being served.")self._lastlog=self.server_logs()engine.teardown(self._serve_context["project_id"])self._client=Noneself._serve_context=Noneatexit.unregister(self.teardown)
def__del__(self)->None:"""Destructor for the Tesseract class. This will teardown the Tesseract if it is being served. """ifself._serve_contextisnotNone:self.teardown()@staticmethoddef_serve(image:str,port:str|None=None,host_ip:str="127.0.0.1",volumes:list[str]|None=None,environment:dict[str,str]|None=None,gpus:list[str]|None=None,debug:bool=False,num_workers:int=1,no_compose:bool=False,)->tuple[str,str,int]:ifportisnotNone:ports=[port]else:ports=Noneproject_id=engine.serve([image],ports=ports,volumes=volumes,environment=environment,gpus=gpus,debug=debug,num_workers=num_workers,host_ip=host_ip,no_compose=no_compose,)first_container=engine.get_project_containers(project_id)[0]returnproject_id,first_container.id,int(first_container.host_port)
[docs]@cached_property@requires_clientdefopenapi_schema(self)->dict:"""Get the OpenAPI schema of this Tessseract. Returns: dictionary with the OpenAPI Schema. """returnself._client.run_tesseract("openapi_schema")
[docs]@cached_property@requires_clientdefinput_schema(self)->dict:"""Get the input schema of this Tessseract. Returns: dictionary with the input schema. """returnself._client.run_tesseract("input_schema")
[docs]@cached_property@requires_clientdefoutput_schema(self)->dict:"""Get the output schema of this Tessseract. Returns: dictionary with the output schema. """returnself._client.run_tesseract("output_schema")
@property@requires_clientdefavailable_endpoints(self)->list[str]:"""Get the list of available endpoints. Returns: a list with all available endpoints for this Tesseract. """return[endpoint.lstrip("/")forendpointinself.openapi_schema["paths"]]
[docs]@requires_clientdefapply(self,inputs:dict)->dict:"""Run apply endpoint. Args: inputs: a dictionary with the inputs. Returns: dictionary with the results. """payload={"inputs":inputs}returnself._client.run_tesseract("apply",payload)
[docs]@requires_clientdefabstract_eval(self,abstract_inputs:dict)->dict:"""Run abstract eval endpoint. Args: abstract_inputs: a dictionary with the (abstract) inputs. Returns: dictionary with the results. """payload={"inputs":abstract_inputs}returnself._client.run_tesseract("abstract_eval",payload)
[docs]@requires_clientdefhealth(self)->dict:"""Check the health of the Tesseract. Returns: dictionary with the health status. """returnself._client.run_tesseract("health")
[docs]@requires_clientdefjacobian(self,inputs:dict,jac_inputs:list[str],jac_outputs:list[str])->dict:"""Calculate the Jacobian of (some of the) outputs w.r.t. (some of the) inputs. Args: inputs: a dictionary with the inputs. jac_inputs: Inputs with respect to which derivatives will be calculated. jac_outputs: Outputs which will be differentiated. Returns: dictionary with the results. """if"jacobian"notinself.available_endpoints:raiseNotImplementedError("Jacobian not implemented for this Tesseract.")payload={"inputs":inputs,"jac_inputs":jac_inputs,"jac_outputs":jac_outputs,}returnself._client.run_tesseract("jacobian",payload)
[docs]@requires_clientdefjacobian_vector_product(self,inputs:dict,jvp_inputs:list[str],jvp_outputs:list[str],tangent_vector:dict,)->dict:"""Calculate the Jacobian Vector Product (JVP) of (some of the) outputs w.r.t. (some of the) inputs. Args: inputs: a dictionary with the inputs. jvp_inputs: Inputs with respect to which derivatives will be calculated. jvp_outputs: Outputs which will be differentiated. tangent_vector: Element of the tangent space to multiply with the Jacobian. Returns: dictionary with the results. """if"jacobian_vector_product"notinself.available_endpoints:raiseNotImplementedError("Jacobian Vector Product (JVP) not implemented for this Tesseract.")payload={"inputs":inputs,"jvp_inputs":jvp_inputs,"jvp_outputs":jvp_outputs,"tangent_vector":tangent_vector,}returnself._client.run_tesseract("jacobian_vector_product",payload)
[docs]@requires_clientdefvector_jacobian_product(self,inputs:dict,vjp_inputs:list[str],vjp_outputs:list[str],cotangent_vector:dict,)->dict:"""Calculate the Vector Jacobian Product (VJP) of (some of the) outputs w.r.t. (some of the) inputs. Args: inputs: a dictionary with the inputs. vjp_inputs: Inputs with respect to which derivatives will be calculated. vjp_outputs: Outputs which will be differentiated. cotangent_vector: Element of the cotangent space to multiply with the Jacobian. Returns: dictionary with the results. """if"vector_jacobian_product"notinself.available_endpoints:raiseNotImplementedError("Vector Jacobian Product (VJP) not implemented for this Tesseract.")payload={"inputs":inputs,"vjp_inputs":vjp_inputs,"vjp_outputs":vjp_outputs,"cotangent_vector":cotangent_vector,}returnself._client.run_tesseract("vector_jacobian_product",payload)
def_tree_map(func:Callable,tree:Any,is_leaf:Callable|None=None)->Any:"""Recursively apply a function to all leaves of a tree-like structure."""ifis_leafisnotNoneandis_leaf(tree):returnfunc(tree)ifisinstance(tree,Mapping):# Dictionary-like structurereturn{key:_tree_map(func,value,is_leaf)forkey,valueintree.items()}ifisinstance(tree,Sequence)andnotisinstance(tree,(str,bytes)):# List, tuple, etc.returntype(tree)(_tree_map(func,item,is_leaf)foritemintree)# If nothing above matched do nothingreturntreedef_encode_array(arr:np.ndarray,b64:bool=True)->dict:ifb64:data={"buffer":base64.b64encode(arr.tobytes()).decode(),"encoding":"base64",}else:data={"buffer":arr.tolist(),"encoding":"raw",}return{"shape":arr.shape,"dtype":arr.dtype.name,"data":data,}def_decode_array(encoded_arr:dict)->np.ndarray:if"data"inencoded_arr:ifencoded_arr["data"]["encoding"]=="base64":data=base64.b64decode(encoded_arr["data"]["buffer"])arr=np.frombuffer(data,dtype=encoded_arr["dtype"])else:arr=np.array(encoded_arr["data"]["buffer"],dtype=encoded_arr["dtype"])else:raiseValueError("Encoded array does not contain 'data' key. Cannot decode.")arr=arr.reshape(encoded_arr["shape"])returnarrclassHTTPClient:"""HTTP Client for Tesseracts."""def__init__(self,url:str)->None:self._url=self._sanitize_url(url)@staticmethoddef_sanitize_url(url:str)->str:parsed=urlparse(url)ifnotparsed.scheme:url=f"http://{url}"parsed=urlparse(url)sanitized=urlunparse((parsed.scheme,parsed.netloc,parsed.path,"","",""))returnsanitized@propertydefurl(self)->str:"""(Sanitized) URL to connect to."""returnself._urldef_request(self,endpoint:str,method:str="GET",payload:dict|None=None)->dict:url=f"{self.url}/{endpoint.lstrip('/')}"ifpayload:encoded_payload=_tree_map(_encode_array,payload,is_leaf=lambdax:hasattr(x,"shape"))else:encoded_payload=Noneresponse=requests.request(method=method,url=url,json=encoded_payload)ifresponse.status_code==requests.codes.unprocessable_entity:# Try and raise a more helpful error if the response is a Pydantic errortry:data=response.json()exceptrequests.JSONDecodeError:# Is not a Pydantic errordata={}if"detail"indata:errors=[]foreindata["detail"]:ctx=e.get("ctx",{})ifnotctx.get("error")ande.get("msg"):# Hacky, but msg contains info like "Value error, ...",# which will be prepended to the message anyway by pydantic.# This way, we remove whatever is before the first comma.msg=e["msg"].partition(", ")[2]ctx["error"]=msgerror=InitErrorDetails(type=e["type"],loc=tuple(e["loc"]),input=e.get("input"),ctx=ctx,)errors.append(error)raiseValidationError.from_exception_data(f"endpoint {endpoint}",line_errors=errors)ifnotresponse.ok:raiseRuntimeError(f"Error {response.status_code} from Tesseract: {response.text}")data=response.json()ifendpointin["apply","jacobian","jacobian_vector_product","vector_jacobian_product",]:data=_tree_map(_decode_array,data,is_leaf=lambdax:type(x)isdictand"shape"inx,)returndatadefrun_tesseract(self,endpoint:str,payload:dict|None=None)->dict:"""Run a Tesseract endpoint. Args: endpoint: The endpoint to run. payload: The payload to send to the endpoint. Returns: The loaded JSON response from the endpoint, with decoded arrays. """ifendpointin["input_schema","output_schema","openapi_schema","health",]:method="GET"else:method="POST"ifendpoint=="openapi_schema":endpoint="openapi.json"returnself._request(endpoint,method,payload)classLocalClient:"""Local Client for Tesseracts."""def__init__(self,tesseract_api:ModuleType)->None:fromtesseract_core.runtime.coreimportcreate_endpointsfromtesseract_core.runtime.serveimportcreate_rest_apiself._endpoints={func.__name__:funcforfuncincreate_endpoints(tesseract_api)}self._openapi_schema=create_rest_api(tesseract_api).openapi()defrun_tesseract(self,endpoint:str,payload:dict|None=None)->dict:"""Run a Tesseract endpoint. Args: endpoint: The endpoint to run. payload: The payload to send to the endpoint. Returns: The loaded JSON response from the endpoint, with decoded arrays. """ifendpoint=="openapi_schema":returnself._openapi_schemaifendpointnotinself._endpoints:raiseRuntimeError(f"Endpoint {endpoint} not found in Tesseract API.")func=self._endpoints[endpoint]InputSchema=func.__annotations__.get("payload",None)OutputSchema=func.__annotations__.get("return",None)ifInputSchemaisnotNone:parsed_payload=InputSchema.model_validate(payload)else:parsed_payload=Nonetry:ifparsed_payloadisnotNone:result=self._endpoints[endpoint](parsed_payload)else:result=self._endpoints[endpoint]()exceptExceptionasex:# Some clients like Tesseract-JAX swallow tracebacks from re-raised exceptions, so we explicitly# format the traceback here to include it in the error message.tb=traceback.format_exc()raiseRuntimeError(f"{tb}\nError running Tesseract API {endpoint}: {ex} (see above for full traceback)")fromNoneifOutputSchemaisnotNone:# Validate via schema, then dump to stay consistent with other clientsifisinstance(OutputSchema,type)andissubclass(OutputSchema,BaseModel):result=OutputSchema.model_validate(result).model_dump()else:result=TypeAdapter(OutputSchema).validate_python(result)returnresult