"""Strict Pydantic records for the centralized user-profile schema.
Each schema section declares a :class:`SensitivityClass` that governs
the encryption tier applied when the section's data is persisted to the
secure DB backend.
"""
from __future__ import annotations
import re
from collections.abc import Iterable, Mapping
from datetime import date
from decimal import Decimal, InvalidOperation
from enum import StrEnum
from functools import lru_cache
from typing import Annotated, Final, Self
from pydantic import BaseModel, Field, StringConstraints, TypeAdapter, field_validator, model_validator
from ...core.classification.policies import SensitivityClass
from ...core.decimal.coercion import coerce_decimal_strict
from ...core.errors.hierarchy import pydantic_validation_boundary
from ...core.modelo import Modelo
from ...core.models import STRICT_FROZEN_CONFIG as _STRICT_FROZEN
from ...core.parsing.dates import parse_iso8601_date
from ...core.parsing.utils import parse_bool
from .errors import UserProfileNotFoundError, UserProfileValidationError
_SchemaId = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9_.-]*$"),
]
_SectionKey = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1, max_length=64, pattern=r"^[a-z][a-z0-9_]*$"),
]
_FieldKey = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1, max_length=64, pattern=r"^[a-z][a-z0-9_]*$"),
]
_FieldPath = Annotated[
str,
StringConstraints(
strip_whitespace=True,
min_length=3,
max_length=160,
pattern=r"^[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+$",
),
]
_Selector = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1, max_length=256),
]
#: A canonical dotted path carrying at least one ``{placeholder}`` segment.
#: Shaped like ``_FieldPath`` plus braces, so a pattern that accidentally
#: carries no placeholder -- or stray uppercase / whitespace -- is refused at
#: schema load rather than silently declaring a single literal path.
_DerivedSelectorPattern = Annotated[
str,
StringConstraints(
strip_whitespace=True,
min_length=3,
max_length=160,
pattern=r"^[a-z][a-z0-9_]*(?:\.[a-z0-9_{}]+)+$",
),
]
_Description = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1, max_length=512),
]
[docs]
class ProfileFieldType(StrEnum):
"""Closed catalogue of field types allowed by the profile schema TOML."""
STRING = "string"
BOOLEAN = "boolean"
INTEGER = "integer"
DECIMAL = "decimal"
MONEY = "money"
DATE = "date"
EMAIL = "email"
ENUM = "enum"
ARRAY = "array"
OBJECT = "object"
[docs]
class ProfileSnapshotPolicy(StrEnum):
"""Accepted profile snapshot stale-check policies."""
IMMUTABLE_SECURE_SNAPSHOT_HASH = "immutable_secure_snapshot_hash"
[docs]
class ProfileRemovePolicy(StrEnum):
"""Accepted profile removal policies."""
LIVE_PROFILE_TOMBSTONE_RETAIN_SNAPSHOTS = "live_profile_tombstone_retain_snapshots"
#: Proves the "sequence of something" shape a bare `isinstance(..., Iterable)`
#: check cannot express, so element handling is typed rather than unknown.
_OBJECT_SEQUENCE_ADAPTER: TypeAdapter[tuple[object, ...]] = TypeAdapter(tuple[object, ...])
def _parse_str_enum(enum_type: type[StrEnum], value: object) -> object:
if isinstance(value, enum_type):
return value
if isinstance(value, str):
return enum_type(value)
return value
def _parse_sensitivity(value: object) -> object:
if isinstance(value, SensitivityClass):
return value
if isinstance(value, str):
return SensitivityClass(value)
return value
[docs]
class ProfileFieldDefinition(BaseModel):
"""One field declared inside a user-profile schema section."""
model_config = _STRICT_FROZEN
key: _FieldKey
type: ProfileFieldType
required: bool = False
nullable: bool = False
sensitivity: SensitivityClass
effective_dated: bool = False
description: _Description
enum_values: tuple[str, ...] = Field(default=())
model_selectors: tuple[_Selector, ...] = Field(default=())
#: Modelos that require this field even though it is not globally required.
#:
#: ``required`` is a property of the field across the whole profile: it
#: drives completeness, overview and presentation, so setting it to satisfy
#: one modelo demands the fact from every taxpayer, including those with no
#: such obligation. This axis carries the modelo-scoped requirement instead,
#: and ONLY the filing-preflight walk consults it.
required_for_modelos: tuple[Modelo, ...] = Field(default=())
schedule_predicates: tuple[_Selector, ...] = Field(default=())
legal_refs: tuple[_Description, ...] = Field(default=())
minimum: Decimal | None = None
maximum: Decimal | None = None
@field_validator("type", mode="before")
@classmethod
@pydantic_validation_boundary
def _parse_type(cls, value: object) -> object:
return _parse_str_enum(ProfileFieldType, value)
@field_validator("required_for_modelos", mode="before")
@classmethod
@pydantic_validation_boundary
def _hydrate_required_for_modelos(cls, value: object) -> object:
"""Hydrate the free-form registry tokens into typed modelo members."""
if isinstance(value, str):
raise UserProfileValidationError("required_for_modelos must be a sequence of modelo ids")
if isinstance(value, Iterable):
# Validated rather than asserted: `isinstance(value, Iterable)` narrows
# to an unparameterised Iterable, so every element would read as
# unknown and each `_parse_str_enum` call would take an untyped
# argument. The adapter proves the sequence shape instead.
entries = _OBJECT_SEQUENCE_ADAPTER.validate_python(value)
return tuple(Modelo(entry) if isinstance(entry, str) else entry for entry in entries)
return value
@field_validator("sensitivity", mode="before")
@classmethod
@pydantic_validation_boundary
def _coerce_sensitivity(cls, value: object) -> object:
return _parse_sensitivity(value)
@field_validator("minimum", "maximum", mode="before")
@classmethod
@pydantic_validation_boundary
def _parse_decimal_bound(cls, value: object) -> object:
if value is None or isinstance(value, Decimal):
return value
if isinstance(value, str | int):
try:
return coerce_decimal_strict(value)
except (InvalidOperation, ValueError) as exc:
raise UserProfileValidationError(f"invalid decimal bound {value!r}") from exc
return value
@model_validator(mode="after")
@pydantic_validation_boundary
def _validate_enum_values(self) -> Self:
_validate_enum_declaration(self)
_validate_unique_enum_values(self)
_validate_numeric_bounds(self)
return self
def _validate_enum_declaration(field: ProfileFieldDefinition) -> None:
"""Enforce the bidirectional relationship between enum type and values."""
if field.type is ProfileFieldType.ENUM and not field.enum_values:
raise UserProfileValidationError(f"field {field.key!r}: enum fields must declare enum_values")
if field.type is not ProfileFieldType.ENUM and field.enum_values:
raise UserProfileValidationError(f"field {field.key!r}: enum_values are only valid for enum fields")
def _validate_unique_enum_values(field: ProfileFieldDefinition) -> None:
"""Reject duplicate enum tokens without changing their declared order."""
if len(set(field.enum_values)) != len(field.enum_values):
raise UserProfileValidationError(f"field {field.key!r}: duplicate enum_values are not allowed")
def _validate_numeric_bounds(field: ProfileFieldDefinition) -> None:
"""Enforce numeric-only bounds and their inclusive ordering."""
numeric_types = {ProfileFieldType.INTEGER, ProfileFieldType.DECIMAL, ProfileFieldType.MONEY}
if (field.minimum is not None or field.maximum is not None) and field.type not in numeric_types:
raise UserProfileValidationError(f"field {field.key!r}: numeric bounds are only valid for numeric fields")
if field.minimum is not None and field.maximum is not None and field.minimum > field.maximum:
raise UserProfileValidationError(f"field {field.key!r}: minimum must be less than or equal to maximum")
[docs]
class ProfileSectionDefinition(BaseModel):
"""A top-level section in the user-profile schema."""
model_config = _STRICT_FROZEN
key: _SectionKey
title: _Description
sensitivity: SensitivityClass
effective_dated: bool = False
repeatable: bool = False
fields: tuple[ProfileFieldDefinition, ...] = Field(min_length=1)
@field_validator("sensitivity", mode="before")
@classmethod
@pydantic_validation_boundary
def _coerce_sensitivity(cls, value: object) -> object:
return _parse_sensitivity(value)
@model_validator(mode="after")
@pydantic_validation_boundary
def _validate_unique_fields(self) -> Self:
keys = tuple(field.key for field in self.fields)
if len(set(keys)) != len(keys):
duplicates = sorted({key for key in keys if keys.count(key) > 1})
raise UserProfileValidationError(f"section {self.key!r}: duplicate field keys {duplicates!r}")
return self
#: Regex fragment each declared derived-selector placeholder expands to.
#:
#: ``filing_year`` is deliberately ``\d{4}`` rather than a wildcard. Two of the
#: declared patterns are prefixes of one another
#: (``descendientes_minimos_aggregate_{filing_year}`` and the ``_autonomico_``
#: variant), so under a looser fragment the shorter pattern would swallow the
#: longer one's paths -- every gate would stay green while the longer pattern's
#: coverage silently vanished.
_DERIVED_SELECTOR_PLACEHOLDERS: Final[Mapping[str, str]] = {"filing_year": r"\d{4}"}
_DERIVED_SELECTOR_PLACEHOLDER_RE: Final[re.Pattern[str]] = re.compile(r"\{([^{}]+)\}")
@lru_cache(maxsize=64)
def _compiled_derived_selector_pattern(pattern: str) -> re.Pattern[str]:
r"""Compile a declared derived-selector pattern into an anchored regex.
Literal segments between placeholders are individually :func:`re.escape`-d
and the placeholder fragments spliced in raw. That ordering matters:
escaping the whole template first would mangle the fragments' own regex
metacharacters. The result carries a terminal ``\Z`` anchor so a pattern
can never match a longer sibling path.
An unrecognised placeholder raises rather than matching anything, so a
typo in the schema TOML fails the load instead of quietly widening or
narrowing the namespace.
Raises:
:class:`UserProfileValidationError`: If *pattern* names a placeholder
with no declared regex fragment.
"""
parts: list[str] = []
position = 0
for match in _DERIVED_SELECTOR_PLACEHOLDER_RE.finditer(pattern):
parts.append(re.escape(pattern[position : match.start()]))
token = match.group(1)
fragment = _DERIVED_SELECTOR_PLACEHOLDERS.get(token)
if fragment is None:
raise UserProfileValidationError(
f"derived selector pattern {pattern!r} names placeholder {{{token}}}, "
f"which has no declared regex fragment; declared placeholders are "
f"{sorted(_DERIVED_SELECTOR_PLACEHOLDERS)}",
)
parts.append(fragment)
position = match.end()
parts.append(re.escape(pattern[position:]))
return re.compile("".join(parts) + r"\Z")
[docs]
class ProfileDerivedSelectorDefinition(BaseModel):
"""A namespace of profile paths the calculation engine owns and computes.
A derived path is NOT taxpayer data. Its value is computed at calculate
time from the source facts named in :attr:`derived_from`, so the schema
declares the namespace as a pattern rather than one field per filing year.
The pattern exists so registry binding selectors targeting these paths
still resolve once the per-year field declarations are gone.
This is a DECLARATION of engine ownership, never a resolution route for
values: nothing here supplies a value to the binding resolver.
"""
model_config = _STRICT_FROZEN
pattern: _DerivedSelectorPattern
derived_from: tuple[_FieldPath, ...] = Field(min_length=1)
entry_surface: _Description
description: _Description
legal_refs: tuple[_Description, ...] = ()
@model_validator(mode="after")
@pydantic_validation_boundary
def _validate_pattern_compiles(self) -> Self:
# Compile eagerly so an unknown placeholder is a schema-load failure
# rather than a silent non-match discovered at validation time.
_compiled_derived_selector_pattern(self.pattern)
return self
[docs]
def matches(self, selector: str) -> bool:
"""Return whether *selector* falls inside this derived namespace."""
return _compiled_derived_selector_pattern(self.pattern).match(selector) is not None
[docs]
def derived_selector_for_path(
path: str,
derived_selectors: Iterable[ProfileDerivedSelectorDefinition],
) -> ProfileDerivedSelectorDefinition | None:
"""Return the declared namespace owning *path*, or ``None`` if it is not derived.
The single written-once judgment on whether a profile path is engine-derived.
Every consumer -- the registry contract validator and the write-door refusal --
asks through here, so the two can never disagree about what "derived" means.
Deliberately NOT routed through :func:`profile_value_refusal`, which is the
authority on whether a VALUE may be stored at a declared field. That judgment
is value-scoped against a :class:`ProfileFieldDefinition` and expressly
declines to judge absence; this one is path-scoped, refuses every value
including a clear, and must keep answering once the per-year field
declarations are gone and there is no declaration left to judge against. The
two live in one module because they are both schema-level judgments, not
because they are the same judgment.
The definition is returned rather than a bare bool so a caller can name the
surface that edits the real source facts without re-scanning the namespace.
"""
for definition in derived_selectors:
if definition.matches(path):
return definition
return None
[docs]
class ProfileSchemaDefinition(BaseModel):
"""The committed centralized user-profile schema."""
model_config = _STRICT_FROZEN
id: _SchemaId
version: int = Field(ge=1)
title: _Description
snapshot_policy: ProfileSnapshotPolicy
remove_policy: ProfileRemovePolicy
sections: tuple[ProfileSectionDefinition, ...] = Field(min_length=1)
derived_selectors: tuple[ProfileDerivedSelectorDefinition, ...] = ()
@field_validator("snapshot_policy", mode="before")
@classmethod
@pydantic_validation_boundary
def _parse_snapshot_policy(cls, value: object) -> object:
return _parse_str_enum(ProfileSnapshotPolicy, value)
@field_validator("remove_policy", mode="before")
@classmethod
@pydantic_validation_boundary
def _parse_remove_policy(cls, value: object) -> object:
return _parse_str_enum(ProfileRemovePolicy, value)
@model_validator(mode="after")
@pydantic_validation_boundary
def _validate_unique_sections(self) -> Self:
keys = tuple(section.key for section in self.sections)
if len(set(keys)) != len(keys):
duplicates = sorted({key for key in keys if keys.count(key) > 1})
raise UserProfileValidationError(f"duplicate section keys {duplicates!r}")
return self
@property
def field_paths(self) -> tuple[str, ...]:
"""Canonical dotted field paths declared by the schema."""
return tuple(f"{section.key}.{field.key}" for section in self.sections for field in section.fields)
[docs]
def section(self, key: str) -> ProfileSectionDefinition:
"""Return a :class:`ProfileSectionDefinition` by canonical section key."""
for section in self.sections:
if section.key == key:
return section
raise UserProfileNotFoundError(f"unknown user-profile section {key!r}")
[docs]
def field(self, path: _FieldPath) -> ProfileFieldDefinition:
"""Return a field by canonical dotted path.
Returns:
The :class:`ProfileFieldDefinition` for the given path.
"""
section_key, field_key = path.split(".", 1)
section = self.section(section_key)
for field in section.fields:
if field.key == field_key:
return field
raise UserProfileNotFoundError(f"unknown user-profile field {path!r}")
[docs]
def path_for_model_selector(self, selector: str) -> str | None:
"""Return the canonical dotted path of the field declaring ``selector``.
The inverse of the ``model_selectors`` declaration. Surfaces that hold
a selector token rather than a path - a deadline-engine gating key, a
registry binding's consumed profile key - need the path before they can
resolve the field's operator label and legal grounding, and every other
schema lookup here runs path-first.
Ambiguity is refused rather than guessed: the schema does not
constrain a token to one declaring field, so a token declared by two
fields has no single correct answer and returning either would
mislabel one of them. An unknown token is likewise not an error - the
callers mix selector tokens with identifiers from other namespaces and
must be able to ask without knowing which they hold.
Returns:
The ``section.field`` path when exactly one field declares
``selector``, otherwise ``None``.
"""
token = selector.strip()
if not token:
return None
matches = [
f"{section.key}.{field.key}"
for section in self.sections
for field in section.fields
if token in field.model_selectors
]
if len(matches) != 1:
return None
return matches[0]
NUMERIC_PROFILE_FIELD_TYPES: frozenset[ProfileFieldType] = frozenset(
{ProfileFieldType.INTEGER, ProfileFieldType.DECIMAL, ProfileFieldType.MONEY},
)
"""The field types whose values are numbers, and whose bounds therefore bind.
Mirrors the set :meth:`ProfileFieldDefinition._validate_enum_values` already
uses to decide where ``minimum`` / ``maximum`` may be declared, so the types
that may CARRY a bound and the types that are CHECKED against one cannot
drift apart.
"""
[docs]
def numeric_value_refusal(field: ProfileFieldDefinition, value: object) -> str | None:
"""Return why ``value`` fails ``field``'s numeric declaration, or ``None``.
The single authority for "is this a legal value for this numeric field".
It exists because the declaration was inert: ``minimum`` and ``maximum``
were validated for their own coherence at schema build and then never
compared to anything, so a participation percentage declared ``0..100``
accepted ``999`` on write and carried it into an attribution calculation
unchallenged. Both the write door and the readers that consume these
facts ask this one function, rather than each forming its own opinion.
Numbers are :class:`~decimal.Decimal` (or :class:`int`), never
:class:`float`. These are financial quantities -- a participation
percentage and an assigned base that divide a taxable amount between
members -- so binary floating point is the wrong carrier: its rounding
is invisible at the point of entry and shows up as a cent that does not
reconcile in a filing. ``bool`` is rejected despite being an ``int``
subclass, because ``True`` is an answer to a different question and
would otherwise silently satisfy a numeric field as ``1``.
Absence is not this rule's business. A ``None`` value is a cleared or
unanswered field, which the required-field check judges; refusing it
here would report one missing field as two unrelated faults.
Non-finite values need no check: the fact carrier's own union rejects a
``NaN`` or infinite :class:`~decimal.Decimal` before it can be
constructed, so an unorderable value cannot reach a bound comparison.
Args:
field: The declaration the value must satisfy.
value: The value carried by the fact, after the fact carrier has
restored its type.
Returns:
An instructive message naming the field and the range it accepts,
or ``None`` when the value is admissible or the field is not
numeric.
"""
if field.type not in NUMERIC_PROFILE_FIELD_TYPES or value is None:
return None
accepted = _accepted_range_clause(field)
if isinstance(value, bool) or not isinstance(value, (int, Decimal)):
return f"{field.key} must be a number{accepted}; got {value!r}"
if field.minimum is not None and value < field.minimum:
return f"{field.key} must be a number{accepted}; got {value}"
if field.maximum is not None and value > field.maximum:
return f"{field.key} must be a number{accepted}; got {value}"
return None
[docs]
def boolean_value_refusal(field: ProfileFieldDefinition, value: object) -> str | None:
"""Return why ``value`` fails ``field``'s boolean declaration, or ``None``.
The single authority for "is this a legal value for this boolean field",
the counterpart to :func:`numeric_value_refusal`. It exists because the
declaration was inert: a field declared ``boolean`` accepted ``banana``,
``placeholder`` and ``''`` on write and stored them unchallenged, while
every reader then had to decide for itself what an unreadable value
meant. Readers that resolve a stored boolean default an unreadable one
to ``False`` -- safe only if a value this door admitted can always be
read, which is what this function makes true.
Readability, not spelling, is the test. A taxpayer answering ``sí`` is
saying yes, and refusing that while accepting ``true`` would be a
vocabulary rule dressed up as a type rule. The judgement therefore comes
from ``cadrumo.core.parsing.parse_bool``, the one boolean vocabulary
the rest of the codebase already reads answers with, rather than a set
spelled out here that could drift from it.
A real :class:`bool` is admissible by construction: it needs no parsing
and carries no ambiguity.
Absence is not this rule's business. A ``None`` value is a cleared or
unanswered field, which the required-field check judges. An empty string
is NOT absence -- it is an answer that says nothing -- and is refused, as
the enum and date declarations already refuse it.
Args:
field: The declaration the value must satisfy.
value: The value carried by the fact, after the fact carrier has
restored its type.
Returns:
An instructive message naming the field and what it accepts, or
``None`` when the value is admissible or the field is not boolean.
"""
if field.type is not ProfileFieldType.BOOLEAN or value is None:
return None
if isinstance(value, bool):
return None
if isinstance(value, str) and parse_bool(value) is not None:
return None
return f"{field.key} must be a yes/no answer (true/false, sí/no, 1/0); got {value!r}"
[docs]
class ProfileValueRefusalKind(StrEnum):
"""Which declaration a refused value failed.
A closed set, because it is the join between the one rule that judges a
value and the two surfaces that have to say something about the verdict:
the validation service turns a kind into its issue code, and an operator
surface turns the same kind into the sentence it shows and the editor it
offers. Both read the kind rather than matching on the message, so the
prose stays free to change without either one silently stopping working.
"""
ENUM = "enum"
DATE = "date"
NUMERIC = "numeric"
BOOLEAN = "boolean"
EMAIL = "email"
[docs]
class ProfileValueRefusal(BaseModel):
"""Why one value fails its field's declaration.
Carries the kind as well as the message so a caller can classify the
refusal without parsing the sentence: the codes an issue report uses and
the copy an operator reads are both decided from
:attr:`kind`.
"""
model_config = _STRICT_FROZEN
kind: ProfileValueRefusalKind
message: str
_ISO_DATE_LAYOUT = re.compile(r"^\d{4}-\d{2}-\d{2}$")
"""The single accepted date layout: zero-padded ``YYYY-MM-DD``.
:meth:`datetime.date.fromisoformat` alone would also accept the compact
basic form (``19780315``), which nothing downstream canonicalises back to a
:class:`datetime.date`. Anchoring the extended hyphenated layout keeps every
persisted date fact in one shape.
"""
[docs]
def date_value_refusal(field: ProfileFieldDefinition, value: object) -> str | None:
"""Return why ``value`` fails ``field``'s date declaration, or ``None``.
The sibling of :func:`boolean_value_refusal` and
:func:`numeric_value_refusal`, and here for the same reason: the rule
lived only inside the validation service, so a surface wanting to refuse
a bad date at the box the operator typed it into had nowhere to ask and
would have had to restate the layout itself.
A real :class:`datetime.date` is admissible by construction. A string is
accepted only when it matches the zero-padded extended layout AND parses
as a calendar day, which together reject a non-ISO layout
(``15/03/1978``), the compact basic form (``19780315``), an impossible
month or day (``1978-13-45``), a non-calendar day (``1978-02-30``), and
plain garbage -- without any hand-rolled calendar maths.
"""
if field.type is not ProfileFieldType.DATE or value is None:
return None
if isinstance(value, date):
return None
if isinstance(value, str) and _ISO_DATE_LAYOUT.match(value):
try:
parse_iso8601_date(value)
except ValueError:
return _invalid_date_message(field, value)
return None
return _invalid_date_message(field, value)
def _invalid_date_message(field: ProfileFieldDefinition, value: object) -> str:
return f"{field.key} must be a valid ISO-8601 calendar date (YYYY-MM-DD); got {value!r}"
_EMAIL_SHAPE = re.compile(r"^[^@\s]+@[^@\s.]+(?:\.[^@\s.]+)+$")
"""Deliberately permissive: one ``@``, something either side, a dotted domain.
Not an RFC 5322 grammar, and not an attempt at one. The addresses this field
holds are the taxpayer's own contact details, and the cost of the two errors
is asymmetric: refusing a legitimate address makes the field uneditable for
whoever holds it, while admitting an odd-but-real one costs nothing here,
because nothing in this application sends mail. A strict grammar buys
accuracy on inputs nobody types and loses on the unusual ones people
genuinely have.
So this catches the entry that is plainly not an address — no ``@`` at all, a
bare local part, whitespace in the middle, a domain with no dot — and admits
everything else. Quoted local parts, plus-addressing, and internationalised
labels all pass.
"""
[docs]
def email_value_refusal(field: ProfileFieldDefinition, value: object) -> str | None:
"""Return why ``value`` fails ``field``'s email declaration, or ``None``.
The declaration was inert before this: a field declared ``email``
accepted ``banana`` and stored it, so the one type in the schema that
names its own content format was the one type nothing checked.
Only a string can be an address. A non-string reaching an ``email`` field
is a fact carrier that promoted the value to something else — a bare
numeral, a date-shaped token — and is refused as the same fault rather
than allowed through for want of a branch.
"""
if field.type is not ProfileFieldType.EMAIL or value is None:
return None
if isinstance(value, str) and _EMAIL_SHAPE.match(value):
return None
return f"{field.key} must be an email address (name@example.com); got {value!r}"
[docs]
def enum_value_refusal(field: ProfileFieldDefinition, value: object) -> str | None:
"""Return why ``value`` is outside ``field``'s declared token set, or ``None``.
Comparison is exact rather than case-folded. The declared sets are
case-significant and inconsistent between fields by design -- some carry
AEAT's own uppercase tokens, others the profile's lowercase vocabulary --
so folding would silently admit a spelling the declaring authority does
not use.
"""
if field.type is not ProfileFieldType.ENUM or value is None:
return None
if str(value) in field.enum_values:
return None
return f"{field.key} must be one of {list(field.enum_values)}; got {value!r}"
[docs]
def profile_value_refusal(field: ProfileFieldDefinition, value: object) -> ProfileValueRefusal | None:
"""Return why ``value`` fails ``field``'s declaration, or ``None``.
THE authority on whether a value may be stored at a field, gathering the
four per-type rules behind one call so that every surface asks the same
question in the same words. The write door asks it to build its issue
report; an operator surface asks it to refuse a value at the box it was
typed into, before a round trip to storage. Two surfaces judging a value
apart is how one comes to accept what the other rejects, and the operator
meets that as a dialog that closes on a value the record then refuses.
Absence is not this rule's business. A ``None`` value is a cleared or
unanswered field, which the required-field check judges.
Args:
field: The declaration the value must satisfy.
value: The value carried by the fact, after the fact carrier has
restored its type. Passing a raw string a fact would have
promoted (``"true"``, ``"1978-03-15"``) asks a different question
from the one the write door asks.
Returns:
A :class:`ProfileValueRefusal` naming what failed and what the field
accepts, or ``None`` when the value is admissible.
"""
if value is None:
return None
for kind, refusal in (
(ProfileValueRefusalKind.ENUM, enum_value_refusal(field, value)),
(ProfileValueRefusalKind.NUMERIC, numeric_value_refusal(field, value)),
(ProfileValueRefusalKind.BOOLEAN, boolean_value_refusal(field, value)),
(ProfileValueRefusalKind.DATE, date_value_refusal(field, value)),
(ProfileValueRefusalKind.EMAIL, email_value_refusal(field, value)),
):
if refusal is not None:
return ProfileValueRefusal(kind=kind, message=refusal)
return None
def _accepted_range_clause(field: ProfileFieldDefinition) -> str:
"""Render the declared bounds as an operator-facing phrase.
Both bounds are INCLUSIVE, and the wording says so: a percentage
declared ``0..100`` must accept exactly ``0`` and exactly ``100``, and
a refusal that does not state which end is included leaves the operator
guessing at the boundary that just refused them.
"""
minimum, maximum = field.minimum, field.maximum
if minimum is not None and maximum is not None:
return f" from {minimum} to {maximum} inclusive"
if minimum is not None:
return f" no less than {minimum}"
if maximum is not None:
return f" no greater than {maximum}"
return ""