"""Descriptor models for the schema-driven wizard.The five strict frozen pydantic v2 records below compose a closed,declarative description of an operator-facing configuration flow.``WizardFlow`` is a tuple of ``WizardSection``s; a section is a tupleof ``WizardQuestion``s; each question binds zero-or-one``profile_key`` to the profile registry, declares exactly one``WizardWidget`` kind, and carries the prompt copy and the optional``WizardCondition`` that gates its visibility. The descriptor is thesingle source of truth: the runtime, the Typer command factory, andthe ``compile_profile_keys`` projection all read off these records."""from__future__importannotationsfromenumimportStrEnumfrompathlibimportPathfrompydanticimportBaseModel,Field,model_validatorfrom...coreimportSTRICT_FROZEN_CONFIGfrom...core.i18nimportTranslatableastr
[docs]classWizardWidget(StrEnum):"""Closed taxonomy of input primitives the wizard runtime supports."""TEXT="text"SECRET="secret"CONFIRM="confirm"SELECT="select"CHECKBOX="checkbox"PATH="path"INTEGER="integer"
[docs]classWizardCondition(BaseModel):"""Single-clause predicate naming one earlier question. The predicate names an earlier question by id and tests its canonical-token answer (``"true"`` / ``"false"`` for booleans, raw string for SELECT/TEXT, comma-joined token set for CHECKBOX). Exactly one of two clause kinds is set: * ``equals`` — the answer must equal this literal token. Used for SELECT and CONFIRM gates (``entity-type == "legal_entity"``). * ``contains`` — the answer, split on commas into a token set, must contain this literal token. Used for CHECKBOX gates (``irpf-income-categories`` includes ``actividad_economica``). """model_config=STRICT_FROZEN_CONFIGquestion_id:str=Field(min_length=1)equals:str|None=Nonecontains:str|None=None@model_validator(mode="after")def_validate_exactly_one_clause(self)->WizardCondition:"""Exactly one of ``equals`` / ``contains`` must be declared."""declared=[nameforname,valuein(("equals",self.equals),("contains",self.contains))ifvalueisnotNone]iflen(declared)!=1:raiseValueError(f"WizardCondition on {self.question_id!r} must declare exactly one of "f"'equals' / 'contains'; got {declaredor['none']}",)returnself
[docs]classWizardVisibility(BaseModel):"""Disjunction of :class:`WizardCondition` clauses. A question is visible when *any* clause is satisfied. A single- clause visibility is the common case; a multi-clause visibility expresses "asked when A or B" (e.g. ``activity`` is collected for a legal entity *or* for a natural person who declared an economic activity). """model_config=STRICT_FROZEN_CONFIGany_of:tuple[WizardCondition,...]=Field(min_length=1)
[docs]classWizardChoice(BaseModel):"""One entry in a SELECT or CHECKBOX widget's closed-set choices."""model_config=STRICT_FROZEN_CONFIGvalue:str=Field(min_length=1)label:trdescription:tr|None=None
[docs]classWizardQuestion(BaseModel):"""One operator-facing question in a wizard flow."""model_config=STRICT_FROZEN_CONFIGid:str=Field(min_length=1)profile_key:str|None=Nonewidget:WizardWidgetprompt:trhelp:tr|None=Nonechoices:tuple[WizardChoice,...]=()default:str|None=Nonerequired:bool=Truevisible_when:WizardCondition|WizardVisibility|None=Noneanswer_type:type[str]|type[bool]|type[int]|type[Path]
[docs]classWizardSection(BaseModel):"""One grouped sequence of questions inside a flow."""model_config=STRICT_FROZEN_CONFIGid:str=Field(min_length=1)title:trquestions:tuple[WizardQuestion,...]=Field(min_length=1)
[docs]classWizardFlow(BaseModel):"""The top-level descriptor for a single wizard surface."""model_config=STRICT_FROZEN_CONFIGid:str=Field(min_length=1)title:trdescription:trsections:tuple[WizardSection,...]=Field(min_length=1)answers_model:type[BaseModel]@model_validator(mode="after")def_validate_translatable_prefix(self)->WizardFlow:"""Every ``Translatable`` in the flow must start with ``wizard.<flow.id>.``."""expected=f"wizard.{self.id}."offenders:list[str]=[]forvalue,locationin_walk_translatables(self):ifnotstr(value).startswith(expected):offenders.append(f"{location}={value!r}")ifoffenders:raiseValueError(f"WizardFlow {self.id!r} carries Translatable values that do not start with "f"{expected!r}: {', '.join(offenders)}",)returnself@model_validator(mode="after")def_validate_unique_question_ids(self)->WizardFlow:"""Question ids must be unique across the entire flow."""seen:set[str]=set()duplicates:list[str]=[]forsectioninself.sections:forquestioninsection.questions:ifquestion.idinseen:duplicates.append(question.id)seen.add(question.id)ifduplicates:raiseValueError(f"WizardFlow {self.id!r} has duplicate question ids: {', '.join(sorted(duplicates))}")returnself@model_validator(mode="after")def_validate_visible_when_targets(self)->WizardFlow:"""Every ``visible_when`` clause must name an earlier question. A multi-clause :class:`WizardVisibility` is checked clause by clause: every named question must precede the gated question so the runtime has the parent answer in hand when it evaluates visibility. """seen:dict[str,int]={}index=0forsectioninself.sections:forquestioninsection.questions:seen[question.id]=indexindex+=1order=0bad:list[str]=[]forsectioninself.sections:forquestioninsection.questions:forconditioniniter_conditions(question.visible_when):target_index=seen.get(condition.question_id)iftarget_indexisNoneortarget_index>=order:bad.append(f"{question.id}->{condition.question_id}")order+=1ifbad:raiseValueError(f"WizardFlow {self.id!r} has visible_when references that do not resolve "f"to earlier questions: {', '.join(bad)}",)returnself
[docs]defiter_conditions(visible_when:WizardCondition|WizardVisibility|None,)->tuple[WizardCondition,...]:"""Return every :class:`WizardCondition` clause in a ``visible_when``. Normalises the three shapes ``visible_when`` can take — ``None`` (no gate), a bare :class:`WizardCondition` (single clause), or a :class:`WizardVisibility` (OR of clauses) — into a flat tuple so consumers iterate one uniform sequence. """ifvisible_whenisNone:return()ifisinstance(visible_when,WizardCondition):return(visible_when,)returnvisible_when.any_of
def_walk_translatables(flow:WizardFlow)->list[tuple[tr,str]]:"""Yield every ``Translatable`` in ``flow`` with a dotted-path location."""result:list[tuple[tr,str]]=[]result.append((flow.title,f"{flow.id}.title"))result.append((flow.description,f"{flow.id}.description"))forsectioninflow.sections:result.append((section.title,f"{flow.id}.{section.id}.title"))forquestioninsection.questions:result.append((question.prompt,f"{flow.id}.{section.id}.{question.id}.prompt"))ifquestion.helpisnotNone:result.append((question.help,f"{flow.id}.{section.id}.{question.id}.help"))forchoiceinquestion.choices:result.append((choice.label,f"{flow.id}.{section.id}.{question.id}.choices.{choice.value}.label"))ifchoice.descriptionisnotNone:result.append((choice.description,f"{flow.id}.{section.id}.{question.id}.choices.{choice.value}.description",),)returnresult