Source code for lhp.models._sandbox
"""Developer-sandbox configuration models (team policy + personal profile).
``SandboxConfig`` is the team-level policy committed in ``lhp.yaml`` under the
``sandbox:`` block; ``SandboxProfile`` is the personal, gitignored
``.lhp/profile.yaml`` payload (nested under its ``sandbox:`` key). Both are
pure input-validation models (constitution §2.6): the loaders map their
``ValidationError``s to coded LHP errors — the models themselves never import
error codes.
"""
import re
import string
from typing import List, Literal, Optional
from pydantic import BaseModel, Field, field_validator
# The only placeholders a table_pattern may reference — and both must appear,
# otherwise distinct sandbox tables (or distinct source tables) would collide.
_PATTERN_PLACEHOLDERS = frozenset({"namespace", "table"})
# Literal text between placeholders must stay an identifier fragment so the
# formatted result is always a legal Unity Catalog table-name leaf.
_PATTERN_LITERAL_RE = re.compile(r"[A-Za-z0-9_]*")
[docs]
class SandboxConfig(BaseModel):
"""Team-level sandbox policy from the ``sandbox:`` block in ``lhp.yaml``."""
strategy: Literal["table"] = "table" # widens to schema/catalog later
table_pattern: str = "{namespace}_{table}" # formats the table LEAF only
allowed_envs: Optional[List[str]] = None # None = unrestricted; [] is the
# parser's problem (CFG_062) — the model deliberately permits it.
@field_validator("table_pattern")
@classmethod
def _check_table_pattern(cls, value: str) -> str:
"""Structural validation behind CFG_063 (raised here as ``ValueError``).
Parsed with ``string.Formatter().parse`` so the check matches exactly
what ``str.format`` will do at rewrite time: placeholders must be a
subset of ``{namespace, table}`` with both present, carry no
conversion (``!r``) or format spec (``:>10``), and literal text is
limited to ``[A-Za-z0-9_]``.
"""
try:
segments = list(string.Formatter().parse(value))
except ValueError as e:
raise ValueError(f"table_pattern is not a valid format string: {e}") from e
seen: set[str] = set()
for literal_text, field_name, format_spec, conversion in segments:
if not _PATTERN_LITERAL_RE.fullmatch(literal_text):
raise ValueError(
"table_pattern literal text may only contain letters, "
f"digits, and underscores; got {literal_text!r}"
)
if field_name is None:
continue
if field_name not in _PATTERN_PLACEHOLDERS:
raise ValueError(
f"table_pattern placeholder {{{field_name}}} is not "
"recognized; only {namespace} and {table} are allowed"
)
if conversion is not None or format_spec:
raise ValueError(
f"table_pattern placeholder {{{field_name}}} must be "
"plain — conversions (e.g. !r) and format specs "
"(e.g. :>10) are not allowed"
)
seen.add(field_name)
missing = _PATTERN_PLACEHOLDERS - seen
if missing:
missing_list = ", ".join(f"{{{name}}}" for name in sorted(missing))
raise ValueError(f"table_pattern must contain {missing_list}")
return value
[docs]
class SandboxProfile(BaseModel):
"""Personal sandbox profile from gitignored ``.lhp/profile.yaml``.
Explicit opt-in only: the namespace and pipeline scope are always stated
by the developer — never auto-detected.
"""
# Lowercase-only, matching the canonicalization lowering applied to
# generated table names; max 64 chars total.
namespace: str = Field(pattern=r"^[a-z][a-z0-9_]{0,63}$")
pipelines: List[str] = Field(min_length=1) # names or fnmatchcase globs