Skip to content

Setup Wizard

The interactive console wizard behind the taters command, and the layers it is built from. See the wizard guide for what it looks like to use.

The modules are deliberately separate. introspect, recipes and compose have no terminal dependency at all, so they can back a different front end — a GUI, a web page — without the logic moving. prompts defines the seam, and live is one renderer on the far side of it.

The front door

The opening menu, and the registry of things a user can choose to do. Each task is a self-contained flow, so adding one is a new module and a registry entry rather than a change to anything that already works.

taters.ui.hub

The front door: "What would you like to do?"

The wizard used to open with "Where is your data?", which quietly assumed the answer to a question nobody had asked -- that you were here to extract features from files. Running a saved pipeline and managing pipelines involve no data at all, and extracting features and running analyses starts from a spreadsheet's columns rather than from a folder.

So the first question is about intent, and the answer chooses a :class:~taters.ui.tasks.Task. Data comes up inside the task that needs it.

Backing out

Cancelling inside a task returns here rather than ending the session: a mistyped path should not cost someone the whole run. Cancelling at this menu exits. That is the difference between ctrl-c meaning "not that" and ctrl-c meaning "I am done".

version

version()

The installed version, or "" if it cannot be determined.

Running from a source tree that was never installed has no distribution metadata, and a wizard that refuses to start because it cannot name itself would be a poor trade.

Source code in src\taters\ui\hub.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def version() -> str:
    """
    The installed version, or "" if it cannot be determined.

    Running from a source tree that was never installed has no distribution
    metadata, and a wizard that refuses to start because it cannot name itself
    would be a poor trade.
    """
    try:
        from importlib.metadata import version as _version
        return _version("taters")
    except Exception:
        try:
            from .. import __version__
            return str(__version__)
        except Exception:
            return ""

title

title()

Short name plus version, for the progress rail.

Source code in src\taters\ui\hub.py
82
83
84
85
def title() -> str:
    """Short name plus version, for the progress rail."""
    v = version()
    return f"Taters v{v}" if v else "Taters"

border_style

border_style(at=None)

The frame's color right now, as a hex string.

Parameters:

Name Type Description Default
at float

A point on the cycle in seconds. Defaults to the monotonic clock, which is what makes it move; tests pass a value to look at a fixed moment.

None
Source code in src\taters\ui\hub.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
def border_style(at: Optional[float] = None) -> str:
    """
    The frame's color right now, as a hex string.

    Parameters
    ----------
    at : float, optional
        A point on the cycle in seconds. Defaults to the monotonic clock, which
        is what makes it move; tests pass a value to look at a fixed moment.
    """
    import colorsys
    import time

    seconds = time.monotonic() if at is None else at
    hue = (seconds / _BORDER_CYCLE_SECONDS) % 1.0
    red, green, blue = colorsys.hsv_to_rgb(hue, _BORDER_SATURATION, _BORDER_VALUE)
    return "#{:02x}{:02x}{:02x}".format(
        round(red * 255), round(green * 255), round(blue * 255)
    )

banner

banner(width=MEASURE - 2)

The header: a potato, and what this program is.

Laid out as art beside text rather than as a stack inside a box. The box is still there, but as a light rounded frame -- the heavy double rule this used to draw made a tool for mashing audio look like a compliance report.

Built rather than hard-coded because the version sits inside it, and a version string of a different length would otherwise push the right-hand border out of line.

Source code in src\taters\ui\hub.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
def banner(width: int = MEASURE - 2) -> str:
    """
    The header: a potato, and what this program is.

    Laid out as art beside text rather than as a stack inside a box. The box is
    still there, but as a light rounded frame -- the heavy double rule this used
    to draw made a tool for mashing audio look like a compliance report.

    Built rather than hard-coded because the version sits inside it, and a
    version string of a different length would otherwise push the right-hand
    border out of line.
    """
    text_rows = (
        f"[{_NAME}]TATERS[/] [dim]{version_line()}[/dim]",
        f"[{_TAG_STYLE}]{_TAGLINE}[/]",
        f"[italic {_LEAF}]{_ENCOURAGEMENT}[/]",
    )

    gap = "   "
    inner = max(
        width - 6,
        max(_ART_WIDTH + len(gap) + len(_visible(row)) for row in text_rows),
    )

    frame = border_style()

    def row(art: str, text: str) -> str:
        art_pad = " " * (_ART_WIDTH - len(_visible(art)))
        body = f"{art}{art_pad}{gap}{text}"
        pad = " " * (inner - len(_visible(body)))
        return f"  [{frame}]│[/]  {body}{pad}  [{frame}]│[/]"

    top = f"  [{frame}]╭" + "─" * (inner + 4) + "╮[/]"
    bottom = f"  [{frame}]╰" + "─" * (inner + 4) + "╯[/]"
    return "\n".join(
        ["", top, *(row(a, t) for a, t in zip(_SPUD_ART, text_rows)), bottom, ""]
    )

version_line

version_line()

The version on its own line, or a note that there is no metadata.

Source code in src\taters\ui\hub.py
223
224
225
226
def version_line() -> str:
    """The version on its own line, or a note that there is no metadata."""
    v = version()
    return f"v{v}" if v else "(running from source)"

run_hub

run_hub(prompter, *, cwd=None)

Show the menu, run what is chosen, and come back for the next thing.

Parameters:

Name Type Description Default
prompter Prompter

Where the questions go.

required
cwd Path

The working folder: pipelines are saved as subfolders of it. Defaults to the current directory.

None

Returns:

Type Description
bool

False if anything that ran finished with problems. taters turns that into a non-zero exit, so a script driving this still learns of a failure even though the session may have done several things.

Source code in src\taters\ui\hub.py
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
def run_hub(prompter: Prompter, *, cwd: Optional[Path] = None) -> bool:
    """
    Show the menu, run what is chosen, and come back for the next thing.

    Parameters
    ----------
    prompter : Prompter
        Where the questions go.
    cwd : Path, optional
        The working folder: pipelines are saved as subfolders of it. Defaults to the
        current directory.

    Returns
    -------
    bool
        False if anything that ran finished with problems. `taters` turns that
        into a non-zero exit, so a script driving this still learns of a
        failure even though the session may have done several things.
    """
    ctx = TaskContext(prompter=prompter, cwd=Path(cwd or Path.cwd()))

    # the renderer redraws the header on every screen, so it has to own it --
    # and then it has to be the *only* thing that draws it, otherwise the banner
    # shows up twice on the opening screen.
    owns_header = hasattr(prompter, "set_header")
    if owns_header:
        # we hand over the function itself, not banner(): the live renderer
        # calls it on every paint, and that's what lets the border color
        # actually drift.
        prompter.set_header(banner)
    if hasattr(prompter, "_title"):
        prompter._title = title()

    prompter.clear()
    if not owns_header:
        # wrap=False here: the banner is rich markup, and `note`'s 64-column
        # wrap counts the markup characters as text -- it folds the border
        # mid-tag and the plain renderer spits out garbage.
        prompter.note(banner(), wrap=False)
    prompter.note("  Ctrl-C backs out at any point. Nothing is written until you say so.\n",
                  style="dim")

    everything_ok = True
    first = True
    while True:
        tasks = all_tasks()
        choices = [
            Choice(task.id, task.label, task.help, disabled=task.blocked(ctx))
            for task in tasks
        ]
        choices.append(Choice("quit", "Quit", ""))

        # "Anything else?" sounds like a follow-up to whatever just happened.
        # coming back from a submenu that's neither accurate nor reassuring --
        # the point is that you're back at the top.
        question = "What would you like to do?" if first else "Main menu"
        first = False

        try:
            picked = str(prompter.select(question, choices))
        except (GoBack, Cancelled):
            # at the front door, back *is* out. everywhere else Esc means "undo
            # the last question", but there's no question above this one -- and
            # a keypress that visibly does nothing looks like we ignored it,
            # which is worse than either answer.
            _farewell(prompter)
            return everything_ok

        if picked == "quit":
            _farewell(prompter)
            return everything_ok

        task = next(t for t in tasks if t.id == picked)
        try:
            try:
                if task.run(ctx) is False:
                    everything_ok = False
            finally:
                # the rail belongs to the task that put it up. without this the
                # wizard's stages stayed on screen above the main menu, still
                # describing a pipeline we weren't building anymore.
                prompter.reset_stages()
        except QuitRequested as quit_:
            # the user picked quit on purpose from a finished screen, so no
            # "backed out" message: the work is done and saying otherwise would
            # be a lie. the verdict rides along on the exception -- raising
            # skipped the task's `return False`, so a failed run's quit used to
            # exit 0.
            _farewell(prompter)
            return everything_ok and quit_.ok
        except (Cancelled, GoBack):
            # Esc at the first question of a task means "not this one after
            # all", which is the same thing as backing out of it.
            prompter.note("\n  Backed out. Nothing was changed.\n", style="dim")
        prompter.note("")

taters.ui.tasks

The things a user can ask Taters to do, as a registry rather than a flow.

The wizard began as one linear script: where is your data, what do you want, here it is. That shape did not survive: "Run a saved pipeline" and "Manage pipelines" do not involve data at all, and "Extract features and run analyses" -- what words relate to an outcome, whether groups differ on a measure -- starts from a spreadsheet's columns rather than from files. A single hard-coded sequence would have to grow branches at the top for each of those.

So the front door is a registry. Each :class:Task is a self-contained flow with a label, a one-line explanation, and a run function. Adding a task later is adding a module and one entry here; it is not a refactor of anything that already works.

What deliberately does not live here

The pipeline machinery, because it already generalizes. An analysis step is a GLOBAL step that reads a features CSV, and :func:taters.ui.compose.resolve_selection already chains backwards from a goal through the capability graph -- ask for something that needs n-grams and it will pull in the transcript, the transcription, and the WAV conversion on its own. Future analysis tasks contribute recipes, not a second execution model.

TaskContext dataclass

TaskContext(prompter, cwd)

Everything a task needs from the outside world.

pipelines_dir property

pipelines_dir

Where the user's own pipelines live. Not created until something is saved.

own_pipelines

own_pipelines()

The user's saved pipelines. Built-ins are never included.

Discovery is delegated to the runner's own rules (:func:run_pipeline.available_presets) rather than re-implemented: a private copy lived here and had already drifted -- the runner searches pipelines/ recursively, the copy did not, so a nested preset appeared under Run but not under Manage. One set of rules, or the two screens disagree about what exists.

Source code in src\taters\ui\tasks\__init__.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def own_pipelines(self) -> List[Path]:
    """
    The user's saved pipelines. Built-ins are never included.

    Discovery is delegated to the runner's own rules
    (:func:`run_pipeline.available_presets`) rather than re-implemented:
    a private copy lived here and had already drifted -- the runner
    searches ``pipelines/`` recursively, the copy did not, so a nested
    preset appeared under Run but not under Manage. One set of rules, or
    the two screens disagree about what exists.
    """
    from ...pipelines.run_pipeline import available_presets, is_builtin_preset

    return sorted(path for path, _meta in available_presets(self.cwd)
                  if not is_builtin_preset(path))

owns_folder staticmethod

owns_folder(path)

Whether this preset has a folder of its own, named for it.

Source code in src\taters\ui\tasks\__init__.py
66
67
68
69
70
@staticmethod
def owns_folder(path: Path) -> bool:
    """Whether this preset has a folder of its own, named for it."""
    path = Path(path)
    return path.parent.name == path.stem

Task dataclass

Task(id, label, help, run, unavailable_because=None)

One thing the user can choose from the opening menu.

Attributes:

Name Type Description
id, label, help str

Identity, the menu line, and the explanation under it.

run callable

run(ctx) -> bool | None. False means the work ran and finished with problems, which is what lets taters still exit non-zero for a script that is watching. None means nothing ran that could succeed or fail. Raising :class:~taters.ui.prompts.Cancelled means "I backed out", and returns the user to the menu rather than ending the session.

unavailable_because (callable, optional)

(ctx) -> str. A non-empty string means the task is shown grayed out with that reason. Showing why something cannot be chosen is the point: hiding "Run a saved pipeline" until a pipeline exists leaves a new user unable to discover that saved pipelines are a thing at all.

all_tasks

all_tasks()

The menu, in order.

Imported lazily so that a task module can import the wizard without the wizard's own import of this package becoming a cycle.

Source code in src\taters\ui\tasks\__init__.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def all_tasks() -> List[Task]:
    """
    The menu, in order.

    Imported lazily so that a task module can import the wizard without the
    wizard's own import of this package becoming a cycle.
    """
    from . import data, extract, run_saved, settings, train

    # wrangling comes first: getting the data into shape is where every
    # project actually starts, and the user asked for it at the top. then the
    # extraction verbs -- features alone, and features plus the statistics
    # that answer a question about them. those are separate entries because
    # they're separate intentions, and the second one lets the wizard assume
    # there's something to analyze. housekeeping (managing pipeline files,
    # checking whether the GPU works) lives under settings, so that the first
    # screen isn't an equal-weight list of "do the thing" and "tidy up".
    # wrangling and running the statistics share one row: both start from a
    # file that already exists and neither extracts anything, so the choice
    # between them is the first question rather than two front-page rows.
    # training a model comes after those: the run exists for the model it
    # leaves behind, which the checklist then applies. the hashbrowns used to
    # sit last, right above Quit; they are a treat rather than a task, so
    # they moved under settings where nobody meets them on the way to work
    return [data.TASK, extract.TASK, extract.ANALYZE_TASK,
            train.TASK, run_saved.TASK, settings.TASK]

Building a pipeline

taters.ui.wizard

The Taters setup wizard: a console front door for people who do not write code.

Run taters with no arguments and this walks you through four questions -- where your data is, what kind it is, what you want out of it, and which options to change -- then writes a pipeline folder (<name>/<name>.yaml, in the working folder) and offers to run it.

Why it writes a file instead of just running

The thing this produces is an ordinary preset, indistinguishable from the ones that ship with Taters. That buys a lot for one design decision:

  • the run gets the pipeline runner's concurrency, its resumability, its manifest and its per-file error isolation, none of which the wizard has to reimplement;
  • the runner recognizes such folders, so the result turns up under --list-presets and can be re-run from the command line forever after;
  • and the user ends up holding a small readable file they can edit, version, or send to a colleague -- which is how somebody graduates from the wizard to the rest of the tool.
Layering

This module contains no terminal code. It asks questions through the :class:~taters.ui.prompts.Prompter protocol, which is why the test suite can drive the whole flow with a scripted list of answers. Swap the prompter and the same logic backs a GUI.

WizardResult dataclass

WizardResult(
    preset,
    preset_path=None,
    folder=None,
    root_dir=None,
    file_type="any",
    source="media",
    inputs=list(),
    ran=False,
    manifest=None,
    ok=None,
)

What the wizard did, for the caller and for the tests.

SourceSpec dataclass

SourceSpec(
    source,
    path,
    file_type="any",
    inputs=list(),
    text_cols=(lambda: ["text"])(),
    id_cols=list(),
    text_mode="concat",
    group_by=list(),
    level="",
    columns=list(),
    delimiter=",",
    kinds=dict(),
    feature_cols=list(),
)

Everything the rest of the wizard needs to know about the input.

root_dir property

root_dir

What to hand the runner as root_dir.

None for text sources: those presets are GLOBAL-only, so the runner skips discovery entirely and the input path travels as a variable instead.

AnalysisSpec dataclass

AnalysisSpec(
    analyses=list(),
    group_col="",
    outcome_cols=list(),
    class_cols=list(),
    control_cols=list(),
    categorical_controls=list(),
    tables=list(),
    per_table=False,
    p_adjust="fdr_bh",
    filters=list(),
    value_filters=dict(),
    extra_features=list(),
    why_not="",
    offered=False,
)

What the optional analysis stage decided.

Empty analyses means the stage was skipped or answered "none", and nothing about the pipeline changes -- which is the common case: plenty of datasets have nothing to test, and plenty of users just want the feature tables.

ask_source

ask_source(
    prompter,
    analyses=None,
    text_only=False,
    sources=None,
    columns_are_measures=False,
)

Ask what kind of data the user has and where it is.

The kind comes first: it decides whether the next question wants a folder or a file, and -- more importantly -- whether there is anything to transcribe at all.

A source that turns up empty returns to the top of this loop rather than just re-asking the path. Picking "Video files" for a folder of mp3s is an easy mistake, and re-asking only the folder would leave the wrong filter in place however many times they retyped it.

analyses=True means the user came here to run statistics, and only a spreadsheet carries the columns to run them against. Saying so here costs one screen; saying it after they have browsed to a folder, picked features and answered the level question costs all of that.

text_only is the training flow: a model is trained on text that already exists, so recordings -- which would first have to be transcribed by a pipeline of their own -- are not offered. sources narrows further to the named source ids (csv, txt_dir, media): a step that predicts outcome columns can only read a spreadsheet, and offering a folder of documents would fail after every question had been answered.

Source code in src\taters\ui\wizard.py
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
def ask_source(prompter: Prompter,
               analyses: Optional[bool] = None,
               text_only: bool = False,
               sources: Optional[Sequence[str]] = None,
               columns_are_measures: bool = False) -> SourceSpec:
    """
    Ask what kind of data the user has and where it is.

    The kind comes first: it decides whether the next question wants a folder
    or a file, and -- more importantly -- whether there is anything to
    transcribe at all.

    A source that turns up empty returns to the *top* of this loop rather than
    just re-asking the path. Picking "Video files" for a folder of mp3s is an
    easy mistake, and re-asking only the folder would leave the wrong filter in
    place however many times they retyped it.

    ``analyses=True`` means the user came here to run statistics, and only a
    spreadsheet carries the columns to run them against. Saying so here costs
    one screen; saying it after they have browsed to a folder, picked
    features and answered the level question costs all of that.

    ``text_only`` is the training flow: a model is trained on text that
    already exists, so recordings -- which would first have to be
    transcribed by a pipeline of their own -- are not offered. ``sources``
    narrows further to the named source ids (``csv``, ``txt_dir``,
    ``media``): a step that predicts outcome columns can only read a
    spreadsheet, and offering a folder of documents would fail after every
    question had been answered.
    """
    while True:
        if analyses:
            # this has to live inside the loop. the renderer clears a reason
            # once its question gets answered, so a rejected file used to come
            # back to a one-option menu with nothing on screen saying why.
            prompter.reason(
                "Statistics need a spreadsheet: the groups to compare or the "
                "outcomes to predict have to be columns sitting beside the "
                "text. A folder of documents or recordings has no such "
                "columns, so only the spreadsheet option is offered here.")
        kinds = ([c for c in SOURCE_KINDS if c.value == "csv"] if analyses
                 else [c for c in SOURCE_KINDS if c.value in ("csv", "txt_dir")]
                 if text_only else SOURCE_KINDS)
        if sources is not None:
            kinds = [c for c in kinds if SOURCE_KIND_MAP[c.value][0] in set(sources)]
        if text_only and not analyses and len(kinds) == 1 and kinds[0].value == "csv":
            prompter.reason(
                "This model predicts columns of a spreadsheet from its text, "
                "so only a spreadsheet -- the text in one column, the outcomes "
                "in others -- can train it.")
        elif text_only and not analyses:
            prompter.reason(
                "A model is trained on text you already have: a folder of "
                "documents or a spreadsheet with the text in one column. To "
                "train on recordings, transcribe them with a pipeline first "
                "and train on the transcripts it writes.")
        kind = str(prompter.select("What kind of data do you have?", kinds))
        source, file_type = SOURCE_KIND_MAP[kind]

        try:
            if source == "csv":
                spec = _ask_csv_source(
                    prompter, needs_metadata=bool(analyses),
                    columns_are_measures=columns_are_measures)
            elif source == "txt_dir":
                spec = _ask_txt_source(prompter)
            else:
                spec = _ask_media_source(prompter, file_type)
        except GoBack:
            # Esc on a source's first question (the file or folder browser)
            # means "not this kind of data after all", so we loop back around.
            # this used to climb right out of the wizard altogether.
            continue

        if spec is not None:
            return spec

ask_features

ask_features(prompter, source='media', analyses=False)

Show the checklist of things a user can ask for, and take their picks.

Filtered by source: there is no vocal pitch to measure in a folder of essays, and offering it would only let someone pick an option guaranteed to fail -- after a multi-gigabyte install to find out.

Grayed out for the same reason where the row needs something the user does not have: scoring with a saved model needs a saved model, and a row that is offered plainly and then refused at the preflight screen was offered from a list that gave no hint it was unavailable.

analyses=True is the "+ run analyses" flow. Statistics join per-text feature tables, and two rows here do not make one -- a document-term matrix and an n-gram frequency list describe the corpus, not each text -- so those are marked, and a pick made only of them is turned back here, at the screen that can change it. It used to be caught two screens later, after the level question, with a two-line note the layout then cut down to "Pick something else to extract, whose measures the statistics" (a real report).

Source code in src\taters\ui\wizard.py
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
def ask_features(prompter: Prompter, source: str = "media",
                 analyses: bool = False) -> List[str]:
    """
    Show the checklist of things a user can ask for, and take their picks.

    Filtered by source: there is no vocal pitch to measure in a folder of
    essays, and offering it would only let someone pick an option guaranteed to
    fail -- after a multi-gigabyte install to find out.

    Grayed out for the same reason where the row needs something the user
    does not have: scoring with a saved model needs a saved model, and a row
    that is offered plainly and then refused at the preflight screen was
    offered from a list that gave no hint it was unavailable.

    ``analyses=True`` is the "+ run analyses" flow. Statistics join per-text
    feature tables, and two rows here do not make one -- a document-term
    matrix and an n-gram frequency list describe the corpus, not each text
    -- so those are marked, and a pick made only of them is turned back
    here, at the screen that can change it. It used to be caught two
    screens later, after the level question, with a two-line note the
    layout then cut down to "Pick something else to extract, whose measures
    the statistics" (a real report).
    """
    choices = [
        Choice(r.id, r.label,
               (r.text_help or r.help) if source != "media" else r.help,
               disabled=_no_saved_model(r) or unavailable_reason(r),
               annotation=(NOT_FOR_STATISTICS
                           if analyses and not r.feature_table else ""))
        for r in _recipes.user_facing(source)
    ]
    # the question here used to be "What do you want out of it?", and people
    # found it vague -- it didn't say that the answer is a set of feature
    # tables, or that the steps they depend on come along for free.
    reason = ("Each row here is a table of measures Taters will produce, "
              f"one row per {'file' if source == 'media' else 'text'}. Tick "
              "as many as you like; anything a pick needs first -- "
              "converting audio, transcribing it, counting words -- is added "
              "for you.")
    if analyses:
        reason += (" You asked for statistics as well, so at least one pick "
                   f"has to be a per-text table; rows marked "
                   f"{NOT_FOR_STATISTICS} describe the whole corpus instead, "
                   "and can come along but not carry the statistics.")
    prompter.reason(reason)
    while True:
        picked = ask_at_least_one(
            prompter, "Which features do you want to extract?", choices,
            thing="one feature")
        if not analyses or any(_recipes.by_id(p).feature_table
                               for p in picked):
            return picked
        prompter.note("  None of those produces a per-text table for the "
                      "statistics to use; add at least one that does.",
                      style="yellow")

resolve_providers

resolve_providers(prompter, selected, source='media')

Settle any capability that more than one recipe could satisfy.

In practice this is one question: a transcript can come from plain transcription or from diarization. It gets asked when a chosen feature needs a transcript and the user did not tick either producer, and it gets asked again if they ticked both -- two transcription steps writing to the same place is never what anyone meant.

Source code in src\taters\ui\wizard.py
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
def resolve_providers(prompter: Prompter, selected: Sequence[str],
                      source: str = "media") -> Dict[str, str]:
    """
    Settle any capability that more than one recipe could satisfy.

    In practice this is one question: a transcript can come from plain
    transcription or from diarization. It gets asked when a chosen feature
    needs a transcript and the user did not tick either producer, and it gets
    asked *again* if they ticked both -- two transcription steps writing to the
    same place is never what anyone meant.
    """
    providers: Dict[str, str] = {}
    picked = set(selected)

    # first thing's first: capabilities where the user ticked more than one
    # producer. these collide, so we need a choice even though nothing's
    # actually missing.
    for capability, description in _recipes.CAPABILITIES.items():
        options = _recipes.providers_of(capability)
        ticked = [o for o in options if o.id in picked]
        if len(options) < 2 or len(ticked) < 2:
            continue
        prompter.reason(
            f"You ticked more than one way to get {description}. "
            "Pick one — they would collide."
        )
        providers[capability] = prompter.select(
            f"How should Taters produce {description}?",
            [Choice(o.id, o.label, o.help) for o in ticked],
            default=ticked[0].id,
        )

    # then we handle capabilities the selection needs but never named. we
    # resolve these transitively -- ticking only "Readability scores" needs a
    # transcript, via the merge step, without ever mentioning one.
    for capability, options in pending_choices(
        [s for s in selected if s not in {o.id
                                          for cap, keep in providers.items()
                                          for o in _recipes.providers_of(cap)
                                          if o.id != keep}],
        source=source,
    ).items():
        prompter.reason(
            f"What you picked needs {_recipes.CAPABILITIES[capability]}."
        )
        providers[capability] = prompter.select(
            f"How should Taters produce {_recipes.CAPABILITIES[capability]}?",
            # grayed for the same reason as on the checklist: diarization on a
            # Python with no NeMo release is not a way to get a transcript
            [Choice(o.id, o.label, o.help, disabled=unavailable_reason(o))
             for o in options],
            default=options[0].id,
        )
    return providers

missing_extras

missing_extras(recipe)

Extras this step needs that are not importable right now.

Source code in src\taters\ui\wizard.py
774
775
776
777
778
779
780
781
def missing_extras(recipe: _recipes.Recipe) -> List[str]:
    """Extras this step needs that are not importable right now."""
    absent = []
    for extra in recipe.extras:
        probes = EXTRA_PROBES.get(extra, ())
        if any(importlib.util.find_spec(m) is None for m in probes):
            absent.append(extra)
    return absent

unavailable_reason

unavailable_reason(recipe)

Why this step cannot run on this Python at all, or "".

A missing extra is usually one pip install away, and the preflight offers to keep the step anyway for exactly that reason. But when our own metadata says the package has no release for this Python (gensim and NeMo on 3.14), no install will ever fix it here -- so the row is grayed out with that reason, rather than offered plainly and refused later. Short, because it has to fit beside the row's label.

Source code in src\taters\ui\wizard.py
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
def unavailable_reason(recipe: _recipes.Recipe) -> str:
    """
    Why this step cannot run on this Python at all, or ``""``.

    A missing extra is usually one pip install away, and the preflight
    offers to keep the step anyway for exactly that reason. But when our own
    metadata says the package has no release for this Python (gensim and
    NeMo on 3.14), no install will ever fix it here -- so the row is grayed
    out with that reason, rather than offered plainly and refused later.
    Short, because it has to fit beside the row's label.
    """
    from .tasks.gpu import unavailable_here

    stuck = [EXTRA_DISTS[e] for e in missing_extras(recipe)
             if e in EXTRA_DISTS and unavailable_here(EXTRA_DISTS[e])]
    if not stuck:
        return ""
    py = f"{sys.version_info.major}.{sys.version_info.minor}"
    return f"not available for Python {py} (no {' or '.join(stuck)} release yet)"

preflight

preflight(prompter, steps)

Check the machine can actually run what was chosen.

Returns:

Type Description
list[str]

Recipe ids to drop. Empty when everything is satisfied, or when the user chose to keep a step anyway -- they may be about to install the missing piece, and a preset that is slightly ahead of the machine is a perfectly reasonable thing to want.

Source code in src\taters\ui\wizard.py
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
def preflight(prompter: Prompter, steps: Sequence[_recipes.Recipe]) -> List[str]:
    """
    Check the machine can actually run what was chosen.

    Returns
    -------
    list[str]
        Recipe ids to drop. Empty when everything is satisfied, or when the
        user chose to keep a step anyway -- they may be about to install the
        missing piece, and a preset that is slightly ahead of the machine is a
        perfectly reasonable thing to want.
    """
    drop: List[str] = []

    if any(r.needs_ffmpeg for r in steps) and shutil.which("ffmpeg") is None:
        prompter.reason(
            "ffmpeg was not found on your PATH. Every pipeline here needs it to "
            "read media files. Install it from https://ffmpeg.org/download.html "
            "(or `sudo apt install ffmpeg` / `brew install ffmpeg`)."
        )
        try:
            carry_on = prompter.confirm("Carry on and write the pipeline anyway?",
                                        default=True)
        except GoBack:
            # Esc mid-preflight shouldn't cost us the whole session (this was
            # round-2 issue 32). every answer so far survives, and we fall back
            # to the default, since "not this question" is the harmless read.
            carry_on = True
        if not carry_on:
            raise Cancelled()

    for recipe in steps:
        absent = missing_extras(recipe)
        if not absent:
            continue

        # we don't escape this for rich, and that's intentional. as a `note`
        # this string went through rich, which reads `[vocalacoustics]` as a
        # style tag and printed the command as `pip install "taters"` -- looks
        # right, runs cleanly, installs nothing that was missing. a description
        # goes to prompt_toolkit as plain text instead, so the brackets survive
        # on their own (and a rich escape would show its backslash).
        install = " ".join(f'"taters[{extra}]"' for extra in absent)
        why = f"Not installed. To add it:  pip install {install}"
        # unless this Python can't have it at all. somebody on 3.14 followed
        # the pip command above, watched it succeed, and still had no gensim,
        # because our metadata (rightly) doesn't ask for it there. so we say
        # what's actually going on instead of handing out a no-op
        from .tasks.gpu import unavailable_here
        stuck = [EXTRA_DISTS[e] for e in absent
                 if e in EXTRA_DISTS and unavailable_here(EXTRA_DISTS[e])]
        if stuck:
            py = f"{sys.version_info.major}.{sys.version_info.minor}"
            why = (f"Not available for Python {py}: {' and '.join(stuck)} has no "
                   f"release for it yet, so pip would install nothing. Run Taters "
                   f"on Python 3.13 or older to use this step.")
        if "diarization" in absent:
            why += ("  ·  Diarization also needs three packages that only exist "
                    "on GitHub — see the install guide. It is the one heavy extra.")

        # a two-option list rather than a yes/no, so that the explanation can
        # ride along as the question's description. as a `note` it got printed
        # before the screen was drawn and ended up above the progress rail --
        # a whole paragraph of context floating a long way from its question.
        try:
            keep = str(prompter.select(
                f"Keep '{recipe.label}' in the pipeline?",
                [Choice("keep", "Yes, keep it", why),
                 Choice("drop", "No, leave it out", why)],
            ))
        except GoBack:
            keep = "keep"       # Esc means the harmless answer, not a reset
        if keep == "drop":
            drop.append(recipe.id)

    # now the library case. a step whose dictionaries come from an empty
    # library would compose just fine and then read nothing at run time. same
    # idea as the missing-extra check above -- we say so while there's still
    # time to do something about it, and offer the fix right here.
    from ..helpers.library import entries as _lib_entries, kind_by_id
    from .library import import_files

    for recipe in steps:
        if recipe.id in drop:
            continue
        for param_name, kind_id in recipe.library.items():
            if param_name in recipe.library_defaults:
                # optional (see Recipe.library_defaults). the step runs fine
                # without one, so an empty library isn't a problem we need to
                # fix.
                continue
            kind = kind_by_id(kind_id)
            while not _lib_entries(kind):
                prompter.reason(
                    f"'{recipe.label}' needs at least one of your "
                    f"{kind.label.lower()}, and you have none imported."
                )
                try:
                    what = str(prompter.select(
                        "What would you like to do?",
                        [Choice("import", "Import one now…", kind.help),
                         Choice("drop", f"Leave '{recipe.label}' out"),
                         Choice("keep", "Keep it anyway",
                                "The pipeline will fail at this step unless "
                                "you import one before running it.")],
                    ))
                except GoBack:
                    what = "keep"
                if what == "import":
                    # straight into the file browser. "Import one now" used to
                    # open the library *manager*, whose first question is --
                    # again -- whether to import files. so we had a menu asking
                    # what the user had just finished telling us. not great.
                    import_files(prompter, kind)
                    continue    # go back and check: did an import actually land?
                if what == "drop":
                    drop.append(recipe.id)
                break
    return drop

ask_level

ask_level(prompter, src, steps=())

Ask what one row of the results should describe.

The single most consequential answer in the whole wizard, and until now not a question at all: the level was fixed per recipe, differently for different recipes. Joining a speaker's utterances before measuring versus measuring each and averaging differ by about a third on vocabulary measures, so a level chosen on the user's behalf is a silent methodological decision in someone's results.

Asked once, for the whole pipeline, rather than per feature. Mixed levels would give feature tables with different row counts that cannot be joined on anything -- which is precisely the defect this replaced on the CSV path, where readability emitted a row per spreadsheet row while sentence embeddings emitted one per participant.

Returns:

Type Description
(level_id, group_by)

group_by is empty unless the chosen level leaves the columns to the user, which only a spreadsheet does.

Source code in src\taters\ui\wizard.py
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
def ask_level(prompter: Prompter, src: SourceSpec,
              steps: Sequence[_recipes.Recipe] = ()) -> Tuple[str, List[str]]:
    """
    Ask what one row of the results should describe.

    The single most consequential answer in the whole wizard, and until now not
    a question at all: the level was fixed per recipe, differently for
    different recipes. Joining a speaker's utterances before measuring versus
    measuring each and averaging differ by about a third on vocabulary
    measures, so a level chosen on the user's behalf is a silent methodological
    decision in someone's results.

    Asked once, for the whole pipeline, rather than per feature. Mixed levels
    would give feature tables with different row counts that cannot be joined
    on anything -- which is precisely the defect this replaced on the CSV path,
    where readability emitted a row per spreadsheet row while sentence
    embeddings emitted one per participant.

    Returns
    -------
    (level_id, group_by)
        ``group_by`` is empty unless the chosen level leaves the columns to the
        user, which only a spreadsheet does.
    """
    # nothing to decide if nothing in this pipeline measures text. a run of
    # acoustics alone has its own grain (one row per speaker's audio) that
    # the level doesn't set, and shouldn't, so we skip the question.
    if steps and not any(_recipes.level_aware(r) for r in steps):
        return _recipes.DEFAULT_LEVEL[src.source], []

    options = _recipes.levels_for(src.source)
    if len(options) < 2:
        # a folder of .txt files is already one text per file. nothing to
        # decide here, and a question with one answer just wastes a screen.
        return options[0].id, []

    # we used to ask "what is your unit of analysis?" here, and it's the wrong
    # phrase. that one belongs to the statistics -- a researcher reads it as
    # "the thing my tests treat as an observation" -- and using it here,
    # before statistics have even come up, made answering feel like settling
    # the analysis question (people told us so). what this actually decides is
    # the grain of the *feature tables*, so we ask about a row of results
    # instead.
    ids = {o.id for o in options}
    state: Dict[str, Any] = {
        # when we get re-asked (Esc from the analysis stage) we want the
        # earlier answer sitting under the pointer, not the catalog default.
        "level": src.level if src.level in ids else _recipes.DEFAULT_LEVEL[src.source],
        "group_by": list(src.group_by),
    }

    def ask_which_level() -> bool:
        state["level"] = str(prompter.select(
            "What should one row of results describe?",
            [Choice(o.id, o.label, o.help) for o in options],
            default=state["level"],
        ))
        return True

    def ask_group_columns() -> bool:
        spec = _recipes.level_by_id(src.source, state["level"])
        if spec.group_by is not None:
            state["group_by"] = []
            return False
        # only a spreadsheet gets here. its grouping columns are the user's
        # own, so the catalog can't name them for us.
        spoken_for = set(src.text_cols) | set(src.feature_cols)
        remaining = [c for c in src.columns if c not in spoken_for]
        if not remaining:
            # a single-column file, or every column ticked as text (or, in the
            # analyze flow, as a predictor). an empty checkbox isn't a
            # question (questionary crashes on one), and there's really only
            # one honest outcome here anyway.
            prompter.reason(
                "Every column is already being analyzed, so there is "
                "nothing left to group rows by. Using one row per "
                "spreadsheet row instead."
            )
            state["level"], state["group_by"] = \
                _recipes.DEFAULT_LEVEL[src.source], []
            return False
        try:
            _cols, sample = peek_csv(src.path, delimiter=src.delimiter)
        except Exception:
            sample = []
        state["group_by"] = ask_at_least_one(
            prompter, "Combine rows that share which column(s)?",
            _kind_rows(remaining, src.kinds, checked=state["group_by"]),
            thing="one column",
            cycle=_cycler(src.kinds, sample, remaining))
        return True

    _ask_in_order([ask_which_level, ask_group_columns])
    return state["level"], list(state["group_by"])

ask_analysis

ask_analysis(
    prompter, src, steps, required=False, picked=None
)

The optional statistics stage: what to test, on what, over which features.

Everything here is skippable -- the checklist takes an empty answer, unlike the feature one -- because the feature tables are the deliverable for plenty of runs and statistics are a bonus. When the source cannot support statistics at all, the stage says why instead of vanishing.

required=True is the "+ run analyses" flow, where the user has already said statistics are the point: the checklist then insists on an answer, because an empty one there is not a decision but a dead end.

Source code in src\taters\ui\wizard.py
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
def ask_analysis(prompter: Prompter, src: SourceSpec,
                 steps: Sequence[_recipes.Recipe],
                 required: bool = False,
                 picked: Optional[Sequence[str]] = None) -> AnalysisSpec:
    """
    The optional statistics stage: what to test, on what, over which features.

    Everything here is skippable -- the checklist takes an empty answer,
    unlike the feature one -- because the feature tables are the deliverable
    for plenty of runs and statistics are a bonus. When the source cannot
    support statistics at all, the stage says why instead of vanishing.

    ``required=True`` is the "+ run analyses" flow, where the user has
    already said statistics are the point: the checklist then insists on an
    answer, because an empty one there is not a decision but a dead end.
    """
    spec = AnalysisSpec()
    possible, why = _analysis_possible(src, steps)
    if not possible:
        # yellow, not dim. this is a thing you asked for and can't have, so
        # it's worth reading. it used to be dim, and a dim note gets drawn
        # above the *next* screen -- so you'd answer the question before this
        # one, land on the options screen, and it looked exactly like
        # statistics were never offered at all (we got a bug report saying
        # just that).
        prompter.note(f"\n  No statistics this time. {why}", style="yellow")
        spec.why_not = why
        return spec
    spec.offered = True

    # this is the same list the composer will wire into the assemble step,
    # named the same way. we used to build it separately, and the two
    # disagreed: the picker offered "Sentence embeddings" *and* "Merge
    # sentence embeddings" like they were two feature sets, when only one of
    # them was ever going to get joined (yep, someone noticed).
    tables = feature_tables(steps, src.source, src.level or None,
                            src.group_by, picked=picked)
    columns, sample = peek_csv(src.path, delimiter=src.delimiter)
    # the columns we've still got to describe a row with, i.e. not the text
    # itself.
    # what we have left to describe a row with: not the text itself, and not
    # the columns that *are* the measures in the analyze-a-spreadsheet flow.
    # a column cannot be both a predictor and the thing being predicted
    spare = [c for c in columns
             if c not in src.text_cols and c not in src.feature_cols]

    reason = ("Taters can also run the statistics for you, on the features "
              "it just extracted: differences between groups, correlations "
              "with an outcome. Results land in a stats_results folder as "
              "tidy tables plus a plain-English report."
              + ("" if required else
                 " Tick nothing to just get the feature tables."))
    if src.text_mode == "separate" and len(src.text_cols) > 1:
        reason += (" You asked for each text column to be measured "
                   "separately, so each is analyzed separately too -- one "
                   "set of results per column, which is what keeps one "
                   "person's several answers from counting as several "
                   "independent observations.")
    catalog = list(_recipes.user_facing(src.source, stage="analyze"))
    # what each column gets treated as, carried over from the source stage
    # (or detected right here for a spec built without it). `could_be` is the
    # wider question -- what a column can be *turned into* with the arrows --
    # and that's what decides whether an analysis is possible at all.
    kinds = src.kinds or {c: column_kind([r.get(c) for r in sample])
                          for c in columns}
    src.kinds = kinds

    def could_be(name: str, kind: str) -> bool:
        return kind in kind_options(kinds.get(name, "text"),
                                    [r.get(name) for r in sample])

    # offered as numbers: whatever holds numbers, or labels that all parse.
    # offered as labels: whatever is labels, or numbers that plausibly are
    # (few, repeating). any column of numbers can still be *made* into labels
    # with the arrows on an earlier picker (the kind then says so here), but
    # we don't go offering a column with a value per row as a group unasked.
    numeric = [c for c in spare if could_be(c, "numbers")]
    label_cols = [c for c in spare
                  if kinds.get(c) == "labels"
                  or (kinds.get(c) == "numbers"
                      and plausible_labels([r.get(c) for r in sample]))]
    blocked = _unrunnable(catalog, numeric, label_cols)

    def settle(name: str, kind: str) -> bool:
        """Treat `name` as `kind`, saying so; False when it cannot be."""
        if kinds.get(name) == kind:
            return True
        if could_be(name, kind):
            kinds[name] = kind
            prompter.note(f"  Treating {name} as {kind} for this.", style="dim")
            return True
        prompter.note(
            f"  {name} holds {kinds.get(name, 'text')} and cannot be treated "
            f"as {kind}"
            + (f": {len(set(str(r.get(name) or '').strip() for r in sample))}"
               f" distinct values in the sample is a measurement or an "
               f"identifier, not a set of categories." if kind == "labels"
               else "."), style="yellow")
        return False
    if blocked:
        # the row itself only has room to name what's missing, so this is
        # where we get to say that it's the spreadsheet that lacks it.
        reason += (" A grayed-out row needs a kind of column this "
                   "spreadsheet does not have"
                   + (": a column of numbers to relate the features to"
                      if not numeric else "")
                   + (", a column whose labels repeat" if not label_cols
                      else "") + ".")
    offered = [Choice(r.id, r.label, r.help, disabled=blocked.get(r.id, ""))
               for r in catalog]
    grouped = bool(src.group_by)
    # every question below is one step of `_ask_in_order`, so Esc means the
    # previous question and not the feature checklist. a step that doesn't
    # apply to what got ticked clears the answer it owns and asks nothing --
    # that way, going back to untick "Correlations" leaves no outcome column
    # lying around for `apply_analysis` to wire up later.
    state: Dict[str, Any] = {"stop": False, "controls": False, "chosen": []}

    # the checks below read whole columns, not the sample. a class of three
    # or an "n/a" on row 812 is invisible in 200 rows and fatal at the last
    # step. we read once per column, on demand. when rows are being combined
    # the raw column isn't what the analysis sees anyway, so we vet nothing.
    full: Dict[str, List[str]] = {}

    def column(name: str) -> Optional[List[str]]:
        if grouped:
            return None
        if name not in full:
            try:
                full.update(read_columns(src.path, [name],
                                         delimiter=src.delimiter))
            except Exception:
                return None
        return full.get(name)

    def vet(finding: Optional[_checks.Finding]) -> bool:
        """Show a finding; offer the fix when there is one. False means the
        question has to be asked again."""
        if finding is None:
            return True
        prompter.note(f"  {finding.message}", style="yellow")
        if finding.fatal:
            return False
        if not finding.keep:
            return True
        counts = _checks.level_counts(full.get(finding.column) or [])
        rows = [Choice(v, v, annotation=f"{n} row{'' if n == 1 else 's'}",
                       checked=v in finding.keep)
                for v, n in counts.most_common()]
        kept = ask_at_least_one(
            prompter, f"Which values of {finding.column} should stay in "
                      f"the analysis?", rows, thing="one value")
        if len(kept) == len(rows):
            spec.value_filters.pop(finding.column, None)
            prompter.note("  Keeping every row.", style="dim")
        else:
            spec.value_filters[finding.column] = kept
            prompter.note(f"  Filter: keep rows where {finding.column} is "
                          f"one of {', '.join(kept)}", style="dim")
        return True

    def ask_which() -> bool:
        prompter.reason(reason)
        rows = [Choice(c.value, c.label, c.help, checked=c.value in spec.analyses,
                       disabled=c.disabled) for c in offered]
        if required:
            picked = ask_at_least_one(
                prompter, "Which statistics do you want?", rows,
                thing="one analysis")
        else:
            picked = list(prompter.checkbox(
                "Run any statistics on the results? (optional)", rows))
        spec.analyses = picked
        state["stop"] = not picked
        return True

    def picked_labels(kind: Optional[str] = None,
                      only: Optional[str] = None) -> List[str]:
        """The analyses ticked that this question serves, by name."""
        return [_recipes.by_id(a).label for a in spec.analyses
                if (only is None or a == only)
                and (kind is None or _recipes.by_id(a).outcome_kind == kind)]

    def ask_group() -> bool:
        if state["stop"] or "stats_group_differences" not in spec.analyses:
            spec.group_col = ""
            return False
        prompter.reason(
            f"For {_and(picked_labels(only='stats_group_differences'))}. "
            "This is a different question from how the rows were combined. "
            "Combining decided what one row *is*; this decides what you want "
            "to *compare* -- a condition, a diagnosis, moderators against "
            "regular users. Every feature is then tested across those "
            "labels."
            + (f" Rows are being combined on "
               f"{', '.join(src.group_by)}, so a column can only be used "
               f"here if it has one value per combined row." if grouped
               else ""))
        while True:
            rows = _label_choices(src, sample, spare, kinds, label_cols)
            spec.group_col = str(prompter.select(
                "Which column separates the groups you want to compare?", rows,
                default=spec.group_col if any(
                    c.value == spec.group_col and not c.disabled for c in rows)
                else None,
                cycle=_cycler(kinds, sample, [c.value for c in rows])))
            if not settle(spec.group_col, "labels"):
                continue
            values = column(spec.group_col)
            if values is None or vet(_checks.small_groups(values,
                                                            spec.group_col)):
                return True

    def ask_outcomes() -> bool:
        if state["stop"] or not any(
                _recipes.by_id(a).outcome_kind == "numeric" for a in spec.analyses):
            spec.outcome_cols = []
            return False
        # no "what if there are no numeric columns" branch here. those rows
        # get grayed out when there are none, so if we've made it this far
        # `numeric` is non-empty. it used to be accept-then-drop, which left
        # the stage with nothing chosen.
        # we name the analyses this serves, and say what comes next. with a
        # classifier also ticked, this screen read as *the* outcome question,
        # so people picked their category column here (or hunted for it here
        # and came up empty), not knowing a separate question for categories
        # was one screen away.
        wants = picked_labels(kind="numeric")
        prompter.reason(
            f"For {_and(wants)}, which "
            f"{'relate' if len(wants) > 1 else 'relates'} the language to "
            "*numbers*: test scores, ratings, personality measures. Only "
            "columns that hold numbers are offered."
            + (" The classification model's categories are a separate "
               "question, asked next."
               if "stats_classify_fit" in spec.analyses else "")
            + (" Rows are being combined, so each outcome becomes the "
               "average for its group." if grouped else ""))
        while True:
            # only what holds numbers, or could be read as numbers. a column
            # of words has nothing to correlate, and the reason line above
            # already says as much.
            rows = _kind_rows(
                numeric, kinds, checked=spec.outcome_cols,
                help_of=lambda c: ", ".join(distinct_values(sample, c, limit=6))
                or "no values sampled")
            spec.outcome_cols = ask_at_least_one(
                prompter, "Which column(s) hold the outcomes?", rows,
                thing="one column",
                cycle=_cycler(kinds, sample, [c.value for c in rows]))
            if not all([settle(c, "numbers") for c in spec.outcome_cols]):
                continue
            findings = [_checks.non_numeric_outcome(column(c) or [], c)
                        for c in spec.outcome_cols if column(c) is not None]
            if all(vet(f) for f in findings if f is not None):
                return True

    def ask_classes() -> bool:
        if state["stop"] or "stats_classify_fit" not in spec.analyses:
            spec.class_cols = []
            return False
        prompter.reason(
            f"For {_and(picked_labels(only='stats_classify_fit'))}, which "
            "learns which *category* a row falls in rather than how much of "
            "something it has -- a diagnosis, a condition, which of two "
            "authors wrote it"
            + (". A different question from the outcomes you just chose: "
               "those were the numbers"
               if picked_labels(kind="numeric") else "")
            + ". Only columns whose labels repeat are offered, because a "
            "column of one-off values would be one class per row."
            + (" Rows are being combined, so the category has to describe "
               "the whole combined row." if grouped else ""))
        while True:
            rows = _label_choices(src, sample, spare, kinds, label_cols)
            for row in rows:
                row.checked = row.value in spec.class_cols
            spec.class_cols = ask_at_least_one(
                prompter, "Which column(s) hold the category to predict?", rows,
                thing="one column",
                cycle=_cycler(kinds, sample, [c.value for c in rows]))
            if not all([settle(c, "labels") for c in spec.class_cols]):
                continue
            findings = [_checks.thin_classes(column(c) or [], c)
                        for c in spec.class_cols if column(c) is not None]
            if all(vet(f) for f in findings if f is not None):
                return True

    def eligible_controls() -> List[str]:
        # not the identifier columns. `pid` is unique per row, so holding it
        # constant would either explain the outcome away entirely or mean
        # nothing, and offering it just invites that mistake. not the group
        # or the outcomes either -- controlling for the thing you're comparing
        # removes the comparison.
        used = (set(spec.outcome_cols) | set(spec.class_cols)
                | {spec.group_col} | set(src.id_cols))
        # a measurement can be a control, and so can a label that repeats.
        # what can't is a column of words unique to every row -- `pid`, a
        # response id, a filename. same test the grouping question uses, for
        # the same reason. `id_cols` on its own isn't enough here because a
        # run that declined to name its identifier columns still has one
        # sitting in the spreadsheet.
        return [c for c in spare
                if c not in used
                and kinds.get(c) in ("numbers", "labels")
                # on a combined run a control has to speak for the whole
                # combined row, same as a grouping column does.
                and (not grouped or c in src.group_by
                     or constant_within(sample, src.group_by, c))]

    def ask_controls_gate() -> bool:
        if state["stop"] or not eligible_controls():
            state["controls"] = False
            spec.control_cols, spec.categorical_controls = [], []
            return False
        prompter.reason(
            "A control is something you want held constant so it cannot "
            "explain your result: age, gender, how long the text is. Group "
            "comparisons become ANCOVA and report adjusted means, "
            "correlations become partial correlations, and the prediction "
            "step also fits the controls on their own -- so you can see "
            "what the language added over them rather than guessing.")
        state["controls"] = bool(prompter.confirm(
            "Control for any of your other columns?",
            default=bool(spec.control_cols)))
        if not state["controls"]:
            spec.control_cols, spec.categorical_controls = [], []
        return True

    def ask_which_controls() -> bool:
        if state["stop"] or not state["controls"]:
            return False
        prompter.reason(
            "Each column shows how it will be held constant: as numbers "
            "(a measurement) or as labels (a category, one coefficient per "
            "level). A category that happens to be numbered -- a 1/2 gender "
            "code -- reads as numbers; press → on its row to make it labels.")
        eligible = eligible_controls()
        spec.control_cols = ask_at_least_one(
            prompter, "Which column(s) should be held constant?",
            _kind_rows(eligible, kinds, checked=spec.control_cols,
                       help_of=lambda c: ", ".join(distinct_values(sample, c, limit=6))
                       or "no values sampled"),
            thing="one column", cycle=_cycler(kinds, sample, eligible))
        spec.categorical_controls = [c for c in spec.control_cols
                                     if kinds.get(c) == "labels"]
        return True

    def vet_controls() -> bool:
        """A categorical control's thin levels, offered for dropping. Asks
        only when there is something to say, so a clean column costs no
        screen."""
        if state["stop"] or not spec.categorical_controls:
            return False
        asked = False
        for c in spec.categorical_controls:
            values = column(c)
            finding = _checks.rare_levels(values, c) if values is not None \
                else None
            if finding is not None:
                asked = True
                vet(finding)
        return asked

    def ask_tables() -> bool:
        if state["stop"] or len(tables) <= 1:
            spec.tables = []
            state["chosen"] = [r.id for r, _ in tables]
            return False
        prompter.reason(
            "By default every feature table you extracted feeds the "
            "statistics together. You can narrow that -- and choose whether "
            "to analyze them as one set or one at a time.")
        pre = set(spec.tables) if spec.tables else {r.id for r, _ in tables}
        chosen = ask_at_least_one(
            prompter, "Which feature tables should feed the statistics?",
            [Choice(r.id, name, checked=r.id in pre) for r, name in tables],
            thing="one table")
        spec.tables = chosen if len(chosen) < len(tables) else []
        state["chosen"] = chosen
        return True

    def ask_together() -> bool:
        if state["stop"] or len(state["chosen"]) <= 1:
            spec.per_table = False
            return False
        spec.per_table = str(prompter.select(
            "Analyze them together, or one table at a time?",
            [Choice("together", "Together — one analysis over all of them",
                    "Features from every table side by side; a prediction "
                    "model is also fitted on each table alone and on every "
                    "combination, so you can see what each one adds."),
             Choice("separate", "Separately — one analysis per table",
                    "Repeats the analysis for each table, so you can "
                    "see which feature set does best.")],
            default="separate" if spec.per_table else "together")) == "separate"
        return True

    def ask_adjust() -> bool:
        if state["stop"] or not any(
                "p_adjust" in _recipes.by_id(a).with_ for a in spec.analyses):
            spec.p_adjust = "fdr_bh"
            return False
        from ..stats._common import P_ADJUST_METHODS

        prompter.reason(
            "Testing many features at once finds things by chance: 160 "
            "measures at p < .05 hands you eight 'findings' from noise "
            "alone. The correction decides what counts as a finding, and "
            "which one to use is a methodological choice, not a default "
            "worth hiding -- so it is asked, and recorded in the report.")
        spec.p_adjust = str(prompter.select(
            "How should p-values be corrected for multiple comparisons?",
            [Choice(key, _ADJUST_LABELS[key], words)
             for key, words in P_ADJUST_METHODS.items()],
            default=spec.p_adjust))
        return True

    def ask_row_filters() -> bool:
        if state["stop"]:
            spec.filters, spec.extra_features = [], []
            return False
        spec.filters, spec.extra_features = _ask_filters(
            prompter, spare, sample,
            tables=[name for r, name in tables
                    if not spec.tables or r.id in spec.tables],
            values_of=column, kinds=kinds)
        return True

    _ask_in_order([ask_which, ask_group, ask_outcomes, ask_classes,
                   ask_controls_gate, ask_which_controls, vet_controls,
                   ask_tables, ask_together, ask_adjust, ask_row_filters])
    return spec

apply_analysis

apply_analysis(spec, src, selected, var_values, overrides)

Write an :class:AnalysisSpec into the things compose reads.

The metadata columns are the subtle part. The statistics read a metadata table gathered from the same spreadsheet with the same identity, and when rows are being combined a numeric column cannot ride along untouched -- a group of twelve rows has twelve openness scores. Those become the group's average, named <column>_mean by the gatherer, so the outcome names handed to the analyses are rewritten to match. Getting that wrong is a step that runs happily and correlates nothing, so it is decided here, once, next to the reason.

Source code in src\taters\ui\wizard.py
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
def apply_analysis(spec: AnalysisSpec, src: SourceSpec,
                   selected: List[str], var_values: Dict[str, Any],
                   overrides: Dict[str, Dict[str, Any]]) -> None:
    """
    Write an :class:`AnalysisSpec` into the things ``compose`` reads.

    The metadata columns are the subtle part. The statistics read a metadata
    table gathered from the same spreadsheet with the same identity, and when
    rows are being combined a *numeric* column cannot ride along untouched --
    a group of twelve rows has twelve openness scores. Those become the
    group's average, named ``<column>_mean`` by the gatherer, so the outcome
    names handed to the analyses are rewritten to match. Getting that wrong
    is a step that runs happily and correlates nothing, so it is decided here,
    once, next to the reason.
    """
    if not spec.wanted:
        return

    grouped = bool(src.group_by)
    for analysis in spec.analyses:
        if analysis not in selected:
            selected.append(analysis)
    # a filter on a measure nothing produces is a run that dies at the last
    # step, so asking to filter on word count adds the step that counts them.
    # we add it as a *filter* table, not a feature one. wanting to drop short
    # texts isn't the same as wanting text length as a predictor, and a ridge
    # regression happily took it as one (this actually happened to someone).
    for recipe_id in spec.extra_features:
        if recipe_id not in selected:
            selected.append(recipe_id)
    if spec.extra_features:
        overrides.setdefault("stats_assemble", {})["filter_csvs"] = [
            "{{" + _recipes.by_id(rid).save_as + "}}"
            for rid in spec.extra_features]

    carry: List[str] = []
    agg: Dict[str, str] = {}

    if spec.group_col:
        var_values["stats_group_col"] = spec.group_col
        # a grouping column that *is* a grouping key is already sitting in
        # the metadata table as a key column, so if we carried it again we'd
        # write it twice under one heading.
        if spec.group_col not in src.group_by:
            carry.append(spec.group_col)

    if src.feature_cols:
        # the analyze-a-spreadsheet flow: the predictors are columns that are
        # already there. ungrouped we carry them through as they are; grouped
        # we average them, exactly as the outcomes below are averaged, and
        # they arrive in the table as <column>_mean
        if grouped:
            var_values["analysis_predictor_agg"] = {c: "mean"
                                                    for c in src.feature_cols}
            # averaging writes a count beside every column, plus one row
            # count for the group. those describe the combining rather than
            # the participant, so they go aside -- a predictor called
            # `f1_n` is the number of rows somebody had, and a model that
            # learns from it has learned about the data collection
            var_values["stats_bookkeeping_cols"] = (
                ["group_count"] + [f"{c}_n" for c in src.feature_cols])
        else:
            var_values["analysis_predictor_cols"] = list(src.feature_cols)

    if spec.outcome_cols:
        if grouped:
            agg = {c: "mean" for c in spec.outcome_cols}
            var_values["stats_outcome_cols"] = [f"{c}_mean"
                                                for c in spec.outcome_cols]
        else:
            carry += [c for c in spec.outcome_cols if c not in carry]
            var_values["stats_outcome_cols"] = list(spec.outcome_cols)

    if spec.class_cols:
        # we carry these raw even on a combined run, where a numeric outcome
        # gets averaged instead -- the mean of a diagnosis is not a diagnosis.
        # the gatherer leaves a column blank when it differs inside a group,
        # and the question only offered columns that don't, so what shows up
        # is one label per combined row.
        carry += [c for c in spec.class_cols if c not in carry]
        var_values["stats_class_cols"] = list(spec.class_cols)

    if spec.control_cols:
        var_values["stats_control_cols"] = list(spec.control_cols)
        if spec.categorical_controls:
            var_values["stats_categorical_controls"] = list(
                spec.categorical_controls)
        # a control has to make it to the analysis table like any other
        # metadata column, otherwise the step gets handed a column name that
        # isn't there. grouped runs keep the raw column rather than averaging
        # it -- the mean of a gender code is not a gender.
        carry += [c for c in spec.control_cols if c not in carry]

    if carry:
        var_values["stats_meta_carry"] = carry
    if agg:
        var_values["stats_meta_agg"] = agg
    if spec.per_table:
        var_values["stats_feature_sets"] = "per_table"
    if src.text_mode == "separate" and len(src.text_cols) > 1:
        # each text column got measured on its own, so we analyze each on its
        # own too. a participant has one row per column, and pooling them
        # would count one person's several answers as several independent
        # observations. the column the gather writes to say which is which
        # is `source_col`.
        var_values["stats_split_col"] = "source_col"
    if spec.p_adjust != "fdr_bh":
        var_values["stats_p_adjust"] = spec.p_adjust
    kept = [[col, "in", list(values)] for col, values in spec.value_filters.items()]
    if kept or spec.filters:
        var_values["stats_filters"] = kept + [list(f) for f in spec.filters]
    if spec.tables:
        # the composer's default is every selected feature table. narrowing
        # it down is just an ordinary per-step override, which outranks it.
        overrides.setdefault("stats_assemble", {})["feature_csvs"] = [
            "{{" + _recipes.by_id(rid).save_as + "}}" for rid in spec.tables]

is_wired

is_wired(recipe, name)

Is this parameter carrying data from an earlier step?

A recipe template like transcript_csv: "{{pick:diar.raw_files.csv}}" is the wiring that makes the pipeline a pipeline. Offering it as an editable option would let someone quietly disconnect their own run, so those parameters are withheld from the options screen entirely.

{{var:...}} templates are deliberately not wiring: they point at a named variable that exists precisely so it can be changed.

Source code in src\taters\ui\wizard.py
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
def is_wired(recipe: _recipes.Recipe, name: str) -> bool:
    """
    Is this parameter carrying data from an earlier step?

    A recipe template like ``transcript_csv: "{{pick:diar.raw_files.csv}}"`` is
    the wiring that makes the pipeline a pipeline. Offering it as an editable
    option would let someone quietly disconnect their own run, so those
    parameters are withheld from the options screen entirely.

    ``{{var:...}}`` templates are deliberately *not* wiring: they point at a
    named variable that exists precisely so it can be changed.
    """
    if name in NEVER_TUNABLE or name in recipe.hidden:
        return True
    value = recipe.with_.get(name)
    if not isinstance(value, str):
        return False
    return value.startswith("{{") and not value.startswith("{{var:")

shared_variables

shared_variables(steps)

The variables more than one step reads, and where to find a spec for each.

overwrite_existing is referenced by thirteen of fifteen recipes, and device, whisper_model and transcripts_dir by three apiece. They are one setting each, living in the preset's vars: block -- so offering them on every step's menu asks the same question over and over and implies an answer that is local when it is not. Changing device under "Transcript" changes it for the embeddings step too.

Which ones are shared is counted, not listed, so this stays right as recipes come and go.

Returns:

Type Description
dict

Variable name -> the (recipe, parameter) to borrow a description, widget and current value from. Any of the referencing steps would do; the first is taken so the order is stable.

Source code in src\taters\ui\wizard.py
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
def shared_variables(steps: Sequence[_recipes.Recipe]) -> Dict[str, Tuple[_recipes.Recipe, str]]:
    """
    The variables more than one step reads, and where to find a spec for each.

    ``overwrite_existing`` is referenced by thirteen of fifteen recipes, and
    ``device``, ``whisper_model`` and ``transcripts_dir`` by three apiece. They
    are one setting each, living in the preset's ``vars:`` block -- so offering
    them on every step's menu asks the same question over and over and implies
    an answer that is local when it is not. Changing ``device`` under
    "Transcript" changes it for the embeddings step too.

    Which ones are shared is counted, not listed, so this stays right as recipes
    come and go.

    Returns
    -------
    dict
        Variable name -> the (recipe, parameter) to borrow a description,
        widget and current value from. Any of the referencing steps would do;
        the first is taken so the order is stable.
    """
    uses: Dict[str, int] = {}
    owner: Dict[str, Tuple[_recipes.Recipe, str]] = {}
    for recipe in steps:
        for name in recipe.with_:
            var = _var_behind(recipe, name)
            if var is None:
                continue
            # we count this even where the parameter itself is held back.
            # `root_dir` on a merge step is wiring nobody should be re-pointing
            # by hand, but it still reads `{{var:transcripts_dir}}` -- so the
            # variable really is shared, and editing it under the step that
            # *does* offer it moves both. if we only counted the offered ends
            # we'd hide that.
            uses[var] = uses.get(var, 0) + 1
            if var not in owner and not is_wired(recipe, name):
                owner[var] = (recipe, name)
    return {var: owner[var] for var, n in uses.items() if n > 1 and var in owner}

step_rows

step_rows(
    recipe,
    spec,
    var_specs,
    overrides,
    var_values,
    shared=(),
)

One step's settings as menu rows: ordered, gated, indented.

Source code in src\taters\ui\wizard.py
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
def step_rows(recipe: _recipes.Recipe, spec, var_specs: Dict[str, dict],
              overrides: Dict[str, Dict[str, Any]], var_values: Dict[str, Any],
              shared: Sequence[str] = ()) -> List[Choice]:
    """One step's settings as menu rows: ordered, gated, indented."""
    everyday, rest = _editable_names(recipe, spec)
    names = _menu_order(recipe, everyday, rest)
    shared = set(shared)
    rows: List[Choice] = []
    for name in names:
        if spec.get(name) is None or not _gate_open(recipe, spec, name, var_specs,
                                                    overrides, var_values):
            continue
        gate = _recipes.gate_of(recipe, name)
        rows.append(_setting_choice(
            recipe, spec.get(name), var_specs, overrides, var_values,
            shared_marker=_var_behind(recipe, name) in shared,
            indent=gate is not None and gate[0] in names))
    return rows

shared_rows

shared_rows(
    shared, var_specs, overrides, var_values, prompter=None
)

The shared section's rows -- (var, owner, param, choice) -- under the same gates as the steps' own menus.

A shared setting is one row for several steps, so it is gated by the step that lends it its spec (the owner). A dependent shared row sits indented under its gate's shared row when that row exists.

Source code in src\taters\ui\wizard.py
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
def shared_rows(shared: Dict[str, Tuple[_recipes.Recipe, str]], var_specs, overrides,
                var_values, prompter=None) -> List[Tuple[str, _recipes.Recipe, Any, Choice]]:
    """
    The shared section's rows -- ``(var, owner, param, choice)`` -- under the
    same gates as the steps' own menus.

    A shared setting is one row for several steps, so it is gated by the
    step that lends it its spec (the owner). A dependent shared row sits
    indented under its gate's shared row when that row exists.
    """
    entries = []
    for var, (recipe, param_name) in shared.items():
        spec = _spec_for(recipe, prompter)
        param = spec.get(param_name) if spec else None
        if param is None:
            continue
        if not _gate_open(recipe, spec, param_name, var_specs, overrides, var_values):
            continue
        gate = _recipes.gate_of(recipe, param_name)
        gate_var = _var_behind(recipe, gate[0]) if gate else None
        entries.append((var, recipe, param, gate_var))
    present = {var for var, *_ in entries}
    ordered = []
    for var, recipe, param, gate_var in entries:
        if gate_var in present:
            continue
        ordered.append((var, recipe, param, False))
        ordered.extend((v, r, p, True) for v, r, p, g in entries if g == var)
    return [(var, recipe, param,
             _setting_choice(recipe, param, var_specs, overrides, var_values,
                             key=var, indent=indent))
            for var, recipe, param, indent in ordered]

tune_shared

tune_shared(
    prompter,
    shared,
    var_specs,
    overrides,
    var_values,
    tables=None,
)

Change the settings that belong to the pipeline rather than to one step.

Source code in src\taters\ui\wizard.py
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
def tune_shared(
    prompter: Prompter,
    shared: Dict[str, Tuple[_recipes.Recipe, str]],
    var_specs: Dict[str, dict],
    overrides: Dict[str, Dict[str, Any]],
    var_values: Dict[str, Any],
    tables: Optional[Callable[[], List[Tuple[str, str]]]] = None,
) -> None:
    """Change the settings that belong to the pipeline rather than to one step."""
    if not shared_rows(shared, var_specs, overrides, var_values, prompter):
        return

    while True:
        rows = shared_rows(shared, var_specs, overrides, var_values, prompter)
        choices = [choice for _var, _recipe, _param, choice in rows]
        choices.insert(0, Choice(_DONE, "✓ Done with shared settings",
                                 tone="good"))

        prompter.note("")
        try:
            picked = str(prompter.select(f"{_SHARED_LABEL} — change a setting:",
                                         choices))
        except GoBack:
            return          # up one level, to the list of steps
        if picked == _DONE:
            return
        for var, recipe, param, _choice in rows:
            if var == picked:
                _edit(prompter, recipe, param, var_specs, overrides, var_values,
                      tables=tables)
                break

tune_one_step

tune_one_step(
    prompter,
    recipe,
    var_specs,
    overrides,
    var_values,
    shared=(),
    tables=None,
)

Work through one step's settings, one at a time, until the user says stop.

A menu rather than a march through every parameter in turn. Being asked eleven questions to change one is the reason the old flow gated itself behind "do you want the common options or all of them?" -- a question nobody can answer before they have seen either list. Showing the list, with the current values on it, answers itself.

Source code in src\taters\ui\wizard.py
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
def tune_one_step(
    prompter: Prompter,
    recipe: _recipes.Recipe,
    var_specs: Dict[str, dict],
    overrides: Dict[str, Dict[str, Any]],
    var_values: Dict[str, Any],
    shared: Sequence[str] = (),
    tables: Optional[Callable[[], List[Tuple[str, str]]]] = None,
) -> None:
    """
    Work through one step's settings, one at a time, until the user says stop.

    A menu rather than a march through every parameter in turn. Being asked
    eleven questions to change one is the reason the old flow gated itself
    behind "do you want the common options or all of them?" -- a question
    nobody can answer before they have seen either list. Showing the list, with
    the current values on it, answers itself.
    """
    # we say so before reading, not after. reading a step's settings means
    # importing the module that implements it, and a module built on a
    # pre-trained model can take seconds to load however carefully its imports
    # are arranged. without a line here the screen just sits there blank, and
    # the only way to read that is "the program hung" -- which is exactly how
    # it got reported to us.
    if recipe.target not in _SPEC_CACHE:
        prompter.working(f"Reading {recipe.label}'s settings…")
    spec = _spec_for(recipe, prompter)
    if spec is None:
        return

    everyday, rest = _editable_names(recipe, spec)
    if not everyday and not rest:
        prompter.note(f"    {recipe.label} has nothing to change.", style="dim")
        return

    # settings bound to shared variables stay on this menu. when they were
    # missing it read as "doesn't exist" (someone hunting for `lemmatize`
    # under the frequency list concluded exactly that), so we keep them here,
    # marked, and edit them as the one pipeline-wide value they are.
    shared = set(shared)

    def _is_shared(name: str) -> bool:
        return _var_behind(recipe, name) in shared

    while True:
        # every setting, with dependents ordered under their gates and gated
        # against the live values. we re-evaluate on each repaint -- see
        # `step_rows`.
        choices = step_rows(recipe, spec, var_specs, overrides, var_values,
                            shared)
        choices.insert(0, Choice(_DONE, f"✓ Done with {recipe.label}",
                                 tone="good"))
        if recipe.text_input:
            # we say that the input plumbing exists and where it went, rather
            # than hiding it silently -- "not on the menu" reads as "doesn't
            # exist" (the lesson the shared variables taught us earlier).
            handled = sorted(
                n for n in _recipes.TEXT_INPUT_KEYS
                if spec.get(n) is not None and n not in NEVER_TUNABLE)
            if handled:
                choices.append(Choice(
                    "_pipeline_handled_", "…input & gathering settings",
                    disabled=f"{len(handled)} set by the gather step, not here",
                ))

        prompter.note("")
        try:
            picked = str(prompter.select(f"{recipe.label} — change a setting:",
                                         choices))
        except GoBack:
            return          # up one level, to the list of steps
        if picked == _DONE:
            return

        param = spec.get(picked)
        if param is not None:
            _edit(prompter, recipe, param, var_specs, overrides, var_values,
                  is_shared=_is_shared(picked), tables=tables)

ask_tuning

ask_tuning(
    prompter,
    steps,
    var_specs,
    overrides=None,
    var_values=None,
    ask_gate=True,
    source="media",
    level=None,
    group_by=(),
    selected=None,
)

Offer to change the pipeline's settings, and collect what was changed.

Two nested menus, each with a way back up:

Which part of the pipeline?  ->  Which setting?  ->  answer it
      ^                              |                  |
      +---- "Done with X" -----------+                  |
      ^                                                 |
      +-------------------------------------------------+

Returning to the list rather than leaving is the point. Changing one step's settings is not evidence that you are finished with the whole screen -- the previous shape asked once, up front, which steps you wanted (a checkbox), walked those in order, and then went straight to saving. Someone who picked the shared settings, changed one, and pressed "Done with shared settings" found their pipeline being written, with no way back to the step they had not thought of yet.

Settings shared by several steps are gathered into one entry of their own rather than repeated under each. See :func:shared_variables.

Parameters:

Name Type Description Default
overrides dict

What has been changed already. Passed in and mutated in place so that leaving this screen and coming back keeps the work: they used to be built fresh on every call, so backing out of the question after it silently threw away every setting the user had just made.

None
var_values dict

What has been changed already. Passed in and mutated in place so that leaving this screen and coming back keeps the work: they used to be built fresh on every call, so backing out of the question after it silently threw away every setting the user had just made.

None

Returns:

Type Description
(overrides, var_values)

The same two dicts, for callers that would rather read than mutate. overrides is per-step, keyed by recipe id, and goes into the step's with: block. var_values goes into the preset's vars: block. See :func:_current_value for which changes end up where.

Raises:

Type Description
GoBack

If the user backs out of the top of this screen. Raised rather than returned: returning meant Esc here carried on to naming the pipeline, so Esc at naming and Esc here bounced between the two screens with no way out in either direction.

Source code in src\taters\ui\wizard.py
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
def ask_tuning(
    prompter: Prompter,
    steps: Sequence[_recipes.Recipe],
    var_specs: Dict[str, dict],
    overrides: Optional[Dict[str, Dict[str, Any]]] = None,
    var_values: Optional[Dict[str, Any]] = None,
    ask_gate: bool = True,
    source: str = "media",
    level: Optional[str] = None,
    group_by: Sequence[str] = (),
    selected: Optional[Sequence[str]] = None,
) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, Any]]:
    """
    Offer to change the pipeline's settings, and collect what was changed.

    Two nested menus, each with a way back up:

        Which part of the pipeline?  ->  Which setting?  ->  answer it
              ^                              |                  |
              +---- "Done with X" -----------+                  |
              ^                                                 |
              +-------------------------------------------------+

    Returning to the list rather than leaving is the point. Changing one step's
    settings is not evidence that you are finished with the whole screen -- the
    previous shape asked once, up front, which steps you wanted (a checkbox),
    walked those in order, and then went straight to saving. Someone who picked
    the shared settings, changed one, and pressed "Done with shared settings"
    found their pipeline being written, with no way back to the step they had
    not thought of yet.

    Settings shared by several steps are gathered into one entry of their own
    rather than repeated under each. See :func:`shared_variables`.

    Parameters
    ----------
    overrides, var_values : dict, optional
        What has been changed already. Passed in and **mutated in place** so
        that leaving this screen and coming back keeps the work: they used to
        be built fresh on every call, so backing out of the question after it
        silently threw away every setting the user had just made.

    Returns
    -------
    (overrides, var_values)
        The same two dicts, for callers that would rather read than mutate.
        ``overrides`` is per-step, keyed by recipe id, and goes into the step's
        ``with:`` block. ``var_values`` goes into the preset's ``vars:`` block.
        See :func:`_current_value` for which changes end up where.

    Raises
    ------
    GoBack
        If the user backs out of the top of this screen. Raised rather than
        returned: returning meant Esc here carried *on* to naming the pipeline,
        so Esc at naming and Esc here bounced between the two screens with no
        way out in either direction.
    """
    overrides = {} if overrides is None else overrides
    var_values = {} if var_values is None else var_values

    if not steps:
        return overrides, var_values

    shared = shared_variables(steps)

    def tables() -> List[Tuple[str, str]]:
        # the feature tables this run will make, named the way the analyses
        # will see them. we read these at the moment of asking, since renaming
        # an output or changing the matrix weighting a row earlier changes the
        # names. (`selected`, not `picked`: the menu loop below rebinds
        # `picked` to the row chosen, and a closure reads the name at call
        # time.)
        return table_names(steps, source, level, group_by, selected,
                           overrides=overrides, var_values=var_values,
                           var_specs=var_specs)

    # only worth asking while the answer is still in doubt. coming back with
    # changes already made, it's obviously yes, and re-asking put the user
    # one screen further from the list they were actually working in -- this
    # is what made backing out of naming land somewhere so odd.
    if ask_gate and not (overrides or var_values):
        prompter.note("")
        prompter.note("  Your pipeline is ready to run as it is. You can also "
                      "change how any of its steps work.", style="dim")
        if not prompter.confirm("Change any settings first?", default=False):
            return overrides, var_values

    while True:
        total = sum(len(v) for v in overrides.values()) + len(var_values)
        choices: List[Choice] = [Choice(
            _DONE,
            "✓ Done — save and continue" if not total
            else f"✓ Done — save {_plural(total, 'change')} and continue",
            tone="good",
        )]
        if shared:
            changed = _changed_count(_SHARED, list(shared), overrides, var_values)
            choices.append(Choice(
                _SHARED, _label_with_count(_SHARED_LABEL, changed),
                help=", ".join(v.replace("_", " ") for v in shared),
            ))
        for recipe in steps:
            # variables in `shared` get counted against the shared entry and
            # nowhere else. a step *reads* `whisper_model`, but that's not
            # where you changed it -- if we credited both, the row counts
            # wouldn't add up to the total on the "Done" line, which looks like
            # a bug.
            names = [var for var in (_var_behind(recipe, n) for n in recipe.with_)
                     if var and var not in shared]
            changed = _changed_count(recipe.id, names, overrides, var_values)
            choices.append(Choice(recipe.id,
                                  _label_with_count(recipe.label, changed),
                                  help=recipe.help))

        prompter.note("")
        try:
            picked = str(prompter.select("What would you like to change?", choices))
        except GoBack:
            # this is the top of this screen, so back means out of it -- and
            # out with the changes intact, since they live in the caller's
            # dicts. we re-raise rather than return so that "back" is actually
            # backwards.
            raise
        if picked == _DONE:
            return overrides, var_values
        if picked == _SHARED:
            tune_shared(prompter, shared, var_specs, overrides, var_values,
                        tables=tables)
            continue
        recipe = next((r for r in steps if r.id == picked), None)
        if recipe is not None:
            tune_one_step(prompter, recipe, var_specs, overrides, var_values,
                          shared, tables=tables)

review

review(prompter, preset, selected, inputs)

Show the composed pipeline as a table before anything is written.

Source code in src\taters\ui\wizard.py
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
def review(prompter: Prompter, preset: dict, selected: Sequence[str],
           inputs: Sequence[Path]) -> None:
    """Show the composed pipeline as a table before anything is written."""
    picked = set(selected)
    rows = []
    for i, step in enumerate(preset["steps"], 1):
        recipe = _recipe_behind(step)
        if recipe is None:
            # a step the catalog doesn't know about. `next(...)` here used to
            # have no default, so this raised StopIteration on the happy path
            # of every run that composed one -- and the composer now splices in
            # private steps that no recipe owns, to extract a saved model's
            # features at the settings the model was fitted with.
            rows.append([
                str(i),
                step.get("label") or step.get("call", "").split(".")[-1],
                "each file" if step["scope"] == "item" else "once",
                step.get("why") or "",
                f"the model {step['for_model']!r} needs it"
                if step.get("for_model") else "added for you",
            ])
            continue
        rows.append([
            str(i),
            recipe.label,
            "each file" if step["scope"] == "item" else "once",
            _measures(recipe, step),
            "you picked" if recipe.id in picked else "added for you",
        ])
    prompter.note("")
    prompter.table(
        f"{preset['meta']['title']}{_plural(len(preset['steps']), 'step')} "
        f"over {_plural(len(inputs), 'file')}",
        rows,
        ["#", "Step", "Runs", "Measures", "Why"],
    )

pipeline_folder

pipeline_folder(preset, cwd)

The folder this pipeline owns: <cwd>/<id>/.

Everything a run produces goes in here -- the preset itself, features/, transcripts/, the manifest. One folder per pipeline beats one shared features/ that three different runs quietly overwrite in turn, and it means a whole analysis can be zipped up and sent to someone.

run_pipeline._get_preset_dirs() recognizes a directory holding a YAML of the same name, so the pipeline is still visible to --list-presets.

Source code in src\taters\ui\wizard.py
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
def pipeline_folder(preset: dict, cwd: Path) -> Path:
    """
    The folder this pipeline owns: ``<cwd>/<id>/``.

    Everything a run produces goes in here -- the preset itself, ``features/``,
    ``transcripts/``, the manifest. One folder per pipeline beats one shared
    ``features/`` that three different runs quietly overwrite in turn, and it
    means a whole analysis can be zipped up and sent to someone.

    ``run_pipeline._get_preset_dirs()`` recognizes a directory holding a YAML
    of the same name, so the pipeline is still visible to ``--list-presets``.
    """
    return Path(cwd) / str(preset["meta"]["id"])

write_preset

write_preset(preset, directory)

Write the preset to <directory>/<id>.yaml.

The filename matching the folder name is what makes the folder recognisable as a pipeline folder rather than any old directory with YAML in it.

Source code in src\taters\ui\wizard.py
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
def write_preset(preset: dict, directory: Path) -> Path:
    """
    Write the preset to ``<directory>/<id>.yaml``.

    The filename matching the folder name is what makes the folder recognisable
    as a pipeline folder rather than any old directory with YAML in it.
    """
    from ..helpers.atomic import atomic_write

    directory.mkdir(parents=True, exist_ok=True)
    path = directory / f"{preset['meta']['id']}.yaml"
    # atomic, because a Ctrl-C mid-write leaves a truncated YAML -- which the
    # tolerant preset lister still shows, and which then crashes whatever
    # opens it. the helper exists for exactly this.
    with atomic_write(path, encoding="utf-8") as fh:
        fh.write(yaml.safe_dump(preset, sort_keys=False, allow_unicode=True,
                                width=88))
    return path

default_workers

default_workers()

A sensible number of files to work on at once.

Three-quarters of the machine's logical cores -- the same "leave a quarter for the human" policy the runner's automatic resolve uses (helpers.parallel_map.auto_workers), so the wizard's recommendation and what workers: 0 actually does are the same number.

Source code in src\taters\ui\wizard.py
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
def default_workers() -> int:
    """
    A sensible number of files to work on at once.

    Three-quarters of the machine's logical cores -- the same "leave a
    quarter for the human" policy the runner's automatic resolve uses
    (`helpers.parallel_map.auto_workers`), so the wizard's recommendation
    and what `workers: 0` actually does are the same number.
    """
    from ..helpers.parallel_map import auto_workers

    return auto_workers()

ask_workers

ask_workers(prompter, *, fans_out)

How many files to process at once.

Only asked when the pipeline fans out over files (fans_out: it has item-scoped steps). Text pipelines parallelize too -- the analyzers spend the same shared workers variable on reader/scorer processes -- but their right default is automatic, so instead of a question they get 0 ("let the dial decide"): the runner resolves it to one process per core, and the setting stays editable under Shared settings. Taken as a bool rather than a recipe list so a saved preset, which is plain dicts, can ask the very same question.

Source code in src\taters\ui\wizard.py
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
def ask_workers(prompter: Prompter, *, fans_out: bool) -> int:
    """
    How many files to process at once.

    Only asked when the pipeline fans out over files (`fans_out`: it has
    item-scoped steps). Text pipelines parallelize too -- the analyzers spend
    the same shared ``workers`` variable on reader/scorer processes -- but
    their right default is automatic, so instead of a question they get 0
    ("let the dial decide"): the runner resolves it to one process per core,
    and the setting stays editable under Shared settings. Taken as a bool
    rather than a recipe list so a *saved* preset, which is plain dicts, can
    ask the very same question.
    """
    if not fans_out:
        return 0

    from ..helpers.parallel_map import max_workers

    suggested = default_workers()
    ceiling = max_workers()
    choices = [
        Choice(str(suggested), f"{suggested} at a time",
               f"recommended: three-quarters of this machine's {ceiling} "
               "cores, leaving some for you"),
        Choice("1", "One at a time", "slowest, but easiest to read if something fails"),
    ]
    if ceiling > suggested:
        choices.append(Choice(
            str(ceiling), f"{ceiling} at a time",
            "everything this machine has; other programs will feel it"))
    picked = prompter.select("How many files should Taters work on at once?", choices)
    try:
        # we clamp whatever gets typed in to what the machine can hold: 1 up
        # to its logical core count. past the cores, more processes is just
        # overhead.
        return min(ceiling, max(1, int(str(picked))))
    except ValueError:
        return suggested

repro_command

repro_command(
    preset_path,
    *,
    root_dir,
    file_type,
    workers,
    overrides=None
)

The exact terminal command that reproduces a TUI run.

Everything the menus decided is spelled out -- the preset file by path, the inputs, the worker count, and any variable changed at run time -- so re-running from a shell is a paste, not an afternoon of reverse-engineering the clicks. Written into the run manifest, which is where someone looks when they ask "what exactly ran here?".

Source code in src\taters\ui\wizard.py
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
def repro_command(preset_path: Path, *, root_dir, file_type: str,
                  workers: int, overrides: Optional[dict] = None) -> str:
    """
    The exact terminal command that reproduces a TUI run.

    Everything the menus decided is spelled out -- the preset file by path, the
    inputs, the worker count, and any variable changed at run time -- so
    re-running from a shell is a paste, not an afternoon of reverse-engineering
    the clicks. Written into the run manifest, which is where someone looks
    when they ask "what exactly ran here?".
    """
    def q(value) -> str:
        text = str(value)
        return f'"{text}"' if (" " in text or not text) else text

    parts = ["python -m taters.pipelines.run_pipeline",
             f"--preset-file {q(preset_path)}"]
    if root_dir is not None:
        parts.append(f"--root_dir {q(root_dir)}")
        parts.append(f"--file_type {file_type}")
    parts.append(f"--workers {workers}")
    for key, value in (overrides or {}).items():
        parts.append(f"--var {q(f'{key}={value}')}")
    return " ".join(parts)

execute_preset

execute_preset(
    prompter,
    preset,
    *,
    root_dir,
    file_type,
    workers,
    work_dir,
    preset_name,
    vars_ctx=None,
    command=None
)

Run a composed or saved preset with the live display, and finish properly.

The one path a run takes, whoever starts it. This block used to exist twice -- here and in "Run a saved pipeline" -- and the copies had already drifted: the wizard asked how many files to work on at once while the saved-pipeline path silently hardcoded four, so the same pipeline ran with different parallelism depending on which menu launched it.

Returns:

Type Description
(ok, manifest)
Source code in src\taters\ui\wizard.py
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
def execute_preset(prompter: Prompter, preset: dict, *, root_dir, file_type,
                   workers: int, work_dir: Path, preset_name: str,
                   vars_ctx: Optional[dict] = None,
                   command: Optional[str] = None) -> Tuple[bool, dict]:
    """
    Run a composed or saved preset with the live display, and finish properly.

    The one path a run takes, whoever starts it. This block used to exist
    twice -- here and in "Run a saved pipeline" -- and the copies had already
    drifted: the wizard asked how many files to work on at once while the
    saved-pipeline path silently hardcoded four, so the same pipeline ran with
    different parallelism depending on which menu launched it.

    Returns
    -------
    (ok, manifest)
    """
    from ..pipelines.run_pipeline import run_preset, summarize_manifest
    from .run_display import RunDisplay, reporter_for

    prompter.note("")
    manifest_path = work_dir / "run_manifest.json"

    kwargs = dict(
        root_dir=root_dir,
        file_type=file_type,
        workers=workers,
        out_manifest=manifest_path,
        work_dir=work_dir,
        preset_name=preset_name,
        command=command,
        verbose=False,
    )
    if vars_ctx is not None:
        kwargs["vars_ctx"] = vars_ctx

    console = getattr(prompter, "_console", None)
    display = RunDisplay(console) if console is not None else None
    if display is not None:
        with display:
            manifest = run_preset(preset, on_event=reporter_for(display),
                                  **kwargs)
        failures = display.failures
    else:
        manifest = run_preset(preset, on_event=_progress_reporter(prompter),
                              **kwargs)
        failures = []

    ok = bool(summarize_manifest(manifest, verbose=False))
    prompter.stage("run", "Run", status="done",
                   detail="ok" if ok else "with problems")
    finish_screen(prompter, ok=ok, manifest=manifest, folder=work_dir,
                  manifest_path=manifest_path, failures=failures)
    return ok, manifest

finish_screen

finish_screen(
    prompter,
    *,
    ok,
    manifest,
    folder,
    manifest_path,
    failures=()
)

Report the outcome and ask what to do next.

A run that simply stops, leaving the last progress bar on screen, gives no sense of having finished and no idea where the results went. This says what happened, names the folder, and offers the two things anyone wants at that point: do something else, or stop.

Raises:

Type Description
QuitRequested

If the user chooses to finish.

Source code in src\taters\ui\wizard.py
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
3501
3502
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
3548
3549
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
3563
3564
3565
3566
3567
def finish_screen(prompter: Prompter, *, ok: bool, manifest: dict,
                  folder: Optional[Path], manifest_path: Path,
                  failures: Sequence[str] = ()) -> None:
    """
    Report the outcome and ask what to do next.

    A run that simply stops, leaving the last progress bar on screen, gives no
    sense of having finished and no idea where the results went. This says what
    happened, names the folder, and offers the two things anyone wants at that
    point: do something else, or stop.

    Raises
    ------
    QuitRequested
        If the user chooses to finish.
    """
    items = manifest.get("items") or []
    failed = [i for i in items if i.get("status") == "error"]

    if ok:
        prompter.note("\n  ✓ Finished. Everything succeeded.", style="green")
    else:
        detail = f"{len(failed)} file(s) failed" if failed else "a step failed"
        prompter.note(f"\n  ✗ Finished with problems: {detail}.",
                      style="bold red")

    # per-file failures first, then step-level ones. the step-level list is
    # where a failed GLOBAL step's message lives, and it used to get shown
    # nowhere -- the screen said "a step failed", the folder sat empty, and
    # the one line explaining why was only in the manifest JSON.
    # ...and we shorten each one. a step that refuses because a column is
    # missing can name every column it *does* have, and we had one
    # sentence-embedding table turn that into a thousand names -- printed
    # four times over, since the step-level list and the manifest both carry
    # it. the whole message is in the manifest, which the next line points at.
    seen: set = set()
    for line in list(failures)[:5]:
        seen.add(str(line))
        prompter.note(f"      {_shortened(line)}", style="red")
    if len(failures) > 5:
        prompter.note(f"      …and {len(failures) - 5} more.", style="red")
    for line in list(manifest.get("errors") or [])[:5]:
        # the same failure usually shows up twice -- once from the display,
        # once from the manifest -- and saying it twice looks like two
        # problems.
        if str(line) in seen:
            continue
        prompter.note(f"      {_shortened(line)}", style="red", wrap=True)

    if folder is not None:
        prompter.note(f"\n  Everything from this run is in:\n    {folder}",
                      style="cyan", wrap=False)
        outputs = _outputs_in(folder)
        for line in outputs:
            prompter.note(f"      {line}", style="dim")
    prompter.note(f"\n  Full details: {manifest_path}", style="dim", wrap=False)

    # the verdict rides on the question itself. the red lines above can
    # scroll past or sit above a busy folder listing; the question is the one
    # line the eye is guaranteed to land on (the cursor is there). we had a
    # failed run get read as a success because everything above it was.
    # a run that saved a model -- a ridge, a topic model, word vectors, a
    # fine-tuned predictor, an adapted encoder -- can hand it to the library
    # from here, so the next study can pick it. we make that a row on the
    # question rather than a question of its own, so the screen keeps the
    # same shape for a run that produced nothing worth keeping.
    from ..helpers.model_spec import models_produced

    produced = models_produced(folder) if folder is not None else []
    while True:
        question = "What now?" if ok else \
            "That run had problems (details above). What now?"
        rows = [Choice("menu", "Do something else", "Back to the main menu")]
        if produced:
            rows.append(Choice(
                "keep", f"Add {_plural(len(produced), 'model')} from this "
                        f"run to my library",
                ", ".join(label for _p, label in produced[:4])
                + (" …" if len(produced) > 4 else ""), tone="good"))
        rows.append(Choice("quit", "Finish", "Close Taters"))
        try:
            choice = str(prompter.select(question, rows))
        except (GoBack, Cancelled):
            # there's nothing above this question to go back to -- the run
            # already happened. Esc used to climb to the hub, which printed
            # "Backed out. Nothing was changed." over a folder full of fresh
            # outputs and threw the run's verdict away, so a failed run
            # exited 0. oops.
            choice = "menu"
        if choice == "keep":
            from .library import offer_library_import

            offer_library_import(prompter, produced)
            produced = []
            continue
        break
    if choice == "quit":
        raise QuitRequested(ok=ok)

run_wizard

run_wizard(
    prompter,
    *,
    cwd=None,
    banner=True,
    analyses=None,
    preselected=None,
    text_only=False,
    before_options=None,
    var_defaults=None
)

Ask the questions, compose a pipeline, write it, and optionally run it.

Parameters:

Name Type Description Default
prompter Prompter

Where the questions go. Production passes :class:~taters.ui.prompts.QuestionaryPrompter; tests pass :class:~taters.ui.prompts.ScriptedPrompter.

required
analyses bool

What to do about the statistics stage.

False leaves it out entirely -- no question, no stage on the rail. True means the user came here to run analyses, so the stage is not optional: at least one has to be picked, and a source that cannot support any is caught at the first question rather than after they have chosen features for it. None (the default) asks and lets them decline, which is what a programmatic caller with no opinion should get.

The front menu offers the first two as separate entries. They are different intentions -- "turn my recordings into numbers" and "find out whether these groups differ" -- and one flow that tried to be both asked everyone the analysis question, most of whom had nothing to answer it with.

None
cwd Path

The working folder: pipelines are saved as subfolders of it. Defaults to the current directory.

None
banner bool

Whether to print the Taters banner first. False when the hub has already printed it, so it does not appear twice.

True
preselected sequence of str

Recipe ids chosen before the wizard opens, in place of the feature checklist. The "Train a model" task uses this: what to train was the task's own first question, so the checklist would be one already-ticked row. Everything else -- the source, the level, the options screen, naming, the run -- is the same flow, and the pipeline it saves is re-runnable like any other.

None
text_only bool

Offer only text sources (documents, a spreadsheet); see :func:ask_source. With preselected steps the sources are narrowed further to what those steps can read.

False
before_options callable

(prompter, src, var_values, overrides) -> None, asked once after the level question and before the options screen, in the slot the analysis stage would take. The training task uses it to ask which columns a predictor should predict -- a question that needs the spreadsheet's columns and belongs to no step's options. Raising :class:GoBack from it returns to the source question.

None
var_defaults dict

Pipeline variables the run should start with already answered. A default rather than a decision: every one of them still appears on the options screen, so the user can change it. The analyze-a-spreadsheet task turns the word clouds off this way.

None

Returns:

Type Description
WizardResult

Raises:

Type Description
Cancelled

If the user backs out. :func:main turns this into a clean exit.

Source code in src\taters\ui\wizard.py
3913
3914
3915
3916
3917
3918
3919
3920
3921
3922
3923
3924
3925
3926
3927
3928
3929
3930
3931
3932
3933
3934
3935
3936
3937
3938
3939
3940
3941
3942
3943
3944
3945
3946
3947
3948
3949
3950
3951
3952
3953
3954
3955
3956
3957
3958
3959
3960
3961
3962
3963
3964
3965
3966
3967
3968
3969
3970
3971
3972
3973
3974
3975
3976
3977
3978
3979
3980
3981
3982
3983
3984
3985
3986
3987
3988
3989
3990
3991
3992
3993
3994
3995
3996
3997
3998
3999
4000
4001
4002
4003
4004
4005
4006
4007
4008
4009
4010
4011
4012
4013
4014
4015
4016
4017
4018
4019
4020
4021
4022
4023
4024
4025
4026
4027
4028
4029
4030
4031
4032
4033
4034
4035
4036
4037
4038
4039
4040
4041
4042
4043
4044
4045
4046
4047
4048
4049
4050
4051
4052
4053
4054
4055
4056
4057
4058
4059
4060
4061
4062
4063
4064
4065
4066
4067
4068
4069
4070
4071
4072
4073
4074
4075
4076
4077
4078
4079
4080
4081
4082
4083
4084
4085
4086
4087
4088
4089
4090
4091
4092
4093
4094
4095
4096
4097
4098
4099
4100
4101
4102
4103
4104
4105
4106
4107
4108
4109
4110
4111
4112
4113
4114
4115
4116
4117
4118
4119
4120
4121
4122
4123
4124
4125
4126
4127
4128
4129
4130
4131
4132
4133
4134
4135
4136
4137
4138
4139
4140
4141
4142
4143
4144
4145
4146
4147
4148
4149
4150
4151
4152
4153
4154
4155
4156
4157
4158
4159
4160
4161
4162
4163
4164
4165
4166
4167
4168
4169
4170
4171
4172
4173
4174
4175
4176
4177
4178
4179
4180
4181
4182
4183
4184
4185
4186
4187
4188
4189
4190
4191
4192
4193
4194
4195
4196
4197
4198
4199
4200
4201
4202
4203
4204
4205
4206
4207
4208
4209
4210
4211
4212
4213
4214
4215
4216
4217
4218
4219
4220
4221
4222
4223
4224
4225
4226
4227
4228
4229
4230
4231
4232
4233
4234
4235
4236
4237
4238
4239
4240
4241
4242
4243
4244
4245
4246
4247
4248
4249
4250
4251
4252
4253
4254
4255
4256
4257
4258
4259
4260
4261
4262
4263
4264
4265
4266
4267
4268
4269
4270
4271
4272
4273
4274
4275
4276
4277
4278
4279
4280
4281
4282
4283
4284
4285
4286
4287
4288
4289
4290
4291
4292
4293
4294
4295
4296
4297
4298
4299
4300
4301
4302
4303
4304
4305
4306
4307
4308
4309
4310
4311
4312
4313
4314
4315
4316
4317
4318
4319
4320
4321
4322
4323
4324
4325
4326
4327
4328
4329
4330
4331
4332
4333
4334
4335
4336
4337
4338
4339
4340
4341
4342
def run_wizard(prompter: Prompter, *, cwd: Optional[Path] = None,
               banner: bool = True,
               analyses: Optional[bool] = None,
               preselected: Optional[Sequence[str]] = None,
               text_only: bool = False,
               before_options: Optional[Callable[..., None]] = None,
               var_defaults: Optional[Dict[str, Any]] = None
               ) -> WizardResult:
    """
    Ask the questions, compose a pipeline, write it, and optionally run it.

    Parameters
    ----------
    prompter : Prompter
        Where the questions go. Production passes
        :class:`~taters.ui.prompts.QuestionaryPrompter`; tests pass
        :class:`~taters.ui.prompts.ScriptedPrompter`.
    analyses : bool, optional
        What to do about the statistics stage.

        ``False`` leaves it out entirely -- no question, no stage on the
        rail. ``True`` means the user came here *to* run analyses, so the
        stage is not optional: at least one has to be picked, and a source
        that cannot support any is caught at the first question rather than
        after they have chosen features for it. ``None`` (the default) asks
        and lets them decline, which is what a programmatic caller with no
        opinion should get.

        The front menu offers the first two as separate entries. They are
        different intentions -- "turn my recordings into numbers" and "find
        out whether these groups differ" -- and one flow that tried to be
        both asked everyone the analysis question, most of whom had nothing
        to answer it with.
    cwd : Path, optional
        The working folder: pipelines are saved as subfolders of it.
        Defaults to the current directory.
    banner : bool, default True
        Whether to print the Taters banner first. False when the hub has
        already printed it, so it does not appear twice.
    preselected : sequence of str, optional
        Recipe ids chosen before the wizard opens, in place of the feature
        checklist. The "Train a model" task uses this: what to train was
        the task's own first question, so the checklist would be one
        already-ticked row. Everything else -- the source, the level, the
        options screen, naming, the run -- is the same flow, and the
        pipeline it saves is re-runnable like any other.
    text_only : bool, default False
        Offer only text sources (documents, a spreadsheet); see
        :func:`ask_source`. With ``preselected`` steps the sources are
        narrowed further to what those steps can read.
    before_options : callable, optional
        ``(prompter, src, var_values, overrides) -> None``, asked once
        after the level question and before the options screen, in the slot
        the analysis stage would take. The training task uses it to ask
        which columns a predictor should predict -- a question that needs
        the spreadsheet's columns and belongs to no step's options. Raising
        :class:`GoBack` from it returns to the source question.
    var_defaults : dict, optional
        Pipeline variables the run should start with already answered. A
        default rather than a decision: every one of them still appears on
        the options screen, so the user can change it. The
        analyze-a-spreadsheet task turns the word clouds off this way.

    Returns
    -------
    WizardResult

    Raises
    ------
    Cancelled
        If the user backs out. :func:`main` turns this into a clean exit.
    """
    cwd = Path(cwd or Path.cwd())
    if banner:
        prompter.note(BANNER)
    _init_stages(prompter, analyses)

    # which stage we enter next. Esc sets it back by one and we loop, and
    # that's what makes "back" mean the same thing on every screen -- the
    # stages are the units a user thinks in ("I picked the wrong folder", "I
    # meant to tick something else"), and re-asking a whole stage is both
    # simpler and less surprising than trying to rewind to some arbitrary
    # question inside one.
    #
    # we write this as a loop over a named stage rather than as nested loops
    # because the nesting could only ever express "back" as "carry on to the
    # next question", which is how Esc at the options screen ended up jumping
    # *forward* to naming the pipeline and bouncing against Esc there.
    at = "source"
    rebuild = False
    #: Set when Esc leaves the analysis stage: the screen before its first
    #: question is the level question, not the feature checklist, so the
    #: features stage re-enters there -- when there was a level question at
    #: all (a run of nothing but acoustics has none, and then the checklist
    #: really is the previous screen).
    resume_at_level = False
    selected: List[str] = []
    providers: Dict[str, str] = {}
    feature_picks: List[str] = []
    overrides: Dict[str, Dict[str, Any]] = {}
    # a task can start the run with a variable already answered -- the
    # analyze-a-spreadsheet flow turns the word clouds off, because a cloud of
    # column names is not what anyone came for. it is a *default*, not a
    # decision: the options screen still shows the row, so it can be turned
    # back on by anyone who joins a document-term matrix later
    var_values: Dict[str, Any] = dict(var_defaults or {})

    while True:
        if at == "source":
            prompter.stage("source", "Source", status="active")
            # no `except GoBack` here, and that's intentional. Esc means
            # "back", and at the first question of the wizard back is out of
            # the wizard -- the menu that launched it is the previous screen.
            # catching it and re-asking made the key look like it got ignored,
            # which is the same bug the main menu had.
            allowed: Optional[List[str]] = None
            if preselected:
                allowed = sorted(set.intersection(
                    *[set(_recipes.by_id(r).sources) for r in preselected]))
            # a preselected step whose columns *are* the measures asks for
            # predictors where the others ask for text. the catalog says so,
            # rather than the task, so the question follows the step
            measures = bool(preselected) and all(
                _recipes.by_id(r).takes_level and not _recipes.by_id(r).text_input
                for r in preselected)
            src = ask_source(prompter, analyses=analyses, text_only=text_only,
                             sources=allowed, columns_are_measures=measures)
            prompter.stage("source", "Source", status="done",
                           detail=f"{src.path}  ({len(src.inputs)} file(s))")
            at = "features"

        if at == "features":
            prompter.stage("features", "Features", status="active")
            # the checklist and the level are one stage, so Esc steps back
            # within it -- from the level question to the checklist, and only
            # from the checklist out to the source.
            back_to_source = False
            skip_checklist = False
            if resume_at_level:
                skip_checklist = any(
                    _recipes.level_aware(r) for r in resolve_selection(
                        feature_picks, providers=providers, source=src.source))
                selected = list(feature_picks)
                resume_at_level = False
            while True:
                if preselected:
                    # the choice was made before the wizard even opened, so
                    # there's no checklist to go back to. Esc at the level
                    # question is Esc out to the source.
                    selected = list(preselected)
                    providers = {}
                elif not skip_checklist:
                    try:
                        selected = ask_features(prompter, src.source,
                                                analyses=bool(analyses))
                        providers = resolve_providers(prompter, selected,
                                                      src.source)
                    except GoBack:
                        back_to_source = True
                        break
                skip_checklist = False
                try:
                    src.level, src.group_by = ask_level(
                        prompter, src,
                        resolve_selection(selected, providers=providers,
                                          source=src.source))
                except GoBack:
                    if preselected:
                        back_to_source = True
                        break
                    continue        # back to the checklist
                except ComposeError as e:
                    # an unsatisfiable selection is a wrong answer to *this*
                    # checklist, so we re-ask -- and this must never climb,
                    # since nothing above the wizard catches it and the
                    # session would die with a traceback.
                    prompter.note(f"  {e}", style="red")
                    continue
                break
            if back_to_source:
                at = "source"
                continue
            _ask_required_models(
                prompter, resolve_selection(selected, providers=providers,
                                            source=src.source), overrides)
            if not preselected:
                # the Train task asked for its encoder before the wizard even
                # opened (and seeds it just before the options screen), so
                # only the ordinary checklist route asks here
                _ask_encoders(
                    prompter, resolve_selection(selected, providers=providers,
                                                source=src.source),
                    var_values, overrides)
            prompter.stage("features", "Features", status="done",
                           detail=", ".join(_recipes.by_id(s).label
                                            for s in selected))
            at = "analysis"
            rebuild = True
            analysis = AnalysisSpec()
            feature_picks = list(selected)

        if at == "analysis" and analyses is False:
            # feature extraction was the whole ask. not asking beats asking a
            # question we already know the answer to.
            if before_options is not None:
                try:
                    before_options(prompter, src, var_values, overrides)
                except GoBack:
                    at = "source"
                    continue
            at = "options"

        if at == "analysis":
            prompter.stage("analysis", "Analysis", status="active")
            # we re-derive this from the feature picks each time through, so
            # that backing out of the stage and answering it differently can't
            # leave the previous answer's statistics steps in the selection.
            selected = list(feature_picks)
            var_values = {k: v for k, v in var_values.items()
                          if not k.startswith("stats_")}
            overrides.pop("stats_assemble", None)
            try:
                analysis = ask_analysis(
                    prompter, src,
                    resolve_selection(feature_picks, providers=providers,
                                      source=src.source),
                    required=bool(analyses), picked=feature_picks)
            except GoBack:
                at = "features"
                resume_at_level = True
                continue
            if analyses and not analysis.wanted:
                # they chose the analyses flow, so an empty answer is a
                # question that hasn't been answered, not a decision. each way
                # of ending up with no statistics gets refused at the screen
                # that can actually change it -- a spreadsheet that's all text
                # at the source question, a pick with nothing joinable at the
                # feature checklist, an analysis this data can't run as a
                # grayed-out row -- because sending someone to a screen that
                # can't fix their problem is how two screens bounce off each
                # other forever. so this is just a backstop. `why_not` was
                # already printed by the stage; repeating it here looked like
                # two separate problems.
                prompter.note(
                    "  Pick at least one feature that makes a per-text table, "
                    "or start again with plain feature extraction.",
                    style="yellow")
                at = "features"
                rebuild = True
                continue
            apply_analysis(analysis, src, selected, var_values, overrides)
            prompter.stage(
                "analysis", "Analysis", status="done",
                detail=", ".join(_recipes.by_id(a).label
                                 for a in analysis.analyses)
                or ("not possible here" if analysis.why_not else "none"))
            at = "options"
            rebuild = True

        # derived from the selection, so we redo it when the selection
        # changes and not otherwise. rebuilding on every pass would re-run
        # `preflight`, which asks whether to keep a step whose extra is
        # missing -- and getting asked that again on the way back from naming
        # the pipeline would be its own small mystery.
        if rebuild:
            try:
                selected, steps, var_specs, source_kwargs = _derive(
                    prompter, src, selected, providers)
            except ComposeError as e:
                # somebody ticked word vectors as their only feature, asked
                # for statistics, then said "leave it out" at the preflight.
                # that left the analysis table with nothing to assemble, and
                # the compose error climbed all the way out as a traceback.
                # the answer to "nothing left to extract" is the checklist
                prompter.note(f"  {e}", style="yellow")
                if preselected:
                    raise Cancelled() from e
                at = "features"
                continue
            rebuild = False

        if at == "options":
            prompter.stage("options", "Options", status="active")
            # `overrides` and `var_values` go in and come back mutated, so
            # stepping back out of this screen and returning to it keeps every
            # setting already changed. we used to rebind them from the return
            # value, which threw the lot away on the way past.
            try:
                ask_tuning(prompter, steps, var_specs, overrides, var_values,
                           source=src.source, level=src.level or None,
                           group_by=src.group_by, selected=selected)
            except GoBack:
                # a stage that asked nothing must not catch "back", otherwise
                # it hands control straight forward again and Esc looks
                # broken (it did, for every media run).
                at = "analysis" if analysis.offered else "features"
                continue
            changed = sum(len(v) for v in overrides.values()) + len(var_values)
            prompter.stage("options", "Options", status="done",
                           detail="defaults" if not changed
                           else f"{changed} changed")
            at = "review"

        # naming, the review table and the closing question are one stage, so
        # Esc moves within it before it leaves -- from the closing question
        # back to naming, and from naming back to the options screen.
        prompter.stage("review", "Review", status="active")
        try:
            title = str(prompter.text("Name this pipeline:",
                                      default="My pipeline")).strip()
        except GoBack:
            at = "options"
            continue
        title = title or "My pipeline"

        # we catch this at naming time, while renaming is still one keypress
        # away. saving used to overwrite a same-named pipeline without a word
        # -- and the replacement then *resumed against the old run's
        # outputs*, because steps skip work whose files already exist. a
        # results folder silently mixing two different pipeline definitions
        # is about the worst thing this screen can produce.
        collided = _resolve_name_collision(prompter, title, cwd)
        if collided is None:
            continue            # they chose to rename, so back to the question
        title = collided

        # on a copy, since these are compose-time defaults, not the user's
        # edits. when we injected them into the live dict they came back as a
        # phantom "(1 changed)" on the options screen after backing out of
        # naming.
        effective = {rid: dict(vals) for rid, vals in overrides.items()}
        _library_defaults(steps, effective)
        try:
            plans = _model_plans(steps, effective)
            for plan in plans:
                for problem in plan.problems:
                    raise ComposeError(problem)
            _check_controls(plans, src)
            preset = compose(
                selected,
                model_plans=plans,
                providers=providers,
                overrides=effective,
                var_values=var_values,
                name=title,
                file_type=src.file_type,
                root_dir=str(src.root_dir) if src.root_dir else None,
                **source_kwargs,
            )
        except ComposeError as e:
            # the composer refuses things a person can fix -- most of all a
            # saved model whose features can't be reproduced from this run's
            # settings. unguarded, that refusal left the session as a
            # traceback after the user had answered every single question. so
            # we say it at the options screen instead, where the answer that
            # caused it can be changed.
            prompter.note(f"  {e}", style="yellow")
            prompter.stage("options", "Options", status="active")
            at = "options"
            continue

        review(prompter, preset, selected, src.inputs)
        result = WizardResult(preset=preset, root_dir=src.root_dir,
                              file_type=src.file_type,
                              inputs=list(src.inputs), source=src.source)

        # one question, three real outcomes, instead of "Save?" then "Run?".
        #
        # naming a pipeline is already a decision to keep it, so asking again
        # read as a formality -- and the "no" branch wasn't "don't save" at
        # all, it threw the whole session away. two yes/no questions in a row
        # also made the common case (save it and run it) take two answers when
        # it's really one act.
        prompter.note("")
        try:
            choice = str(prompter.select("What next?", [
                Choice("run", "Save and run it now"),
                Choice("save", "Save it — I'll run it later"),
                Choice("discard", "Discard it and start over"),
            ]))
        except GoBack:
            at = "review"       # back to naming it
            continue
        break

    if choice == "discard":
        raise Cancelled()

    folder = pipeline_folder(preset, cwd)
    path = write_preset(preset, folder)
    result.preset_path = path
    result.folder = folder
    prompter.stage("review", "Review", status="done", detail=path.name)
    prompter.stage("run", "Run", status="active")
    prompter.note(f"\n  Saved to {path}", style="green")
    prompter.note(
        "  Run it again any time with:\n"
        f"    {preset['meta']['cli_example']}",
        style="dim",
        wrap=False,     # must stay one line to survive copy-paste
    )

    if choice != "run":
        prompter.note("  Saved. Run it whenever you are ready.")
        return result

    # we ask this here rather than earlier, since someone who declines to run
    # shouldn't have to answer a question about how the run would have gone.
    try:
        workers = ask_workers(
            prompter, fans_out=any(step.scope == "item" for step in steps))
    except (GoBack, Cancelled):
        # the preset is already on disk. letting this climb reached the hub's
        # "Backed out. Nothing was changed." -- a lie, over a saved file.
        prompter.note("  Saved, not run. Run it any time from the main menu.",
                      style="dim")
        return result

    result.ran = True
    result.ok, result.manifest = execute_preset(
        prompter, preset,
        root_dir=src.root_dir,
        file_type=src.file_type,
        workers=workers,
        work_dir=folder,
        preset_name=preset["meta"]["id"],
        command=repro_command(path, root_dir=src.root_dir,
                              file_type=src.file_type, workers=workers),
    )
    return result

main

main(argv=None)

Console entry point for the taters command.

Source code in src\taters\ui\wizard.py
4345
4346
4347
4348
4349
4350
4351
4352
4353
4354
4355
4356
4357
4358
4359
4360
4361
4362
4363
4364
4365
4366
4367
4368
4369
4370
4371
4372
4373
4374
4375
4376
4377
4378
4379
4380
4381
4382
def main(argv: Optional[Sequence[str]] = None) -> int:
    """Console entry point for the ``taters`` command."""
    import argparse

    ap = argparse.ArgumentParser(
        prog="taters",
        description="Interactive setup wizard: build and run a Taters pipeline.",
    )
    ap.add_argument("--dir", default=None,
                    help="Working folder: pipelines are saved as subfolders "
                         "here (default: current folder)")
    ap.add_argument("--plain", action="store_true",
                    help="Ask one question at a time, without the progress rail. "
                         "Use this if your terminal renders the rail badly.")
    args = ap.parse_args(argv)

    try:
        if args.plain:
            from .prompts import QuestionaryPrompter
            prompter: Prompter = QuestionaryPrompter()
        else:
            from .live import LivePrompter
            prompter = LivePrompter()
    except ImportError as e:
        print(str(e), file=sys.stderr)
        return 2

    from .hub import run_hub

    try:
        ok = run_hub(prompter, cwd=Path(args.dir) if args.dir else None)
    except Cancelled:
        print("\nCancelled. Nothing was changed.")
        return 130
    except KeyboardInterrupt:
        print("\nCancelled. Nothing was changed.")
        return 130
    return 0 if ok else 1

Describing a function to a UI

Reads a function's signature and numpydoc docstring into renderable field descriptions. This is what lets the wizard offer a module's options without anyone hand-writing a list of them.

taters.ui.introspect

Turn a Taters function into a description a user interface can render.

Every analysis function in this package is a plain Python function with type annotations and a numpydoc docstring. That is already most of a form: the signature says what the fields are called and what type they hold, and the Parameters block says what each one means in prose. This module joins the two into :class:ParamSpec objects so a UI never has to hard-code a list of options for each module.

Deliberately UI-agnostic: nothing here imports questionary, rich, or anything else terminal-shaped. The console wizard renders these specs, and a future GUI or web front end can render the same ones -- they serialize to JSON cleanly.

What this module can and cannot tell you

It gives you the knobs: names, types, defaults, help text, enumerated choices. It cannot give you the wiring -- nothing in analyze_vocal_acoustics's signature says its transcript_csv should be fed the output of an earlier transcription step. That part is declared by hand in :mod:taters.ui.recipes.

ParamDoc dataclass

ParamDoc(name, type_str='', desc='')

The docstring half of a parameter: its prose and its declared type.

ParamSpec dataclass

ParamSpec(
    name,
    annotation=None,
    annotation_str="",
    default=EMPTY,
    required=False,
    kw_only=False,
    desc="",
    widget="text",
    choices=None,
    open_ended=False,
)

One renderable field: everything a UI needs to draw a single input.

as_dict

as_dict()

A JSON-friendly view, for a web UI or an MCP schema.

Source code in src\taters\ui\introspect.py
404
405
406
407
408
409
410
411
412
413
414
415
def as_dict(self) -> dict:
    """A JSON-friendly view, for a web UI or an MCP schema."""
    return {
        "name": self.name,
        "type": self.annotation_str,
        "default": None if not self.has_default else _plain(self.default),
        "required": self.required,
        "desc": self.desc,
        "widget": self.widget,
        "choices": self.choices,
        "open_ended": self.open_ended,
    }

FunctionSpec dataclass

FunctionSpec(
    name, qualname, summary="", doc="", params=list()
)

A whole function: its summary line and its renderable parameters.

clean_doc

clean_doc(text)

Turn a numpydoc parameter description into a sentence a person can read.

Two things are in the way. Docstrings are hard-wrapped at some column, so a description arrives as several short lines and anything showing only the first gets half a sentence. And they carry reStructuredText markup -- None, :func:some.thing -- which renders as literal backticks in a terminal.

Done here rather than in the wizard so every front end gets readable text from the same place, and so the raw markup never has to be handled twice.

Paragraph breaks are kept; a run of lines within a paragraph is joined.

Source code in src\taters\ui\introspect.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def clean_doc(text: str) -> str:
    """
    Turn a numpydoc parameter description into a sentence a person can read.

    Two things are in the way. Docstrings are hard-wrapped at some column, so a
    description arrives as several short lines and anything showing only the
    first gets half a sentence. And they carry reStructuredText markup --
    ``None``, :func:`some.thing` -- which renders as literal backticks in a
    terminal.

    Done here rather than in the wizard so every front end gets readable text
    from the same place, and so the raw markup never has to be handled twice.

    Paragraph breaks are kept; a run of lines within a paragraph is joined.
    """
    if not text:
        return ""

    body = inspect.cleandoc(text)
    body = _RST_ROLE_RE.sub(r"\1", body)      # :func:`x.y` -> x.y
    body = _RST_LITERAL_RE.sub(r"\1", body)   # ``None``    -> None

    paragraphs = []
    for chunk in re.split(r"\n\s*\n", body):
        joined = " ".join(line.strip() for line in chunk.splitlines() if line.strip())
        if joined:
            paragraphs.append(joined)
    return "\n\n".join(paragraphs).strip()

parse_numpydoc_params

parse_numpydoc_params(doc)

Pull the Parameters block out of a numpydoc docstring.

Parameters:

Name Type Description Default
doc str or None

A raw docstring. None and docstrings with no Parameters section both yield an empty dict rather than raising -- an undocumented function should still be usable, just with less help text.

required

Returns:

Type Description
dict[str, ParamDoc]

Keyed by parameter name. An a / b entry is expanded into one key per name, both pointing at the same description.

Source code in src\taters\ui\introspect.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
def parse_numpydoc_params(doc: Optional[str]) -> Dict[str, ParamDoc]:
    """
    Pull the ``Parameters`` block out of a numpydoc docstring.

    Parameters
    ----------
    doc : str or None
        A raw docstring. ``None`` and docstrings with no ``Parameters`` section
        both yield an empty dict rather than raising -- an undocumented
        function should still be usable, just with less help text.

    Returns
    -------
    dict[str, ParamDoc]
        Keyed by parameter name. An ``a / b`` entry is expanded into one key
        per name, both pointing at the same description.
    """
    if not doc:
        return {}

    doc = inspect.cleandoc(doc)

    # find the Parameters heading, then cut at whatever section comes next.
    body: Optional[str] = None
    for match in _SECTION_RE.finditer(doc):
        if match.group(1) != "Parameters":
            continue
        start = match.end()
        nxt = _SECTION_RE.search(doc, start)
        body = doc[start:nxt.start()] if nxt else doc[start:]
        break
    if body is None:
        return {}

    out: Dict[str, ParamDoc] = {}
    current: List[str] = []          # description lines for the entry in hand
    names: List[str] = []
    type_str = ""

    def flush() -> None:
        if not names:
            return
        desc = clean_doc("\n".join(current))
        for name in names:
            out[name] = ParamDoc(name=name, type_str=type_str, desc=desc)

    for line in body.splitlines():
        if not line.strip():
            current.append("")
            continue
        # indented lines carry on the description of the entry above.
        if line[:1] in (" ", "\t"):
            current.append(line)
            continue
        header = _PARAM_HEADER_RE.match(line.rstrip())
        if not header:
            # not a header we recognize -- treat it as more description so we
            # never silently drop text.
            current.append(line)
            continue
        flush()
        # numpydoc's own form is `a, b : type`; we've also got `a / b` in this
        # tree. both mean several names sharing one description, and a comma
        # header that didn't match here used to contribute nothing -- both
        # parameters reached the wizard with no help text at all.
        names = [n.strip().lstrip("*")
                 for n in re.split(r"[/,]", header.group("names"))]
        type_str = (header.group("type") or "").strip()
        current = []
    flush()
    return out

widget_for

widget_for(annotation, name='', default=EMPTY)

Choose a rendering hint for one parameter.

Parameters:

Name Type Description Default
annotation Any

The evaluated annotation (see :func:describe, which resolves string annotations for you).

required
name str

The parameter name. Used only to spot path-ish parameters that are annotated as bare str: anything ending in _path, _dir, _csv or _wav.

''
default Any

The default value, used as a last resort when there is no annotation.

EMPTY

Returns:

Type Description
str

One of text, path, dir, int, float, bool, list.

Notes

choice is not returned here -- it comes from the docstring's literal set, not the annotation, so :func:describe applies it afterwards.

Source code in src\taters\ui\introspect.py
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
def widget_for(annotation: Any, name: str = "", default: Any = EMPTY) -> str:
    """
    Choose a rendering hint for one parameter.

    Parameters
    ----------
    annotation : Any
        The evaluated annotation (see :func:`describe`, which resolves string
        annotations for you).
    name : str, optional
        The parameter name. Used only to spot path-ish parameters that are
        annotated as bare ``str``: anything ending in ``_path``, ``_dir``,
        ``_csv`` or ``_wav``.
    default : Any, optional
        The default value, used as a last resort when there is no annotation.

    Returns
    -------
    str
        One of ``text``, ``path``, ``dir``, ``int``, ``float``, ``bool``,
        ``list``.

    Notes
    -----
    ``choice`` is not returned here -- it comes from the docstring's literal
    set, not the annotation, so :func:`describe` applies it afterwards.
    """
    inner, _ = _unwrap_optional(annotation)

    # "one or many" -- Union[PathLike, Sequence[PathLike]] -- is a list as
    # far as a screen or a command line cares: one value is a list of one.
    # we have to look at the union *before* it gets collapsed to its first
    # member below, otherwise this reads as a plain str and a repeated
    # --model-json flag quietly keeps only the last one
    if _names_a_sequence(annotation):
        return "list"

    # concrete scalar types settle it outright. the name heuristic below only
    # gets a say for string-ish parameters -- `include_source_path` is a bool
    # despite the suffix, and guessing from the name there would give us a
    # filesystem prompt for a yes/no question.
    if inner is bool:
        return "bool"
    if inner is int:
        return "int"
    if inner is float:
        return "float"

    lowered = name.lower()
    if lowered.endswith("_dir") or lowered in {"root_dir", "out_dir", "tmp_root"}:
        return "dir"
    if lowered.endswith(("_path", "_csv", "_wav", "_file")):
        return "path"

    if inner is Path or inner is str:
        return "text"

    origin = typing.get_origin(inner)
    if origin in (list, tuple, set, frozenset) or inner in (list, tuple, set):
        return "list"
    if origin is not None and origin in (Sequence, typing.Sequence):
        return "list"
    # typing.Sequence[str] and friends surface as collections.abc.Sequence
    if getattr(origin, "__name__", "") in {"Sequence", "Iterable", "Collection"}:
        return "list"

    # no usable annotation, so we fall back to the shape of the default value.
    if isinstance(default, bool):
        return "bool"
    if isinstance(default, int):
        return "int"
    if isinstance(default, float):
        return "float"
    if isinstance(default, (list, tuple, set)):
        return "list"
    return "text"

describe

describe(func)

Build a :class:FunctionSpec from a live callable.

Parameters:

Name Type Description Default
func Callable

Any Taters analysis function. **kwargs-forwarding facade methods are the wrong input here -- pass the function they forward to, which :func:load_target will fetch for you.

required

Returns:

Type Description
FunctionSpec

Parameters in declaration order. *args/**kwargs are omitted: they are not renderable fields.

Notes

Signatures are read with eval_str=True. Every module in this package starts with from __future__ import annotations, which makes annotations plain strings at runtime; without eval_str you would get the string "Optional[Union[str, Path]]" instead of a type to dispatch on. If evaluation fails -- a name that only exists under TYPE_CHECKING, say -- we fall back to the unevaluated signature and the string annotations still give a usable, if coarser, result.

Source code in src\taters\ui\introspect.py
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
def describe(func: Callable) -> FunctionSpec:
    """
    Build a :class:`FunctionSpec` from a live callable.

    Parameters
    ----------
    func : Callable
        Any Taters analysis function. ``**kwargs``-forwarding facade methods
        are the wrong input here -- pass the function they forward *to*, which
        :func:`load_target` will fetch for you.

    Returns
    -------
    FunctionSpec
        Parameters in declaration order. ``*args``/``**kwargs`` are omitted:
        they are not renderable fields.

    Notes
    -----
    Signatures are read with ``eval_str=True``. Every module in this package
    starts with ``from __future__ import annotations``, which makes annotations
    plain strings at runtime; without ``eval_str`` you would get the string
    ``"Optional[Union[str, Path]]"`` instead of a type to dispatch on. If
    evaluation fails -- a name that only exists under ``TYPE_CHECKING``, say --
    we fall back to the unevaluated signature and the string annotations still
    give a usable, if coarser, result.
    """
    try:
        sig = inspect.signature(func, eval_str=True)
    except Exception:
        sig = inspect.signature(func)

    doc = inspect.getdoc(func) or ""
    docs = parse_numpydoc_params(doc)
    summary = doc.strip().splitlines()[0].strip() if doc.strip() else ""

    params: List[ParamSpec] = []
    for p in sig.parameters.values():
        if p.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
            continue

        pdoc = docs.get(p.name, ParamDoc(name=p.name))
        default = EMPTY if p.default is inspect.Parameter.empty else p.default
        annotation = None if p.annotation is inspect.Parameter.empty else p.annotation

        spec = ParamSpec(
            name=p.name,
            annotation=annotation,
            annotation_str=_annotation_str(p.annotation) or pdoc.type_str,
            default=default,
            required=isinstance(default, _Empty),
            kw_only=p.kind is inspect.Parameter.KEYWORD_ONLY,
            desc=pdoc.desc,
            widget=widget_for(annotation, p.name, default),
        )
        # the docstring's ``{...}`` set first, since that's written for the
        # reader; the annotation's ``Literal[...]`` when the docstring doesn't
        # have one. either way we know the answers, and a known set is a
        # picker, never a box to spell the answer into.
        choices = (_choices_from_type_str(pdoc.type_str)
                   or _choices_from_annotation(annotation))
        if choices:
            spec.choices = choices
            spec.widget = "choice"
            spec.open_ended = _open_ended(pdoc.type_str)
        params.append(spec)

    return FunctionSpec(
        name=func.__name__,
        qualname=f"{func.__module__}.{func.__name__}",
        summary=summary,
        doc=doc,
        params=params,
    )

load_target

load_target(target)

Import and return the function named by a "module:function" string.

Parameters:

Name Type Description Default
target str

E.g. "taters.audio.convert_to_wav:convert_audio_to_wav". A dotted form without the colon is also accepted, with the last segment taken as the attribute name.

required

Returns:

Type Description
Callable

Raises:

Type Description
ImportError

Propagated unchanged from the import. Callers are expected to catch this and translate it into an install hint -- most Taters modules pull heavy optional dependencies, and this function is the point where that cost is paid, which is exactly why recipes name their target as a string instead of importing it at module load.

Source code in src\taters\ui\introspect.py
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
def load_target(target: str) -> Callable:
    """
    Import and return the function named by a ``"module:function"`` string.

    Parameters
    ----------
    target : str
        E.g. ``"taters.audio.convert_to_wav:convert_audio_to_wav"``. A dotted
        form without the colon is also accepted, with the last segment taken
        as the attribute name.

    Returns
    -------
    Callable

    Raises
    ------
    ImportError
        Propagated unchanged from the import. Callers are expected to catch
        this and translate it into an install hint -- most Taters modules pull
        heavy optional dependencies, and this function is the point where that
        cost is paid, which is exactly why recipes name their target as a
        string instead of importing it at module load.
    """
    module_name, _, attr = target.partition(":")
    if not attr:
        module_name, _, attr = target.rpartition(".")
    module = importlib.import_module(module_name)
    return getattr(module, attr)

The recipe catalog

The declared wiring: which steps exist, what each one needs, and what it produces. Signatures cannot tell you that the transcription step's output feeds the acoustics step's transcript_csv, so that part is written down here.

taters.ui.recipes

The declared data-flow catalog: what a user can ask for, and what it needs.

:mod:taters.ui.introspect can read every knob off a function's signature, but it cannot read the wiring. Nothing in analyze_vocal_acoustics's signature says its transcript_csv should be fed {{pick:diar.raw_files.csv}} from an earlier step. That knowledge lives here, declared by hand, one :class:Recipe per pipeline step.

Every with_ block below is transcribed from the two shipped presets (conversation_video.yaml and single_speaker_media.yaml). That is deliberate: those presets are the known-good wiring, and tests/test_compose.py asserts that selecting the right recipes reproduces them. If you change a template here, that test tells you.

How dependencies work

Steps are linked by capability strings -- "wav", "transcript_csv", and so on -- rather than by naming each other directly. A recipe declares what it requires and what it produces, and :mod:taters.ui.compose walks the graph. The indirection buys one important thing: transcript_csv has two providers (plain transcription and diarization), so the user gets to choose how a requirement is met without any recipe knowing that a choice exists.

auto_with covers the one relationship capabilities cannot express. A gather step does not provide anything the feature step needs -- it tidies up afterwards -- so it cannot be pulled in by requires. Naming it in auto_with says "whenever you include me, include this too."

Level dataclass

Level(id, label, help, group_by)

One answer to "what should a row of results describe?".

Attributes:

Name Type Description
id str

Stable identifier, stored in the preset's meta.

label, help str

What the option says, and the sentence under it. The label alone is never enough: "one row per speaker" does not say what happened to the utterances, and joining a speaker's words before measuring versus averaging their per-utterance scores differ by ~35% on vocabulary measures. The help says which happened.

group_by tuple of str, or None

The columns to aggregate on. Empty means no aggregation -- the raw row is the unit. None means the user picks the columns, which is only the case for a spreadsheet, where the catalog cannot know their names.

Recipe dataclass

Recipe(
    id,
    label,
    help,
    call,
    target,
    scope,
    save_as,
    with_,
    requires=frozenset(),
    produces=frozenset(),
    auto_with=(),
    extras=(),
    needs_ffmpeg=False,
    gpu_use=None,
    hidden=(),
    vars=dict(),
    user_facing=True,
    sources=("media",),
    text_input=False,
    source_with=dict(),
    text_help="",
    library=dict(),
    library_defaults=dict(),
    param_when=dict(),
    tags=(),
    stage="extract",
    feature_table=False,
    outcome_kind=None,
    consumes_feature_tables=False,
    encoder_param=None,
    feature_tables_optional=False,
    takes_level=False,
    keys_like_metadata=False,
)

One pipeline step, plus everything a UI needs to offer it.

Attributes:

Name Type Description
id str

Stable identifier. This is what the wizard passes to :func:taters.ui.compose.compose.

label, help str

What the checkbox says, and the one-line explanation under it.

call str

The preset call: value, e.g. "potato.audio.convert_to_wav".

target str

"module:function" for the function call ultimately reaches. Kept as a string so importing it -- which can pull in torch, NeMo, or parselmouth -- happens only when a user actually selects this step.

scope {'item', 'global'}

item steps run once per input file; global steps run once.

save_as str

Name the step's result is bound to for later {{templates}}.

requires, produces frozenset[str]

Capability strings. See the module docstring.

auto_with tuple[str, ...]

Recipe ids to include alongside this one -- used for the gather steps that follow a feature step.

extras tuple[str, ...]

pip extras this step needs, e.g. ("vocalacoustics",).

needs_ffmpeg bool

Whether the step shells out to ffmpeg.

gpu_use str

One of :data:GPU_USE. Declares what a second worker costs in GPU memory, which is the one thing no amount of inspection can work out from the outside: transcribe and whisper_embeddings have nearly identical signatures and differ fourfold in how their VRAM scales.

Defaults to "gpu_model_each" -- the cautious answer -- for any step that reads the device variable and has not said otherwise, so forgetting to declare it on a new module costs speed rather than a crashed run.

with_ dict

The preset with: block, {{templates}} already written.

hidden tuple[str, ...]

Parameters never offered, even under "show advanced". These are the alternate-input arguments -- txt_dir and analysis_csv on the text analyzers -- which are mutually exclusive with the csv_path the pipeline wires in, so setting one silently detaches the step from the run.

vars dict[str, dict]

Contributions to the preset's vars: block, each entry shaped {"default": ..., "desc": ...} so it can feed meta.variables too.

user_facing bool

Whether this appears in the feature checklist. Prerequisites and gathers are False -- they get added for you.

sources tuple[str, ...]

Which of :data:SOURCES this step makes sense for. Defaults to ("media",) because most of the catalog is audio machinery: there is nothing to convert to WAV in a folder of essays. The feature checklist is filtered by this, so a user who says "I have text files" is never offered vocal acoustics.

text_input bool

Whether this step's input binding is rewritten by :func:text_binding when the source is not media. True for the five text analyzers, which read gathered transcripts on the media path but read the user's own files or spreadsheet directly otherwise.

source_with dict[str, dict]

Per-source patches merged into with_ last, keyed by source. Covers steps that are not themselves text inputs but still have to change -- the embedding gather groups by speaker on the media path, and there is no speaker column when the input was a folder of essays.

text_help str

Replaces help when the source is not media. The catalog describes results "per speaker per file", which is exactly right for a recorded conversation and simply untrue for a folder of essays -- and the help line under a checkbox is most of what a non-programmer has to go on.

resolved_gpu_use property

resolved_gpu_use

This step's declared GPU behavior, or the cautious guess.

A step that reads the device variable and has not declared itself is treated as "gpu_model_each" -- one file at a time. Wrong-but-slow is a recoverable mistake; wrong-and-out-of-memory is not, and it fails halfway through a batch rather than at the start.

worker_cap property

worker_cap

Most files this step may work on at once, or None for no limit.

to_step

to_step()

Render this recipe as a preset step dict.

Source code in src\taters\ui\recipes.py
437
438
439
440
441
442
443
444
445
446
447
def to_step(self) -> dict:
    """Render this recipe as a preset step dict."""
    step: dict = {"scope": self.scope, "call": self.call, "save_as": self.save_as}
    step["with"] = dict(self.with_)
    # we write the cap into the preset rather than leaving it implicit, so
    # a preset someone edits by hand (or sends to a colleague) carries the
    # limit with it instead of depending on a catalog they might not have.
    cap = self.worker_cap
    if cap is not None and self.scope == "item":
        step["max_workers"] = cap
    return step

level_aware

level_aware(recipe)

Does the analysis level decide this step's grain?

Derived rather than declared, so a new module needs no wizard work:

  • a text analyzer (text_input) measures text, and the level says what a piece of text is;
  • a merge (aggregate: True) that consumes a capability some text step produces is the "measure each, then average" half of a text feature, so it has to collapse on the same key the analyzers grouped by.

One step declares it instead (takes_level): the one that reads the user's own spreadsheet columns as the measures. It analyzes no text, so nothing derives it, but "one row per participant rather than one per spreadsheet row" is exactly the question the level asks, and the answer decides whether its columns are carried or averaged.

Everything else keeps the grain it declares. That deliberately excludes the audio features: acoustics groups by speaker within one file because it is item-scoped, and gather_whisper_embeddings aggregates audio segments. "One row per utterance" has no meaning for either -- there is no per-utterance WAV to measure -- and overwriting their keys would silently change what they average.

Source code in src\taters\ui\recipes.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
def level_aware(recipe: "Recipe") -> bool:
    """
    Does the analysis level decide this step's grain?

    Derived rather than declared, so a new module needs no wizard work:

    * a text analyzer (``text_input``) measures text, and the level says what
      a piece of text is;
    * a merge (``aggregate: True``) that consumes a capability some text step
      produces is the "measure each, then average" half of a text feature, so
      it has to collapse on the same key the analyzers grouped by.

    One step declares it instead (``takes_level``): the one that reads the
    user's own spreadsheet columns as the measures. It analyzes no text, so
    nothing derives it, but "one row per participant rather than one per
    spreadsheet row" is exactly the question the level asks, and the answer
    decides whether its columns are carried or averaged.

    Everything else keeps the grain it declares. That deliberately excludes the
    audio features: ``acoustics`` groups by ``speaker`` within one file because
    it is item-scoped, and ``gather_whisper_embeddings`` aggregates audio
    segments. "One row per utterance" has no meaning for either -- there is no
    per-utterance WAV to measure -- and overwriting their keys would silently
    change what they average.
    """
    if recipe.text_input or recipe.takes_level:
        return True
    if recipe.with_.get("aggregate") is not True:
        return False
    produced_by_text = {c for r in RECIPES if r.text_input for c in r.produces}
    return bool(recipe.requires & produced_by_text)

levels_for

levels_for(source)

Every level that makes sense for source.

Source code in src\taters\ui\recipes.py
202
203
204
205
206
def levels_for(source: str) -> Tuple[Level, ...]:
    """Every level that makes sense for `source`."""
    if source not in LEVELS:
        raise KeyError(f"unknown source {source!r}. Known: {', '.join(sorted(LEVELS))}")
    return LEVELS[source]

level_by_id

level_by_id(source, level=None)

One level, by id, falling back to the source's default.

Source code in src\taters\ui\recipes.py
209
210
211
212
213
214
215
216
def level_by_id(source: str, level: Optional[str] = None) -> Level:
    """One level, by id, falling back to the source's default."""
    wanted = level or DEFAULT_LEVEL[source]
    for candidate in levels_for(source):
        if candidate.id == wanted:
            return candidate
    known = ", ".join(c.id for c in levels_for(source))
    raise KeyError(f"unknown level {wanted!r} for {source!r}. Known: {known}")

gate_of

gate_of(recipe, param)

A gated setting's rule as (gate, op, value), or None when ungated.

Two spellings are accepted -- (gate, value) means == -- and anything else raises, so a typo in a declaration is a test failure rather than a row that never shows.

Source code in src\taters\ui\recipes.py
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
def gate_of(recipe: "Recipe", param: str) -> Optional[Tuple[str, str, str]]:
    """
    A gated setting's rule as ``(gate, op, value)``, or None when ungated.

    Two spellings are accepted -- ``(gate, value)`` means ``==`` -- and
    anything else raises, so a typo in a declaration is a test failure
    rather than a row that never shows.
    """
    raw = recipe.param_when.get(param)
    if raw is None:
        return None
    if len(raw) == 2:
        return str(raw[0]), "==", str(raw[1])
    if len(raw) == 3 and raw[1] in ("==", "!="):
        return str(raw[0]), str(raw[1]), str(raw[2])
    raise ValueError(f"{recipe.id}: param_when[{param!r}] must be (gate, value) "
                     f"or (gate, '!='|'==', value), got {raw!r}")

gate_holds

gate_holds(gate, current)

Whether a gated setting should be shown, given its gate's live value.

Fails open: a gate whose value is unknown (None, or the wizard's EMPTY sentinel) shows the row, because a hidden setting that should be visible is the one mistake this screen must never make. The comparison is on the value's text, as the recipes spell it.

Source code in src\taters\ui\recipes.py
641
642
643
644
645
646
647
648
649
650
651
652
653
def gate_holds(gate: Tuple[str, str, str], current: object) -> bool:
    """
    Whether a gated setting should be shown, given its gate's live value.

    Fails **open**: a gate whose value is unknown (`None`, or the wizard's
    EMPTY sentinel) shows the row, because a hidden setting that should be
    visible is the one mistake this screen must never make. The comparison
    is on the value's text, as the recipes spell it.
    """
    if current is None or type(current).__name__ == "_Empty":
        return True
    _gate, op, value = gate
    return (str(current) == value) if op == "==" else (str(current) != value)

text_binding

text_binding(
    source,
    *,
    text_cols=("text",),
    id_cols=(),
    pass_through=False,
    text_mode="concat",
    group_by=()
)

Build the input arguments a text analyzer needs for a given source.

The analyzers take exactly one of three input modes and the arguments for the other two are silently ignored, so this returns the complete group rather than a patch. Callers strip :data:TEXT_INPUT_KEYS first and merge this in, which makes it impossible for a leftover csv_path to sit alongside a fresh txt_dir.

Parameters:

Name Type Description Default
source ('media', 'txt_dir', 'csv')

"media" returns {}: on that path the transcript wiring already in the recipe is correct and must not be touched.

"media"
text_cols sequence of str

Only meaningful for "csv" -- which columns hold the text, and which identify the row. id_cols composes text_id; with none, the gatherer falls back to row_<n>.

('text',)
id_cols sequence of str

Only meaningful for "csv" -- which columns hold the text, and which identify the row. id_cols composes text_id; with none, the gatherer falls back to row_<n>.

('text',)
text_mode ('concat', 'separate')

What to do when text_cols names more than one column. concat joins them into one piece of text per row; separate measures each column on its own, giving one output row per column per input row with a source_col saying which is which. Meaningless for a single column, where the two are identical.

"concat"
pass_through bool

Whether this step wants pass_through_cols (sentence embeddings does; the others do not).

False

Returns:

Type Description
dict

Arguments to merge into a step's with: block.

Source code in src\taters\ui\recipes.py
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
def text_binding(
    source: str,
    *,
    text_cols: Sequence[str] = ("text",),
    id_cols: Sequence[str] = (),
    pass_through: bool = False,
    text_mode: str = "concat",
    group_by: Sequence[str] = (),
) -> dict:
    """
    Build the input arguments a text analyzer needs for a given source.

    The analyzers take exactly one of three input modes and the arguments for
    the other two are silently ignored, so this returns the *complete* group
    rather than a patch. Callers strip :data:`TEXT_INPUT_KEYS` first and merge
    this in, which makes it impossible for a leftover ``csv_path`` to sit
    alongside a fresh ``txt_dir``.

    Parameters
    ----------
    source : {"media", "txt_dir", "csv"}
        ``"media"`` returns ``{}``: on that path the transcript wiring already
        in the recipe is correct and must not be touched.
    text_cols, id_cols : sequence of str
        Only meaningful for ``"csv"`` -- which columns hold the text, and which
        identify the row. ``id_cols`` composes ``text_id``; with none, the
        gatherer falls back to ``row_<n>``.
    text_mode : {"concat", "separate"}
        What to do when ``text_cols`` names more than one column. ``concat``
        joins them into one piece of text per row; ``separate`` measures each
        column on its own, giving one output row per column per input row with
        a ``source_col`` saying which is which. Meaningless for a single
        column, where the two are identical.
    pass_through : bool
        Whether this step wants ``pass_through_cols`` (sentence embeddings
        does; the others do not).

    Returns
    -------
    dict
        Arguments to merge into a step's ``with:`` block.
    """
    if source == "media":
        return {}

    if source == "txt_dir":
        # the folder walker gets text_id from the filename, so there are no id
        # columns to choose and nothing to group by: one text per file.
        #
        # gathered_csv keeps the intermediate table inside the run's own
        # folder. left to itself the analyzer writes it beside the *source*,
        # so analyzing a folder in someone's Downloads would leave a stray
        # file in their Downloads.
        binding: dict = {
            "txt_dir": "{{var:input_dir}}",
            "gathered_csv": "gathered/texts.csv",
            "pattern": "{{var:txt_pattern}}",
            "recursive": True,
            "id_from": "stem",
            "include_source_path": True,
        }
        if pass_through:
            binding["pass_through_cols"] = []
        return binding

    if source == "csv":
        # one feature row per spreadsheet row unless the user asked us to
        # combine rows. `group_by` and `id_cols` are either/or, not both: the
        # gatherer composes `text_id` from `id_cols` *only when not grouping*,
        # so if we sent both, one of them would get silently ignored.
        binding = {
            "csv_path": "{{var:input_csv}}",
            "gathered_csv": "gathered/texts.csv",
            "text_cols": list(text_cols),
            "mode": text_mode,
            "delimiter": "{{var:csv_delimiter}}",
        }
        carried = list(group_by) if group_by else list(id_cols)
        if group_by:
            binding["group_by"] = list(group_by)
        elif id_cols:
            # `id_cols` compose `text_id` -- for EVERY text step, the
            # pass-through one included. the sentence-embedding step used to
            # keep a synthetic `row_<n>` id instead, to protect its merge
            # from a repeating id column; but the metadata gather and every
            # other analyzer build theirs from the id columns, so the
            # embeddings never joined anything. this bit us once: an
            # embeddings + classification run died at the join with "no key
            # value appears in every input" (938 rows, 0 matched). so now it's
            # one identity everywhere, and we catch a repeating id before the
            # run instead -- the wizard checks the column, and the join
            # refuses duplicates.
            binding["id_cols"] = list(id_cols)
        if pass_through:
            # `source_col` is what tells a headline row from a body row under
            # `mode="separate"`. the analyzer only writes `text_id` plus
            # whatever we tell it to pass through, so without this the two
            # become indistinguishable rows sharing an id -- and the merge
            # downstream then averages them into one, undoing the very
            # separation the user asked for.
            binding["pass_through_cols"] = (
                carried + ["source_col"] if text_mode == "separate" else carried
            )
        return binding

    raise KeyError(f"unknown source {source!r}. Known: {', '.join(sorted(SOURCES))}")

by_id

by_id(recipe_id)

Look a recipe up by id.

Raises:

Type Description
KeyError

With the list of valid ids, because this is almost always a typo in a caller and the bare id is not enough to fix it.

Source code in src\taters\ui\recipes.py
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
def by_id(recipe_id: str) -> Recipe:
    """
    Look a recipe up by id.

    Raises
    ------
    KeyError
        With the list of valid ids, because this is almost always a typo in a
        caller and the bare id is not enough to fix it.
    """
    if recipe_id not in _BY_ID:
        _BY_ID.update({r.id: r for r in RECIPES})
    try:
        return _BY_ID[recipe_id]
    except KeyError:
        raise KeyError(
            f"unknown recipe {recipe_id!r}. "
            f"Known: {', '.join(sorted(r.id for r in RECIPES))}"
        ) from None

user_facing

user_facing(source='media', stage='extract')

The recipes to show in one wizard checklist, in catalog order.

Filtered by source, so someone who said "I have a folder of essays" is never offered vocal acoustics -- an option that could only ever fail for them, and that costs a multi-gigabyte install to find out. And by stage: the feature checklist and the statistics stage are different questions, and mixing "extract cohesion" with "run an ANOVA" on one screen buries both.

Source code in src\taters\ui\recipes.py
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
def user_facing(source: str = "media", stage: str = "extract") -> List[Recipe]:
    """
    The recipes to show in one wizard checklist, in catalog order.

    Filtered by source, so someone who said "I have a folder of essays" is
    never offered vocal acoustics -- an option that could only ever fail for
    them, and that costs a multi-gigabyte install to find out. And by stage:
    the feature checklist and the statistics stage are different questions,
    and mixing "extract cohesion" with "run an ANOVA" on one screen buries
    both.
    """
    if source not in SOURCES:
        raise KeyError(f"unknown source {source!r}. Known: {', '.join(sorted(SOURCES))}")
    return [r for r in RECIPES
            if r.user_facing and source in r.sources and r.stage == stage]

providers_of

providers_of(capability)

Every recipe that can satisfy a capability, in catalog order.

More than one means the user has a choice to make -- transcript_csv is the case that matters, with plain transcription and diarization both able to produce it.

Source code in src\taters\ui\recipes.py
3006
3007
3008
3009
3010
3011
3012
3013
3014
def providers_of(capability: str) -> List[Recipe]:
    """
    Every recipe that can satisfy a capability, in catalog order.

    More than one means the user has a choice to make -- ``transcript_csv`` is
    the case that matters, with plain transcription and diarization both able
    to produce it.
    """
    return [r for r in RECIPES if capability in r.produces]

Composing a preset

Turns a set of chosen features into a runnable preset: resolves prerequisites, orders the steps, and writes the metadata. Pure — no I/O, no analysis imports.

taters.ui.compose

Turn a set of chosen features into a runnable preset.

This module is pure: it takes recipe ids and option overrides, and returns a dict shaped exactly like the YAML in taters/pipelines/presets/. It touches no files, imports no analysis code, and runs in microseconds -- which is what makes it worth testing hard. tests/test_compose.py puts its output through the same validator that guards the shipped presets, so the composer structurally cannot emit a step naming a parameter that does not exist.

The job has three parts:

  1. Closure. The user checks "Readability"; that needs the merged transcript table, which needs a transcript, which needs a WAV. Four steps from one tick.
  2. Ordering. Dependencies first, and -- matching how both shipped presets are laid out -- every per-file step before every run-once step.
  3. Metadata. A full meta: block, so a composed preset is a first-class citizen: --list-presets and --describe-preset work on it exactly as they do on the built-ins.

ComposeError

Bases: RuntimeError

A selection that cannot be turned into a runnable pipeline.

slugify

slugify(text)

Reduce a title to a safe preset id / filename stem.

Source code in src\taters\ui\compose.py
52
53
54
55
def slugify(text: str) -> str:
    """Reduce a title to a safe preset id / filename stem."""
    slug = re.sub(r"[^a-z0-9]+", "_", str(text).strip().lower()).strip("_")
    return slug or "custom_pipeline"

resolve_selection

resolve_selection(
    selected, *, providers=None, source="media"
)

Expand a user's picks into the full, ordered list of steps to run.

Parameters:

Name Type Description Default
selected sequence of str

Recipe ids the user checked off.

required
providers dict[str, str]

How to satisfy a capability that more than one recipe can provide, e.g. {"transcript_csv": "diarize"}. When a needed capability has exactly one provider it is chosen automatically; when it has several and no choice was given, the first in catalog order wins. The wizard always asks rather than relying on that fallback, but a scripted caller should not have to.

None
source ('media', 'txt_dir', 'csv')

Where the text comes from. Anything but "media" means the text already exists, so the text analyzers stop requiring a transcript and the whole audio half of the catalog drops out of the closure.

"media"

Returns:

Type Description
list[Recipe]

Every step to run, dependencies included, in execution order: item steps first (each after whatever it depends on), then global steps.

Raises:

Type Description
ComposeError

If a requirement has no provider at all, or if the graph is cyclic.

Notes

Capability satisfaction is checked against the chosen set, not against the catalog. That distinction matters: transcript_csv has two providers, and pulling in both would give the run two transcription steps writing to the same save_as.

Source code in src\taters\ui\compose.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
def resolve_selection(
    selected: Sequence[str],
    *,
    providers: Optional[Dict[str, str]] = None,
    source: str = "media",
) -> List[Recipe]:
    """
    Expand a user's picks into the full, ordered list of steps to run.

    Parameters
    ----------
    selected : sequence of str
        Recipe ids the user checked off.
    providers : dict[str, str], optional
        How to satisfy a capability that more than one recipe can provide, e.g.
        ``{"transcript_csv": "diarize"}``. When a needed capability has exactly
        one provider it is chosen automatically; when it has several and no
        choice was given, the *first in catalog order* wins. The wizard always
        asks rather than relying on that fallback, but a scripted caller should
        not have to.
    source : {"media", "txt_dir", "csv"}, default "media"
        Where the text comes from. Anything but ``"media"`` means the text
        already exists, so the text analyzers stop requiring a transcript and
        the whole audio half of the catalog drops out of the closure.

    Returns
    -------
    list[Recipe]
        Every step to run, dependencies included, in execution order: `item`
        steps first (each after whatever it depends on), then `global` steps.

    Raises
    ------
    ComposeError
        If a requirement has no provider at all, or if the graph is cyclic.

    Notes
    -----
    Capability satisfaction is checked against the *chosen set*, not against
    the catalog. That distinction matters: ``transcript_csv`` has two providers,
    and pulling in both would give the run two transcription steps writing to
    the same ``save_as``.
    """
    if source not in SOURCES:
        raise ComposeError(
            f"unknown source {source!r}. Known: {', '.join(sorted(SOURCES))}"
        )

    try:
        unsupported = sorted({r for r in selected
                              if source not in by_id(r).sources})
    except KeyError as e:
        # `by_id` throws a KeyError on a typo'd id, but we promise ComposeError
        # here (and that's what callers catch), so let's convert it.
        raise ComposeError(str(e).strip("'\"")) from None
    if unsupported:
        raise ComposeError(
            f"{unsupported} cannot run on {source!r} input "
            f"({SOURCES[source]}). Ask recipes.user_facing({source!r}) for what can."
        )

    providers = dict(providers or {})

    # if the user explicitly picked a provider, that settles it -- we drop any
    # *other* producer of that capability from the selection. otherwise, ticking
    # both "Transcript (one speaker)" and "Transcript with speaker labels" puts
    # both in the pipeline even after we asked them to choose, and since they
    # share `save_as: diar`, the second one silently clobbers the first for
    # every step downstream.
    rejected = {
        r.id
        for capability, keep in providers.items()
        for r in providers_of(capability)
        if r.id != keep
    }
    trimmed = [r for r in selected if r not in rejected]
    if selected and not trimmed:
        raise ComposeError(
            "the provider choice removed everything that was selected: "
            f"{sorted(set(selected) & rejected)} contradict "
            f"{ {k: v for k, v in providers.items()} }"
        )
    selected = trimmed

    chosen: Dict[str, Recipe] = {}

    # first, let's figure out what the user's own ticks produce, up front. the
    # walk used to only look at what it had *already added*, so the result
    # depended on tick order: ["readability", "diarize"] hit readability's need
    # for a transcript first, auto-picked `transcribe`, and then added the
    # user's `diarize` on top -- two transcript steps sharing save_as="diar",
    # silently clobbering each other. the reverse order worked fine. ugh.
    selected_producers: Dict[str, str] = {}
    for rid in selected:
        for capability in by_id(rid).produces:
            if (capability in selected_producers
                    and selected_producers[capability] != rid):
                # two ticked producers and nobody chose between them. if we
                # shipped both they'd share a save_as and silently clobber each
                # other downstream. the wizard always asks first
                # (resolve_providers), so if we get here it's a programmatic
                # caller's mistake -- let's make it a loud one.
                raise ComposeError(
                    f"'{selected_producers[capability]}' and '{rid}' both "
                    f"produce '{capability}'. Pass providers="
                    f"{{'{capability}': <one of them>}} to choose."
                )
            selected_producers.setdefault(capability, rid)

    def add(recipe_id: str, trail: tuple = ()) -> None:
        if recipe_id in chosen:
            return
        if recipe_id in trail:
            cycle = " -> ".join(trail[trail.index(recipe_id):] + (recipe_id,))
            raise ComposeError(f"recipes form a cycle: {cycle}")
        recipe = by_id(recipe_id)
        chosen[recipe_id] = recipe
        trail = trail + (recipe_id,)

        for capability in sorted(_requires(recipe, source)):
            # already covered by something the user picked directly? skip it.
            if any(capability in r.produces for r in chosen.values()):
                continue
            options = providers_of(capability)
            if not options:
                raise ComposeError(
                    f"'{recipe_id}' needs '{capability}', which nothing produces"
                )
            picked = providers.get(capability)
            if picked is None:
                # a producer the user actually ticked beats the catalog's first
                # option, whether or not the walk has gotten to it yet.
                picked = selected_producers.get(capability, options[0].id)
                providers[capability] = picked
            elif picked not in {o.id for o in options}:
                raise ComposeError(
                    f"'{picked}' was chosen for '{capability}' but does not "
                    f"produce it (options: {', '.join(o.id for o in options)})"
                )
            add(picked, trail)

        for follow_on in recipe.auto_with:
            add(follow_on, trail)

    for recipe_id in selected:
        add(recipe_id)

    # lastly, every feature table gets descriptive stats whether they asked or
    # not. the first thing anyone does with a new measure is look at its
    # distribution, and that used to mean opening each CSV by hand. one rule
    # here beats sticking an `auto_with` on thirteen recipes.
    if any(getattr(r, "feature_table", False) for r in chosen.values()):
        add("describe_features")

    return _order(chosen.values(), source)

pending_choices

pending_choices(selected, *, source='media')

Capabilities this selection needs that more than one recipe could satisfy.

Parameters:

Name Type Description Default
selected sequence of str

The recipe ids the user ticked.

required
source ('media', 'txt_dir', 'csv')

Where the text comes from. On a text source there is nothing to transcribe, so this returns empty and the wizard skips the question.

"media"

Returns:

Type Description
dict[str, list[Recipe]]

Capability -> the recipes that could provide it. Empty when nothing is ambiguous.

Notes

The need is resolved transitively, which is the whole reason this is not a one-line check over requires. Someone who ticks only "Readability scores" has not asked for a transcript and does not mention one anywhere in their selection -- but readability reads the merged transcript table, and that merge step is what needs a transcript. Ask them how to make one anyway, because the two answers differ by a multi-gigabyte install.

A capability whose producer the user ticked directly is not returned: they have already answered.

Source code in src\taters\ui\compose.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
def pending_choices(selected: Sequence[str], *, source: str = "media") -> Dict[str, List[Recipe]]:
    """
    Capabilities this selection needs that more than one recipe could satisfy.

    Parameters
    ----------
    selected : sequence of str
        The recipe ids the user ticked.
    source : {"media", "txt_dir", "csv"}, default "media"
        Where the text comes from. On a text source there is nothing to
        transcribe, so this returns empty and the wizard skips the question.

    Returns
    -------
    dict[str, list[Recipe]]
        Capability -> the recipes that could provide it. Empty when nothing is
        ambiguous.

    Notes
    -----
    The need is resolved *transitively*, which is the whole reason this is not
    a one-line check over ``requires``. Someone who ticks only "Readability
    scores" has not asked for a transcript and does not mention one anywhere in
    their selection -- but readability reads the merged transcript table, and
    that merge step is what needs a transcript. Ask them how to make one
    anyway, because the two answers differ by a multi-gigabyte install.

    A capability whose producer the user ticked directly is not returned: they
    have already answered.
    """
    steps = resolve_selection(selected, source=source)
    picked = set(selected)

    out: Dict[str, List[Recipe]] = {}
    for capability in CAPABILITIES:
        options = providers_of(capability)
        if len(options) < 2:
            continue
        if not any(capability in _requires(step, source) for step in steps):
            continue
        if any(option.id in picked for option in options):
            continue
        out[capability] = options
    return out

feature_tables

feature_tables(
    steps,
    source="media",
    level=None,
    group_by=(),
    picked=None,
)

The steps whose output joins into an analysis table, and what to call it.

Returns (recipe, name) pairs, in step order.

picked is what the user actually ticked. A feature table pulled in only as another step's input does not join: the topic model needs a document-term matrix, and someone who asked for topic scores has not asked for every one of the matrix's five thousand term columns to be correlated with their outcome as well. Without the list, every feature table in steps joins, which is what a hand-written pipeline means.

Two steps can describe one table. "Sentence embeddings" writes a row per utterance; "Merge sentence embeddings" averages those to a row per unit of analysis -- and only one of them is the per-text table the statistics can join. Whichever survives, the name comes from the measure rather than from the plumbing: a screen offering "Merge sentence embeddings" alongside "Sentence embeddings" reads as two feature sets, which is a thing they have never been (a real report).

One rule, one answer: the composer wires the assemble step from this and the wizard builds its picker from it, so the tables offered are exactly the tables used. They were computed separately, and disagreed.

Source code in src\taters\ui\compose.py
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
def feature_tables(steps, source: str = "media", level: Optional[str] = None,
                   group_by: Sequence[str] = (),
                   picked: Optional[Sequence[str]] = None) -> List[tuple]:
    """
    The steps whose output joins into an analysis table, and what to call it.

    Returns ``(recipe, name)`` pairs, in step order.

    ``picked`` is what the user actually ticked. A feature table pulled in
    only as another step's input does not join: the topic model needs a
    document-term matrix, and someone who asked for topic scores has not
    asked for every one of the matrix's five thousand term columns to be
    correlated with their outcome as well. Without the list, every feature
    table in ``steps`` joins, which is what a hand-written pipeline means.

    Two steps can describe one table. "Sentence embeddings" writes a row per
    utterance; "Merge sentence embeddings" averages those to a row per unit
    of analysis -- and only one of them is the per-text table the statistics
    can join. Whichever survives, the *name* comes from the measure rather
    than from the plumbing: a screen offering "Merge sentence embeddings"
    alongside "Sentence embeddings" reads as two feature sets, which is a
    thing they have never been (a real report).

    One rule, one answer: the composer wires the assemble step from this and
    the wizard builds its picker from it, so the tables offered are exactly
    the tables used. They were computed separately, and disagreed.
    """
    kept = [r for r in steps
            if getattr(r, "feature_table", False)
            and not _is_pointless_merge(r, source, level, group_by)]
    superseded = {}
    for r in kept:
        for other in kept:
            if (other is not r and (other.requires & r.produces)
                    # only a *merge* of the measure supersedes it. a step
                    # that eats one table to compute a different one (the
                    # topic model reading the document-term matrix) is a
                    # second table, not the same one re-shaped. if we folded
                    # the two together we'd end up labeling topic scores
                    # "Document-term matrix", which is just wrong.
                    and level_aware(other)
                    and other.with_.get("aggregate") is True):
                # `other` collects what `r` produced, so `other` is the
                # table -- but under `r`'s name, since that's the measure.
                superseded[r.id] = other.id
    names = {r.id: r.label for r in kept}
    for lost, winner in superseded.items():
        names[winner] = names.get(lost, names[winner])
    if picked is not None:
        wanted = set(picked)
        # a merge survives if the measure it merges was picked: the user
        # ticked "Sentence embeddings", and the per-text table is the merge.
        kept = [r for r in kept
                if r.id in wanted
                or any(lost in wanted for lost, winner in superseded.items()
                       if winner == r.id)]
    return [(r, names[r.id]) for r in kept if r.id not in superseded]

table_names

table_names(
    steps,
    source="media",
    level=None,
    group_by=(),
    picked=None,
    *,
    overrides=None,
    var_values=None,
    var_specs=None
)

What each feature table will be called in the analyses, with the step it comes from.

Returns (name, label) pairs in step order -- ("dictionary", "Dictionary categories") -- for the tables :func:feature_tables says will join. The name is the stem of the file the step writes, which is how the assemble step names a feature set and how pca and unverified_ok refer to one. It is worked out the way the composer will write it: the step bound to this source and level (a non-default level suffixes the filename), the user's own override of the output path on top, and every {{var:...}} in it rendered from the live variable values -- so a matrix under weighting: tfidf is offered as doc_term_matrix_tfidf, which is the name the run will use.

This exists so the wizard can offer the names instead of asking for them to be typed: "off, all, or the name of a feature set" was a text box, and the names it wanted were file stems nobody had seen yet.

Source code in src\taters\ui\compose.py
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
def table_names(steps, source: str = "media", level: Optional[str] = None,
                group_by: Sequence[str] = (),
                picked: Optional[Sequence[str]] = None, *,
                overrides: Optional[Dict[str, Dict[str, Any]]] = None,
                var_values: Optional[Dict[str, Any]] = None,
                var_specs: Optional[Dict[str, dict]] = None) -> List[tuple]:
    """
    What each feature table will be called in the analyses, with the step it
    comes from.

    Returns ``(name, label)`` pairs in step order -- ``("dictionary",
    "Dictionary categories")`` -- for the tables :func:`feature_tables` says
    will join. The name is the stem of the file the step writes, which is how
    the assemble step names a feature set and how ``pca`` and
    ``unverified_ok`` refer to one. It is worked out the way the composer
    will write it: the step bound to this source and level (a non-default
    level suffixes the filename), the user's own override of the output path
    on top, and every ``{{var:...}}`` in it rendered from the live variable
    values -- so a matrix under ``weighting: tfidf`` is offered as
    ``doc_term_matrix_tfidf``, which is the name the run will use.

    This exists so the wizard can *offer* the names instead of asking for
    them to be typed: "off, all, or the name of a feature set" was a text
    box, and the names it wanted were file stems nobody had seen yet.
    """
    overrides = overrides or {}
    var_values = var_values or {}
    var_specs = _collect_vars(steps) if var_specs is None else var_specs

    def live(name: str) -> str:
        if name in var_values:
            return str(var_values[name])
        return str(var_specs.get(name, {}).get("default", ""))

    out: List[tuple] = []
    for recipe, label in feature_tables(steps, source, level, group_by,
                                        picked=picked):
        step = _bind_source(recipe, source, text_cols=("text",), id_cols=(),
                            group_by=group_by, level=level)
        with_ = {**step["with"], **overrides.get(recipe.id, {})}
        template = next((with_[k] for k in _OUTPUT_KEYS
                         if isinstance(with_.get(k), str)), None)
        name = recipe.save_as
        if template is not None:
            rendered = re.sub(r"\{\{var:([^}]+)\}\}",
                              lambda m: live(m.group(1).strip()), template)
            stem = rendered.replace("\\", "/").rsplit("/", 1)[-1]
            stem = stem.rsplit(".", 1)[0] if "." in stem else stem
            # if we can't render the template (another artifact's name in
            # the path) it's not a name worth offering; the artifact key is
            # at least a stable word for the table.
            if stem and "{{" not in stem:
                name = stem
        out.append((name, label))
    return out

compose

compose(
    selected,
    *,
    providers=None,
    overrides=None,
    var_values=None,
    name="My pipeline",
    file_type="any",
    root_dir=None,
    notes="",
    source="media",
    input_path=None,
    text_cols=("text",),
    id_cols=(),
    text_mode="concat",
    group_by=(),
    delimiter=",",
    level=None,
    model_plans=()
)

Build a complete preset dict from a set of chosen features.

Parameters:

Name Type Description Default
selected sequence of str

Recipe ids the user checked off. Prerequisites are added for you.

required
providers dict[str, str]

Capability -> recipe id, for capabilities with more than one provider. In practice this is {"transcript_csv": "transcribe" | "diarize"}.

None
overrides dict[str, dict]

Per-step parameter overrides, keyed by recipe id: {"transcribe": {"beam_size": 1}}. These are written straight into the step's with: block, so they beat the {{var:...}} templates.

None
var_values dict[str, Any]

Overrides for the preset's vars: block, keyed by variable name. Unknown names are kept -- a preset may legitimately carry a variable that no shipped recipe declares.

None
name str

Human title. The preset id is its slug.

"My pipeline"
file_type ('audio', 'video', 'any')

Recorded in meta.inputs and in the generated cli_example, so re-running from the command line does not require remembering it.

"audio"
root_dir str

The input folder, baked into cli_example so that line is copy-pasteable rather than a template to fill in.

None
notes str

Free text appended to meta.notes.

''
source ('media', 'txt_dir', 'csv')

Where the text comes from. The two non-media sources rewire the text analyzers to read the user's own files, and drop transcription and everything under it.

"media"
input_path str

The folder of .txt files or the spreadsheet, depending on source. Stored as the input_dir / input_csv variable rather than baked into each step, so the preset can be re-pointed with --var instead of edited.

None
text_cols sequence of str

For source="csv": which columns hold the text, and which columns identify a row. Ignored for the other sources, where the answer is not the user's to give.

('text',)
id_cols sequence of str

For source="csv": which columns hold the text, and which columns identify a row. Ignored for the other sources, where the answer is not the user's to give.

('text',)
text_mode ('concat', 'separate')

For source="csv" with more than one text column: measure them joined together, or one at a time.

"concat"

Returns:

Type Description
dict

A preset with meta, vars, and steps keys, ready to hand to yaml.safe_dump or straight to :func:taters.pipelines.run_pipeline.run_preset.

Raises:

Type Description
ComposeError

Propagated from :func:resolve_selection.

Source code in src\taters\ui\compose.py
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
def compose(
    selected: Sequence[str],
    *,
    providers: Optional[Dict[str, str]] = None,
    overrides: Optional[Dict[str, Dict[str, Any]]] = None,
    var_values: Optional[Dict[str, Any]] = None,
    name: str = "My pipeline",
    file_type: str = "any",
    root_dir: Optional[str] = None,
    notes: str = "",
    source: str = "media",
    input_path: Optional[str] = None,
    text_cols: Sequence[str] = ("text",),
    id_cols: Sequence[str] = (),
    text_mode: str = "concat",
    group_by: Sequence[str] = (),
    delimiter: str = ",",
    level: Optional[str] = None,
    model_plans: Sequence[Any] = (),
) -> dict:
    """
    Build a complete preset dict from a set of chosen features.

    Parameters
    ----------
    selected : sequence of str
        Recipe ids the user checked off. Prerequisites are added for you.
    providers : dict[str, str], optional
        Capability -> recipe id, for capabilities with more than one provider.
        In practice this is ``{"transcript_csv": "transcribe" | "diarize"}``.
    overrides : dict[str, dict], optional
        Per-step parameter overrides, keyed by recipe id:
        ``{"transcribe": {"beam_size": 1}}``. These are written straight into
        the step's ``with:`` block, so they beat the ``{{var:...}}`` templates.
    var_values : dict[str, Any], optional
        Overrides for the preset's ``vars:`` block, keyed by variable name.
        Unknown names are kept -- a preset may legitimately carry a variable
        that no shipped recipe declares.
    name : str, default "My pipeline"
        Human title. The preset id is its slug.
    file_type : {"audio", "video", "any"}, default "any"
        Recorded in ``meta.inputs`` and in the generated ``cli_example``, so
        re-running from the command line does not require remembering it.
    root_dir : str, optional
        The input folder, baked into ``cli_example`` so that line is
        copy-pasteable rather than a template to fill in.
    notes : str, optional
        Free text appended to ``meta.notes``.
    source : {"media", "txt_dir", "csv"}, default "media"
        Where the text comes from. The two non-media sources rewire the text
        analyzers to read the user's own files, and drop transcription and
        everything under it.
    input_path : str, optional
        The folder of ``.txt`` files or the spreadsheet, depending on
        ``source``. Stored as the ``input_dir`` / ``input_csv`` variable rather
        than baked into each step, so the preset can be re-pointed with
        ``--var`` instead of edited.
    text_cols, id_cols : sequence of str
        For ``source="csv"``: which columns hold the text, and which columns
        identify a row. Ignored for the other sources, where the answer is not
        the user's to give.
    text_mode : {"concat", "separate"}
        For ``source="csv"`` with more than one text column: measure them
        joined together, or one at a time.

    Returns
    -------
    dict
        A preset with ``meta``, ``vars``, and ``steps`` keys, ready to hand to
        ``yaml.safe_dump`` or straight to
        :func:`taters.pipelines.run_pipeline.run_preset`.

    Raises
    ------
    ComposeError
        Propagated from :func:`resolve_selection`.
    """
    if not selected:
        raise ComposeError("nothing selected -- pick at least one thing to extract")

    steps = resolve_selection(selected, providers=providers, source=source)
    overrides = dict(overrides or {})
    var_values = dict(var_values or {})
    steps = _wire_controls(steps, model_plans, source=source,
                           overrides=overrides, var_values=var_values)

    # the stats assemble step joins the selected feature tables, and only we
    # (the composer) can see which ones those are. we inject them as a default
    # UNDER any caller override, so the wizard's "which tables should feed the
    # analysis" narrowing (just an ordinary override) still wins.
    tables = _feature_table_templates(steps, source, level, group_by,
                                      picked=selected)
    for r in steps:
        if getattr(r, "consumes_feature_tables", False):
            # a table we only joined so a filter could name its column isn't
            # a feature. this bit us once: a word count computed for "drop
            # texts under 25 words" got left in the feature list and showed
            # up as a ridge *predictor* -- a result that looks fine and isn't.
            filter_only = set(overrides.get(r.id, {}).get("filter_csvs") or [])
            # never itself, either: the scoring step's own output is a feature
            # table too, and a step listed among its own inputs would sit
            # waiting on an artifact that it's the one producing.
            mine = "{{" + r.save_as + "}}"
            wanted = [tpl for tpl in tables
                      if tpl not in filter_only and tpl != mine]
            if not wanted and not getattr(r, "feature_tables_optional", False):
                raise ComposeError(
                    f"'{r.label}' needs at least one feature table -- select "
                    f"a feature to extract first")
            overrides[r.id] = {"feature_csvs": wanted,
                               **overrides.get(r.id, {})}

    chosen_level = level_by_id(source, _level_for(source, level, group_by))

    var_specs = _collect_vars(steps)
    for key, spec in SOURCE_VARS[source].items():
        var_specs.setdefault(key, dict(spec))
    if input_path is not None and source != "media":
        var_specs[_INPUT_VAR[source]]["default"] = input_path
    if source == "csv":
        # the caller sniffs this from the file itself, because the extension
        # is only a hint: plenty of tab-separated files are named .csv or .txt.
        var_specs["csv_delimiter"]["default"] = delimiter
    for key, value in (var_values or {}).items():
        if key in var_specs:
            var_specs[key]["default"] = value
        else:
            var_specs[key] = {"default": value, "desc": ""}

    preset_id = slugify(name)
    chosen_ids = {r.id for r in steps}
    extras = sorted({e for r in steps for e in r.extras})
    needs_ffmpeg = any(r.needs_ffmpeg for r in steps)

    picked_labels = [by_id(i).label for i in selected if i in chosen_ids]
    added = [r.label for r in steps if r.id not in set(selected)]

    summary = "Built by the Taters wizard. Extracts: " + "; ".join(picked_labels) + "."
    note_lines = [
        ("Generated by the Taters setup wizard. It is an ordinary preset -- "
         "edit it, re-run it, or hand it to someone else."),
        "",
        f"You asked for: {', '.join(picked_labels)}.",
    ]
    if added:
        note_lines += ["", f"Added automatically as prerequisites: {', '.join(added)}."]
    # we write this down because it's the single decision that changes the
    # numbers most and otherwise leaves no trace in the output -- the same
    # measure can differ by a third between levels.
    note_lines += ["", f"One row of results describes: {chosen_level.label.lower()} "
                       f"-- {chosen_level.help}"]
    note_lines += ["", "Safe to re-run; steps short-circuit if their outputs exist."]
    if notes:
        note_lines += ["", notes]

    meta = {
        "id": preset_id,
        "title": name,
        "summary": summary,
        "inputs": _inputs_meta(source, file_type, input_path),
        "requirements": {
            "cpu": True,
            "gpu/cuda": "optional",
            "ffmpeg": needs_ffmpeg,
            "extras": extras,
        },
        "variables": {
            k: {"default": v.get("default"), "desc": v.get("desc", "")}
            for k, v in var_specs.items()
        },
        "tags": sorted({t for r in steps for t in _tags_for(r)}),
        "level": chosen_level.id,
        "version": 1,
        "notes": "\n".join(note_lines),
        "cli_example": _cli_example(preset_id, source, file_type, root_dir),
    }

    built = [
        _apply_overrides(
            _bind_source(r, source, text_cols=text_cols, id_cols=id_cols,
                         text_mode=text_mode, group_by=group_by,
                         level=level),
            overrides.get(r.id, {}),
        )
        for r in steps
        if not _is_pointless_merge(r, source, level, group_by)
    ]
    built = _splice_model_extractions(
        built, steps, model_plans, source=source, text_cols=text_cols,
        id_cols=id_cols, text_mode=text_mode, group_by=group_by, level=level,
        overrides=overrides, var_specs=var_specs)

    return {
        "meta": meta,
        "vars": {k: v.get("default") for k, v in var_specs.items()},
        "steps": built,
    }

Prompting

The interface the wizard asks its questions through, and the implementations: one backed by questionary, one that reads canned answers for the test suite.

taters.ui.prompts

The thin layer between the wizard's questions and whatever is asking them.

:class:Prompter is the whole contract: five ways to ask something, one way to wait, and two ways to say something. :mod:taters.ui.wizard is written against it and never imports questionary or rich itself.

That indirection earns its keep twice. It lets the test suite drive the entire wizard with a scripted answer list and no terminal at all -- see :class:ScriptedPrompter -- and it means a GUI would replace this one file rather than the wizard's logic.

QuitRequested

QuitRequested(ok=True)

Bases: Exception

The user chose to finish from inside a task.

Distinct from :class:Cancelled, which means "not this, take me back". A finished run offers "Quit" as a deliberate ending, and reporting that as having been backed out of would be a lie about work that succeeded.

Carries the run's verdict, because the exception skips the task's normal return False path: quitting from a failed run's finish screen used to exit 0, and anything scripted around the TUI read that as success.

Source code in src\taters\ui\prompts.py
41
42
43
def __init__(self, ok: bool = True):
    self.ok = ok
    super().__init__()

GoBack

Bases: Exception

The user pressed Esc: undo the last question rather than the whole task.

Distinct from :class:Cancelled because the two mean opposite things. Cancelled is "stop, I did not want this"; GoBack is "keep going, I just answered something wrong". Collapsing them would make a typo cost the whole session, which is exactly the sharp edge Esc exists to file off.

Cancelled

Bases: Exception

The user backed out -- Ctrl-C, or Esc on a questionary prompt.

Stage dataclass

Stage(key, label, status='todo', detail='')

One entry in the wizard's progress rail.

Choice dataclass

Choice(
    value,
    label,
    help="",
    checked=False,
    disabled="",
    annotation="",
    tone="",
)

One option in a select or checkbox list.

Prompter

Bases: Protocol

What the wizard needs from a user interface.

QuestionaryPrompter

QuestionaryPrompter()
Source code in src\taters\ui\prompts.py
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
def __init__(self) -> None:
    try:
        import questionary
        from rich.console import Console
    except ImportError as e:  # pragma: no cover - both are base dependencies
        raise ImportError(
            "The setup wizard needs `questionary` and `rich`, which ship "
            "with the base install. Reinstall with `pip install taters`."
        ) from e
    self._q = questionary
    style_descriptions_separately()
    scroll_long_lists()
    # `highlight=False` because rich's automatic highlighter colors
    # anything that looks like a number, a path or a URL. in prose that's
    # noise, and in the banner it was a visible bug: `v0.2.1` came out with
    # `v0.` dim and `2.1` in cyan, like the version got cut in half. if
    # something's styled here, it's because we styled it.
    self._console = Console(highlight=False)
    # questionary renders its own colors; keeping rich to plain output for
    # notes stops the two from fighting over the same line.
    # one accent color (teal) for whatever the user is acting on, one for
    # what they've already settled (green), and gray for context. any more
    # than that and the terminal starts to look like a fruit machine.
    self._style = questionary.Style([
        ("qmark", "fg:#00afaf bold"),
        ("question", "fg:#d7d7d7 bold"),
        ("answer", "fg:#00af5f bold"),
        ("pointer", "fg:#00afaf bold"),
        ("highlighted", "fg:#00afaf bold"),
        ("selected", "fg:#00af5f"),
        ("separator", "fg:#585858"),
        ("instruction", "fg:#808080 italic"),
        ("text", "fg:#d7d7d7"),
        # trailing facts on a row (file counts, sizes): green, the same
        # green as "done" and "answer", so that a folder with usable files
        # in it reads as a hit while you're scanning.
        ("annotation", "fg:#00af5f"),
        ("tone-good", "fg:#00af5f bold"),
        # wayfinding rows (↑ Up to …): warm, and distinct from both the
        # affirmative green and the cyan pointer, so the way back is easy
        # to find at a glance.
        ("tone-nav", "fg:#d7af5f"),
        # destructive acts: red, so "Delete the 6 ticked files" can't be
        # mistaken for one more forward-moving green row.
        ("tone-danger", "fg:#d75f5f"),
        # the help for whichever option is highlighted. dimmer than the
        # options and italic, so it reads as a note attached to the list
        # rather than another thing in it.
        ("description", "fg:#9a9a9a italic"),
        ("disabled", "fg:#585858 italic"),
    ])

ticks_in_place class-attribute instance-attribute

ticks_in_place = False

A :class:Prompter backed by questionary for input and rich for output.

Both are imported in __init__ rather than at module scope, so that importing :mod:taters.ui.wizard -- which the tests do -- never requires a terminal library to be present.

repaint

repaint()

Redraw the screen furniture. A no-op here: this renderer has none.

Source code in src\taters\ui\prompts.py
317
318
319
320
def repaint(self) -> None:
    """
    Redraw the screen furniture. A no-op here: this renderer has none.
    """

working

working(text)

Announce slow work before it starts, on screen immediately.

A note plus a repaint, which is the pair that guarantees the line is visible before the blocking call rather than after it: the live renderer's screen is wiped per question, and a note printed onto a just-finished screen without the repaint could be cleared before it was ever seen. Dim, because it is narration, not an answer.

Source code in src\taters\ui\prompts.py
322
323
324
325
326
327
328
329
330
331
332
333
def working(self, text: str) -> None:
    """
    Announce slow work *before* it starts, on screen immediately.

    A note plus a repaint, which is the pair that guarantees the line is
    visible before the blocking call rather than after it: the live
    renderer's screen is wiped per question, and a note printed onto a
    just-finished screen without the repaint could be cleared before it
    was ever seen. Dim, because it is narration, not an answer.
    """
    self.note(f"  {text}", style="dim")
    self.repaint()

clear

clear()

Start on a clean screen.

The scrollback is untouched -- this scrolls the screen rather than erasing history, so whatever the user had before is still there to page back to.

Source code in src\taters\ui\prompts.py
335
336
337
338
339
340
341
342
343
344
345
346
347
def clear(self) -> None:
    """
    Start on a clean screen.

    The scrollback is untouched -- this scrolls the screen rather than
    erasing history, so whatever the user had before is still there to page
    back to.
    """
    self._console.clear()
    # nothing on screen yet, so a leading blank note has nothing to
    # separate; we drop it rather than push the first line down.
    self._blank_last = True
    self._rows_painted = 0

note

note(text, *, style='', wrap=True)

Print a note, keeping its left margin on every line.

Callers indent by writing spaces into the string, which reads naturally at the call site but only ever indented the first line: rich wrapped the rest back to column 0. Paragraphs therefore had a ragged left edge that alternated between the margin and the screen edge, which is what made blocks of notes run into each other instead of stacking.

So the leading spaces are read off as a margin and re-applied as a hanging indent, per line, and the text is wrapped inside what is left. Every line of a block now starts in the same column, which is what lets the eye see where one block stops and the next begins.

Source code in src\taters\ui\prompts.py
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
def note(self, text: str, *, style: str = "", wrap: bool = True) -> None:
    """
    Print a note, keeping its left margin on every line.

    Callers indent by writing spaces into the string, which reads naturally
    at the call site but only ever indented the *first* line: rich wrapped
    the rest back to column 0. Paragraphs therefore had a ragged left edge
    that alternated between the margin and the screen edge, which is what
    made blocks of notes run into each other instead of stacking.

    So the leading spaces are read off as a **margin** and re-applied as a
    hanging indent, per line, and the text is wrapped inside what is left.
    Every line of a block now starts in the same column, which is what lets
    the eye see where one block stops and the next begins.
    """
    # `wrap=False` is for lines that have to survive copy-paste intact -- a
    # command with a long path in it, say. better to let those run off the
    # edge than fold them in half, so we print them exactly as given.
    if not wrap:
        self._console.print(text, style=style or None, soft_wrap=True)
        self._blank_last = False
        self._rows_painted += str(text).count("\n") + 1
        return

    for wrapped in note_lines(text, self.note_width()):
        if not wrapped.strip():
            # consecutive blanks (and a blank landing at the top of a fresh
            # screen) collapse into one. spacing between blocks is written
            # by hand at ~40 call sites, so doubled and leading gaps were
            # bound to happen; swallowing them here keeps the rhythm of the
            # screen even without us auditing every caller.
            if not self._blank_last:
                self._console.print()
                self._blank_last = True
                self._rows_painted += 1
            continue
        self._console.print(wrapped, style=style or None, soft_wrap=True)
        self._rows_painted += 1
        self._blank_last = False

note_width

note_width()

How wide a note's text may be, in cells.

Source code in src\taters\ui\prompts.py
389
390
391
def note_width(self) -> int:
    """How wide a note's text may be, in cells."""
    return max(min(self._console.width, MEASURE), 20)

stage

stage(key, label, *, status='active', detail='')

No-op: this renderer prints line by line and has no rail to update.

Source code in src\taters\ui\prompts.py
413
414
415
def stage(self, key: str, label: str, *, status: str = "active",
          detail: str = "") -> None:
    """No-op: this renderer prints line by line and has no rail to update."""

reset_stages

reset_stages()

No-op, for the same reason: there is no rail to forget.

Source code in src\taters\ui\prompts.py
417
418
def reset_stages(self) -> None:
    """No-op, for the same reason: there is no rail to forget."""

reason

reason(text)

Say why the next question is being asked.

Distinct from :meth:note in where it lands and how loudly. A note is commentary printed at the top of the screen; a reason is the sentence that makes the question underneath it make sense, and it was getting lost -- dim, and separated from its question by the whole rail.

Source code in src\taters\ui\prompts.py
420
421
422
423
424
425
426
427
428
429
430
def reason(self, text: str) -> None:
    """
    Say why the next question is being asked.

    Distinct from :meth:`note` in where it lands and how loudly. A note is
    commentary printed at the top of the screen; a reason is the sentence
    that makes the question underneath it make sense, and it was getting
    lost -- dim, and separated from its question by the whole rail.
    """
    self.note("")
    self.note(f"  {_REASON_MARK} {text}", style=_REASON_STYLE)

pause

pause(message=PAUSE_MESSAGE)

Wait for the reader, and take no answer from them.

Deliberately not routed through :meth:_ask, which turns Esc into Cancelled. There is nothing here to cancel -- the screen has already been shown -- and a reader who presses Esc means the same thing as one who presses Enter.

Source code in src\taters\ui\prompts.py
454
455
456
457
458
459
460
461
462
463
def pause(self, message: str = PAUSE_MESSAGE) -> None:
    """
    Wait for the reader, and take no answer from them.

    Deliberately not routed through :meth:`_ask`, which turns Esc into
    `Cancelled`. There is nothing here to cancel -- the screen has already
    been shown -- and a reader who presses Esc means the same thing as one
    who presses Enter.
    """
    self._wait_for_key(message)

ScriptedPrompter dataclass

ScriptedPrompter(
    answers=list(),
    asked=list(),
    presented=list(),
    offered=list(),
    cycled=list(),
    tables=list(),
    stages=list(),
    reasons=list(),
    select_defaults=dict(),
    breadcrumbs=list(),
    output=list(),
    _cursor=0,
)

ticks_in_place class-attribute instance-attribute

ticks_in_place = True

A :class:Prompter that reads its answers from a list instead of a person.

This is what makes the wizard testable end to end. Give it the answers a user would have given, in order, and every question is answered without a terminal:

p = ScriptedPrompter(["./media", "audio", ["transcribe"], False, "run", True, False])

Answers are consumed in the order the wizard asks. Everything printed is kept in :attr:output, every question in :attr:asked, every pre-filled default in :attr:presented, and every list of options in :attr:offered -- so a test can assert on what the user would have seen, not just on what came back. presented matters more than it looks: a prompt's default is how the wizard shows you the current value of a setting, and showing the wrong one is a real bug even though the returned answer is unaffected.

text_output property

text_output

Everything printed, joined -- convenient for in assertions.

pause

pause(message=PAUSE_MESSAGE)

Recorded, but consumes no scripted answer.

That is the point of it being separate from confirm: a pause is not a decision, so a test driving a screen that ends in one does not have to supply an answer for it -- and cannot accidentally feed the pause an answer meant for the next real question.

Source code in src\taters\ui\prompts.py
722
723
724
725
726
727
728
729
730
731
732
def pause(self, message: str = PAUSE_MESSAGE) -> None:
    """
    Recorded, but consumes no scripted answer.

    That is the point of it being separate from `confirm`: a pause is not a
    decision, so a test driving a screen that ends in one does not have to
    supply an answer for it -- and cannot accidentally feed the pause an
    answer meant for the next real question.
    """
    self.asked.append(("pause", message))
    self.output.append(message)

offered_choices

offered_choices(question_startswith)

The options shown for the first matching question.

What a user was offered is as much a part of the interface as what they answered: a checklist that quietly includes vocal acoustics for a folder of essays is a bug no assertion on the return value would catch.

Source code in src\taters\ui\prompts.py
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
def offered_choices(self, question_startswith: str) -> List[Choice]:
    """
    The options shown for the first matching question.

    What a user was *offered* is as much a part of the interface as what
    they answered: a checklist that quietly includes vocal acoustics for a
    folder of essays is a bug no assertion on the return value would catch.
    """
    for question, choices in self.offered:
        if question.startswith(question_startswith):
            return choices
    raise AssertionError(
        f"no question started with {question_startswith!r}. Asked: "
        + "; ".join(q for q, _ in self.offered)
    )

with_annotation

with_annotation(title, annotation)

A rendered row title with its annotation replaced (or added).

Rows are built by _to_q as [(label_class, label), ("class:annotation", " " + annotation)]; a plain string is a row that never had one. The left/right binding rewrites the pointed row's annotation in place so the list repaints without being rebuilt.

Source code in src\taters\ui\prompts.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def with_annotation(title, annotation: str):
    """
    A rendered row title with its annotation replaced (or added).

    Rows are built by ``_to_q`` as ``[(label_class, label),
    ("class:annotation", " " + annotation)]``; a plain string is a row that
    never had one. The left/right binding rewrites the pointed row's
    annotation in place so the list repaints without being rebuilt.
    """
    if isinstance(title, str):
        return [("class:text", title), ("class:annotation", " " + annotation)]
    rows = [tuple(t) for t in title]
    if rows and rows[-1][0] == "class:annotation":
        rows[-1] = ("class:annotation", " " + annotation)
    else:
        rows.append(("class:annotation", " " + annotation))
    return rows

flip_tick_mark

flip_tick_mark(title)

"[ ]" <-> "[x]" in a row's rendered title, whichever shape it has.

questionary titles are either a plain string or a list of (style, text) tuples (ours carry the annotation as a second tuple); the mark always lives in the first text segment. Used by the in-place space toggle, where the row must change on screen without the prompt being torn down.

Source code in src\taters\ui\prompts.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def flip_tick_mark(title):
    """
    "[ ]" <-> "[x]" in a row's rendered title, whichever shape it has.

    questionary titles are either a plain string or a list of (style, text)
    tuples (ours carry the annotation as a second tuple); the mark always
    lives in the first text segment. Used by the in-place space toggle, where
    the row must change on screen without the prompt being torn down.
    """
    def swap(text: str) -> str:
        if "[ ]" in text:
            return text.replace("[ ]", "[x]", 1)
        return text.replace("[x]", "[ ]", 1)

    if isinstance(title, str):
        return swap(title)
    if isinstance(title, list) and title:
        style, text = title[0]
        return [(style, swap(text))] + list(title[1:])
    return title

ask_at_least_one

ask_at_least_one(
    prompter, question, choices, *, thing="one", cycle=None
)

A checkbox that will not take "nothing" for an answer.

Three copies of this loop had grown -- the text columns, the grouping columns, the feature checklist -- each with its own wording for the same complaint. An empty answer is never meaningful at any of them: it would either crash later or quietly analyze nothing.

The "space to tick, enter when done" gloss the questions used to carry is gone: the key hint bar under every checkbox already says [space] tick · [enter] confirm, so it was the same instruction twice, and it pushed the questions past the width prose wraps at.

Source code in src\taters\ui\prompts.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
def ask_at_least_one(prompter: "Prompter", question: str,
                     choices: Sequence[Choice], *, thing: str = "one",
                     cycle: Optional[Callable[[str, int], Optional[str]]] = None
                     ) -> List[str]:
    """
    A checkbox that will not take "nothing" for an answer.

    Three copies of this loop had grown -- the text columns, the grouping
    columns, the feature checklist -- each with its own wording for the same
    complaint. An empty answer is never meaningful at any of them: it would
    either crash later or quietly analyze nothing.

    The "space to tick, enter when done" gloss the questions used to carry is
    gone: the key hint bar under every checkbox already says `[space] tick ·
    [enter] confirm`, so it was the same instruction twice, and it pushed the
    questions past the width prose wraps at.
    """
    while True:
        picked = list(prompter.checkbox(question, choices, cycle=cycle)
                      if cycle is not None else prompter.checkbox(question, choices))
        if picked:
            return picked
        prompter.note(f"  Nothing ticked — pick at least {thing}.", style="yellow")

terminal_width

terminal_width(default=80)

Usable width, for wrapping help text.

Source code in src\taters\ui\prompts.py
796
797
798
def terminal_width(default: int = 80) -> int:
    """Usable width, for wrapping help text."""
    return shutil.get_terminal_size((default, 24)).columns

set_chrome_rows

set_chrome_rows(rows)

Tell :func:visible_rows how much of the screen is already spoken for.

Source code in src\taters\ui\prompts.py
821
822
823
824
def set_chrome_rows(rows: Optional[int]) -> None:
    """Tell :func:`visible_rows` how much of the screen is already spoken for."""
    global _measured_chrome
    _measured_chrome = rows

set_description_rows

set_description_rows(rows)

Hold the description block under a list at a fixed height.

questionary draws the pointed row's description under the list and nothing at all when that row has none, so a list's height changed with every arrow press -- and each change scrolled the terminal, walking the explanation printed above the question up the screen a line or two at a time (a real report: "the yellow text moves up"). Reserving the tallest description's height and padding shorter ones keeps the whole screen still.

Source code in src\taters\ui\prompts.py
832
833
834
835
836
837
838
839
840
841
842
843
844
845
def set_description_rows(rows: int) -> None:
    """
    Hold the description block under a list at a fixed height.

    questionary draws the pointed row's description under the list and
    nothing at all when that row has none, so a list's height changed with
    every arrow press -- and each change scrolled the terminal, walking the
    explanation printed above the question up the screen a line or two at a
    time (a real report: "the yellow text moves up"). Reserving the tallest
    description's height and padding shorter ones keeps the whole screen
    still.
    """
    global _reserved_description_rows
    _reserved_description_rows = max(0, int(rows))

description_rows

description_rows(choices, *, width=None, cap=6)

The lines the tallest description among choices will take, capped so one essay of a help text cannot eat the list's room.

Source code in src\taters\ui\prompts.py
848
849
850
851
852
853
854
855
856
857
def description_rows(choices: Sequence["Choice"], *,
                     width: Optional[int] = None, cap: int = 6) -> int:
    """The lines the tallest description among ``choices`` will take, capped
    so one essay of a help text cannot eat the list's room."""
    tallest = 0
    for choice in choices:
        if choice.help:
            lines = wrap_description(choice.help, width=width).count("\n") + 1
            tallest = max(tallest, lines)
    return min(tallest, cap)

visible_rows

visible_rows(lines=None)

How many choices a list can show before it has to scroll.

Measured from the terminal prompt_toolkit is actually drawing into, not from shutil. The two agree in production and can differ anywhere the output is redirected -- a captured session, a test harness -- and a window sized to a different terminal than the one being drawn puts rows off the bottom while claiming they are visible.

Source code in src\taters\ui\prompts.py
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
def visible_rows(lines: Optional[int] = None) -> int:
    """
    How many choices a list can show before it has to scroll.

    Measured from the terminal prompt_toolkit is actually drawing into, not from
    `shutil`. The two agree in production and can differ anywhere the output is
    redirected -- a captured session, a test harness -- and a window sized to a
    different terminal than the one being drawn puts rows off the bottom while
    claiming they are visible.
    """
    if lines is None:
        try:
            from prompt_toolkit.application.current import get_app

            lines = get_app().output.get_size().rows
        except Exception:
            import shutil

            lines = shutil.get_terminal_size((80, 24)).lines
    chrome = _CHROME_LINES if _measured_chrome is None else _measured_chrome
    return max(_MIN_VISIBLE_ROWS, lines - chrome - _reserved_description_rows)

note_lines

note_lines(text, width)

Exactly the lines :meth:Prompter.note will print for this text.

Wrapped here rather than by rich so the indent is real text instead of padding. Padding fills each line out to the full width, which leaves trailing whitespace on every line of every note -- invisible on screen, and there in anything the user copies out of the terminal.

Shared with the screen painter, which has to know how tall a note is before printing it so a long one cannot crowd the question underneath. Measuring by re-implementing the wrapping is how the two drift apart, so there is one function and the printer calls it too. (Runs of blank lines collapse when printed, which depends on what came before; this returns them uncollapsed, so a height taken from it is an upper bound.)

Source code in src\taters\ui\prompts.py
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
def note_lines(text: object, width: int) -> List[str]:
    """
    Exactly the lines :meth:`Prompter.note` will print for this text.

    Wrapped here rather than by rich so the indent is real text instead of
    padding. Padding fills each line out to the full width, which leaves
    trailing whitespace on every line of every note -- invisible on screen,
    and there in anything the user copies out of the terminal.

    Shared with the screen painter, which has to know how tall a note is
    *before* printing it so a long one cannot crowd the question underneath.
    Measuring by re-implementing the wrapping is how the two drift apart, so
    there is one function and the printer calls it too. (Runs of blank lines
    collapse when printed, which depends on what came before; this returns
    them uncollapsed, so a height taken from it is an upper bound.)
    """
    import textwrap

    out: List[str] = []
    for line in str(text).split("\n"):
        body = line.strip()
        if not body:
            out.append("")
            continue
        margin = " " * (len(line) - len(line.lstrip(" ")))
        out += textwrap.wrap(
            body, width=width, initial_indent=margin, subsequent_indent=margin,
            # both off, because both would mangle what they touch here: paths
            # get broken across lines at their separators, and a sentence
            # ending in a version number picks up a stray second space.
            break_on_hyphens=False, break_long_words=False,
        ) or [margin + body]
    return out

scroll_long_lists

scroll_long_lists()

Show a window onto a long list, with markers for what is off each end.

prompt_toolkit already scrolls to keep the pointer visible, so a list longer than the terminal has always been navigable -- but nothing said so. Rows simply were not there, with no hint that arrowing further would reveal them, which reads as a list that is missing options rather than one that continues.

Applied to every list at once -- module options, the file browser, the step menus -- because the fix belongs to the renderer rather than to any one question.

Source code in src\taters\ui\prompts.py
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
def scroll_long_lists() -> bool:
    """
    Show a window onto a long list, with markers for what is off each end.

    prompt_toolkit already scrolls to keep the pointer visible, so a list longer
    than the terminal has always been navigable -- but nothing said so. Rows
    simply were not there, with no hint that arrowing further would reveal them,
    which reads as a list that is missing options rather than one that continues.

    Applied to every list at once -- module options, the file browser, the step
    menus -- because the fix belongs to the renderer rather than to any one
    question.
    """
    try:
        from questionary.prompts.common import InquirerControl
    except Exception:
        return False

    if getattr(InquirerControl, "_taters_scrolls", False):
        return True

    original = InquirerControl._get_choice_tokens

    def windowed(self):
        tokens = original(self)
        rows: List[list] = [[]]
        for token in tokens:
            if tuple(token[:2]) == ("", "\n"):
                rows.append([])
            else:
                rows[-1].append(token)

        count = len(getattr(self, "filtered_choices", []) or [])
        choices, tail = rows[:count], rows[count:]
        tail = _padded_tail(tail, _reserved_description_rows)
        pointed = int(getattr(self, "pointed_at", 0) or 0)

        # the pointed row turns the highlight color *whole*. questionary only
        # applies `class:highlighted` to plain-string titles; a row carrying
        # its own styles (the green "✓ Use this folder", an annotated folder)
        # kept them when pointed, so the only sign of where you were was the
        # pointer glyph. we append the class here so the highlight wins, since
        # later classes override earlier ones.
        if 0 <= pointed < len(choices):
            choices[pointed] = [
                (f"{style} class:highlighted" if "pointer" not in style else style,
                 text)
                for style, text in choices[pointed]
            ]

        room = visible_rows()
        if not choices or len(choices) <= room:
            out = []
            for chunk in choices:
                out += chunk
                out.append(("", "\n"))
            for chunk in tail:
                out += chunk
                out.append(("", "\n"))
            if out and tuple(out[-1][:2]) == ("", "\n"):
                out.pop()
            return out

        # rows pinned above the scroll. a gated tick screen sets one: its
        # "✓ Done" row is the way out, and a way out that scrolls off the top
        # of a forty-option list is a door someone has to go looking for. the
        # pinned rows stay put; the "▲ N more above" marker sits between them
        # and the window.
        sticky_n = min(int(getattr(self, "_taters_sticky_rows", 0) or 0),
                       len(choices))
        sticky, rest = choices[:sticky_n], choices[sticky_n:]
        shown, above, below = _window_rows(
            rest, max(0, int(getattr(self, "pointed_at", 0) or 0) - sticky_n),
            max(1, room - sticky_n))

        out: List[tuple] = []
        for row in sticky:
            out += row
            out.append(("", "\n"))
        # both marker rows get drawn once a list scrolls, blank when there's
        # nothing on that side. we used to draw them only when they had
        # something to say, so the list grew a line the moment the pointer
        # passed the middle (▲ showed up) and lost one at the very end (▼ went
        # away) -- and every change of height scrolled the terminal, walking
        # the explanation above the question up the screen. now a windowed
        # list is the same height on every keypress, and it's the height we
        # budgeted for anyway (_BELOW_LIST counts both markers)
        out += [("class:instruction", f"   ▲ {above} more above" if above else " "),
                ("", "\n")]
        for row in shown:
            out += row
            out.append(("", "\n"))
        out += [("class:instruction", f"   ▼ {below} more below" if below else " ")]
        for row in tail:
            if out and tuple(out[-1][:2]) != ("", "\n"):
                out.append(("", "\n"))
            out += row
        return out

    try:
        InquirerControl._get_choice_tokens = windowed
        InquirerControl._taters_scrolls = True
    except Exception:      # pragma: no cover - a hardened questionary
        return False
    return True

style_descriptions_separately

style_descriptions_separately()

Give a choice's description its own style class, so it can be colored.

questionary tags the description with class:text -- the same class it uses for every unselected option title. Restyling that class would recolour the whole list, so the description cannot be told apart from the options it is explaining without this.

The interception is deliberately narrow: it rewrites the class of exactly one token, identified by the prefix questionary itself writes, and leaves every other token as it found it. Applied once, and reported rather than assumed -- if a future questionary builds its tokens differently the styling is simply not applied, which is the state this started in.

Returns:

Type Description
bool

Whether the interception is in place.

Source code in src\taters\ui\prompts.py
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
def style_descriptions_separately() -> bool:
    """
    Give a choice's description its own style class, so it can be colored.

    questionary tags the description with ``class:text`` -- the same class it
    uses for every unselected option title. Restyling that class would recolour
    the whole list, so the description cannot be told apart from the options it
    is explaining without this.

    The interception is deliberately narrow: it rewrites the class of exactly
    one token, identified by the prefix questionary itself writes, and leaves
    every other token as it found it. Applied once, and reported rather than
    assumed -- if a future questionary builds its tokens differently the styling
    is simply not applied, which is the state this started in.

    Returns
    -------
    bool
        Whether the interception is in place.
    """
    try:
        from questionary.prompts.common import InquirerControl
    except Exception:
        return False

    if getattr(InquirerControl, "_taters_description_class", False):
        return True

    original = InquirerControl._get_choice_tokens

    def with_description_class(self):
        out = []
        for token in original(self):
            if (len(token) == 2 and isinstance(token[1], str)
                    and token[1].startswith(_DESCRIPTION_PREFIX)):
                # a breath between the menu and the sentence explaining the
                # pointed row. butted straight up against the last option, the
                # description read as one more row of the list.
                out.append(("", "\n"))
                out.append(("class:description", token[1]))
            else:
                out.append(token)
        return out

    try:
        InquirerControl._get_choice_tokens = with_description_class
        InquirerControl._taters_description_class = True
    except Exception:      # pragma: no cover - a hardened questionary
        return False
    return True

wrap_description

wrap_description(text, *, width=None)

Fold a choice's description so all of it is visible.

questionary renders a description as a single run of text and does not wrap it, so anything longer than the terminal is simply cut -- and the sentence that explains an option is exactly the sentence someone is reading when they cannot decide. One real menu ended mid-word: "...which is a large install (NeMo) plu".

Continuation lines are indented to sit under the first rather than returning to column zero, so the block reads as one paragraph attached to the option instead of as unrelated text under the list.

Source code in src\taters\ui\prompts.py
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
def wrap_description(text: str, *, width: Optional[int] = None) -> str:
    """
    Fold a choice's description so all of it is visible.

    questionary renders a description as a single run of text and does not wrap
    it, so anything longer than the terminal is simply cut -- and the sentence
    that explains an option is exactly the sentence someone is reading when they
    cannot decide. One real menu ended mid-word: "...which is a large install
    (NeMo) plu".

    Continuation lines are indented to sit under the first rather than returning
    to column zero, so the block reads as one paragraph attached to the option
    instead of as unrelated text under the list.
    """
    import textwrap

    columns = (width or terminal_width()) - len(_DESCRIPTION_PREFIX) - 2
    if columns < 20:                     # a terminal this narrow is beyond help
        return text
    lines = textwrap.wrap(" ".join(str(text).split()), width=columns)
    return ("\n" + " " * len(_DESCRIPTION_PREFIX)).join(lines)

fit

fit(text, *, reserve=0, width=None)

Shorten one line so the terminal never has to break it.

questionary draws a choice on a single line and does no wrapping of its own, so anything too long is hard-wrapped by the terminal -- mid-word, with the remainder dangling on the next line under no pointer. In a menu that turns a tidy list into a wall. Better to lose the tail of a description than the shape of the list, so this elides instead.

Parameters:

Name Type Description Default
reserve int

Columns already spoken for by whatever draws the line -- questionary's pointer and checkbox glyphs, an indent.

0
Source code in src\taters\ui\prompts.py
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
def fit(text: str, *, reserve: int = 0, width: Optional[int] = None) -> str:
    """
    Shorten one line so the terminal never has to break it.

    questionary draws a choice on a single line and does no wrapping of its
    own, so anything too long is hard-wrapped by the terminal -- mid-word, with
    the remainder dangling on the next line under no pointer. In a menu that
    turns a tidy list into a wall. Better to lose the tail of a description
    than the shape of the list, so this elides instead.

    Parameters
    ----------
    reserve : int
        Columns already spoken for by whatever draws the line -- questionary's
        pointer and checkbox glyphs, an indent.
    """
    limit = (width or terminal_width()) - reserve
    if limit < 12:                       # a terminal this narrow is beyond help
        return text
    # we only collapse line breaks, not runs of spaces: callers pad names
    # into columns, and flattening that would undo the very alignment this is
    # supposed to protect.
    single = str(text).replace("\r", " ").replace("\n", " ").replace("\t", " ")
    if len(single) <= limit:
        return single
    return single[: limit - 1].rstrip() + "…"

Choosing a file

A filesystem browser built from the ordinary selection prompt, so it needs nothing from the renderer that the scripted prompter cannot also do — which is what makes it testable without a terminal.

taters.ui.browse

Pick a folder or a file by looking at them, rather than by typing a path.

Typing a path is the single most error-prone thing the wizard asks for. It is also the one place where a mistake is invisible until it is too late: a typo in a folder name produces "no files found", which reads as "there is nothing here" rather than "you are looking in the wrong place". People who do not live in a terminal do not necessarily know where they are, what the working directory is, or that ~ means anything.

So this is a browser built out of the ordinary select prompt: entries are the folders and files you can see, plus the moves you can make from here. It needs nothing from the renderer that the scripted prompter cannot also do, which is why it is testable without a terminal.

Typing is still available, because for someone who does know the path, browsing to it is the slow way round.

short_path

short_path(path, keep=2)

A path short enough to sit in a question, with the end kept.

The end is the part that identifies it -- …/scratchpad/hub says where you are; the first sixty characters of a temp directory do not. Home is abbreviated to ~ for the same reason.

Source code in src\taters\ui\browse.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def short_path(path: Path, keep: int = 2) -> str:
    """
    A path short enough to sit in a question, with the end kept.

    The end is the part that identifies it -- ``…/scratchpad/hub`` says where
    you are; the first sixty characters of a temp directory do not. Home is
    abbreviated to ``~`` for the same reason.
    """
    path = Path(path)
    try:
        path = Path("~") / path.relative_to(Path.home())
    except ValueError:
        pass

    parts = path.parts
    if len(parts) <= keep + 1:
        return str(path)
    return "…/" + "/".join(parts[-keep:])

human_size

human_size(n)

A file size short enough to sit in a column.

Source code in src\taters\ui\browse.py
100
101
102
103
104
105
106
def human_size(n: int) -> str:
    """A file size short enough to sit in a column."""
    for unit in ("B", "KB", "MB", "GB"):
        if n < 1024 or unit == "GB":
            return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
        n /= 1024.0
    return ""

browse_for_folder

browse_for_folder(
    prompter,
    *,
    question="Which folder?",
    start=None,
    want_files=None
)

Walk the filesystem and return a folder.

Parameters:

Name Type Description Default
want_files sequence of str

Suffixes worth counting, e.g. (".txt",). Shown beside each folder, so the right one can be recognized rather than remembered.

None
Source code in src\taters\ui\browse.py
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
def browse_for_folder(prompter: Prompter, *, question: str = "Which folder?",
                      start: Optional[Path] = None,
                      want_files: Optional[Sequence[str]] = None) -> Path:
    """
    Walk the filesystem and return a folder.

    Parameters
    ----------
    want_files : sequence of str, optional
        Suffixes worth counting, e.g. ``(".txt",)``. Shown beside each folder,
        so the right one can be recognized rather than remembered.
    """
    return _browse(
        prompter,
        question=question,
        start=start or Path.cwd(),
        want_files=None,
        count_suffixes=tuple(s.lower() for s in want_files) if want_files else None,
        type_prompt="Path to the folder:",
    )

browse_and_tick

browse_and_tick(
    prompter,
    *,
    question="Which files?",
    start=None,
    suffixes=(".csv",)
)

A file browser that is also the selector: walk folders, tick files where they are, confirm once.

Space ticks a file in place -- no redraw, no flash -- and the selection survives walking between folders, so a set spread over several folders is still one trip. Enter means proceed: on a folder it opens it, on a file it finishes -- with the ticked set if anything is ticked, or with just that file (the quick single-file path). There is no "Import N files" menu row to hunt for; two earlier shapes hid either the files or the way out.

Returns the chosen files. Esc raises GoBack, like every other screen.

Source code in src\taters\ui\browse.py
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
def browse_and_tick(prompter: Prompter, *, question: str = "Which files?",
                    start: Optional[Path] = None,
                    suffixes: Sequence[str] = (".csv",)) -> List[Path]:
    """
    A file browser that is also the selector: walk folders, tick files where
    they are, confirm once.

    Space ticks a file in place -- no redraw, no flash -- and the selection
    survives walking between folders, so a set spread over several folders is
    still one trip. Enter means proceed: on a folder it opens it, on a file it
    finishes -- with the ticked set if anything is ticked, or with just that
    file (the quick single-file path). There is no "Import N files" menu row to
    hunt for; two earlier shapes hid either the files or the way out.

    Returns the chosen files. Esc raises GoBack, like every other screen.
    """
    suffixes = tuple(x.lower() for x in suffixes)
    box = {"here": Path(start or Path.cwd()).expanduser().resolve(),
           "note": "", "toggles": set()}
    if not box["here"].is_dir():
        box["here"] = Path.cwd().resolve()
    ticked: set = set()          # str paths, the same strings the rows carry

    def listing() -> List[Choice]:
        choices, toggles = _listing(box, want_files=suffixes,
                                    count_suffixes=suffixes, ticked=ticked)
        box["toggles"] = toggles
        return choices

    def crumb() -> str:
        return _crumb(box)

    def navigate(value: str) -> Optional[dict]:
        # enter on a folder (or Up) swaps the rows inside the running prompt;
        # the ticks live in `ticked` and ride out the walk untouched. a file or
        # an action row is a real answer and exits.
        if value == _UP:
            target = box["here"].parent
        else:
            target = Path(value)
            if value == _TYPE or not target.is_dir():
                return None
        box["here"] = target
        return {"choices": listing(), "toggle_values": box["toggles"]}

    # paint the screen furniture once; navigation happens inside the prompt.
    prompter.repaint()
    while True:
        choices = listing()
        picked = str(prompter.select(
            question, choices, transient=True,
            # space ticks in place -- the prompt keeps running, so there's no
            # redraw and the pointer doesn't move at all.
            toggle_values=box["toggles"],
            ticked=ticked,
            navigate=navigate, breadcrumb=crumb,
        ))

        if picked == _UP:
            # only a renderer that can't swap in place ever hands back a
            # navigation value; this loop is its fallback, one screen per step.
            box["here"] = box["here"].parent
            continue
        if picked == _TYPE:
            raw = str(prompter.path("Type a path (a folder to open, or a file "
                                    "to add):", default="")).strip()
            if not raw:
                continue
            typed = Path(raw).expanduser()
            if typed.is_dir():
                box["here"] = typed.resolve()
            elif typed.is_file() and typed.suffix.lower() in suffixes:
                # typing a file's path IS choosing it: we proceed with it (plus
                # whatever's already ticked) rather than making the user find
                # the row they just named and hit enter again.
                return sorted({Path(v) for v in ticked} | {typed.resolve()})
            else:
                prompter.note(f"  '{typed}' is not something I can use. "
                              "Still browsing.", style="yellow")
                prompter.repaint()
            continue

        chosen = Path(picked)
        if chosen.is_dir():
            box["here"] = chosen    # fallback renderers only, as with _UP
            continue
        # enter on a file proceeds with everything ticked plus the pointed file
        # itself -- we never silently drop ticks, and with nothing ticked this
        # is the quick single-file path.
        return sorted({Path(v) for v in ticked} | {chosen})

browse_for_file

browse_for_file(
    prompter,
    *,
    question="Which file?",
    start=None,
    suffixes=(".csv",)
)

Walk the filesystem and return a file with one of suffixes.

Source code in src\taters\ui\browse.py
418
419
420
421
422
423
424
425
426
427
428
429
430
def browse_for_file(prompter: Prompter, *, question: str = "Which file?",
                    start: Optional[Path] = None,
                    suffixes: Sequence[str] = (".csv",)) -> Path:
    """Walk the filesystem and return a file with one of ``suffixes``."""
    suffixes = tuple(s.lower() for s in suffixes)
    return _browse(
        prompter,
        question=question,
        start=start or Path.cwd(),
        want_files=suffixes,
        count_suffixes=suffixes,
        type_prompt="Path to the file:",
    )

Showing progress

Turns the pipeline runner's event stream into stacked progress bars: one for the run, one for the current step, and one for each file being worked on.

taters.ui.run_display

Live progress for a running pipeline.

The wizard used to print a line per step and then go quiet. For a single global step -- a whole spreadsheet scored in one call -- that meant a cursor sitting on an unchanging line for minutes, which is indistinguishable from a hang. The first thing a user does then is press Ctrl-C, which is the one thing guaranteed to waste the work.

So there are two bars, stacked:

  • Overall -- steps finished out of steps planned. Always meaningful.
  • Current step -- files finished out of files found, when the step fans out over inputs. A GLOBAL step is one call and cannot report its own internal progress, so it gets an elapsed-time spinner instead: no false precision, but visible proof of life.

run_preset already emits everything needed through its on_event callback, so nothing in the runner changes to support this.

RunDisplay

RunDisplay(console=None)

A rich progress display driven by run_preset events.

Used as a context manager. Failures are collected rather than printed as they happen: writing into the area a live display owns corrupts it, and a per-file error is better read at the end anyway, next to the count.

Source code in src\taters\ui\run_display.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def __init__(self, console: Any = None) -> None:
    from rich.console import Console

    self._console = console or Console()
    self._progress = None
    self._overall = None
    self._step = None
    self._label = ""
    self._slots: Dict[int, Any] = {}      # item -> visible task
    self._inflight: Dict[int, str] = {}   # item -> filename, visible or not
    # item -> (done, total, message, unit), for files that report their own
    # position. we keep this even for files with no bar on screen, because
    # a hidden file can get promoted into a slot at any moment, and a bar
    # that shows up at zero and then jumps is worse than one that shows up
    # where the work actually is.
    self._itemprog: Dict[int, tuple] = {}
    self._overflow = None
    self.failures: List[str] = []
    # warnings raised while the display is up, deduped. pandas and
    # sentence-transformers both like to warn during a perfectly normal
    # run, and warnings go to stderr rather than through `verbose`, so
    # quieting the steps doesn't quiet these. we do want to show them --
    # just not on top of a live progress bar, and not three times.
    self.notices: List[str] = []
    self._showwarning = None
    # `item_start` fires from inside the worker thread (that's the whole
    # point of it) while every other event comes in on the main thread. so
    # the bookkeeping below runs concurrently, and its check-then-act
    # ("not in _slots", then "add_task") is exactly the kind of thing that
    # loses a race: two workers both see a file unslotted, both add a bar,
    # and one of them gets orphaned. an orphaned bar isn't in any of our
    # dicts, so nothing ever removes it and it survives every step
    # boundary. that's how a four-file run ended up showing the same
    # filename three times under a step that finished minutes earlier.
    # hence the lock.
    self._lock = threading.RLock()

reporter_for

reporter_for(display)

Adapt a display to the on_event signature run_preset expects.

Source code in src\taters\ui\run_display.py
617
618
619
620
621
622
623
def reporter_for(display: Optional[RunDisplay]) -> Callable[..., None]:
    """Adapt a display to the ``on_event`` signature ``run_preset`` expects."""
    def on_event(name: str, **payload: Dict[str, Any]) -> None:
        if display is not None:
            display.handle(name, **payload)

    return on_event

The live-region renderer

The default look of the taters command: a progress rail pinned above whatever question is open, drawn inline so the terminal's scrollback survives the run.

taters.ui.live

The application-style renderer: a live region above each question.

:class:~taters.ui.prompts.QuestionaryPrompter asks one question after another and lets them scroll past, which reads like a shell script rather than a program. This renderer keeps a progress rail pinned above whatever question is currently open -- what you have answered, what you are answering, what is still to come -- so the wizard feels like one application rather than a sequence of prompts.

Why it is not a full-screen app

Taking over the terminal (the alt screen, like vim or htop) would make the framing easier, and it would also throw the entire session away the moment the wizard exits: no scrollback, nothing to copy, nothing to paste into a bug report. So this renders inline, in the normal buffer. The rail is drawn as part of the prompt's own layout, which means prompt_toolkit erases it when the question is answered, leaving only questionary's one-line record of the answer behind. Scroll up after a run and you see the questions and your answers, in order, exactly as if they had been printed.

How it works

Every questionary question is a prompt_toolkit Application whose layout is reachable as question.application.layout. :meth:LivePrompter._ask wraps that layout in an HSplit with the rail on top and a key hint below, then hands it back. questionary's own widgets -- and all of their editing, filtering and validation behavior -- are untouched, which is the whole reason this is a hundred lines instead of a thousand.

LivePrompter

LivePrompter(title='Taters')

Bases: QuestionaryPrompter

Source code in src\taters\ui\live.py
201
202
203
204
205
206
207
def __init__(self, title: str = "Taters") -> None:
    super().__init__()
    self._title = title
    self._stages: List[Stage] = []
    self._header: str = ""
    self._screen_notes: List[tuple] = []
    self._reason: str = ""

ticks_in_place class-attribute instance-attribute

ticks_in_place = True

A :class:~taters.ui.prompts.Prompter that keeps a live progress rail.

Everything :class:~taters.ui.prompts.QuestionaryPrompter does for output and input is inherited unchanged; this only adds the framing.

set_header

set_header(header)

The banner to redraw at the top of every screen.

Accepts a string, or a zero-argument callable rendered at each paint. The callable is what makes the banner's slow border-color drift real: rendered once into a string, the drift was recomputed exactly once per session and the "animation" never visibly moved.

Source code in src\taters\ui\live.py
210
211
212
213
214
215
216
217
218
219
def set_header(self, header) -> None:
    """
    The banner to redraw at the top of every screen.

    Accepts a string, or a zero-argument callable rendered at each paint.
    The callable is what makes the banner's slow border-color drift real:
    rendered once into a string, the drift was recomputed exactly once per
    session and the "animation" never visibly moved.
    """
    self._header = header

note

note(text, *, style='', wrap=True)

Print, and remember it for the next screen.

Each question wipes the screen, so a note printed between two questions would vanish before it had been read. Holding onto it until the next question has been answered is what lets "Found 412 .txt files" or "ffmpeg was not found" stay visible for exactly as long as it is about the thing on screen.

Source code in src\taters\ui\live.py
221
222
223
224
225
226
227
228
229
230
231
232
def note(self, text: str, *, style: str = "", wrap: bool = True) -> None:
    """
    Print, and remember it for the next screen.

    Each question wipes the screen, so a note printed between two questions
    would vanish before it had been read. Holding onto it until the next
    question has been *answered* is what lets "Found 412 .txt files" or
    "ffmpeg was not found" stay visible for exactly as long as it is about
    the thing on screen.
    """
    super().note(text, style=style, wrap=wrap)
    self._screen_notes.append(("note", (text, style, wrap)))

table

table(title, rows, headers)

Print a table, and remember it for the next screen.

Same reason as :meth:note, and the omission was worse here: a whole setup report would be drawn, then wiped by the very next question, leaving only the one-line advice underneath a header that suggested nothing had been printed at all.

Source code in src\taters\ui\live.py
234
235
236
237
238
239
240
241
242
243
244
245
246
247
def table(self, title: str, rows, headers) -> None:
    """
    Print a table, and remember it for the next screen.

    Same reason as :meth:`note`, and the omission was worse here: a whole
    setup report would be drawn, then wiped by the very next question,
    leaving only the one-line advice underneath a header that suggested
    nothing had been printed at all.
    """
    super().table(title, rows, headers)
    self._screen_notes.append((
        "table",
        (title, [list(r) for r in rows], list(headers)),
    ))

repaint

repaint()

Redraw the screen furniture without asking anything.

Source code in src\taters\ui\live.py
249
250
251
def repaint(self) -> None:
    """Redraw the screen furniture without asking anything."""
    self._paint()

stage

stage(key, label, *, status='active', detail='')

Add or update one entry in the rail.

Updating in place (rather than appending) is what lets the wizard mark a stage done without knowing whether it had announced it before.

Source code in src\taters\ui\live.py
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
def stage(self, key: str, label: str, *, status: str = "active",
          detail: str = "") -> None:
    """
    Add or update one entry in the rail.

    Updating in place (rather than appending) is what lets the wizard mark
    a stage done without knowing whether it had announced it before.
    """
    for i, existing in enumerate(self._stages):
        if existing.key == key:
            existing.label = label
            existing.status = status
            if detail:
                existing.detail = detail
            if status == "active":
                # a rail has exactly one "you are here". backing up to an
                # earlier stage used to leave the later ones marked active
                # from the previous pass, so the rail showed two arrows and
                # claimed you were in two places at once. anything after
                # the active stage is, by definition, not done yet.
                for later in self._stages[i + 1:]:
                    later.status = "todo"
                    later.detail = ""
            return
    self._stages.append(Stage(key=key, label=label, status=status, detail=detail))

reason

reason(text)

Say why the next question is being asked -- next to the question.

Held rather than printed. A note goes to the console above the rail, so the sentence explaining a question ended up separated from it by the whole rail and every other note on the screen, in a color that read as more commentary. This one is drawn inside the question's own layout, immediately above it, and in a color that is meant to be caught.

Source code in src\taters\ui\live.py
430
431
432
433
434
435
436
437
438
439
440
def reason(self, text: str) -> None:
    """
    Say why the next question is being asked -- next to the question.

    Held rather than printed. A note goes to the console above the rail, so
    the sentence explaining a question ended up separated from it by the
    whole rail and every other note on the screen, in a color that read as
    more commentary. This one is drawn inside the question's own layout,
    immediately above it, and in a color that is meant to be caught.
    """
    self._reason = text

reset_stages

reset_stages()

Forget the rail.

A rail belongs to the task that raised it. Nothing cleared it, so after the wizard finished -- or was backed out of -- its stages stayed on screen above the main menu, describing a pipeline that was no longer being built.

Source code in src\taters\ui\live.py
455
456
457
458
459
460
461
462
463
464
def reset_stages(self) -> None:
    """
    Forget the rail.

    A rail belongs to the task that raised it. Nothing cleared it, so after
    the wizard finished -- or was backed out of -- its stages stayed on
    screen above the main menu, describing a pipeline that was no longer
    being built.
    """
    self._stages = []

select

select(
    question,
    choices,
    *,
    default=None,
    transient=False,
    numbered=True,
    toggle_values=(),
    ticked=None,
    navigate=None,
    breadcrumb=None,
    hint_override=None,
    enter_gate=None,
    cycle=None
)

Pick one option.

Numbered so a menu can be answered with a single digit as well as with the arrow keys -- and the digit both selects and confirms, where questionary's own shortcuts only move the cursor and still want enter.

A digit can only ever address nine things, so a list longer than that is not numbered at all. Numbering the first nine and leaving the rest bare -- what this used to do -- reads as a list that lost its numbers half way down, and a label reading "10." would advertise a key that does nothing. Either every row carries a number or none does.

Source code in src\taters\ui\live.py
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
def select(self, question: str, choices: Sequence[Choice],
           *, default: Optional[str] = None, transient: bool = False,
           numbered: bool = True,
           toggle_values: Sequence[str] = (),
           ticked: Optional[set] = None,
           navigate=None, breadcrumb=None,
           hint_override: Optional[str] = None,
           enter_gate: Optional[str] = None,
           cycle=None) -> str:
    """
    Pick one option.

    Numbered so a menu can be answered with a single digit as well as with
    the arrow keys -- and the digit both selects *and* confirms, where
    questionary's own shortcuts only move the cursor and still want enter.

    A digit can only ever address nine things, so a list longer than that
    is not numbered at all. Numbering the first nine and leaving the rest
    bare -- what this used to do -- reads as a list that lost its numbers
    half way down, and a label reading "10." would advertise a key that
    does nothing. Either every row carries a number or none does.
    """
    shown = list(choices)
    # a navigating select swaps its rows out from under any digit
    # bindings, leaving keys wired to values that aren't on screen anymore
    # -- so we never number one.
    numbered = numbered and len(shown) <= 9 and navigate is None
    if numbered:
        shown = [replace(c, label=f"{i}. {c.label}")
                 for i, c in enumerate(shown, 1)]

    self._desc_rows = description_rows(shown)
    question_obj = self._q.select(
        question,
        choices=[self._to_q(c) for c in shown],
        default=default,
        style=self._style,
        use_shortcuts=False,
        # the hint bar below already names the keys; questionary's own
        # "(Use arrow keys)" just pushes the question past the right edge.
        # it tests the instruction for truthiness, so "" falls back to the
        # default -- a single space is what actually shuts it up.
        instruction=" ",
    )
    if numbered:
        self._bind_number_keys(question_obj.application, choices)
    # one shared, mutable set: navigation swaps its contents when the rows
    # change, and the space binding reads it live -- so a folder we enter
    # mid-walk brings its files' tickability along with it.
    toggles = set(toggle_values)
    if ticked is not None or toggles:
        self._bind_space_toggle(question_obj.application, toggles, ticked)
    if navigate is not None:
        self._bind_navigation(question_obj.application, navigate, toggles)
    if enter_gate is not None:
        self._bind_enter_gate(question_obj.application, enter_gate,
                              ticked if ticked is not None else set())
        # the gate row is the way out of the screen, and it lives at the
        # top -- we pin it there so a long list scrolling underneath can
        # never hide it (someone asked for this after fighting a
        # forty-column spreadsheet).
        self._pin_top_rows(question_obj.application, 1)
    if cycle is not None:
        self._bind_cycle(question_obj.application, cycle)
    if hint_override is not None:
        hint = hint_override
    elif ticked is not None or toggles:
        hint = _KEY_HINTS["tick"]
    else:
        hint = _KEY_HINTS["select"] if numbered else _KEY_HINTS["select_long"]
    if cycle is not None:
        hint = hint.replace(" · [esc] back", " · [←→] change type · [esc] back")
    # a list is coming, so the painter above owes it room. only as much as
    # there is to show, though: a three-option menu has no use for twelve
    # rows, and claiming them would trim notes for nothing.
    self._reserve_rows = min(_LIST_FLOOR, len(shown))
    try:
        return self._ask(question_obj, hint, transient=transient,
                         breadcrumb=breadcrumb)
    finally:
        self._reserve_rows = 0

checkbox

checkbox(question, choices, *, cycle=None)

Multi-selection on ONE screen, in the one dialect every tick screen uses: rows wear [x] boxes, [space] toggles a box in place, and [enter] does exactly one thing -- confirm, and only while pointing at the "✓ Done" row with at least one box ticked. Everywhere else, enter is a no-op: the screen does not move, does not flash, and can never carry a half-made selection forward.

This is the third iteration of this screen, each driven by a real user report. Stock questionary's enter proceeded with the empty set (dropping the row someone was pointing at); a reloop-per-toggle design fixed that but tore the prompt down on every enter, and the redraw read as "the screen changed" when it had not. The fix is a key-binding gate inside a SINGLE prompt: nothing is rebuilt, space flips marks in place, and enter is inert except on Done.

Source code in src\taters\ui\live.py
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
def checkbox(self, question: str, choices: Sequence[Choice], *,
             cycle=None) -> List[str]:
    """
    Multi-selection on ONE screen, in the one dialect every tick screen
    uses: rows wear ``[x]`` boxes, [space] toggles a box in place, and
    [enter] does exactly one thing -- confirm, and only while pointing at
    the "✓ Done" row with at least one box ticked. Everywhere else,
    enter is a no-op: the screen does not move, does not flash, and can
    never carry a half-made selection forward.

    This is the third iteration of this screen, each driven by a real
    user report. Stock questionary's enter proceeded with the empty set
    (dropping the row someone was pointing at); a reloop-per-toggle
    design fixed that but tore the prompt down on every enter, and the
    redraw read as "the screen changed" when it had not. The fix is a
    key-binding gate inside a SINGLE prompt: nothing is rebuilt, space
    flips marks in place, and enter is inert except on Done.
    """
    ticked = {c.value for c in choices if c.checked}
    order = [c.value for c in choices]
    rows = [Choice(self._TICKS_DONE, "✓ Done — use the ticked items",
                   help="Tick boxes with [space]; this row's [enter] "
                        "confirms them. It waits until something is "
                        "ticked.",
                   tone="good")]
    for c in choices:
        mark = "x" if c.value in ticked else " "
        rows.append(replace(c, label=f"[{mark}] {c.label}"))
    self.select(question, rows, numbered=False,
                toggle_values=set(order), ticked=ticked,
                enter_gate=self._TICKS_DONE,
                hint_override=(_KEY_HINTS["checkbox"] if cycle is None else
                               _KEY_HINTS["checkbox"].replace(
                                   " · [esc] back",
                                   " · [←→] change type · [esc] back")),
                cycle=cycle)
    return [v for v in order if v in ticked]

pause

pause(message=PAUSE_MESSAGE)

Repaint the screen, then wait to be dismissed.

The paint is the whole reason for the override. Every other prompt gets it from _ask, which pause deliberately skips -- so without this the reader is left waiting at a screen the last clear() wiped.

Clearing the held notes afterwards matters just as much, and for the same reason _ask does it: they have been read now. Without it a whole setup report was redrawn on the next screen too, and the menu that followed appeared underneath it, near the bottom of the terminal.

Source code in src\taters\ui\live.py
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
def pause(self, message: str = PAUSE_MESSAGE) -> None:
    """
    Repaint the screen, then wait to be dismissed.

    The paint is the whole reason for the override. Every other prompt gets
    it from `_ask`, which `pause` deliberately skips -- so without this the
    reader is left waiting at a screen the last `clear()` wiped.

    Clearing the held notes afterwards matters just as much, and for the
    same reason `_ask` does it: they have been read now. Without it a whole
    setup report was redrawn on the *next* screen too, and the menu that
    followed appeared underneath it, near the bottom of the terminal.
    """
    self._paint()
    self._wait_for_key(message)
    self._screen_notes.clear()

confirm

confirm(question, *, default=True)

Yes or no, as a list you move through rather than a word you type.

questionary's own confirm renders as (Y/n) and waits on a text buffer. A single y does answer it, but nothing on screen says so, so it reads as "type a word and press enter" -- which is the one interaction in the whole wizard that works differently from the rest.

This is an ordinary two-item selection, so arrow keys and enter behave exactly as they do everywhere else, with y/n and 1/0 bound as shortcuts for anyone who would rather not move at all.

Source code in src\taters\ui\live.py
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
def confirm(self, question: str, *, default: bool = True) -> bool:
    """
    Yes or no, as a list you move through rather than a word you type.

    questionary's own confirm renders as `(Y/n)` and waits on a text
    buffer. A single `y` does answer it, but nothing on screen says so, so
    it reads as "type a word and press enter" -- which is the one
    interaction in the whole wizard that works differently from the rest.

    This is an ordinary two-item selection, so arrow keys and enter behave
    exactly as they do everywhere else, with `y`/`n` and `1`/`0` bound as
    shortcuts for anyone who would rather not move at all.
    """
    # the default row SAYS it's the default. the old signal (it came first
    # and started highlighted) was a design convention nobody was told
    # about, and users read the highlight as anything from "already
    # chosen" to "the best answer".
    yes = Choice("yes", "Yes", annotation="(default)" if default else "")
    no = Choice("no", "No", annotation="" if default else "(default)")
    ordered = [yes, no] if default else [no, yes]

    self._desc_rows = description_rows(ordered)
    question_obj = self._q.select(
        question,
        choices=[self._to_q(c) for c in ordered],
        default="yes" if default else "no",
        style=self._style,
        use_shortcuts=False,
        instruction=" ",
    )
    self._bind_confirm_keys(question_obj.application)
    return self._ask(question_obj, _KEY_HINTS["confirm"]) == "yes"

working

working(text)

Announce slow work before it starts, on screen immediately.

A note plus a repaint, which is the pair that guarantees the line is visible before the blocking call rather than after it: the live renderer's screen is wiped per question, and a note printed onto a just-finished screen without the repaint could be cleared before it was ever seen. Dim, because it is narration, not an answer.

Source code in src\taters\ui\prompts.py
322
323
324
325
326
327
328
329
330
331
332
333
def working(self, text: str) -> None:
    """
    Announce slow work *before* it starts, on screen immediately.

    A note plus a repaint, which is the pair that guarantees the line is
    visible before the blocking call rather than after it: the live
    renderer's screen is wiped per question, and a note printed onto a
    just-finished screen without the repaint could be cleared before it
    was ever seen. Dim, because it is narration, not an answer.
    """
    self.note(f"  {text}", style="dim")
    self.repaint()

clear

clear()

Start on a clean screen.

The scrollback is untouched -- this scrolls the screen rather than erasing history, so whatever the user had before is still there to page back to.

Source code in src\taters\ui\prompts.py
335
336
337
338
339
340
341
342
343
344
345
346
347
def clear(self) -> None:
    """
    Start on a clean screen.

    The scrollback is untouched -- this scrolls the screen rather than
    erasing history, so whatever the user had before is still there to page
    back to.
    """
    self._console.clear()
    # nothing on screen yet, so a leading blank note has nothing to
    # separate; we drop it rather than push the first line down.
    self._blank_last = True
    self._rows_painted = 0

note_width

note_width()

How wide a note's text may be, in cells.

Source code in src\taters\ui\prompts.py
389
390
391
def note_width(self) -> int:
    """How wide a note's text may be, in cells."""
    return max(min(self._console.width, MEASURE), 20)

glide_positions

glide_positions(
    start, target, *, seconds=GLIDE_SECONDS, fps=GLIDE_FPS
)

The pointer positions of a glide from start to target, one per frame, ending exactly on the target.

Fixed duration, not fixed speed: a list of five hundred rows glides in the same two seconds as a list of forty, so the wait never grows with the list. Never more frames than rows -- a three-row glide is three frames -- and never fewer than one.

Source code in src\taters\ui\live.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def glide_positions(start: int, target: int, *, seconds: float = GLIDE_SECONDS,
                    fps: int = GLIDE_FPS) -> List[int]:
    """
    The pointer positions of a glide from ``start`` to ``target``, one per
    frame, ending exactly on the target.

    Fixed duration, not fixed speed: a list of five hundred rows glides in
    the same two seconds as a list of forty, so the wait never grows with
    the list. Never more frames than rows -- a three-row glide is three
    frames -- and never fewer than one.
    """
    distance = target - start
    if distance == 0:
        return []
    frames = max(1, min(abs(distance), int(round(seconds * fps))))
    return [start + round(distance * (i / frames)) for i in range(1, frames + 1)]