Source code for embedl_hub._internal.tracking.context

"""Resolve the tracking context: which Hub, project, run and batch.

Every value is resolved through the same layers, highest first:

1. an argument (a CLI flag, or a keyword passed in Python),
2. the environment (``EMBEDL_HUB_PROJECT``, ``EMBEDL_HUB_RUN``, ...) —
   how ``embedl-hub exec``, schedulers and CI hand context down,
3. the nearest workspace (``.ehub/``, created by ``embedl-hub init``),
4. for the project only, the global config.

There is deliberately no global run or batch: an ambient "current run"
shared by every console is how logs end up on the wrong run.

The CLI and :class:`~embedl_hub.tracking.Client` both resolve through
:func:`resolve_context`, so they cannot disagree.
"""

from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Literal

from embedl_hub._internal import config as global_config_file
from embedl_hub._internal.config import load_global_config, resolve_base_url
from embedl_hub._internal.tracking.errors import TrackingContextError
from embedl_hub._internal.types import LocalPath
from embedl_hub._internal.workspace import Workspace, load_use_context

RUN_ENV_VAR_NAME = "EMBEDL_HUB_RUN"
PROJECT_ENV_VAR_NAME = "EMBEDL_HUB_PROJECT"
BATCH_ENV_VAR_NAME = "EMBEDL_HUB_BATCH"
# Set by `embedl-hub exec --safe` so that adopting the run it handed over
# tolerates an unreachable Hub, as creating that run did.  It covers the
# handover only: whether the command's own logging tolerates failure is
# the command's decision, made with `safe_log`.
SAFE_ENV_VAR_NAME = "EMBEDL_HUB_SAFE"
# Set by `embedl-hub exec` when it skipped tracking, so the command it
# wraps never falls through to the workspace's run.
UNTRACKED_ENV_VAR_NAME = "EMBEDL_HUB_UNTRACKED"

SettingOrigin = Literal["argument", "env", "workspace", "global", "default"]

_HUB_ORIGINS: dict[str, SettingOrigin] = {
    "env": "env",
    "workspace": "workspace",
    "config": "global",
    "default": "default",
}

_NOUNS = {"project": "project name", "run": "run ID", "batch": "batch ID"}


[docs] @dataclass(frozen=True) class Setting: """One resolved value and where it came from. A layer that is present but empty — a blank argument, or a variable that is set but empty — has an `origin` but no `value`. That is almost always a shell expansion that produced nothing, so it is reported rather than skipped. :ivar value: The resolved value, or ``None``. :ivar origin: The layer that supplied it, or ``None`` when no layer has one. :ivar detail: The environment variable's name or the file's path, when the origin has one. """ value: str | None origin: SettingOrigin | None = None detail: str | None = None
[docs] @dataclass(frozen=True) class Context: """The tracking context resolved from every layer. :ivar hub: The Hub's address. :ivar project: The project new runs start in. :ivar run: The run to log to. ``EMBEDL_HUB_UNTRACKED`` shows up here as an ``"env"`` setting with no value. :ivar batch: The batch log commands queue into. """ hub: Setting project: Setting run: Setting batch: Setting
[docs] def resolve_context( *, project: str | None = None, run: str | None = None, batch: str | None = None, ) -> Context: """Resolve the tracking context, changing nothing. Never raises for a missing value: an unset setting has no origin. :param project: A project name that outranks every other layer. :param run: A run ID that outranks every other layer. :param batch: A batch ID that outranks every other layer. :return: Each value together with where it came from. :raises TrackingContextError: If the global config cannot be read. """ return _resolve_context(project=project, run=run, batch=batch)
def _resolve_context( *, project: str | None = None, run: str | None = None, batch: str | None = None, start: LocalPath | None = None, global_config: dict[str, str] | None = None, ) -> Context: """Resolve the context, with the seams the CLI and tests need. :param start: Where the workspace lookup starts (default: the current directory). :param global_config: The global config already loaded, as the CLI holds it; read from disk when ``None``. """ values, workspace = load_use_context(start) if global_config is None: global_config = load_global_config() config = global_config project_setting = _pick(project, PROJECT_ENV_VAR_NAME, values, workspace) if project_setting is None: stored = config.get("project") or config.get("project_name") project_setting = ( Setting(stored, "global", str(global_config_file.CONFIG_FILE)) if stored else Setting(None) ) run_setting = _pick(run, RUN_ENV_VAR_NAME, None, None) if run_setting is None and _is_set(UNTRACKED_ENV_VAR_NAME): run_setting = Setting(None, "env", UNTRACKED_ENV_VAR_NAME) if run_setting is None: run_setting = _pick(None, None, values, workspace, key="run") batch_setting = _pick( batch, BATCH_ENV_VAR_NAME, values, workspace, key="batch" ) server = resolve_base_url() return Context( hub=Setting( server.value, _HUB_ORIGINS[server.source], server.origin or None ), project=project_setting, run=run_setting or Setting(None), batch=batch_setting or Setting(None), ) def require_value(setting: Setting, kind: str) -> str | None: """Return a setting's value, rejecting a layer that is present but empty. :param setting: The setting to read. :param kind: ``"project"``, ``"run"`` or ``"batch"``, for the message. :return: The value, or ``None`` when no layer has one. :raises TrackingContextError: If an argument or variable is empty. """ if setting.value is not None or setting.origin is None: return setting.value if setting.detail == UNTRACKED_ENV_VAR_NAME: return None noun = _NOUNS[kind] if setting.origin == "env": raise TrackingContextError( f"{setting.detail} is set but empty. This usually means a " "shell capture failed before it was exported; unset the " "variable or give it a value." ) raise TrackingContextError( f"The {noun} is empty. This usually means a shell variable " f"expanded to nothing; pass a {noun}." ) def is_untracked(setting: Setting) -> bool: """Whether `embedl-hub exec` marked this process as untracked.""" return setting.value is None and setting.detail == UNTRACKED_ENV_VAR_NAME def _pick( argument: str | None, env_var: str | None, values: dict[str, str] | None, workspace: Workspace | None, key: str = "project", ) -> Setting | None: """Take the first layer that has `key`: argument, env, workspace.""" if argument is not None: return Setting(argument if argument.strip() else None, "argument") if env_var is not None and (raw := os.getenv(env_var)) is not None: return Setting(raw if raw.strip() else None, "env", env_var) if workspace is not None and values and (stored := values.get(key)): return Setting(stored, "workspace", str(workspace.source_of(key))) return None def _is_set(env_var: str) -> bool: return bool((os.getenv(env_var) or "").strip())