Source code for embedl_hub._internal.tracking.hub_run

"""Turn a function into a tracked run, with lineage carried on its result."""

from __future__ import annotations

import functools
import inspect
from collections.abc import Callable
from dataclasses import dataclass
from typing import Concatenate, Generic, Protocol, TypeVar, cast, overload

from embedl_hub._internal.tracking.client import Client, _resolve_run_type
from embedl_hub._internal.tracking.rest_api import Run, RunType

_T = TypeVar("_T")
_V_co = TypeVar("_V_co", covariant=True)
_A0 = TypeVar("_A0")
_A1 = TypeVar("_A1")
_A2 = TypeVar("_A2")
_A3 = TypeVar("_A3")
_A4 = TypeVar("_A4")
_A5 = TypeVar("_A5")

_PLACEHOLDER = getattr(functools, "Placeholder", object())


[docs] @dataclass(frozen=True) class Tracked(Generic[_V_co]): """A value together with the run that produced it. Returned by every :func:`hub_run` function. Passing it to another :func:`hub_run` function records that run as the parent of the new one, and hands the function :attr:`value` rather than the wrapper. :param value: What the function returned. :param run: The run the function ran in, or ``None`` when it ran with ``safe=True`` and the run could not be created. """ value: _V_co run: Run | None
class _HubRunDecorator(Protocol): @overload def __call__( self, fn: Callable[[Client], _T], / ) -> Callable[..., Tracked[_T]]: ... @overload def __call__( self, fn: Callable[[Client, _A0], _T], / ) -> Callable[ Concatenate[Client, _A0 | Tracked[_A0], ...], Tracked[_T] ]: ... @overload def __call__( self, fn: Callable[[Client, _A0, _A1], _T], / ) -> Callable[ Concatenate[Client, _A0 | Tracked[_A0], _A1 | Tracked[_A1], ...], Tracked[_T], ]: ... @overload def __call__( self, fn: Callable[[Client, _A0, _A1, _A2], _T], / ) -> Callable[ Concatenate[ Client, _A0 | Tracked[_A0], _A1 | Tracked[_A1], _A2 | Tracked[_A2], ..., ], Tracked[_T], ]: ... @overload def __call__( self, fn: Callable[[Client, _A0, _A1, _A2, _A3], _T], / ) -> Callable[ Concatenate[ Client, _A0 | Tracked[_A0], _A1 | Tracked[_A1], _A2 | Tracked[_A2], _A3 | Tracked[_A3], ..., ], Tracked[_T], ]: ... @overload def __call__( self, fn: Callable[[Client, _A0, _A1, _A2, _A3, _A4], _T], / ) -> Callable[ Concatenate[ Client, _A0 | Tracked[_A0], _A1 | Tracked[_A1], _A2 | Tracked[_A2], _A3 | Tracked[_A3], _A4 | Tracked[_A4], ..., ], Tracked[_T], ]: ... @overload def __call__( self, fn: Callable[[Client, _A0, _A1, _A2, _A3, _A4, _A5], _T], / ) -> Callable[ Concatenate[ Client, _A0 | Tracked[_A0], _A1 | Tracked[_A1], _A2 | Tracked[_A2], _A3 | Tracked[_A3], _A4 | Tracked[_A4], _A5 | Tracked[_A5], ..., ], Tracked[_T], ]: ... @overload def __call__( self, fn: Callable[..., _T], / ) -> Callable[..., Tracked[_T]]: ...
[docs] def hub_run( run_type: RunType | str, *, name: str | None = None, safe: bool = False, ) -> _HubRunDecorator: """Run each call of the decorated function as a Hub run. The function takes the :class:`~embedl_hub.tracking.Client` as its first parameter, after ``self`` on a method. Each call starts a run with :meth:`~embedl_hub.tracking.Client.start_run` and returns the result wrapped in a :class:`Tracked`. Every :class:`Tracked` argument, positional or keyword, is unwrapped to its value before the call, and the first one that has a run becomes the parent of the new run. :class:`Tracked` values inside containers are passed as they are. .. code-block:: python @hub_run("train") def train(client: Client, data: Path) -> Path: ... @hub_run("evaluate") def evaluate(client: Client, model: Path) -> float: ... model = train(client, data) score = evaluate(client, model) # a child run of train Type checkers check the required arguments after the client, up to six, each as its declared type or a :class:`Tracked` of it. Pass the client and these arguments positionally; given by keyword, they are reported as missing. Arguments for parameters with defaults are not checked. Methods, and functions with more than six required arguments, are checked only for their return type. :param run_type: A :class:`~embedl_hub.tracking.RunType` or a custom type label, as :meth:`~embedl_hub.tracking.Client.start_run` takes. :param name: The run name. Defaults to the function's name. :param safe: Passed to :meth:`~embedl_hub.tracking.Client.start_run`: when the run cannot be created or finished, warn instead of raising. A function whose run could not be created still runs, and returns a :class:`Tracked` whose ``run`` is ``None``. :return: A decorator for a synchronous function. :raises TypeError: If the decorated function is async or a generator, whose run would finish before its body runs; has no parameter for the client; or is a ``classmethod`` or ``staticmethod`` object, which must be applied above :func:`hub_run` instead. At call time, if the client argument is not a :class:`~embedl_hub.tracking.Client`. :raises ValueError: If ``run_type`` is ``RunType.CUSTOM``, which needs a label: pass the label itself instead. :raises TrackingUsageError: At call time, if a run is already active on the client: runs do not nest. """ try: _resolve_run_type(run_type) except ValueError as exc: raise ValueError( "hub_run takes a custom run type as its label, e.g. " "@hub_run('analyse'), not RunType.CUSTOM." ) from exc def decorate(fn: Callable[..., _T]) -> Callable[..., Tracked[_T]]: fn_name = getattr(fn, "__name__", type(fn).__name__) if isinstance(fn, (classmethod, staticmethod)): raise TypeError( f"'{fn_name}' is a {type(fn).__name__} object. Put " f"@{type(fn).__name__} above @hub_run, so hub_run wraps " "the function itself." ) target, bound_args, bound_kwargs = _unwrap_partial(fn) body = ( target if inspect.isfunction(target) or inspect.ismethod(target) else getattr(type(target), "__call__", target) ) if ( inspect.iscoroutinefunction(body) or inspect.isgeneratorfunction(body) or inspect.isasyncgenfunction(body) ): raise TypeError( f"'{fn_name}' is an async or generator function, which " "hub_run cannot track: the run would finish before its " "body runs. Decorate a function that returns its result, " "or call Client.start_run inside the body instead." ) run_name = name or fn_name signature = inspect.signature(target) client_param = _client_parameter(signature, fn_name) @functools.wraps(fn) def wrapper(*args: object, **kwargs: object) -> Tracked[_T]: all_args = _fill_placeholders(bound_args, args) missing = sum( value is _PLACEHOLDER for value in all_args[: len(bound_args)] ) if missing: raise TypeError( "missing positional arguments in 'partial' call for " f"{missing} placeholder(s)" ) all_kwargs = {**bound_kwargs, **kwargs} bound = signature.bind(*all_args, **all_kwargs) bound.apply_defaults() client = bound.arguments.get(client_param.name) if client_param.kind is inspect.Parameter.VAR_POSITIONAL: client = client[0] if client else None if not isinstance(client, Client): raise TypeError( f"'{fn_name}' must be called with a Client as its " f"'{client_param.name}' argument, got " f"{type(client).__name__}. Pass the client the run " "should be tracked on." ) parent = _first_parent(all_args, all_kwargs) call_args = tuple(_unwrap(a) for a in all_args) call_kwargs = {k: _unwrap(v) for k, v in all_kwargs.items()} with client.start_run( run_type, name=run_name, parent_run_id=parent.id if parent is not None else None, safe=safe, ) as run: value = target(*call_args, **call_kwargs) return Tracked(cast(_T, value), run) return wrapper return cast(_HubRunDecorator, decorate)
def _unwrap_partial( fn: Callable[..., object], ) -> tuple[Callable[..., object], tuple[object, ...], dict[str, object]]: args: tuple[object, ...] = () kwargs: dict[str, object] = {} while isinstance(fn, functools.partial): args = _fill_placeholders(fn.args, args) kwargs = {**fn.keywords, **kwargs} fn = fn.func return fn, args, kwargs def _fill_placeholders( bound: tuple[object, ...], args: tuple[object, ...] ) -> tuple[object, ...]: rest = iter(args) filled = tuple( next(rest, _PLACEHOLDER) if value is _PLACEHOLDER else value for value in bound ) return (*filled, *rest) def _client_parameter( signature: inspect.Signature, fn_name: str ) -> inspect.Parameter: params = list(signature.parameters.values()) if params and params[0].name in ("self", "cls"): params = params[1:] if not params or params[0].kind is inspect.Parameter.VAR_KEYWORD: raise TypeError( f"'{fn_name}' needs a parameter for the Client, first or " "right after self or cls. Add one, e.g. " f"'def {fn_name}(client, ...)'." ) return params[0] def _first_parent( args: tuple[object, ...], kwargs: dict[str, object] ) -> Run | None: for value in (*args, *kwargs.values()): if isinstance(value, Tracked) and value.run is not None: return value.run return None def _unwrap(value: object) -> object: return value.value if isinstance(value, Tracked) else value