From 7abd4f80d439b5146d6228df6c3432ce72a2c995 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Mon, 4 May 2026 15:51:26 +0200 Subject: [PATCH 01/28] =?UTF-8?q?feat:=20=E2=9C=A8=20use=20pager=20for=20m?= =?UTF-8?q?ulti-page=20styles=20in=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/interface/cli.qmd | 4 +- docs/design/interface/python.qmd | 8 ++-- docs/guide/cli.qmd | 13 ++++-- docs/guide/contribute-style.qmd | 7 ++-- src/seedcase_flower/cli.py | 26 ++++++++---- src/seedcase_flower/styles.py | 4 +- tests/test_cli.py | 68 +++++++++++++++++++++++++------- 7 files changed, 94 insertions(+), 36 deletions(-) diff --git a/docs/design/interface/cli.qmd b/docs/design/interface/cli.qmd index 2500a819..f9219f57 100644 --- a/docs/design/interface/cli.qmd +++ b/docs/design/interface/cli.qmd @@ -200,7 +200,7 @@ flowchart LR source("datapackage.json
[SOURCE: file, https,
gh/github]") view("view") style_opt("--style
[option]") - output("Output
[Terminal]") + output("Output
[Terminal pager]") source --> view style_opt --> view @@ -211,7 +211,7 @@ flowchart LR differences: - No output files are generated---`view` only displays the metadata in - the terminal. + the terminal pager. - No configuration file is used. The only way to change the output is via `--style` with one of the built-in styles. diff --git a/docs/design/interface/python.qmd b/docs/design/interface/python.qmd index 5c6ddcbf..33c684aa 100644 --- a/docs/design/interface/python.qmd +++ b/docs/design/interface/python.qmd @@ -83,9 +83,9 @@ flowchart TD ### {{< var done >}} `view()` `view()` takes the same parameters as `build()`: `source` and `style`. -Unlike `build()`, `view()`'s `style` parameter only accepts built-in -one-page styles (the terminal displays a single page) and ignores custom -styles and the configuration file. For details about the parameters, see +Unlike `build()`, `view()` only accepts built-in styles and ignores +custom styles and the configuration file. The rendered metadata is shown +in a terminal pager. For details about the parameters, see [`view()`](/docs/reference/view.qmd). The internal flow of `view()` is shown in the diagram below. @@ -98,7 +98,7 @@ flowchart TD style_cfg[style]:::input --> Config address --> read_properties{{"read_properties()"}} --> properties properties & Config --> build_sections{{"build_sections()"}} --> output["list[BuiltSection]"] - output --> pretty_print{{"pretty_print()"}} + output --> pager{{"pager"}} classDef input fill:#FFF ``` diff --git a/docs/guide/cli.qmd b/docs/guide/cli.qmd index d647843c..53d3ce74 100644 --- a/docs/guide/cli.qmd +++ b/docs/guide/cli.qmd @@ -103,7 +103,7 @@ terminal, so some aspects of it might not display as expected here. ### Styling terminal output Use the `--style` flag to format the terminal output with any of the -[built-in view styles](/docs/reference/ViewStyle.qmd). For example: +[built-in styles](/docs/reference/Style.qmd). For example: ```{.bash filename="Terminal"} seedcase-flower view --style quarto-one-page gh:seedcase-project/example-seed-beetle @@ -126,9 +126,14 @@ terminal, so some aspects of it might not display as expected here. ``` ::: -`view` only works with single-page -[built-in styles](/docs/reference/Style.qmd); multi-page styles are not -supported. +For long output, `view` opens the rendered metadata in a terminal pager. +Multi-section styles, such as `quarto-resource-listing`, are shown as a +single paged document with each generated section labelled by its output +path. + +Flower preserves terminal colors in the pager. If colors do not display, +set your pager to one that supports ANSI styles, such as +`PAGER="less -R"`. ::: callout-important `view` is only configurable via command line flags. This means that any diff --git a/docs/guide/contribute-style.qmd b/docs/guide/contribute-style.qmd index 30d28423..b653123a 100644 --- a/docs/guide/contribute-style.qmd +++ b/docs/guide/contribute-style.qmd @@ -34,10 +34,9 @@ should be short, descriptive, and distinct from existing built-in styles. It should be given in snake case (e.g., `my_new_style`). ::: callout-note -A terminal style for the `view` command must be a single-page output -style, meaning it can only use a single `[[one]]` section. `view` -ignores `output-path` since it doesn't output files and single-page -styles can be used by `build` as well. +The `view` command can display built-in styles with one or more sections. +Multi-section styles are rendered as one paged terminal document, with +each section labelled by its `output-path`. ::: ## Adding the style as built-in diff --git a/src/seedcase_flower/cli.py b/src/seedcase_flower/cli.py index 61793a66..fc57a164 100644 --- a/src/seedcase_flower/cli.py +++ b/src/seedcase_flower/cli.py @@ -17,10 +17,10 @@ setup_cli, ) -from seedcase_flower.build_sections import build_sections +from seedcase_flower.build_sections import BuiltSection, build_sections from seedcase_flower.config import Config from seedcase_flower.internals import _number -from seedcase_flower.styles import Style, ViewStyle +from seedcase_flower.styles import Style from seedcase_flower.write_sections import write_sections app = setup_cli( @@ -92,7 +92,7 @@ def view( source: str = "datapackage.json", /, # End of positional-only args *, # Start of keyword-only params - style: ViewStyle = ViewStyle.quarto_one_page, + style: Style = Style.quarto_one_page, ) -> None: """Display the contents of a `datapackage.json` in a human-friendly way. @@ -103,17 +103,29 @@ def view( in the repo root (in the format `gh:org/repo`, which can also include reference to a tag or branch, such as `gh:org/repo@main` or `gh:org/repo@1.0.1`). - style: The style used to display the output in the terminal. Must be a - single-page style. + style: The built-in style used to display the output in the terminal. """ address: Address = parse_source(source) properties: dict[str, Any] = read_properties(address) check(properties, error=True) - built_sections = build_sections(properties, Config(style=Style[style.name])) + built_sections = build_sections(properties, Config(style=style)) console = Console(theme=CONSOLE_THEME) # TODO move back console theme? will it be used in CDP? print() # One line separation between the command and the datapackage title - console.print(Markdown(built_sections[0].content)) + with console.pager(styles=True): + console.print(Markdown(_format_view_sections(built_sections))) + + +def _format_view_sections(built_sections: list[BuiltSection]) -> str: + if len(built_sections) == 1: + return built_sections[0].content + + return "\n\n".join( + ( + f"# {section.output_path or f'Section {index}'}\n\n{section.content}" + for index, section in enumerate(built_sections, start=1) + ) + ) def main() -> None: diff --git a/src/seedcase_flower/styles.py b/src/seedcase_flower/styles.py index 48912b5b..78c21972 100644 --- a/src/seedcase_flower/styles.py +++ b/src/seedcase_flower/styles.py @@ -10,6 +10,8 @@ class Style(Enum): class ViewStyle(Enum): - """Subset of styles suitable for terminal output (single-page only).""" + """Built-in styles suitable for terminal output.""" quarto_one_page = "quarto_one_page" + quarto_resource_listing = "quarto_resource_listing" + quarto_resource_tables = "quarto_resource_tables" diff --git a/tests/test_cli.py b/tests/test_cli.py index dcdc21c0..3e83875f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,10 +8,8 @@ from seedcase_flower.build_sections import ( BuiltSection, - _get_template_dir, - _load_sections_toml, ) -from seedcase_flower.cli import app +from seedcase_flower.cli import _format_view_sections, app from seedcase_flower.config import Config from seedcase_flower.styles import Style, ViewStyle @@ -148,19 +146,11 @@ def test_view_ignores_flower_toml(tmp_path, monkeypatch): # view ==== -def test_view_styles_are_one_page(): - """Every ViewStyle member must map to a single-section (one-page) style.""" +def test_view_styles_map_to_builtin_styles(): + """Every ViewStyle member must map to a built-in style.""" for member in ViewStyle: style = Style[member.name] - sections = _load_sections_toml(_get_template_dir(style)) - assert not sections.many_sections, ( - f"ViewStyle.{member.name} includes `Many` sections, " - "but view styles must be single-page (exactly 1 `One` section)." - ) - assert len(sections.one_sections) == 1, ( - f"ViewStyle.{member.name} has {len(sections.one_sections)} sections, " - "but view styles must be single-page (exactly 1 `One` section)." - ) + assert style.value == member.value def test_view_with_mocked_internals(mocker): @@ -188,9 +178,59 @@ def test_view_with_mocked_internals(mocker): mock_read_properties.return_value, Config(style=Style.quarto_one_page), ) + mock_console.pager.assert_called_once_with(styles=True) assert mock_console.print.called +def test_view_with_multi_section_style(mocker): + """view should allow multi-section styles and render via a pager.""" + mock_parse_source = mocker.patch("seedcase_flower.cli.parse_source") + mock_read_properties = mocker.patch("seedcase_flower.cli.read_properties") + mocker.patch("seedcase_flower.cli.check") + mock_build_sections = mocker.patch("seedcase_flower.cli.build_sections") + mock_console_cls = mocker.patch("seedcase_flower.cli.Console") + mock_console = mock_console_cls.return_value + + mock_build_sections.return_value = [ + BuiltSection(content="# Package", output_path=Path("index.qmd")), + BuiltSection( + content="Resource details", output_path=Path("resources/data.qmd") + ), + ] + + fake_source = Address(value="file:///datapackage.json", local=True) + mock_parse_source.return_value = fake_source + + app( + ["view", "datapackage.json", "--style", "quarto-resource-listing"], + result_action="return_value", + ) + + mock_read_properties.assert_called_once_with(fake_source) + mock_build_sections.assert_called_once_with( + mock_read_properties.return_value, + Config(style=Style.quarto_resource_listing), + ) + mock_console.pager.assert_called_once_with(styles=True) + assert mock_console.print.called + + +def test_format_view_sections_adds_section_headings(): + """Multi-section view output should include each section path.""" + output = _format_view_sections( + [ + BuiltSection(content="# Package", output_path=Path("index.qmd")), + BuiltSection( + content="Resource details", output_path=Path("resources/data.qmd") + ), + ] + ) + + assert "# index.qmd" in output + assert "# resources/data.qmd" in output + assert "Resource details" in output + + def test_build_raises_on_invalid_datapackage(tmp_path): """build should check datapackage content and fail for malformed metadata.""" json_file = tmp_path / "datapackage.json" From b9da415db8a05bd9c75a0801888ed6b2d5c62a9d Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Mon, 4 May 2026 16:11:13 +0200 Subject: [PATCH 02/28] =?UTF-8?q?style:=20=F0=9F=92=84=20centralize=20titl?= =?UTF-8?q?e=20and=20subtitle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seedcase_flower/cli.py | 62 +++++++++++++++++++++++++++++++----- tests/test_cli.py | 65 +++++++++++++++++++++++++++++++++++--- 2 files changed, 115 insertions(+), 12 deletions(-) diff --git a/src/seedcase_flower/cli.py b/src/seedcase_flower/cli.py index fc57a164..759d54a4 100644 --- a/src/seedcase_flower/cli.py +++ b/src/seedcase_flower/cli.py @@ -4,8 +4,11 @@ from typing import Any, Optional from check_datapackage import check -from rich.console import Console +from rich.align import Align +from rich.console import Console, Group, RenderableType from rich.markdown import Markdown +from rich.rule import Rule +from rich.text import Text from seedcase_soil import ( CONSOLE_THEME, Address, @@ -113,21 +116,64 @@ def view( # TODO move back console theme? will it be used in CDP? print() # One line separation between the command and the datapackage title with console.pager(styles=True): - console.print(Markdown(_format_view_sections(built_sections))) + console.print(_format_view_sections(built_sections)) -def _format_view_sections(built_sections: list[BuiltSection]) -> str: +def _format_view_sections(built_sections: list[BuiltSection]) -> RenderableType: if len(built_sections) == 1: - return built_sections[0].content - - return "\n\n".join( - ( - f"# {section.output_path or f'Section {index}'}\n\n{section.content}" + return _format_view_section_content(built_sections[0].content) + + return Group( + *( + Group( + Align.center(Text(str(section.output_path or index), style="bold")), + Rule(style="dim"), + _format_view_section_content(section.content), + ) for index, section in enumerate(built_sections, start=1) ) ) +def _format_view_section_content(content: str) -> RenderableType: + front_matter, body = _split_front_matter(content) + if not front_matter: + return Markdown(body) + + headings = [] + title = _front_matter_value(front_matter, "title") + subtitle = _front_matter_value(front_matter, "subtitle") + if title: + headings.append(Markdown(f"# {title}")) + if subtitle: + headings.append(Align.center(Text(subtitle.strip("`"), style="bold dim"))) + + if not headings: + return Markdown(body.lstrip()) + return Group(*headings, Markdown(body.lstrip())) + + +def _split_front_matter(content: str) -> tuple[list[str], str]: + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return [], content + + for index, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + return lines[1:index], "\n".join(lines[index + 1 :]) + + return [], content + + +def _front_matter_value(front_matter: list[str], key: str) -> str: + prefix = f"{key}:" + for line in front_matter: + if not line.startswith(prefix): + continue + return line.removeprefix(prefix).strip().strip("\"'") + return "" + + def main() -> None: """Create an entry point to run the cli without tracebacks.""" run_without_tracebacks(app) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3e83875f..d7433053 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,6 +4,7 @@ import pytest from check_datapackage.check import DataPackageError +from rich.console import Console from seedcase_soil import Address from seedcase_flower.build_sections import ( @@ -14,6 +15,12 @@ from seedcase_flower.styles import Style, ViewStyle +def _render_view_sections(sections: list[BuiltSection]) -> str: + console = Console(record=True, width=80, color_system=None) + console.print(_format_view_sections(sections)) + return console.export_text() + + @pytest.fixture def mock_parse_source(mocker): """Mock _parse_source to isolate CLI tests from filesystem resolution.""" @@ -215,9 +222,9 @@ def test_view_with_multi_section_style(mocker): assert mock_console.print.called -def test_format_view_sections_adds_section_headings(): +def test_format_view_sections_adds_section_labels(): """Multi-section view output should include each section path.""" - output = _format_view_sections( + output = _render_view_sections( [ BuiltSection(content="# Package", output_path=Path("index.qmd")), BuiltSection( @@ -226,11 +233,61 @@ def test_format_view_sections_adds_section_headings(): ] ) - assert "# index.qmd" in output - assert "# resources/data.qmd" in output + assert "index.qmd" in output + assert "resources/data.qmd" in output + assert "─" in output assert "Resource details" in output +def test_format_view_sections_removes_listing_front_matter(): + """Index page listing front matter should not be displayed in the pager.""" + output = _render_view_sections( + [ + BuiltSection( + content=( + "---\n" + "listing:\n" + " type: default\n" + " contents: resources\n" + "---\n\n" + "# Package" + ), + output_path=Path("index.qmd"), + ) + ] + ) + + assert "listing:" not in output + assert "contents: resources" not in output + assert "Package" in output + + +def test_format_view_sections_converts_resource_front_matter_to_headings(): + """Resource title and subtitle front matter should become Markdown headings.""" + output = _render_view_sections( + [ + BuiltSection( + content=( + "---\n" + 'title: "Species Catalog"\n' + 'subtitle: "`species_catalog`"\n' + 'description: "Resource description"\n' + "---\n\n" + "- Path: `data/species.csv`" + ), + output_path=Path("resources/species_catalog.qmd"), + ) + ] + ) + + assert "title:" not in output + assert "subtitle:" not in output + assert "description:" not in output + assert "Species Catalog" in output + assert " species_catalog" in output + assert "Path: data/species.csv" in output + + def test_build_raises_on_invalid_datapackage(tmp_path): """build should check datapackage content and fail for malformed metadata.""" json_file = tmp_path / "datapackage.json" From f93c1de46e5b6937a2466e1f3baedf39d629c199 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Mon, 4 May 2026 16:24:43 +0200 Subject: [PATCH 03/28] =?UTF-8?q?style:=20=F0=9F=92=84=20color=20multi-pag?= =?UTF-8?q?e=20output=20the=20same=20as=20single=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seedcase_flower/cli.py | 21 ++++++++------------- tests/test_cli.py | 13 +++++++------ 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/seedcase_flower/cli.py b/src/seedcase_flower/cli.py index 759d54a4..e2687570 100644 --- a/src/seedcase_flower/cli.py +++ b/src/seedcase_flower/cli.py @@ -4,7 +4,6 @@ from typing import Any, Optional from check_datapackage import check -from rich.align import Align from rich.console import Console, Group, RenderableType from rich.markdown import Markdown from rich.rule import Rule @@ -123,16 +122,12 @@ def _format_view_sections(built_sections: list[BuiltSection]) -> RenderableType: if len(built_sections) == 1: return _format_view_section_content(built_sections[0].content) - return Group( - *( - Group( - Align.center(Text(str(section.output_path or index), style="bold")), - Rule(style="dim"), - _format_view_section_content(section.content), - ) - for index, section in enumerate(built_sections, start=1) - ) - ) + sections: list[RenderableType] = [] + for index, section in enumerate(built_sections): + if index > 0: + sections.append(Rule(style="dim")) + sections.append(_format_view_section_content(section.content)) + return Group(*sections) def _format_view_section_content(content: str) -> RenderableType: @@ -144,9 +139,9 @@ def _format_view_section_content(content: str) -> RenderableType: title = _front_matter_value(front_matter, "title") subtitle = _front_matter_value(front_matter, "subtitle") if title: - headings.append(Markdown(f"# {title}")) + headings.append(Text(title, style="markdown.h1")) if subtitle: - headings.append(Align.center(Text(subtitle.strip("`"), style="bold dim"))) + headings.append(Text(subtitle.strip("`"), style="yellow bold")) if not headings: return Markdown(body.lstrip()) diff --git a/tests/test_cli.py b/tests/test_cli.py index d7433053..30cf9fd2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -222,8 +222,8 @@ def test_view_with_multi_section_style(mocker): assert mock_console.print.called -def test_format_view_sections_adds_section_labels(): - """Multi-section view output should include each section path.""" +def test_format_view_sections_separates_sections_with_rule(): + """Multi-section view output should separate sections without file labels.""" output = _render_view_sections( [ BuiltSection(content="# Package", output_path=Path("index.qmd")), @@ -233,9 +233,10 @@ def test_format_view_sections_adds_section_labels(): ] ) - assert "index.qmd" in output - assert "resources/data.qmd" in output + assert "index.qmd" not in output + assert "resources/data.qmd" not in output assert "─" in output + assert "Package" in output assert "Resource details" in output @@ -283,8 +284,8 @@ def test_format_view_sections_converts_resource_front_matter_to_headings(): assert "title:" not in output assert "subtitle:" not in output assert "description:" not in output - assert "Species Catalog" in output - assert " species_catalog" in output + assert "Species Catalog" in output.splitlines() + assert "species_catalog" in output.splitlines() assert "Path: data/species.csv" in output From 29020d874b0cfc2e777c1cd0ed85d3d31f03d82d Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Mon, 4 May 2026 16:29:33 +0200 Subject: [PATCH 04/28] =?UTF-8?q?style:=20=F0=9F=92=84=20add=20spacing=20a?= =?UTF-8?q?round=20hr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seedcase_flower/cli.py | 2 ++ tests/test_cli.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/src/seedcase_flower/cli.py b/src/seedcase_flower/cli.py index e2687570..7dee7523 100644 --- a/src/seedcase_flower/cli.py +++ b/src/seedcase_flower/cli.py @@ -125,7 +125,9 @@ def _format_view_sections(built_sections: list[BuiltSection]) -> RenderableType: sections: list[RenderableType] = [] for index, section in enumerate(built_sections): if index > 0: + sections.append(Text("")) sections.append(Rule(style="dim")) + sections.append(Text("")) sections.append(_format_view_section_content(section.content)) return Group(*sections) diff --git a/tests/test_cli.py b/tests/test_cli.py index 30cf9fd2..4fcca0cf 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -239,6 +239,11 @@ def test_format_view_sections_separates_sections_with_rule(): assert "Package" in output assert "Resource details" in output + output_lines = output.splitlines() + rule_index = next(index for index, line in enumerate(output_lines) if "─" in line) + assert output_lines[rule_index - 1] == "" + assert output_lines[rule_index + 1] == "" + def test_format_view_sections_removes_listing_front_matter(): """Index page listing front matter should not be displayed in the pager.""" From c9368bfe22422d877ef91933709ae234bec39eb7 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Mon, 4 May 2026 16:45:32 +0200 Subject: [PATCH 05/28] =?UTF-8?q?feat:=20=E2=9C=A8=20use=20textual=20to=20?= =?UTF-8?q?more=20easily=20navigate=20multi-page=20terminal=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guide/cli.qmd | 11 ++- pyproject.toml | 1 + src/seedcase_flower/cli.py | 17 +++++ src/seedcase_flower/tui.py | 151 +++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 29 +++++++ tests/test_tui.py | 53 +++++++++++++ uv.lock | 57 ++++++++++++++ 7 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 src/seedcase_flower/tui.py create mode 100644 tests/test_tui.py diff --git a/docs/guide/cli.qmd b/docs/guide/cli.qmd index 53d3ce74..4aeddeed 100644 --- a/docs/guide/cli.qmd +++ b/docs/guide/cli.qmd @@ -128,13 +128,20 @@ terminal, so some aspects of it might not display as expected here. For long output, `view` opens the rendered metadata in a terminal pager. Multi-section styles, such as `quarto-resource-listing`, are shown as a -single paged document with each generated section labelled by its output -path. +single paged document. Flower preserves terminal colors in the pager. If colors do not display, set your pager to one that supports ANSI styles, such as `PAGER="less -R"`. +To try the interactive Textual viewer, use `--viewer textual`. This shows +the package and resources in a left-hand table of contents and the +selected section in a scrollable content pane: + +```{.bash filename="Terminal"} +seedcase-flower view --style quarto-resource-listing --viewer textual +``` + ::: callout-important `view` is only configurable via command line flags. This means that any options set in Flower's configuration file are ignored, even those with diff --git a/pyproject.toml b/pyproject.toml index f76bb274..b81a864c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "python-jsonpath>=2.0.2", "rich>=14.3.3", "seedcase-soil>=0.11.0", + "textual>=8.2.5", ] [project.urls] diff --git a/src/seedcase_flower/cli.py b/src/seedcase_flower/cli.py index 7dee7523..6374d7c8 100644 --- a/src/seedcase_flower/cli.py +++ b/src/seedcase_flower/cli.py @@ -1,5 +1,6 @@ """Functions for the exposed CLI.""" +from enum import Enum from pathlib import Path from typing import Any, Optional @@ -32,6 +33,13 @@ ) +class Viewer(Enum): + """Ways to display `view` output in the terminal.""" + + pager = "pager" + textual = "textual" + + @app.command() def build( source: str = "datapackage.json", @@ -95,6 +103,7 @@ def view( /, # End of positional-only args *, # Start of keyword-only params style: Style = Style.quarto_one_page, + viewer: Viewer = Viewer.pager, ) -> None: """Display the contents of a `datapackage.json` in a human-friendly way. @@ -106,11 +115,19 @@ def view( reference to a tag or branch, such as `gh:org/repo@main` or `gh:org/repo@1.0.1`). style: The built-in style used to display the output in the terminal. + viewer: The terminal viewer to use. Use `pager` for plain scrolling output + or `textual` for an interactive interface with section navigation. """ address: Address = parse_source(source) properties: dict[str, Any] = read_properties(address) check(properties, error=True) built_sections = build_sections(properties, Config(style=style)) + if viewer == Viewer.textual: + from seedcase_flower.tui import run_textual_viewer + + run_textual_viewer(built_sections) + return + console = Console(theme=CONSOLE_THEME) # TODO move back console theme? will it be used in CDP? print() # One line separation between the command and the datapackage title diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py new file mode 100644 index 00000000..43637387 --- /dev/null +++ b/src/seedcase_flower/tui.py @@ -0,0 +1,151 @@ +"""Textual terminal app for browsing built Data Package sections.""" + +from dataclasses import dataclass +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import Footer, Header, Label, ListItem, ListView, Markdown + +from seedcase_flower.build_sections import BuiltSection + + +@dataclass(frozen=True) +class ViewPage: + """A built section prepared for navigation in the Textual viewer.""" + + label: str + content: str + + +def prepare_view_pages(built_sections: list[BuiltSection]) -> list[ViewPage]: + """Prepare built sections for display in the Textual viewer.""" + return [ + ViewPage( + label=_section_label(section, index), + content=_section_content(section.content), + ) + for index, section in enumerate(built_sections, start=1) + ] + + +def _section_label(section: BuiltSection, index: int) -> str: + front_matter, _ = _split_front_matter(section.content) + title = _front_matter_value(front_matter, "title") + subtitle = _front_matter_value(front_matter, "subtitle").strip("`") + if title and subtitle: + return f"{subtitle}: {title}" + if title or subtitle: + return title or subtitle + if section.output_path: + return _output_path_label(section.output_path) + return f"Section {index}" + + +def _output_path_label(output_path: Path) -> str: + if output_path.name == "index.qmd": + return "Package" + return output_path.stem.replace("_", " ").replace("-", " ").title() + + +def _section_content(content: str) -> str: + front_matter, body = _split_front_matter(content) + if not front_matter: + return body + + headings = [] + title = _front_matter_value(front_matter, "title") + subtitle = _front_matter_value(front_matter, "subtitle") + if title: + headings.append(f"# {title}") + if subtitle: + headings.append(f"## {subtitle}") + + if not headings: + return body.lstrip() + return "\n\n".join([*headings, body.lstrip()]) + + +def _split_front_matter(content: str) -> tuple[list[str], str]: + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return [], content + + for index, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + return lines[1:index], "\n".join(lines[index + 1 :]) + + return [], content + + +def _front_matter_value(front_matter: list[str], key: str) -> str: + prefix = f"{key}:" + for line in front_matter: + if line.startswith(prefix): + return line.removeprefix(prefix).strip().strip("\"'") + return "" + + +class FlowerViewApp(App[None]): + """Interactive terminal viewer for Data Package documentation sections.""" + + CSS = """ + Screen { + layout: vertical; + } + + #body { + height: 1fr; + } + + #toc { + width: 32; + min-width: 24; + height: 100%; + border-right: solid $primary; + } + + #content { + width: 1fr; + height: 100%; + padding: 0 1; + } + + ListItem > Label { + padding: 0 1; + } + """ + BINDINGS = [("q", "quit", "Quit")] + TITLE = "Flower" + + def __init__(self, pages: list[ViewPage]) -> None: + """Initialize the app with pages to display.""" + super().__init__(ansi_color=True) + self.pages = pages + + def compose(self) -> ComposeResult: + """Compose the page navigation and content widgets.""" + initial_page = self.pages[0] + self.sub_title = initial_page.label + yield Header() + with Horizontal(id="body"): + yield ListView( + *[ListItem(Label(page.label)) for page in self.pages], id="toc" + ) + yield Markdown(initial_page.content, id="content") + yield Footer() + + def on_mount(self) -> None: + """Focus page navigation when the app starts.""" + self.query_one("#toc", ListView).focus() + + async def on_list_view_selected(self, event: ListView.Selected) -> None: + """Show the selected page in the content pane.""" + page = self.pages[event.index] + self.sub_title = page.label + await self.query_one("#content", Markdown).update(page.content) + + +def run_textual_viewer(built_sections: list[BuiltSection]) -> None: + """Run the interactive Textual viewer for built sections.""" + FlowerViewApp(prepare_view_pages(built_sections)).run() diff --git a/tests/test_cli.py b/tests/test_cli.py index 4fcca0cf..4a622ee8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -148,6 +148,7 @@ def test_view_ignores_flower_toml(tmp_path, monkeypatch): _, bound, _ = app.parse_args(["view"]) assert "source" not in bound.arguments assert "style" not in bound.arguments + assert "viewer" not in bound.arguments # view ==== @@ -222,6 +223,34 @@ def test_view_with_multi_section_style(mocker): assert mock_console.print.called +def test_view_with_textual_viewer(mocker): + """view should route built sections to the Textual viewer when requested.""" + mock_parse_source = mocker.patch("seedcase_flower.cli.parse_source") + mock_read_properties = mocker.patch("seedcase_flower.cli.read_properties") + mocker.patch("seedcase_flower.cli.check") + mock_build_sections = mocker.patch("seedcase_flower.cli.build_sections") + mock_textual_viewer = mocker.patch("seedcase_flower.tui.run_textual_viewer") + mock_console_cls = mocker.patch("seedcase_flower.cli.Console") + built_sections = [BuiltSection(content="# Package", output_path=Path("index.qmd"))] + + fake_source = Address(value="file:///datapackage.json", local=True) + mock_parse_source.return_value = fake_source + mock_build_sections.return_value = built_sections + + app( + ["view", "datapackage.json", "--viewer", "textual"], + result_action="return_value", + ) + + mock_read_properties.assert_called_once_with(fake_source) + mock_build_sections.assert_called_once_with( + mock_read_properties.return_value, + Config(style=Style.quarto_one_page), + ) + mock_textual_viewer.assert_called_once_with(built_sections) + mock_console_cls.assert_not_called() + + def test_format_view_sections_separates_sections_with_rule(): """Multi-section view output should separate sections without file labels.""" output = _render_view_sections( diff --git a/tests/test_tui.py b/tests/test_tui.py new file mode 100644 index 00000000..bd06411a --- /dev/null +++ b/tests/test_tui.py @@ -0,0 +1,53 @@ +"""Tests for the Textual viewer helpers.""" + +from pathlib import Path + +from seedcase_flower.build_sections import BuiltSection +from seedcase_flower.tui import prepare_view_pages + + +def test_prepare_view_pages_uses_package_label_for_index(): + pages = prepare_view_pages( + [BuiltSection(content="# Test Package", output_path=Path("index.qmd"))] + ) + + assert pages[0].label == "Package" + assert pages[0].content == "# Test Package" + + +def test_prepare_view_pages_uses_resource_front_matter_for_label_and_content(): + pages = prepare_view_pages( + [ + BuiltSection( + content=( + "---\n" + 'title: "Species Catalog"\n' + 'subtitle: "`species_catalog`"\n' + 'description: "Resource description"\n' + "---\n\n" + "- Path: `data/species.csv`" + ), + output_path=Path("resources/species_catalog.qmd"), + ) + ] + ) + + assert pages[0].label == "species_catalog: Species Catalog" + assert "title:" not in pages[0].content + assert "description:" not in pages[0].content + assert "# Species Catalog" in pages[0].content + assert "## `species_catalog`" in pages[0].content + assert "- Path: `data/species.csv`" in pages[0].content + + +def test_prepare_view_pages_falls_back_to_output_stem(): + pages = prepare_view_pages( + [ + BuiltSection( + content="Resource details", + output_path=Path("resources/growth-records.qmd"), + ) + ] + ) + + assert pages[0].label == "Growth Records" diff --git a/uv.lock b/uv.lock index 9d885885..50742e75 100644 --- a/uv.lock +++ b/uv.lock @@ -1234,6 +1234,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1246,6 +1258,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1321,6 +1338,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -2301,6 +2330,7 @@ dependencies = [ { name = "python-jsonpath" }, { name = "rich" }, { name = "seedcase-soil" }, + { name = "textual" }, ] [package.dev-dependencies] @@ -2330,6 +2360,7 @@ requires-dist = [ { name = "python-jsonpath", specifier = ">=2.0.2" }, { name = "rich", specifier = ">=14.3.3" }, { name = "seedcase-soil", specifier = ">=0.11.0" }, + { name = "textual", specifier = ">=8.2.5" }, ] [package.metadata.requires-dev] @@ -2468,6 +2499,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, ] +[[package]] +name = "textual" +version = "8.2.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/1e/1eedc5bac184d00aaa5f9a99095f7e266af3ec46fa926c1051be5d358da1/textual-8.2.5.tar.gz", hash = "sha256:6c894e65a879dadb4f6cf46ddcfedb0173ff7e0cb1fe605ff7b357a597bdbc90", size = 1851596, upload-time = "2026-04-30T08:02:58.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/01/c4555f9c8a692ff83d84930150540f743ce94c89234f9e9a15ff4baba3a8/textual-8.2.5-py3-none-any.whl", hash = "sha256:247d2aa2faf222749c321f88a736247f37ee2c023604079c7490bfacddfcd4b2", size = 727050, upload-time = "2026-04-30T08:03:01.421Z" }, +] + [[package]] name = "tinycss2" version = "1.4.0" @@ -2583,6 +2631,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, ] +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + [[package]] name = "uri-template" version = "1.3.0" From 8e656a1d37dc45da4fe682300bcd2ecc443ceab7 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Mon, 4 May 2026 17:08:45 +0200 Subject: [PATCH 06/28] =?UTF-8?q?style:=20=F0=9F=92=84=20style=20like=20te?= =?UTF-8?q?rminal=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seedcase_flower/tui.py | 133 +++++++++++++++++++++++++++++++++++-- tests/test_tui.py | 41 +++++++++++- 2 files changed, 165 insertions(+), 9 deletions(-) diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index 43637387..6c15f28e 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -33,10 +33,10 @@ def _section_label(section: BuiltSection, index: int) -> str: front_matter, _ = _split_front_matter(section.content) title = _front_matter_value(front_matter, "title") subtitle = _front_matter_value(front_matter, "subtitle").strip("`") - if title and subtitle: - return f"{subtitle}: {title}" - if title or subtitle: - return title or subtitle + if subtitle: + return subtitle + if title: + return title if section.output_path: return _output_path_label(section.output_path) return f"Section {index}" @@ -92,30 +92,132 @@ class FlowerViewApp(App[None]): CSS = """ Screen { layout: vertical; + background: ansi_default; + color: ansi_default; + } + + Header, Footer { + background: ansi_default; + color: ansi_default; } #body { height: 1fr; + background: ansi_default; } #toc { width: 32; min-width: 24; height: 100%; - border-right: solid $primary; + background: ansi_default; + color: ansi_default; + border-right: solid ansi_white; } #content { width: 1fr; height: 100%; padding: 0 1; + background: ansi_default; } ListItem > Label { padding: 0 1; + background: ansi_default; + color: ansi_default; + } + + Markdown { + background: ansi_default; + color: ansi_default; + } + + MarkdownBlock > .code_inline { + color: ansi_yellow; + background: ansi_default; + text-style: bold; + } + + MarkdownBlock > .strong { + text-style: bold; + } + + MarkdownBlock > .em { + text-style: italic; + } + + MarkdownBlockQuote { + background: ansi_default; + border-left: outer ansi_magenta; + color: ansi_magenta; + } + + MarkdownBullet { + color: ansi_cyan; + } + + MarkdownFence { + color: ansi_cyan; + background: ansi_black; + } + + MarkdownHeader { + margin: 1 0 0 0; + } + + MarkdownH1 { + content-align: left middle; + color: ansi_blue; + text-style: bold; + margin: 0; + } + + MarkdownH2 { + color: ansi_yellow; + text-style: bold; + margin: 0; + } + + MarkdownH3 { + color: ansi_blue; + } + + MarkdownH4 { + color: ansi_magenta; + text-style: italic; + } + + MarkdownH5 { + text-style: italic; + } + + MarkdownH6 { + text-opacity: 60%; + } + + MarkdownHorizontalRule { + border-bottom: solid ansi_white; + } + + MarkdownTableContent { + keyline: thin ansi_white; + } + + MarkdownTableContent > .header { + color: ansi_blue; + } + + MarkdownTableContent > .markdown-table--header { + color: ansi_blue; + text-style: bold; } """ - BINDINGS = [("q", "quit", "Quit")] + BINDINGS = [ + ("j", "toc_down", "Down"), + ("k", "toc_up", "Up"), + ("q", "quit", "Quit"), + ] TITLE = "Flower" def __init__(self, pages: list[ViewPage]) -> None: @@ -139,9 +241,26 @@ def on_mount(self) -> None: """Focus page navigation when the app starts.""" self.query_one("#toc", ListView).focus() + async def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: + """Show the highlighted page in the content pane.""" + index = event.list_view.index + if index is not None: + await self._show_page(index) + async def on_list_view_selected(self, event: ListView.Selected) -> None: """Show the selected page in the content pane.""" - page = self.pages[event.index] + await self._show_page(event.index) + + def action_toc_down(self) -> None: + """Move down in the page navigation.""" + self.query_one("#toc", ListView).action_cursor_down() + + def action_toc_up(self) -> None: + """Move up in the page navigation.""" + self.query_one("#toc", ListView).action_cursor_up() + + async def _show_page(self, index: int) -> None: + page = self.pages[index] self.sub_title = page.label await self.query_one("#content", Markdown).update(page.content) diff --git a/tests/test_tui.py b/tests/test_tui.py index bd06411a..376d5b75 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -3,7 +3,44 @@ from pathlib import Path from seedcase_flower.build_sections import BuiltSection -from seedcase_flower.tui import prepare_view_pages +from seedcase_flower.tui import FlowerViewApp, prepare_view_pages + + +def test_flower_view_app_has_vim_navigation_bindings(): + assert ("j", "toc_down", "Down") in FlowerViewApp.BINDINGS + assert ("k", "toc_up", "Up") in FlowerViewApp.BINDINGS + + +def test_flower_view_app_styles_markdown_headings_like_terminal_output(): + assert "MarkdownH1" in FlowerViewApp.CSS + assert "content-align: left middle" in FlowerViewApp.CSS + assert "color: ansi_blue" in FlowerViewApp.CSS + assert "MarkdownH2" in FlowerViewApp.CSS + assert "color: ansi_yellow" in FlowerViewApp.CSS + assert "MarkdownHeader" in FlowerViewApp.CSS + assert "margin: 1 0 0 0" in FlowerViewApp.CSS + + +def test_flower_view_app_themes_chrome_like_main_panel(): + assert "Header, Footer" in FlowerViewApp.CSS + assert "#toc" in FlowerViewApp.CSS + assert "background: ansi_default" in FlowerViewApp.CSS + assert "color: ansi_default" in FlowerViewApp.CSS + + +def test_flower_view_app_maps_markdown_colors_to_ansi_terminal_styles(): + assert "background: ansi_default" in FlowerViewApp.CSS + assert "MarkdownBlock > .code_inline" in FlowerViewApp.CSS + assert "color: ansi_yellow" in FlowerViewApp.CSS + assert "MarkdownFence" in FlowerViewApp.CSS + assert "color: ansi_cyan" in FlowerViewApp.CSS + assert "background: ansi_black" in FlowerViewApp.CSS + assert "MarkdownBlockQuote" in FlowerViewApp.CSS + assert "border-left: outer ansi_magenta" in FlowerViewApp.CSS + assert "MarkdownBullet" in FlowerViewApp.CSS + assert "MarkdownTableContent" in FlowerViewApp.CSS + assert "keyline: thin ansi_white" in FlowerViewApp.CSS + assert "MarkdownTableContent > .header" in FlowerViewApp.CSS def test_prepare_view_pages_uses_package_label_for_index(): @@ -32,7 +69,7 @@ def test_prepare_view_pages_uses_resource_front_matter_for_label_and_content(): ] ) - assert pages[0].label == "species_catalog: Species Catalog" + assert pages[0].label == "species_catalog" assert "title:" not in pages[0].content assert "description:" not in pages[0].content assert "# Species Catalog" in pages[0].content From a9d04bbc7f497cfce228ac30fa114c28a5f641be Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Mon, 4 May 2026 17:32:51 +0200 Subject: [PATCH 07/28] =?UTF-8?q?style:=20=F0=9F=92=84=20minor=20contrast?= =?UTF-8?q?=20imporovements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seedcase_flower/tui.py | 24 ++++++++++++++++++++---- tests/test_tui.py | 10 ++++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index 6c15f28e..5a26c70b 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -97,7 +97,14 @@ class FlowerViewApp(App[None]): } Header, Footer { - background: ansi_default; + background: #292E42; + background-tint: ansi_default 0%; + color: ansi_default; + } + + FooterLabel, FooterKey { + background: #292E42; + background-tint: ansi_default 0%; color: ansi_default; } @@ -110,11 +117,16 @@ class FlowerViewApp(App[None]): width: 32; min-width: 24; height: 100%; - background: ansi_default; + background: #292E42; + background-tint: ansi_default 0%; color: ansi_default; border-right: solid ansi_white; } + #toc:focus { + background-tint: ansi_default 0%; + } + #content { width: 1fr; height: 100%; @@ -122,9 +134,13 @@ class FlowerViewApp(App[None]): background: ansi_default; } + ListItem { + width: 100%; + } + ListItem > Label { + width: 100%; padding: 0 1; - background: ansi_default; color: ansi_default; } @@ -176,7 +192,7 @@ class FlowerViewApp(App[None]): MarkdownH2 { color: ansi_yellow; text-style: bold; - margin: 0; + margin: 0 0 1 0; } MarkdownH3 { diff --git a/tests/test_tui.py b/tests/test_tui.py index 376d5b75..5d71315c 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -19,13 +19,19 @@ def test_flower_view_app_styles_markdown_headings_like_terminal_output(): assert "color: ansi_yellow" in FlowerViewApp.CSS assert "MarkdownHeader" in FlowerViewApp.CSS assert "margin: 1 0 0 0" in FlowerViewApp.CSS + assert "margin: 0 0 1 0" in FlowerViewApp.CSS -def test_flower_view_app_themes_chrome_like_main_panel(): +def test_flower_view_app_themes_chrome_and_full_width_toc_rows(): assert "Header, Footer" in FlowerViewApp.CSS + assert "FooterLabel, FooterKey" in FlowerViewApp.CSS assert "#toc" in FlowerViewApp.CSS - assert "background: ansi_default" in FlowerViewApp.CSS + assert "#toc:focus" in FlowerViewApp.CSS + assert "background: #292E42" in FlowerViewApp.CSS + assert "background-tint: ansi_default 0%" in FlowerViewApp.CSS assert "color: ansi_default" in FlowerViewApp.CSS + assert "ListItem" in FlowerViewApp.CSS + assert "width: 100%" in FlowerViewApp.CSS def test_flower_view_app_maps_markdown_colors_to_ansi_terminal_styles(): From 7f977a2848acf80cfd8ae0e0cc6ff212de2d02b8 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Mon, 4 May 2026 18:35:25 +0200 Subject: [PATCH 08/28] =?UTF-8?q?perf:=20=E2=9A=A1=EF=B8=8F=20improve=20pe?= =?UTF-8?q?rformance=20by=20pre-loading=20all=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replaced per-switch Markdown.update(page.content) with ContentSwitcher. - Each metadata page is mounted once as its own Markdown widget. - Switching pages now only sets ContentSwitcher.current, so Textual does not re-parse/re-render Markdown on every navigation. - Added tests confirming page switches do not call Markdown.update. - Startup is heavier for large packages because all pages are rendered up front. --- src/seedcase_flower/tui.py | 25 +++++++++++++++---- tests/test_tui.py | 49 +++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index 5a26c70b..7f43e911 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -5,7 +5,15 @@ from textual.app import App, ComposeResult from textual.containers import Horizontal -from textual.widgets import Footer, Header, Label, ListItem, ListView, Markdown +from textual.widgets import ( + ContentSwitcher, + Footer, + Header, + Label, + ListItem, + ListView, + Markdown, +) from seedcase_flower.build_sections import BuiltSection @@ -16,6 +24,7 @@ class ViewPage: label: str content: str + id: str def prepare_view_pages(built_sections: list[BuiltSection]) -> list[ViewPage]: @@ -24,6 +33,7 @@ def prepare_view_pages(built_sections: list[BuiltSection]) -> list[ViewPage]: ViewPage( label=_section_label(section, index), content=_section_content(section.content), + id=f"page-{index}", ) for index, section in enumerate(built_sections, start=1) ] @@ -127,13 +137,18 @@ class FlowerViewApp(App[None]): background-tint: ansi_default 0%; } - #content { + #content-switcher { width: 1fr; height: 100%; padding: 0 1; background: ansi_default; } + .content-page { + width: 1fr; + height: 100%; + } + ListItem { width: 100%; } @@ -250,7 +265,9 @@ def compose(self) -> ComposeResult: yield ListView( *[ListItem(Label(page.label)) for page in self.pages], id="toc" ) - yield Markdown(initial_page.content, id="content") + with ContentSwitcher(id="content-switcher", initial=initial_page.id): + for page in self.pages: + yield Markdown(page.content, id=page.id, classes="content-page") yield Footer() def on_mount(self) -> None: @@ -278,7 +295,7 @@ def action_toc_up(self) -> None: async def _show_page(self, index: int) -> None: page = self.pages[index] self.sub_title = page.label - await self.query_one("#content", Markdown).update(page.content) + self.query_one("#content-switcher", ContentSwitcher).current = page.id def run_textual_viewer(built_sections: list[BuiltSection]) -> None: diff --git a/tests/test_tui.py b/tests/test_tui.py index 5d71315c..227af311 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -1,9 +1,11 @@ """Tests for the Textual viewer helpers.""" +import asyncio +import inspect from pathlib import Path from seedcase_flower.build_sections import BuiltSection -from seedcase_flower.tui import FlowerViewApp, prepare_view_pages +from seedcase_flower.tui import FlowerViewApp, ViewPage, prepare_view_pages def test_flower_view_app_has_vim_navigation_bindings(): @@ -11,6 +13,12 @@ def test_flower_view_app_has_vim_navigation_bindings(): assert ("k", "toc_up", "Up") in FlowerViewApp.BINDINGS +def test_flower_view_app_pre_mounts_markdown_pages(): + assert "ContentSwitcher" in inspect.getsource(FlowerViewApp.compose) + assert "#content-switcher" in FlowerViewApp.CSS + assert ".content-page" in FlowerViewApp.CSS + + def test_flower_view_app_styles_markdown_headings_like_terminal_output(): assert "MarkdownH1" in FlowerViewApp.CSS assert "content-align: left middle" in FlowerViewApp.CSS @@ -56,6 +64,7 @@ def test_prepare_view_pages_uses_package_label_for_index(): assert pages[0].label == "Package" assert pages[0].content == "# Test Package" + assert pages[0].id == "page-1" def test_prepare_view_pages_uses_resource_front_matter_for_label_and_content(): @@ -76,6 +85,7 @@ def test_prepare_view_pages_uses_resource_front_matter_for_label_and_content(): ) assert pages[0].label == "species_catalog" + assert pages[0].id == "page-1" assert "title:" not in pages[0].content assert "description:" not in pages[0].content assert "# Species Catalog" in pages[0].content @@ -94,3 +104,40 @@ def test_prepare_view_pages_falls_back_to_output_stem(): ) assert pages[0].label == "Growth Records" + + +def test_flower_view_app_switches_between_pre_mounted_pages(): + async def run_test() -> None: + app = FlowerViewApp( + [ + ViewPage(label="Package", content="# Package", id="page-1"), + ViewPage(label="species_catalog", content="# Species", id="page-2"), + ] + ) + + async with app.run_test(): + await app._show_page(1) + + assert app.sub_title == "species_catalog" + assert app.query_one("#content-switcher").current == "page-2" + + asyncio.run(run_test()) + + +def test_flower_view_app_does_not_update_markdown_on_page_switch(): + async def run_test() -> None: + app = FlowerViewApp( + [ + ViewPage(label="Package", content="# Package", id="page-1"), + ViewPage(label="species_catalog", content="# Species", id="page-2"), + ] + ) + + async with app.run_test(): + markdown = app.query_one("#page-2") + markdown.update = None # type: ignore[method-assign] + await app._show_page(1) + + assert app.query_one("#content-switcher").current == "page-2" + + asyncio.run(run_test()) From d95d61858c5019701bee746ec9aa7128ff8faa05 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Tue, 5 May 2026 09:22:59 +0200 Subject: [PATCH 09/28] =?UTF-8?q?feat:=20=E2=9C=A8=20use=20datatable=20for?= =?UTF-8?q?=20more=20feature=20and=20better=20perf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdown table rendering might not be the best choice in terminal --- src/seedcase_flower/tui.py | 173 +++++++++++++++++++++++++++++++++++-- tests/test_tui.py | 76 ++++++++++++++-- 2 files changed, 235 insertions(+), 14 deletions(-) diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index 7f43e911..e9872766 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -4,9 +4,10 @@ from pathlib import Path from textual.app import App, ComposeResult -from textual.containers import Horizontal +from textual.containers import Horizontal, VerticalScroll from textual.widgets import ( ContentSwitcher, + DataTable, Footer, Header, Label, @@ -25,18 +26,114 @@ class ViewPage: label: str content: str id: str + blocks: list["ViewBlock"] + + +@dataclass(frozen=True) +class MarkdownBlock: + """A Markdown fragment in a Textual viewer page.""" + + content: str + + +@dataclass(frozen=True) +class TableBlock: + """A Markdown table prepared for Textual's DataTable.""" + + headers: list[str] + rows: list[list[str]] + caption: str = "" + + +type ViewBlock = MarkdownBlock | TableBlock def prepare_view_pages(built_sections: list[BuiltSection]) -> list[ViewPage]: """Prepare built sections for display in the Textual viewer.""" - return [ - ViewPage( - label=_section_label(section, index), - content=_section_content(section.content), - id=f"page-{index}", + pages = [] + for index, section in enumerate(built_sections, start=1): + content = _section_content(section.content) + pages.append( + ViewPage( + label=_section_label(section, index), + content=content, + id=f"page-{index}", + blocks=_extract_view_blocks(content), + ) ) - for index, section in enumerate(built_sections, start=1) - ] + return pages + + +def _extract_view_blocks(content: str) -> list[ViewBlock]: + lines = content.splitlines() + blocks: list[ViewBlock] = [] + markdown_lines: list[str] = [] + index = 0 + + while index < len(lines): + if _starts_markdown_table(lines, index): + _append_markdown_block(blocks, markdown_lines) + markdown_lines = [] + table, index = _extract_table_block(lines, index) + blocks.append(table) + continue + + markdown_lines.append(lines[index]) + index += 1 + + _append_markdown_block(blocks, markdown_lines) + return blocks + + +def _append_markdown_block(blocks: list[ViewBlock], markdown_lines: list[str]) -> None: + markdown = "\n".join(markdown_lines).strip() + if markdown: + blocks.append(MarkdownBlock(markdown)) + + +def _starts_markdown_table(lines: list[str], index: int) -> bool: + return ( + index + 1 < len(lines) + and _is_table_row(lines[index]) + and _is_table_separator(lines[index + 1]) + ) + + +def _extract_table_block(lines: list[str], index: int) -> tuple[TableBlock, int]: + header = _table_cells(lines[index]) + index += 2 + rows = [] + while index < len(lines) and _is_table_row(lines[index]): + rows.append(_table_cells(lines[index])) + index += 1 + + caption = "" + if index + 1 < len(lines) and not lines[index].strip(): + if lines[index + 1].startswith(":"): + caption = lines[index + 1].removeprefix(":").strip() + index += 2 + elif index < len(lines) and lines[index].startswith(":"): + caption = lines[index].removeprefix(":").strip() + index += 1 + + return TableBlock(headers=header, rows=rows, caption=caption), index + + +def _is_table_row(line: str) -> bool: + return line.strip().startswith("|") and line.strip().endswith("|") + + +def _is_table_separator(line: str) -> bool: + if not _is_table_row(line): + return False + cells = _table_cells(line) + return bool(cells) and all( + cell and all(character in "-: " for character in cell) for cell in cells + ) + + +def _table_cells(line: str) -> list[str]: + return [cell.strip() for cell in line.strip().strip("|").split("|")] def _section_label(section: BuiltSection, index: int) -> str: @@ -243,6 +340,37 @@ class FlowerViewApp(App[None]): color: ansi_blue; text-style: bold; } + + PageView { + width: 1fr; + height: 100%; + background: ansi_default; + } + + .field-table { + width: 1fr; + height: 1fr; + min-height: 8; + background: ansi_default; + color: ansi_default; + } + + .field-table > .datatable--header { + background: ansi_default; + color: ansi_blue; + text-style: bold; + } + + .field-table > .datatable--cursor { + background: #292E42; + color: ansi_default; + } + + .table-caption { + color: ansi_default; + text-style: italic; + margin: 0 0 1 0; + } """ BINDINGS = [ ("j", "toc_down", "Down"), @@ -267,7 +395,7 @@ def compose(self) -> ComposeResult: ) with ContentSwitcher(id="content-switcher", initial=initial_page.id): for page in self.pages: - yield Markdown(page.content, id=page.id, classes="content-page") + yield PageView(page.blocks, id=page.id, classes="content-page") yield Footer() def on_mount(self) -> None: @@ -301,3 +429,30 @@ async def _show_page(self, index: int) -> None: def run_textual_viewer(built_sections: list[BuiltSection]) -> None: """Run the interactive Textual viewer for built sections.""" FlowerViewApp(prepare_view_pages(built_sections)).run() + + +class PageView(VerticalScroll): + """A cached Textual page composed from Markdown and native tables.""" + + def __init__(self, blocks: list[ViewBlock], **kwargs: object) -> None: + """Initialize the page with prepared content blocks.""" + super().__init__(**kwargs) + self.blocks = blocks + + def compose(self) -> ComposeResult: + """Compose Markdown fragments and DataTables for the page.""" + for block in self.blocks: + if isinstance(block, MarkdownBlock): + yield Markdown(block.content) + else: + table = DataTable( + show_row_labels=False, + zebra_stripes=True, + cursor_type="row", + classes="field-table", + ) + table.add_columns(*block.headers) + table.add_rows(block.rows) + yield table + if block.caption: + yield Label(block.caption, classes="table-caption") diff --git a/tests/test_tui.py b/tests/test_tui.py index 227af311..d897dca8 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -5,7 +5,14 @@ from pathlib import Path from seedcase_flower.build_sections import BuiltSection -from seedcase_flower.tui import FlowerViewApp, ViewPage, prepare_view_pages +from seedcase_flower.tui import ( + FlowerViewApp, + MarkdownBlock, + PageView, + TableBlock, + ViewPage, + prepare_view_pages, +) def test_flower_view_app_has_vim_navigation_bindings(): @@ -57,6 +64,12 @@ def test_flower_view_app_maps_markdown_colors_to_ansi_terminal_styles(): assert "MarkdownTableContent > .header" in FlowerViewApp.CSS +def test_flower_view_app_uses_native_datatables_for_tables(): + assert "DataTable" in inspect.getsource(PageView.compose) + assert "field-table" in FlowerViewApp.CSS + assert "table-caption" in FlowerViewApp.CSS + + def test_prepare_view_pages_uses_package_label_for_index(): pages = prepare_view_pages( [BuiltSection(content="# Test Package", output_path=Path("index.qmd"))] @@ -65,6 +78,7 @@ def test_prepare_view_pages_uses_package_label_for_index(): assert pages[0].label == "Package" assert pages[0].content == "# Test Package" assert pages[0].id == "page-1" + assert pages[0].blocks == [MarkdownBlock("# Test Package")] def test_prepare_view_pages_uses_resource_front_matter_for_label_and_content(): @@ -91,6 +105,38 @@ def test_prepare_view_pages_uses_resource_front_matter_for_label_and_content(): assert "# Species Catalog" in pages[0].content assert "## `species_catalog`" in pages[0].content assert "- Path: `data/species.csv`" in pages[0].content + assert pages[0].blocks == [ + MarkdownBlock( + "# Species Catalog\n\n## `species_catalog`\n\n- Path: `data/species.csv`" + ) + ] + + +def test_prepare_view_pages_extracts_markdown_tables_into_table_blocks(): + pages = prepare_view_pages( + [ + BuiltSection( + content=( + "# Resource\n\n" + "| Name | Type |\n" + "|------|------|\n" + "| `id` | integer |\n" + "| `name` | string |\n\n" + ": Fields in the resource." + ), + output_path=Path("resources/data.qmd"), + ) + ] + ) + + assert pages[0].blocks == [ + MarkdownBlock("# Resource"), + TableBlock( + headers=["Name", "Type"], + rows=[["`id`", "integer"], ["`name`", "string"]], + caption="Fields in the resource.", + ), + ] def test_prepare_view_pages_falls_back_to_output_stem(): @@ -110,8 +156,18 @@ def test_flower_view_app_switches_between_pre_mounted_pages(): async def run_test() -> None: app = FlowerViewApp( [ - ViewPage(label="Package", content="# Package", id="page-1"), - ViewPage(label="species_catalog", content="# Species", id="page-2"), + ViewPage( + label="Package", + content="# Package", + id="page-1", + blocks=[MarkdownBlock("# Package")], + ), + ViewPage( + label="species_catalog", + content="# Species", + id="page-2", + blocks=[MarkdownBlock("# Species")], + ), ] ) @@ -128,8 +184,18 @@ def test_flower_view_app_does_not_update_markdown_on_page_switch(): async def run_test() -> None: app = FlowerViewApp( [ - ViewPage(label="Package", content="# Package", id="page-1"), - ViewPage(label="species_catalog", content="# Species", id="page-2"), + ViewPage( + label="Package", + content="# Package", + id="page-1", + blocks=[MarkdownBlock("# Package")], + ), + ViewPage( + label="species_catalog", + content="# Species", + id="page-2", + blocks=[MarkdownBlock("# Species")], + ), ] ) From 40ed4e364d99b147addd37d970e1b375ca87190d Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Tue, 5 May 2026 09:36:25 +0200 Subject: [PATCH 10/28] =?UTF-8?q?fix:=20=F0=9F=90=9B=20Deal=20with=20liter?= =?UTF-8?q?al=20`|`=20in=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seedcase_flower/tui.py | 10 ++++++++- tests/test_tui.py | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index e9872766..69f78098 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -104,7 +104,7 @@ def _extract_table_block(lines: list[str], index: int) -> tuple[TableBlock, int] index += 2 rows = [] while index < len(lines) and _is_table_row(lines[index]): - rows.append(_table_cells(lines[index])) + rows.append(_normalize_table_row(_table_cells(lines[index]), len(header))) index += 1 caption = "" @@ -119,6 +119,14 @@ def _extract_table_block(lines: list[str], index: int) -> tuple[TableBlock, int] return TableBlock(headers=header, rows=rows, caption=caption), index +def _normalize_table_row(row: list[str], width: int) -> list[str]: + if len(row) == width: + return row + if len(row) < width: + return row + [""] * (width - len(row)) + return [*row[: width - 1], " | ".join(row[width - 1 :])] + + def _is_table_row(line: str) -> bool: return line.strip().startswith("|") and line.strip().endswith("|") diff --git a/tests/test_tui.py b/tests/test_tui.py index d897dca8..117fe133 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -139,6 +139,50 @@ def test_prepare_view_pages_extracts_markdown_tables_into_table_blocks(): ] +def test_prepare_view_pages_keeps_extra_pipes_in_final_table_cell(): + pages = prepare_view_pages( + [ + BuiltSection( + content=( + "| Name | Type | Description |\n" + "|------|------|-------------|\n" + "| `vas` | number | Left anchor | right anchor |\n" + ), + output_path=Path("resources/data.qmd"), + ) + ] + ) + + assert pages[0].blocks == [ + TableBlock( + headers=["Name", "Type", "Description"], + rows=[["`vas`", "number", "Left anchor | right anchor"]], + ) + ] + + +def test_prepare_view_pages_pads_short_table_rows(): + pages = prepare_view_pages( + [ + BuiltSection( + content=( + "| Name | Type | Description |\n" + "|------|------|-------------|\n" + "| `id` | integer |\n" + ), + output_path=Path("resources/data.qmd"), + ) + ] + ) + + assert pages[0].blocks == [ + TableBlock( + headers=["Name", "Type", "Description"], + rows=[["`id`", "integer", ""]], + ) + ] + + def test_prepare_view_pages_falls_back_to_output_stem(): pages = prepare_view_pages( [ From cf2f3773653c234ce5ef80714024255caa5794f8 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Tue, 5 May 2026 10:06:35 +0200 Subject: [PATCH 11/28] =?UTF-8?q?perf:=20=E2=9A=A1=EF=B8=8F=20use=20dedica?= =?UTF-8?q?ted=20terminal=20rendering=20instead=20of=20going=20through=20m?= =?UTF-8?q?arkdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seedcase_flower/cli.py | 4 +- src/seedcase_flower/tui.py | 383 +++++++++++++++---------------------- tests/test_cli.py | 11 +- tests/test_tui.py | 255 +++++++++++------------- 4 files changed, 270 insertions(+), 383 deletions(-) diff --git a/src/seedcase_flower/cli.py b/src/seedcase_flower/cli.py index 6374d7c8..c427439b 100644 --- a/src/seedcase_flower/cli.py +++ b/src/seedcase_flower/cli.py @@ -121,13 +121,13 @@ def view( address: Address = parse_source(source) properties: dict[str, Any] = read_properties(address) check(properties, error=True) - built_sections = build_sections(properties, Config(style=style)) if viewer == Viewer.textual: from seedcase_flower.tui import run_textual_viewer - run_textual_viewer(built_sections) + run_textual_viewer(properties) return + built_sections = build_sections(properties, Config(style=style)) console = Console(theme=CONSOLE_THEME) # TODO move back console theme? will it be used in CDP? print() # One line separation between the command and the datapackage title diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index 69f78098..85bda9f3 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -1,8 +1,9 @@ -"""Textual terminal app for browsing built Data Package sections.""" +"""Textual terminal app for browsing Data Package metadata.""" from dataclasses import dataclass -from pathlib import Path +from typing import Any +from rich.text import Text from textual.app import App, ComposeResult from textual.containers import Horizontal, VerticalScroll from textual.widgets import ( @@ -13,192 +14,188 @@ Label, ListItem, ListView, - Markdown, + Static, ) -from seedcase_flower.build_sections import BuiltSection - @dataclass(frozen=True) class ViewPage: - """A built section prepared for navigation in the Textual viewer.""" + """A Data Package page prepared for navigation in the Textual viewer.""" label: str - content: str id: str blocks: list["ViewBlock"] @dataclass(frozen=True) -class MarkdownBlock: - """A Markdown fragment in a Textual viewer page.""" +class TextBlock: + """A text fragment in a Textual viewer page.""" content: str + style: str = "" + classes: str = "body-text" @dataclass(frozen=True) class TableBlock: - """A Markdown table prepared for Textual's DataTable.""" + """Structured table data prepared for Textual's DataTable.""" headers: list[str] rows: list[list[str]] caption: str = "" -type ViewBlock = MarkdownBlock | TableBlock +type ViewBlock = TextBlock | TableBlock -def prepare_view_pages(built_sections: list[BuiltSection]) -> list[ViewPage]: - """Prepare built sections for display in the Textual viewer.""" - pages = [] - for index, section in enumerate(built_sections, start=1): - content = _section_content(section.content) - pages.append( - ViewPage( - label=_section_label(section, index), - content=content, - id=f"page-{index}", - blocks=_extract_view_blocks(content), - ) +def prepare_view_pages(properties: dict[str, Any]) -> list[ViewPage]: + """Prepare Data Package properties for display in the Textual viewer.""" + pages = [ + ViewPage( + label="Package", + id="page-1", + blocks=_package_blocks(properties), ) + ] + pages.extend( + ViewPage( + label=resource.get("name", f"Resource {index}"), + id=f"page-{index + 1}", + blocks=_resource_blocks(resource), + ) + for index, resource in enumerate(properties.get("resources", []), start=1) + ) return pages -def _extract_view_blocks(content: str) -> list[ViewBlock]: - lines = content.splitlines() +def _package_blocks(properties: dict[str, Any]) -> list[ViewBlock]: blocks: list[ViewBlock] = [] - markdown_lines: list[str] = [] - index = 0 - - while index < len(lines): - if _starts_markdown_table(lines, index): - _append_markdown_block(blocks, markdown_lines) - markdown_lines = [] - table, index = _extract_table_block(lines, index) - blocks.append(table) - continue - - markdown_lines.append(lines[index]) - index += 1 - - _append_markdown_block(blocks, markdown_lines) + title = _package_title(properties) + if title: + blocks.append(TextBlock(title, style="ansi_blue bold", classes="title")) + + if licenses := properties.get("licenses"): + blocks.append(TextBlock(_licenses_text(licenses))) + if version := properties.get("version"): + blocks.append(TextBlock(f"Version: {version}")) + if description := properties.get("description"): + blocks.append(TextBlock(description)) + if contributors := properties.get("contributors"): + blocks.append( + TextBlock("Contributors", style="ansi_yellow bold", classes="heading") + ) + blocks.append(TextBlock("\n".join(_contributor_text(contributors)))) + if resources := properties.get("resources"): + blocks.append( + TextBlock("Resources", style="ansi_yellow bold", classes="heading") + ) + blocks.append( + TableBlock( + headers=["Name", "Title", "Description"], + rows=[ + [ + resource.get("name", ""), + resource.get("title", ""), + resource.get("description", ""), + ] + for resource in resources + ], + ) + ) return blocks -def _append_markdown_block(blocks: list[ViewBlock], markdown_lines: list[str]) -> None: - markdown = "\n".join(markdown_lines).strip() - if markdown: - blocks.append(MarkdownBlock(markdown)) - - -def _starts_markdown_table(lines: list[str], index: int) -> bool: - return ( - index + 1 < len(lines) - and _is_table_row(lines[index]) - and _is_table_separator(lines[index + 1]) - ) - - -def _extract_table_block(lines: list[str], index: int) -> tuple[TableBlock, int]: - header = _table_cells(lines[index]) - index += 2 - rows = [] - while index < len(lines) and _is_table_row(lines[index]): - rows.append(_normalize_table_row(_table_cells(lines[index]), len(header))) - index += 1 - - caption = "" - if index + 1 < len(lines) and not lines[index].strip(): - if lines[index + 1].startswith(":"): - caption = lines[index + 1].removeprefix(":").strip() - index += 2 - elif index < len(lines) and lines[index].startswith(":"): - caption = lines[index].removeprefix(":").strip() - index += 1 - - return TableBlock(headers=header, rows=rows, caption=caption), index +def _package_title(properties: dict[str, Any]) -> str: + name = properties.get("name") + title = properties.get("title") + if name and title: + return f"{name}: {title}" + return name or title or "" -def _normalize_table_row(row: list[str], width: int) -> list[str]: - if len(row) == width: - return row - if len(row) < width: - return row + [""] * (width - len(row)) - return [*row[: width - 1], " | ".join(row[width - 1 :])] +def _licenses_text(licenses: list[dict[str, Any]]) -> str: + labels = [license.get("title") or license.get("name") for license in licenses] + return "Licenses: " + ", ".join(label for label in labels if label) -def _is_table_row(line: str) -> bool: - return line.strip().startswith("|") and line.strip().endswith("|") +def _contributor_text(contributors: list[dict[str, Any]]) -> list[str]: + return [ + text + for contributor in contributors + if (text := _single_contributor_text(contributor)) + ] -def _is_table_separator(line: str) -> bool: - if not _is_table_row(line): - return False - cells = _table_cells(line) - return bool(cells) and all( - cell and all(character in "-: " for character in cell) for cell in cells +def _single_contributor_text(contributor: dict[str, Any]) -> str: + full_name = ( + f"{contributor.get('firstName', '')} {contributor.get('lastName', '')}" + ).strip() + label = ( + contributor.get("title") + or full_name + or contributor.get("organization") + or contributor.get("email") + or "" ) + roles = ", ".join(contributor.get("roles", [])) + return f"- {label}{': ' + roles if roles else ''}" if label else "" -def _table_cells(line: str) -> list[str]: - return [cell.strip() for cell in line.strip().strip("|").split("|")] - - -def _section_label(section: BuiltSection, index: int) -> str: - front_matter, _ = _split_front_matter(section.content) - title = _front_matter_value(front_matter, "title") - subtitle = _front_matter_value(front_matter, "subtitle").strip("`") - if subtitle: - return subtitle - if title: - return title - if section.output_path: - return _output_path_label(section.output_path) - return f"Section {index}" - - -def _output_path_label(output_path: Path) -> str: - if output_path.name == "index.qmd": - return "Package" - return output_path.stem.replace("_", " ").replace("-", " ").title() - - -def _section_content(content: str) -> str: - front_matter, body = _split_front_matter(content) - if not front_matter: - return body - - headings = [] - title = _front_matter_value(front_matter, "title") - subtitle = _front_matter_value(front_matter, "subtitle") - if title: - headings.append(f"# {title}") - if subtitle: - headings.append(f"## {subtitle}") - - if not headings: - return body.lstrip() - return "\n\n".join([*headings, body.lstrip()]) - - -def _split_front_matter(content: str) -> tuple[list[str], str]: - lines = content.splitlines() - if not lines or lines[0].strip() != "---": - return [], content +def _resource_blocks(resource: dict[str, Any]) -> list[ViewBlock]: + blocks: list[ViewBlock] = [] + title = resource.get("title") or resource.get("name") or "Resource" + blocks.append(TextBlock(title, style="ansi_blue bold", classes="title")) + + if name := resource.get("name"): + blocks.append(TextBlock(name, style="ansi_yellow bold", classes="subtitle")) + if description := resource.get("description"): + blocks.append(TextBlock(description)) + if path := resource.get("path"): + blocks.append(TextBlock(f"Path: {path}")) + + schema = resource.get("schema") or {} + if primary_key := schema.get("primaryKey"): + blocks.append(TextBlock(f"Primary key: {_as_list_text(primary_key)}")) + if foreign_keys := schema.get("foreignKeys"): + blocks.append( + TextBlock("Foreign keys", style="ansi_yellow bold", classes="heading") + ) + blocks.append(TextBlock("\n".join(_foreign_key_text(foreign_keys, resource)))) + if fields := schema.get("fields"): + blocks.append( + TableBlock( + headers=["Name", "Title", "Type", "Description"], + rows=[ + [ + field.get("name", ""), + field.get("title", ""), + field.get("type", "any"), + field.get("description", ""), + ] + for field in fields + ], + caption=f"Fields in the {resource.get('name', 'resource')} resource.", + ) + ) + return blocks - for index, line in enumerate(lines[1:], start=1): - if line.strip() == "---": - return lines[1:index], "\n".join(lines[index + 1 :]) - return [], content +def _as_list_text(value: str | list[str]) -> str: + return value if isinstance(value, str) else ", ".join(value) -def _front_matter_value(front_matter: list[str], key: str) -> str: - prefix = f"{key}:" - for line in front_matter: - if line.startswith(prefix): - return line.removeprefix(prefix).strip().strip("\"'") - return "" +def _foreign_key_text( + foreign_keys: list[dict[str, Any]], resource: dict[str, Any] +) -> list[str]: + lines = [] + for foreign_key in foreign_keys: + reference = foreign_key.get("reference", {}) + reference_resource = reference.get("resource") or resource.get("name", "") + lines.append( + f"- {_as_list_text(foreign_key.get('fields', []))} -> " + f"{reference_resource}.{_as_list_text(reference.get('fields', []))}" + ) + return lines class FlowerViewApp(App[None]): @@ -264,95 +261,27 @@ class FlowerViewApp(App[None]): color: ansi_default; } - Markdown { - background: ansi_default; - color: ansi_default; - } - - MarkdownBlock > .code_inline { - color: ansi_yellow; - background: ansi_default; - text-style: bold; - } - - MarkdownBlock > .strong { - text-style: bold; - } - - MarkdownBlock > .em { - text-style: italic; - } - - MarkdownBlockQuote { + PageView { + width: 1fr; + height: 100%; background: ansi_default; - border-left: outer ansi_magenta; - color: ansi_magenta; - } - - MarkdownBullet { - color: ansi_cyan; } - MarkdownFence { - color: ansi_cyan; - background: ansi_black; - } - - MarkdownHeader { - margin: 1 0 0 0; + .body-text { + margin: 0 0 1 0; + color: ansi_default; } - MarkdownH1 { - content-align: left middle; - color: ansi_blue; - text-style: bold; + .title { margin: 0; } - MarkdownH2 { - color: ansi_yellow; - text-style: bold; + .subtitle { margin: 0 0 1 0; } - MarkdownH3 { - color: ansi_blue; - } - - MarkdownH4 { - color: ansi_magenta; - text-style: italic; - } - - MarkdownH5 { - text-style: italic; - } - - MarkdownH6 { - text-opacity: 60%; - } - - MarkdownHorizontalRule { - border-bottom: solid ansi_white; - } - - MarkdownTableContent { - keyline: thin ansi_white; - } - - MarkdownTableContent > .header { - color: ansi_blue; - } - - MarkdownTableContent > .markdown-table--header { - color: ansi_blue; - text-style: bold; - } - - PageView { - width: 1fr; - height: 100%; - background: ansi_default; + .heading { + margin: 1 0 1 0; } .field-table { @@ -434,13 +363,13 @@ async def _show_page(self, index: int) -> None: self.query_one("#content-switcher", ContentSwitcher).current = page.id -def run_textual_viewer(built_sections: list[BuiltSection]) -> None: - """Run the interactive Textual viewer for built sections.""" - FlowerViewApp(prepare_view_pages(built_sections)).run() +def run_textual_viewer(properties: dict[str, Any]) -> None: + """Run the interactive Textual viewer for Data Package properties.""" + FlowerViewApp(prepare_view_pages(properties)).run() class PageView(VerticalScroll): - """A cached Textual page composed from Markdown and native tables.""" + """A cached Textual page composed from text and native tables.""" def __init__(self, blocks: list[ViewBlock], **kwargs: object) -> None: """Initialize the page with prepared content blocks.""" @@ -448,10 +377,12 @@ def __init__(self, blocks: list[ViewBlock], **kwargs: object) -> None: self.blocks = blocks def compose(self) -> ComposeResult: - """Compose Markdown fragments and DataTables for the page.""" + """Compose text fragments and DataTables for the page.""" for block in self.blocks: - if isinstance(block, MarkdownBlock): - yield Markdown(block.content) + if isinstance(block, TextBlock): + yield Static( + Text(block.content, style=block.style), classes=block.classes + ) else: table = DataTable( show_row_labels=False, diff --git a/tests/test_cli.py b/tests/test_cli.py index 4a622ee8..b909b3c4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -224,18 +224,16 @@ def test_view_with_multi_section_style(mocker): def test_view_with_textual_viewer(mocker): - """view should route built sections to the Textual viewer when requested.""" + """view should route Data Package properties to the Textual viewer.""" mock_parse_source = mocker.patch("seedcase_flower.cli.parse_source") mock_read_properties = mocker.patch("seedcase_flower.cli.read_properties") mocker.patch("seedcase_flower.cli.check") mock_build_sections = mocker.patch("seedcase_flower.cli.build_sections") mock_textual_viewer = mocker.patch("seedcase_flower.tui.run_textual_viewer") mock_console_cls = mocker.patch("seedcase_flower.cli.Console") - built_sections = [BuiltSection(content="# Package", output_path=Path("index.qmd"))] fake_source = Address(value="file:///datapackage.json", local=True) mock_parse_source.return_value = fake_source - mock_build_sections.return_value = built_sections app( ["view", "datapackage.json", "--viewer", "textual"], @@ -243,11 +241,8 @@ def test_view_with_textual_viewer(mocker): ) mock_read_properties.assert_called_once_with(fake_source) - mock_build_sections.assert_called_once_with( - mock_read_properties.return_value, - Config(style=Style.quarto_one_page), - ) - mock_textual_viewer.assert_called_once_with(built_sections) + mock_build_sections.assert_not_called() + mock_textual_viewer.assert_called_once_with(mock_read_properties.return_value) mock_console_cls.assert_not_called() diff --git a/tests/test_tui.py b/tests/test_tui.py index 117fe133..d63d0c3d 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -2,14 +2,12 @@ import asyncio import inspect -from pathlib import Path -from seedcase_flower.build_sections import BuiltSection from seedcase_flower.tui import ( FlowerViewApp, - MarkdownBlock, PageView, TableBlock, + TextBlock, ViewPage, prepare_view_pages, ) @@ -20,21 +18,16 @@ def test_flower_view_app_has_vim_navigation_bindings(): assert ("k", "toc_up", "Up") in FlowerViewApp.BINDINGS -def test_flower_view_app_pre_mounts_markdown_pages(): +def test_flower_view_app_pre_mounts_pages(): assert "ContentSwitcher" in inspect.getsource(FlowerViewApp.compose) assert "#content-switcher" in FlowerViewApp.CSS assert ".content-page" in FlowerViewApp.CSS -def test_flower_view_app_styles_markdown_headings_like_terminal_output(): - assert "MarkdownH1" in FlowerViewApp.CSS - assert "content-align: left middle" in FlowerViewApp.CSS - assert "color: ansi_blue" in FlowerViewApp.CSS - assert "MarkdownH2" in FlowerViewApp.CSS - assert "color: ansi_yellow" in FlowerViewApp.CSS - assert "MarkdownHeader" in FlowerViewApp.CSS - assert "margin: 1 0 0 0" in FlowerViewApp.CSS - assert "margin: 0 0 1 0" in FlowerViewApp.CSS +def test_flower_view_app_uses_native_datatables_for_tables(): + assert "DataTable" in inspect.getsource(PageView.compose) + assert "field-table" in FlowerViewApp.CSS + assert "table-caption" in FlowerViewApp.CSS def test_flower_view_app_themes_chrome_and_full_width_toc_rows(): @@ -49,151 +42,123 @@ def test_flower_view_app_themes_chrome_and_full_width_toc_rows(): assert "width: 100%" in FlowerViewApp.CSS -def test_flower_view_app_maps_markdown_colors_to_ansi_terminal_styles(): - assert "background: ansi_default" in FlowerViewApp.CSS - assert "MarkdownBlock > .code_inline" in FlowerViewApp.CSS - assert "color: ansi_yellow" in FlowerViewApp.CSS - assert "MarkdownFence" in FlowerViewApp.CSS - assert "color: ansi_cyan" in FlowerViewApp.CSS - assert "background: ansi_black" in FlowerViewApp.CSS - assert "MarkdownBlockQuote" in FlowerViewApp.CSS - assert "border-left: outer ansi_magenta" in FlowerViewApp.CSS - assert "MarkdownBullet" in FlowerViewApp.CSS - assert "MarkdownTableContent" in FlowerViewApp.CSS - assert "keyline: thin ansi_white" in FlowerViewApp.CSS - assert "MarkdownTableContent > .header" in FlowerViewApp.CSS - - -def test_flower_view_app_uses_native_datatables_for_tables(): - assert "DataTable" in inspect.getsource(PageView.compose) - assert "field-table" in FlowerViewApp.CSS - assert "table-caption" in FlowerViewApp.CSS - - -def test_prepare_view_pages_uses_package_label_for_index(): +def test_prepare_view_pages_builds_package_page_from_properties(): pages = prepare_view_pages( - [BuiltSection(content="# Test Package", output_path=Path("index.qmd"))] + { + "name": "test-package", + "title": "Test Package", + "version": "1.0.0", + "description": "A test package.", + "licenses": [{"name": "MIT"}], + "contributors": [{"title": "Ada", "roles": ["author"]}], + "resources": [ + { + "name": "species_catalog", + "title": "Species Catalog", + "description": "Species metadata.", + } + ], + } ) assert pages[0].label == "Package" - assert pages[0].content == "# Test Package" - assert pages[0].id == "page-1" - assert pages[0].blocks == [MarkdownBlock("# Test Package")] - - -def test_prepare_view_pages_uses_resource_front_matter_for_label_and_content(): - pages = prepare_view_pages( - [ - BuiltSection( - content=( - "---\n" - 'title: "Species Catalog"\n' - 'subtitle: "`species_catalog`"\n' - 'description: "Resource description"\n' - "---\n\n" - "- Path: `data/species.csv`" - ), - output_path=Path("resources/species_catalog.qmd"), - ) - ] - ) - - assert pages[0].label == "species_catalog" assert pages[0].id == "page-1" - assert "title:" not in pages[0].content - assert "description:" not in pages[0].content - assert "# Species Catalog" in pages[0].content - assert "## `species_catalog`" in pages[0].content - assert "- Path: `data/species.csv`" in pages[0].content - assert pages[0].blocks == [ - MarkdownBlock( - "# Species Catalog\n\n## `species_catalog`\n\n- Path: `data/species.csv`" - ) - ] - - -def test_prepare_view_pages_extracts_markdown_tables_into_table_blocks(): - pages = prepare_view_pages( - [ - BuiltSection( - content=( - "# Resource\n\n" - "| Name | Type |\n" - "|------|------|\n" - "| `id` | integer |\n" - "| `name` | string |\n\n" - ": Fields in the resource." - ), - output_path=Path("resources/data.qmd"), - ) - ] - ) - assert pages[0].blocks == [ - MarkdownBlock("# Resource"), + TextBlock( + "test-package: Test Package", style="ansi_blue bold", classes="title" + ), + TextBlock("Licenses: MIT"), + TextBlock("Version: 1.0.0"), + TextBlock("A test package."), + TextBlock("Contributors", style="ansi_yellow bold", classes="heading"), + TextBlock("- Ada: author"), + TextBlock("Resources", style="ansi_yellow bold", classes="heading"), TableBlock( - headers=["Name", "Type"], - rows=[["`id`", "integer"], ["`name`", "string"]], - caption="Fields in the resource.", + headers=["Name", "Title", "Description"], + rows=[["species_catalog", "Species Catalog", "Species metadata."]], ), ] -def test_prepare_view_pages_keeps_extra_pipes_in_final_table_cell(): +def test_prepare_view_pages_builds_resource_page_from_properties(): pages = prepare_view_pages( - [ - BuiltSection( - content=( - "| Name | Type | Description |\n" - "|------|------|-------------|\n" - "| `vas` | number | Left anchor | right anchor |\n" - ), - output_path=Path("resources/data.qmd"), - ) - ] + { + "name": "test-package", + "resources": [ + { + "name": "species_catalog", + "title": "Species Catalog", + "description": "Species metadata.", + "path": "data/species.csv", + "schema": { + "primaryKey": "id", + "fields": [ + { + "name": "id", + "title": "Identifier", + "type": "integer", + "description": "Stable identifier.", + }, + { + "name": "species", + "title": "Species", + "type": "string", + "description": "Scientific name.", + }, + ], + }, + } + ], + } ) - assert pages[0].blocks == [ + assert pages[1].label == "species_catalog" + assert pages[1].id == "page-2" + assert pages[1].blocks == [ + TextBlock("Species Catalog", style="ansi_blue bold", classes="title"), + TextBlock("species_catalog", style="ansi_yellow bold", classes="subtitle"), + TextBlock("Species metadata."), + TextBlock("Path: data/species.csv"), + TextBlock("Primary key: id"), TableBlock( - headers=["Name", "Type", "Description"], - rows=[["`vas`", "number", "Left anchor | right anchor"]], - ) + headers=["Name", "Title", "Type", "Description"], + rows=[ + ["id", "Identifier", "integer", "Stable identifier."], + ["species", "Species", "string", "Scientific name."], + ], + caption="Fields in the species_catalog resource.", + ), ] -def test_prepare_view_pages_pads_short_table_rows(): +def test_prepare_view_pages_handles_foreign_keys(): pages = prepare_view_pages( - [ - BuiltSection( - content=( - "| Name | Type | Description |\n" - "|------|------|-------------|\n" - "| `id` | integer |\n" - ), - output_path=Path("resources/data.qmd"), - ) - ] + { + "name": "test-package", + "resources": [ + { + "name": "plots", + "schema": { + "foreignKeys": [ + { + "fields": ["species_id"], + "reference": { + "resource": "species", + "fields": ["id"], + }, + } + ] + }, + } + ], + } ) - assert pages[0].blocks == [ - TableBlock( - headers=["Name", "Type", "Description"], - rows=[["`id`", "integer", ""]], - ) - ] - - -def test_prepare_view_pages_falls_back_to_output_stem(): - pages = prepare_view_pages( - [ - BuiltSection( - content="Resource details", - output_path=Path("resources/growth-records.qmd"), - ) - ] + assert ( + TextBlock("Foreign keys", style="ansi_yellow bold", classes="heading") + in pages[1].blocks ) - - assert pages[0].label == "Growth Records" + assert TextBlock("- species_id -> species.id") in pages[1].blocks def test_flower_view_app_switches_between_pre_mounted_pages(): @@ -202,15 +167,13 @@ async def run_test() -> None: [ ViewPage( label="Package", - content="# Package", id="page-1", - blocks=[MarkdownBlock("# Package")], + blocks=[TextBlock("Package")], ), ViewPage( label="species_catalog", - content="# Species", id="page-2", - blocks=[MarkdownBlock("# Species")], + blocks=[TextBlock("Species")], ), ] ) @@ -224,28 +187,26 @@ async def run_test() -> None: asyncio.run(run_test()) -def test_flower_view_app_does_not_update_markdown_on_page_switch(): +def test_flower_view_app_does_not_update_page_on_switch(): async def run_test() -> None: app = FlowerViewApp( [ ViewPage( label="Package", - content="# Package", id="page-1", - blocks=[MarkdownBlock("# Package")], + blocks=[TextBlock("Package")], ), ViewPage( label="species_catalog", - content="# Species", id="page-2", - blocks=[MarkdownBlock("# Species")], + blocks=[TextBlock("Species")], ), ] ) async with app.run_test(): - markdown = app.query_one("#page-2") - markdown.update = None # type: ignore[method-assign] + page = app.query_one("#page-2") + page.remove = None # type: ignore[method-assign] await app._show_page(1) assert app.query_one("#content-switcher").current == "page-2" From e7cf9f983453d6aff0e32b5ff74af4d527905916 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Tue, 5 May 2026 14:58:08 +0200 Subject: [PATCH 12/28] =?UTF-8?q?style:=20=F0=9F=92=84=20use=20correct=20c?= =?UTF-8?q?olors=20everywhere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seedcase_flower/tui.py | 144 ++++++++++++++++++++++++++++--------- tests/test_tui.py | 51 +++++++++---- 2 files changed, 151 insertions(+), 44 deletions(-) diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index 85bda9f3..324f447d 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -17,6 +17,9 @@ Static, ) +RICH_BLUE = "color(4) bold" +RICH_YELLOW = "color(3) bold" + @dataclass(frozen=True) class ViewPage: @@ -34,6 +37,7 @@ class TextBlock: content: str style: str = "" classes: str = "body-text" + spans: tuple[tuple[int, int, str], ...] = () @dataclass(frozen=True) @@ -72,23 +76,18 @@ def _package_blocks(properties: dict[str, Any]) -> list[ViewBlock]: blocks: list[ViewBlock] = [] title = _package_title(properties) if title: - blocks.append(TextBlock(title, style="ansi_blue bold", classes="title")) + blocks.append(TextBlock(title, style=RICH_BLUE, classes="title")) - if licenses := properties.get("licenses"): - blocks.append(TextBlock(_licenses_text(licenses))) - if version := properties.get("version"): - blocks.append(TextBlock(f"Version: {version}")) + metadata = _package_metadata_text(properties) + if metadata: + blocks.append(metadata) if description := properties.get("description"): blocks.append(TextBlock(description)) if contributors := properties.get("contributors"): - blocks.append( - TextBlock("Contributors", style="ansi_yellow bold", classes="heading") - ) + blocks.append(TextBlock("Contributors", style=RICH_YELLOW, classes="heading")) blocks.append(TextBlock("\n".join(_contributor_text(contributors)))) if resources := properties.get("resources"): - blocks.append( - TextBlock("Resources", style="ansi_yellow bold", classes="heading") - ) + blocks.append(TextBlock("Resources", style=RICH_YELLOW, classes="heading")) blocks.append( TableBlock( headers=["Name", "Title", "Description"], @@ -115,7 +114,29 @@ def _package_title(properties: dict[str, Any]) -> str: def _licenses_text(licenses: list[dict[str, Any]]) -> str: labels = [license.get("title") or license.get("name") for license in licenses] - return "Licenses: " + ", ".join(label for label in labels if label) + return ", ".join(label for label in labels if label) + + +def _package_metadata_text(properties: dict[str, Any]) -> TextBlock | None: + lines = [] + spans = [] + if licenses := properties.get("licenses"): + lines.append(("Licenses: ", _licenses_text(licenses))) + if version := properties.get("version"): + lines.append(("Version: ", version)) + + if not lines: + return None + + content_lines = [] + offset = 0 + for label, value in lines: + line = f"{label}{value}" + start = offset + len(label) + spans.append((start, start + len(str(value)), RICH_YELLOW)) + content_lines.append(line) + offset += len(line) + 1 + return TextBlock("\n".join(content_lines), spans=tuple(spans)) def _contributor_text(contributors: list[dict[str, Any]]) -> list[str]: @@ -138,29 +159,22 @@ def _single_contributor_text(contributor: dict[str, Any]) -> str: or "" ) roles = ", ".join(contributor.get("roles", [])) - return f"- {label}{': ' + roles if roles else ''}" if label else "" + return f"• {label}{': ' + roles if roles else ''}" if label else "" def _resource_blocks(resource: dict[str, Any]) -> list[ViewBlock]: blocks: list[ViewBlock] = [] - title = resource.get("title") or resource.get("name") or "Resource" - blocks.append(TextBlock(title, style="ansi_blue bold", classes="title")) + resource_name = resource.get("name", "") + if (title := resource.get("title")) and _metadata_label(title) != resource_name: + blocks.append(TextBlock(title, style=RICH_BLUE, classes="title")) - if name := resource.get("name"): - blocks.append(TextBlock(name, style="ansi_yellow bold", classes="subtitle")) - if description := resource.get("description"): + if description := _resource_description(resource): blocks.append(TextBlock(description)) - if path := resource.get("path"): - blocks.append(TextBlock(f"Path: {path}")) schema = resource.get("schema") or {} - if primary_key := schema.get("primaryKey"): - blocks.append(TextBlock(f"Primary key: {_as_list_text(primary_key)}")) - if foreign_keys := schema.get("foreignKeys"): - blocks.append( - TextBlock("Foreign keys", style="ansi_yellow bold", classes="heading") - ) - blocks.append(TextBlock("\n".join(_foreign_key_text(foreign_keys, resource)))) + if bullets := _resource_bullets(resource, schema): + blocks.append(_compact_bullets(bullets)) + if fields := schema.get("fields"): blocks.append( TableBlock( @@ -180,6 +194,62 @@ def _resource_blocks(resource: dict[str, Any]) -> list[ViewBlock]: return blocks +def _resource_description(resource: dict[str, Any]) -> str: + description = resource.get("description", "") + resource_name = resource.get("name", "") + title = resource.get("title", "") + labels = {_metadata_label(resource_name), _metadata_label(title)} + return "" if _metadata_label(description) in labels else description + + +def _metadata_label(value: str) -> str: + return value.strip().strip("`") + + +def _resource_bullets( + resource: dict[str, Any], schema: dict[str, Any] +) -> list[TextBlock]: + blocks = [] + if path := resource.get("path"): + blocks.append(_label_value_line("• Path: ", path)) + if primary_key := schema.get("primaryKey"): + blocks.append(_label_value_line("• Primary key: ", _as_list_text(primary_key))) + if foreign_keys := schema.get("foreignKeys"): + blocks.append(_label_value_line("• Foreign keys:", "")) + blocks.extend( + _label_value_line(" ◦ ", foreign_key) + for foreign_key in _foreign_key_text(foreign_keys, resource) + ) + return blocks + + +def _label_value_line(label: str, value: str) -> TextBlock: + content = f"{label}{value}" + if not value: + return TextBlock(content) + return TextBlock( + content, + spans=((len(label), len(content), RICH_YELLOW),), + ) + + +def _compact_bullets(bullets: list[TextBlock]) -> TextBlock: + content_lines = [] + spans = [] + offset = 0 + for bullet in bullets: + content_lines.append(bullet.content) + spans.extend( + (offset + start, offset + end, style) for start, end, style in bullet.spans + ) + offset += len(bullet.content) + 1 + return TextBlock( + "\n".join(content_lines), + classes="compact-list", + spans=tuple(spans), + ) + + def _as_list_text(value: str | list[str]) -> str: return value if isinstance(value, str) else ", ".join(value) @@ -192,7 +262,7 @@ def _foreign_key_text( reference = foreign_key.get("reference", {}) reference_resource = reference.get("resource") or resource.get("name", "") lines.append( - f"- {_as_list_text(foreign_key.get('fields', []))} -> " + f"{_as_list_text(foreign_key.get('fields', []))} -> " f"{reference_resource}.{_as_list_text(reference.get('fields', []))}" ) return lines @@ -272,8 +342,15 @@ class FlowerViewApp(App[None]): color: ansi_default; } + .compact-list { + margin: 0 0 1 0; + color: ansi_default; + } + .title { - margin: 0; + margin: 0 0 1 0; + color: ansi_blue; + text-style: bold; } .subtitle { @@ -282,6 +359,8 @@ class FlowerViewApp(App[None]): .heading { margin: 1 0 1 0; + color: ansi_yellow; + text-style: bold; } .field-table { @@ -380,9 +459,10 @@ def compose(self) -> ComposeResult: """Compose text fragments and DataTables for the page.""" for block in self.blocks: if isinstance(block, TextBlock): - yield Static( - Text(block.content, style=block.style), classes=block.classes - ) + text = Text(block.content, style=block.style) + for start, end, style in block.spans: + text.stylize(style, start, end) + yield Static(text, classes=block.classes) else: table = DataTable( show_row_labels=False, diff --git a/tests/test_tui.py b/tests/test_tui.py index d63d0c3d..6c7affd7 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -40,6 +40,9 @@ def test_flower_view_app_themes_chrome_and_full_width_toc_rows(): assert "color: ansi_default" in FlowerViewApp.CSS assert "ListItem" in FlowerViewApp.CSS assert "width: 100%" in FlowerViewApp.CSS + assert ".title" in FlowerViewApp.CSS + assert ".compact-list" in FlowerViewApp.CSS + assert "margin: 0 0 1 0" in FlowerViewApp.CSS def test_prepare_view_pages_builds_package_page_from_properties(): @@ -64,15 +67,15 @@ def test_prepare_view_pages_builds_package_page_from_properties(): assert pages[0].label == "Package" assert pages[0].id == "page-1" assert pages[0].blocks == [ + TextBlock("test-package: Test Package", style="color(4) bold", classes="title"), TextBlock( - "test-package: Test Package", style="ansi_blue bold", classes="title" + "Licenses: MIT\nVersion: 1.0.0", + spans=((10, 13, "color(3) bold"), (23, 28, "color(3) bold")), ), - TextBlock("Licenses: MIT"), - TextBlock("Version: 1.0.0"), TextBlock("A test package."), - TextBlock("Contributors", style="ansi_yellow bold", classes="heading"), - TextBlock("- Ada: author"), - TextBlock("Resources", style="ansi_yellow bold", classes="heading"), + TextBlock("Contributors", style="color(3) bold", classes="heading"), + TextBlock("• Ada: author"), + TextBlock("Resources", style="color(3) bold", classes="heading"), TableBlock( headers=["Name", "Title", "Description"], rows=[["species_catalog", "Species Catalog", "Species metadata."]], @@ -115,11 +118,13 @@ def test_prepare_view_pages_builds_resource_page_from_properties(): assert pages[1].label == "species_catalog" assert pages[1].id == "page-2" assert pages[1].blocks == [ - TextBlock("Species Catalog", style="ansi_blue bold", classes="title"), - TextBlock("species_catalog", style="ansi_yellow bold", classes="subtitle"), + TextBlock("Species Catalog", style="color(4) bold", classes="title"), TextBlock("Species metadata."), - TextBlock("Path: data/species.csv"), - TextBlock("Primary key: id"), + TextBlock( + "• Path: data/species.csv\n• Primary key: id", + classes="compact-list", + spans=((8, 24, "color(3) bold"), (40, 42, "color(3) bold")), + ), TableBlock( headers=["Name", "Title", "Type", "Description"], rows=[ @@ -155,10 +160,32 @@ def test_prepare_view_pages_handles_foreign_keys(): ) assert ( - TextBlock("Foreign keys", style="ansi_yellow bold", classes="heading") + TextBlock( + "• Foreign keys:\n ◦ species_id -> species.id", + classes="compact-list", + spans=((20, 44, "color(3) bold"),), + ) in pages[1].blocks ) - assert TextBlock("- species_id -> species.id") in pages[1].blocks + + +def test_prepare_view_pages_omits_resource_name_from_main_content(): + pages = prepare_view_pages( + { + "name": "test-package", + "resources": [ + { + "name": "species_catalog", + "title": "species_catalog", + "description": "`species_catalog`", + "schema": {"fields": []}, + } + ], + } + ) + + assert pages[1].label == "species_catalog" + assert pages[1].blocks == [] def test_flower_view_app_switches_between_pre_mounted_pages(): From 5b7f70ea33a7b9e0dbe0c33ff4c346f0ffe7d593 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Tue, 5 May 2026 15:18:46 +0200 Subject: [PATCH 13/28] =?UTF-8?q?style:=20=F0=9F=92=84=20further=20fine=20?= =?UTF-8?q?tune=20the=20styling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seedcase_flower/tui.py | 134 ++++++++++++++++++------------------- tests/test_tui.py | 46 +++++++++---- 2 files changed, 100 insertions(+), 80 deletions(-) diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index 324f447d..efdd3b77 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -78,14 +78,26 @@ def _package_blocks(properties: dict[str, Any]) -> list[ViewBlock]: if title: blocks.append(TextBlock(title, style=RICH_BLUE, classes="title")) - metadata = _package_metadata_text(properties) - if metadata: - blocks.append(metadata) if description := properties.get("description"): blocks.append(TextBlock(description)) + if version := properties.get("version"): + blocks.append(_labeled_list("Version", [version])) + if licenses := properties.get("licenses"): + blocks.append( + TextBlock("Licenses", style=RICH_YELLOW, classes="compact-heading") + ) + blocks.append( + TextBlock("\n".join(_license_text(licenses)), classes="metadata-list") + ) if contributors := properties.get("contributors"): - blocks.append(TextBlock("Contributors", style=RICH_YELLOW, classes="heading")) - blocks.append(TextBlock("\n".join(_contributor_text(contributors)))) + blocks.append( + TextBlock("Contributors", style=RICH_YELLOW, classes="compact-heading") + ) + blocks.append( + TextBlock( + "\n".join(_contributor_text(contributors)), classes="metadata-list" + ) + ) if resources := properties.get("resources"): blocks.append(TextBlock("Resources", style=RICH_YELLOW, classes="heading")) blocks.append( @@ -112,31 +124,12 @@ def _package_title(properties: dict[str, Any]) -> str: return name or title or "" -def _licenses_text(licenses: list[dict[str, Any]]) -> str: - labels = [license.get("title") or license.get("name") for license in licenses] - return ", ".join(label for label in labels if label) - - -def _package_metadata_text(properties: dict[str, Any]) -> TextBlock | None: - lines = [] - spans = [] - if licenses := properties.get("licenses"): - lines.append(("Licenses: ", _licenses_text(licenses))) - if version := properties.get("version"): - lines.append(("Version: ", version)) - - if not lines: - return None - - content_lines = [] - offset = 0 - for label, value in lines: - line = f"{label}{value}" - start = offset + len(label) - spans.append((start, start + len(str(value)), RICH_YELLOW)) - content_lines.append(line) - offset += len(line) + 1 - return TextBlock("\n".join(content_lines), spans=tuple(spans)) +def _license_text(licenses: list[dict[str, Any]]) -> list[str]: + return [ + f"• {label}" + for license in licenses + if (label := license.get("title") or license.get("name")) + ] def _contributor_text(contributors: list[dict[str, Any]]) -> list[str]: @@ -165,17 +158,18 @@ def _single_contributor_text(contributor: dict[str, Any]) -> str: def _resource_blocks(resource: dict[str, Any]) -> list[ViewBlock]: blocks: list[ViewBlock] = [] resource_name = resource.get("name", "") - if (title := resource.get("title")) and _metadata_label(title) != resource_name: + title = resource.get("title") or resource_name + if title: blocks.append(TextBlock(title, style=RICH_BLUE, classes="title")) if description := _resource_description(resource): blocks.append(TextBlock(description)) schema = resource.get("schema") or {} - if bullets := _resource_bullets(resource, schema): - blocks.append(_compact_bullets(bullets)) + blocks.extend(_resource_metadata_blocks(resource, schema)) if fields := schema.get("fields"): + blocks.append(TextBlock("Fields", style=RICH_YELLOW, classes="heading")) blocks.append( TableBlock( headers=["Name", "Title", "Type", "Description"], @@ -206,47 +200,27 @@ def _metadata_label(value: str) -> str: return value.strip().strip("`") -def _resource_bullets( +def _resource_metadata_blocks( resource: dict[str, Any], schema: dict[str, Any] ) -> list[TextBlock]: blocks = [] if path := resource.get("path"): - blocks.append(_label_value_line("• Path: ", path)) + blocks.append(_labeled_list("Path", [path])) if primary_key := schema.get("primaryKey"): - blocks.append(_label_value_line("• Primary key: ", _as_list_text(primary_key))) + blocks.append(_labeled_list("Primary key", [_as_list_text(primary_key)])) if foreign_keys := schema.get("foreignKeys"): - blocks.append(_label_value_line("• Foreign keys:", "")) - blocks.extend( - _label_value_line(" ◦ ", foreign_key) - for foreign_key in _foreign_key_text(foreign_keys, resource) + blocks.append( + _labeled_list("Foreign keys", _foreign_key_text(foreign_keys, resource)) ) return blocks -def _label_value_line(label: str, value: str) -> TextBlock: - content = f"{label}{value}" - if not value: - return TextBlock(content) - return TextBlock( - content, - spans=((len(label), len(content), RICH_YELLOW),), - ) - - -def _compact_bullets(bullets: list[TextBlock]) -> TextBlock: - content_lines = [] - spans = [] - offset = 0 - for bullet in bullets: - content_lines.append(bullet.content) - spans.extend( - (offset + start, offset + end, style) for start, end, style in bullet.spans - ) - offset += len(bullet.content) + 1 +def _labeled_list(label: str, values: list[str]) -> TextBlock: + lines = [label] + [f"• {value}" for value in values] return TextBlock( - "\n".join(content_lines), - classes="compact-list", - spans=tuple(spans), + "\n".join(lines), + classes="metadata-list", + spans=((0, len(label), RICH_YELLOW),), ) @@ -262,7 +236,7 @@ def _foreign_key_text( reference = foreign_key.get("reference", {}) reference_resource = reference.get("resource") or resource.get("name", "") lines.append( - f"{_as_list_text(foreign_key.get('fields', []))} -> " + f"{_as_list_text(foreign_key.get('fields', []))} → " f"{reference_resource}.{_as_list_text(reference.get('fields', []))}" ) return lines @@ -331,6 +305,26 @@ class FlowerViewApp(App[None]): color: ansi_default; } + #toc > ListItem.-highlight { + background: ansi_yellow; + color: #1A1B26; + } + + #toc > ListItem.-highlight > Label { + background: ansi_yellow; + color: #1A1B26; + } + + #toc:focus > ListItem.-highlight { + background: ansi_yellow; + color: #1A1B26; + } + + #toc:focus > ListItem.-highlight > Label { + background: ansi_yellow; + color: #1A1B26; + } + PageView { width: 1fr; height: 100%; @@ -342,8 +336,8 @@ class FlowerViewApp(App[None]): color: ansi_default; } - .compact-list { - margin: 0 0 1 0; + .metadata-list { + margin: 0; color: ansi_default; } @@ -358,7 +352,13 @@ class FlowerViewApp(App[None]): } .heading { - margin: 1 0 1 0; + margin: 1 0 0 0; + color: ansi_yellow; + text-style: bold; + } + + .compact-heading { + margin: 0; color: ansi_yellow; text-style: bold; } diff --git a/tests/test_tui.py b/tests/test_tui.py index 6c7affd7..b968cda5 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -39,9 +39,15 @@ def test_flower_view_app_themes_chrome_and_full_width_toc_rows(): assert "background-tint: ansi_default 0%" in FlowerViewApp.CSS assert "color: ansi_default" in FlowerViewApp.CSS assert "ListItem" in FlowerViewApp.CSS + assert "#toc > ListItem.-highlight" in FlowerViewApp.CSS + assert "background: ansi_yellow" in FlowerViewApp.CSS + assert "color: #1A1B26" in FlowerViewApp.CSS + assert "color: $footer-key-foreground" not in FlowerViewApp.CSS assert "width: 100%" in FlowerViewApp.CSS assert ".title" in FlowerViewApp.CSS - assert ".compact-list" in FlowerViewApp.CSS + assert ".compact-list" not in FlowerViewApp.CSS + assert ".metadata-list" in FlowerViewApp.CSS + assert ".compact-heading" in FlowerViewApp.CSS assert "margin: 0 0 1 0" in FlowerViewApp.CSS @@ -68,13 +74,16 @@ def test_prepare_view_pages_builds_package_page_from_properties(): assert pages[0].id == "page-1" assert pages[0].blocks == [ TextBlock("test-package: Test Package", style="color(4) bold", classes="title"), + TextBlock("A test package."), TextBlock( - "Licenses: MIT\nVersion: 1.0.0", - spans=((10, 13, "color(3) bold"), (23, 28, "color(3) bold")), + "Version\n• 1.0.0", + classes="metadata-list", + spans=((0, 7, "color(3) bold"),), ), - TextBlock("A test package."), - TextBlock("Contributors", style="color(3) bold", classes="heading"), - TextBlock("• Ada: author"), + TextBlock("Licenses", style="color(3) bold", classes="compact-heading"), + TextBlock("• MIT", classes="metadata-list"), + TextBlock("Contributors", style="color(3) bold", classes="compact-heading"), + TextBlock("• Ada: author", classes="metadata-list"), TextBlock("Resources", style="color(3) bold", classes="heading"), TableBlock( headers=["Name", "Title", "Description"], @@ -121,10 +130,16 @@ def test_prepare_view_pages_builds_resource_page_from_properties(): TextBlock("Species Catalog", style="color(4) bold", classes="title"), TextBlock("Species metadata."), TextBlock( - "• Path: data/species.csv\n• Primary key: id", - classes="compact-list", - spans=((8, 24, "color(3) bold"), (40, 42, "color(3) bold")), + "Path\n• data/species.csv", + classes="metadata-list", + spans=((0, 4, "color(3) bold"),), + ), + TextBlock( + "Primary key\n• id", + classes="metadata-list", + spans=((0, 11, "color(3) bold"),), ), + TextBlock("Fields", style="color(3) bold", classes="heading"), TableBlock( headers=["Name", "Title", "Type", "Description"], rows=[ @@ -159,11 +174,14 @@ def test_prepare_view_pages_handles_foreign_keys(): } ) + assert pages[1].blocks[0] == TextBlock( + "plots", style="color(4) bold", classes="title" + ) assert ( TextBlock( - "• Foreign keys:\n ◦ species_id -> species.id", - classes="compact-list", - spans=((20, 44, "color(3) bold"),), + "Foreign keys\n• species_id → species.id", + classes="metadata-list", + spans=((0, 12, "color(3) bold"),), ) in pages[1].blocks ) @@ -185,7 +203,9 @@ def test_prepare_view_pages_omits_resource_name_from_main_content(): ) assert pages[1].label == "species_catalog" - assert pages[1].blocks == [] + assert pages[1].blocks == [ + TextBlock("species_catalog", style="color(4) bold", classes="title") + ] def test_flower_view_app_switches_between_pre_mounted_pages(): From d2306ddac5740c2e1babe55c7032ba4e5a213179 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Tue, 5 May 2026 15:54:36 +0200 Subject: [PATCH 14/28] =?UTF-8?q?perf:=20=E2=9A=A1=EF=B8=8F=20debounce=20s?= =?UTF-8?q?o=20that=20table=20is=20not=20loaded=20if=20quickly=20scrolling?= =?UTF-8?q?=20through=20the=20resources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E.g. if someone is holding down the arrow key or similar --- src/seedcase_flower/tui.py | 22 +++++++++++++++++++++- tests/test_tui.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index efdd3b77..f7d88f5b 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -6,6 +6,7 @@ from rich.text import Text from textual.app import App, ComposeResult from textual.containers import Horizontal, VerticalScroll +from textual.timer import Timer from textual.widgets import ( ContentSwitcher, DataTable, @@ -19,6 +20,7 @@ RICH_BLUE = "color(4) bold" RICH_YELLOW = "color(3) bold" +HIGHLIGHT_DEBOUNCE_SECONDS = 0.1 @dataclass(frozen=True) @@ -399,6 +401,8 @@ def __init__(self, pages: list[ViewPage]) -> None: """Initialize the app with pages to display.""" super().__init__(ansi_color=True) self.pages = pages + self._pending_page_index: int | None = None + self._highlight_timer: Timer | None = None def compose(self) -> ComposeResult: """Compose the page navigation and content widgets.""" @@ -422,10 +426,19 @@ async def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: """Show the highlighted page in the content pane.""" index = event.list_view.index if index is not None: - await self._show_page(index) + self._pending_page_index = index + if self._highlight_timer is not None: + self._highlight_timer.stop() + self._highlight_timer = self.set_timer( + HIGHLIGHT_DEBOUNCE_SECONDS, + self._show_pending_page, + ) async def on_list_view_selected(self, event: ListView.Selected) -> None: """Show the selected page in the content pane.""" + if self._highlight_timer is not None: + self._highlight_timer.stop() + self._pending_page_index = None await self._show_page(event.index) def action_toc_down(self) -> None: @@ -436,6 +449,13 @@ def action_toc_up(self) -> None: """Move up in the page navigation.""" self.query_one("#toc", ListView).action_cursor_up() + async def _show_pending_page(self) -> None: + """Show the latest highlighted page after a short navigation debounce.""" + if self._pending_page_index is not None: + await self._show_page(self._pending_page_index) + self._pending_page_index = None + self._highlight_timer = None + async def _show_page(self, index: int) -> None: page = self.pages[index] self.sub_title = page.label diff --git a/tests/test_tui.py b/tests/test_tui.py index b968cda5..6c2eb487 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -234,6 +234,42 @@ async def run_test() -> None: asyncio.run(run_test()) +def test_flower_view_app_debounces_highlight_navigation(): + async def run_test() -> None: + app = FlowerViewApp( + [ + ViewPage( + label="Package", + id="page-1", + blocks=[TextBlock("Package")], + ), + ViewPage( + label="species_catalog", + id="page-2", + blocks=[TextBlock("Species")], + ), + ViewPage( + label="location_catalog", + id="page-3", + blocks=[TextBlock("Locations")], + ), + ] + ) + + async with app.run_test() as pilot: + app.action_toc_down() + app.action_toc_down() + + assert app.query_one("#content-switcher").current == "page-1" + + await pilot.pause(0.15) + + assert app.sub_title == "location_catalog" + assert app.query_one("#content-switcher").current == "page-3" + + asyncio.run(run_test()) + + def test_flower_view_app_does_not_update_page_on_switch(): async def run_test() -> None: app = FlowerViewApp( From 7420b317e24aa3b46163efb867df35edd56b5871 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Tue, 5 May 2026 18:23:23 +0200 Subject: [PATCH 15/28] =?UTF-8?q?feat:=20=E2=9C=A8=20implement=20search=20?= =?UTF-8?q?and=20sort=20of=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guide/cli.qmd | 6 ++ src/seedcase_flower/tui.py | 140 ++++++++++++++++++++++++++++++++++--- tests/test_tui.py | 84 ++++++++++++++++++++++ 3 files changed, 222 insertions(+), 8 deletions(-) diff --git a/docs/guide/cli.qmd b/docs/guide/cli.qmd index 4aeddeed..6f33441a 100644 --- a/docs/guide/cli.qmd +++ b/docs/guide/cli.qmd @@ -142,6 +142,12 @@ selected section in a scrollable content pane: seedcase-flower view --style quarto-resource-listing --viewer textual ``` +In the Textual viewer, use `j` and `k` or the arrow keys to move through +the table of contents. For tables, use `s` to cycle sorting through the +columns and directions, `/` to search the current table, and `Esc` to clear +the search. The sorted column shows an arrow for ascending or descending +order. + ::: callout-important `view` is only configurable via command line flags. This means that any options set in Flower's configuration file are ignored, even those with diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index f7d88f5b..9807ce46 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -12,11 +12,13 @@ DataTable, Footer, Header, + Input, Label, ListItem, ListView, Static, ) +from textual.widgets._data_table import ColumnKey, measure RICH_BLUE = "color(4) bold" RICH_YELLOW = "color(3) bold" @@ -389,8 +391,20 @@ class FlowerViewApp(App[None]): text-style: italic; margin: 0 0 1 0; } + + #table-search { + display: none; + height: 3; + background: #292E42; + color: ansi_yellow; + text-style: bold; + border: solid ansi_yellow; + } """ BINDINGS = [ + ("/", "search_table", "Search table"), + ("s", "sort_table", "Sort table"), + ("escape", "clear_search", "Clear search"), ("j", "toc_down", "Down"), ("k", "toc_up", "Up"), ("q", "quit", "Quit"), @@ -416,6 +430,7 @@ def compose(self) -> ComposeResult: with ContentSwitcher(id="content-switcher", initial=initial_page.id): for page in self.pages: yield PageView(page.blocks, id=page.id, classes="content-page") + yield Input(placeholder="Search current table", id="table-search") yield Footer() def on_mount(self) -> None: @@ -449,6 +464,36 @@ def action_toc_up(self) -> None: """Move up in the page navigation.""" self.query_one("#toc", ListView).action_cursor_up() + def action_search_table(self) -> None: + """Focus the table search input.""" + search = self.query_one("#table-search", Input) + search.display = True + search.focus() + + def action_sort_table(self) -> None: + """Sort the current page table by the next column.""" + if table := self._current_table(): + table.sort_next_column() + + def action_clear_search(self) -> None: + """Clear table filtering and hide the search input.""" + search = self.query_one("#table-search", Input) + search.value = "" + search.display = False + if table := self._current_table(): + table.filter_rows("") + self.query_one("#toc", ListView).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + """Filter the current page table while typing in the search input.""" + if event.input.id == "table-search" and (table := self._current_table()): + table.filter_rows(event.value) + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Return focus to navigation after submitting table search.""" + if event.input.id == "table-search": + self.query_one("#toc", ListView).focus() + async def _show_pending_page(self) -> None: """Show the latest highlighted page after a short navigation debounce.""" if self._pending_page_index is not None: @@ -460,6 +505,18 @@ async def _show_page(self, index: int) -> None: page = self.pages[index] self.sub_title = page.label self.query_one("#content-switcher", ContentSwitcher).current = page.id + search = self.query_one("#table-search", Input) + if table := self._current_table(): + table.filter_rows(search.value) + + def _current_table(self) -> "SearchableDataTable | None": + """Return the first table on the currently visible page.""" + switcher = self.query_one("#content-switcher", ContentSwitcher) + if not switcher.current: + return None + page = self.query_one(f"#{switcher.current}", PageView) + tables = page.query(SearchableDataTable) + return tables.first() if tables else None def run_textual_viewer(properties: dict[str, Any]) -> None: @@ -484,14 +541,81 @@ def compose(self) -> ComposeResult: text.stylize(style, start, end) yield Static(text, classes=block.classes) else: - table = DataTable( - show_row_labels=False, - zebra_stripes=True, - cursor_type="row", - classes="field-table", - ) - table.add_columns(*block.headers) - table.add_rows(block.rows) + table = SearchableDataTable(block) yield table if block.caption: yield Label(block.caption, classes="table-caption") + + +class SearchableDataTable(DataTable[str]): + """DataTable with simple current-page sorting and row filtering.""" + + def __init__(self, block: TableBlock) -> None: + """Initialize a table from prepared table data.""" + super().__init__( + show_row_labels=False, + zebra_stripes=True, + cursor_type="row", + classes="field-table", + ) + self.headers = block.headers + self.all_rows = block.rows + self._sort_column_index: int | None = None + self._sort_reverse = False + + def on_mount(self) -> None: + """Populate the table once it has app context for measuring columns.""" + for header in self.headers: + self.add_column(header, key=header) + self.filter_rows("") + + def filter_rows(self, query: str) -> None: + """Show only rows that contain the query text.""" + normalized_query = query.casefold() + rows = [ + row + for row in self.all_rows + if not normalized_query + or normalized_query in " ".join(str(cell) for cell in row).casefold() + ] + self.clear() + self.add_rows(rows) + if self._sort_column_index is not None: + self._sort_by_column(self._sort_column_index) + + def sort_next_column(self) -> None: + """Sort rows by columns, toggling direction before moving on.""" + if not self.headers: + return + if self._sort_column_index is None: + self._sort_column_index = 0 + self._sort_reverse = False + elif not self._sort_reverse: + self._sort_reverse = True + else: + self._sort_column_index = (self._sort_column_index + 1) % len(self.headers) + self._sort_reverse = False + self._sort_by_column(self._sort_column_index) + + def _sort_by_column(self, column_index: int) -> None: + """Sort rows case-insensitively by one column.""" + self._refresh_sort_indicators() + self.sort( + self.headers[column_index], + key=lambda value: str(value).casefold(), + reverse=self._sort_reverse, + ) + + def _refresh_sort_indicators(self) -> None: + """Show the active sort column and direction in table headers.""" + for column_index, header in enumerate(self.headers): + label = header + if column_index == self._sort_column_index: + label = f"{header} {'↓' if self._sort_reverse else '↑'}" + text = Text(label) + column = self.columns[ColumnKey(header)] + column.label = text + column.content_width = measure(self.app.console, text, 1) + if column.auto_width: + column.width = column.content_width + self.refresh_column(column_index) diff --git a/tests/test_tui.py b/tests/test_tui.py index 6c2eb487..730e5724 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -6,6 +6,7 @@ from seedcase_flower.tui import ( FlowerViewApp, PageView, + SearchableDataTable, TableBlock, TextBlock, ViewPage, @@ -16,6 +17,9 @@ def test_flower_view_app_has_vim_navigation_bindings(): assert ("j", "toc_down", "Down") in FlowerViewApp.BINDINGS assert ("k", "toc_up", "Up") in FlowerViewApp.BINDINGS + assert ("/", "search_table", "Search table") in FlowerViewApp.BINDINGS + assert ("s", "sort_table", "Sort table") in FlowerViewApp.BINDINGS + assert ("escape", "clear_search", "Clear search") in FlowerViewApp.BINDINGS def test_flower_view_app_pre_mounts_pages(): @@ -26,8 +30,12 @@ def test_flower_view_app_pre_mounts_pages(): def test_flower_view_app_uses_native_datatables_for_tables(): assert "DataTable" in inspect.getsource(PageView.compose) + assert "SearchableDataTable" in inspect.getsource(PageView.compose) assert "field-table" in FlowerViewApp.CSS assert "table-caption" in FlowerViewApp.CSS + assert "#table-search" in FlowerViewApp.CSS + assert "color: ansi_yellow" in FlowerViewApp.CSS + assert "border: solid ansi_yellow" in FlowerViewApp.CSS def test_flower_view_app_themes_chrome_and_full_width_toc_rows(): @@ -295,3 +303,79 @@ async def run_test() -> None: assert app.query_one("#content-switcher").current == "page-2" asyncio.run(run_test()) + + +def test_flower_view_app_filters_current_page_table(): + async def run_test() -> None: + app = FlowerViewApp( + [ + ViewPage( + label="Package", + id="page-1", + blocks=[ + TableBlock( + headers=["Name", "Type"], + rows=[ + ["species", "string"], + ["plot_id", "integer"], + ], + ) + ], + ) + ] + ) + + async with app.run_test() as pilot: + table = app.query_one(SearchableDataTable) + + app.action_search_table() + await pilot.press("s", "p") + + assert table.row_count == 1 + assert table.get_row_at(0) == ["species", "string"] + + app.action_clear_search() + + assert table.row_count == 2 + + asyncio.run(run_test()) + + +def test_flower_view_app_sorts_current_page_table(): + async def run_test() -> None: + app = FlowerViewApp( + [ + ViewPage( + label="Package", + id="page-1", + blocks=[ + TableBlock( + headers=["Name", "Type"], + rows=[ + ["species", "string"], + ["plot_id", "integer"], + ], + ) + ], + ) + ] + ) + + async with app.run_test(): + table = app.query_one(SearchableDataTable) + + app.action_sort_table() + + assert table.get_row_at(0) == ["plot_id", "integer"] + assert table.columns["Name"].label.plain == "Name ↑" + + app.action_sort_table() + assert table.get_row_at(0) == ["species", "string"] + assert table.columns["Name"].label.plain == "Name ↓" + + app.action_sort_table() + assert table.get_row_at(0) == ["plot_id", "integer"] + assert table.columns["Name"].label.plain == "Name" + assert table.columns["Type"].label.plain == "Type ↑" + + asyncio.run(run_test()) From 99fcbfd5b264ecd6f00765b6e92d8b701f787e56 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Tue, 5 May 2026 18:30:38 +0200 Subject: [PATCH 16/28] =?UTF-8?q?feat:=20=E2=9C=A8=20hide=20resources=20fr?= =?UTF-8?q?om=20navigation=20that=20don't=20match=20the=20search=20term?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guide/cli.qmd | 7 ++-- src/seedcase_flower/tui.py | 73 +++++++++++++++++++++++++++++++++----- tests/test_tui.py | 62 ++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 12 deletions(-) diff --git a/docs/guide/cli.qmd b/docs/guide/cli.qmd index 6f33441a..d8d6942a 100644 --- a/docs/guide/cli.qmd +++ b/docs/guide/cli.qmd @@ -144,9 +144,10 @@ seedcase-flower view --style quarto-resource-listing --viewer textual In the Textual viewer, use `j` and `k` or the arrow keys to move through the table of contents. For tables, use `s` to cycle sorting through the -columns and directions, `/` to search the current table, and `Esc` to clear -the search. The sorted column shows an arrow for ascending or descending -order. +columns and directions, `/` to search all tables, and `Esc` to clear the +search. The sorted column shows an arrow for ascending or descending order, +and the table of contents hides resources whose tables have no search +matches. ::: callout-important `view` is only configurable via command line flags. This means that any diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index 9807ce46..f41ecc7f 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -18,7 +18,6 @@ ListView, Static, ) -from textual.widgets._data_table import ColumnKey, measure RICH_BLUE = "color(4) bold" RICH_YELLOW = "color(3) bold" @@ -430,7 +429,7 @@ def compose(self) -> ComposeResult: with ContentSwitcher(id="content-switcher", initial=initial_page.id): for page in self.pages: yield PageView(page.blocks, id=page.id, classes="content-page") - yield Input(placeholder="Search current table", id="table-search") + yield Input(placeholder="Search all tables", id="table-search") yield Footer() def on_mount(self) -> None: @@ -458,11 +457,30 @@ async def on_list_view_selected(self, event: ListView.Selected) -> None: def action_toc_down(self) -> None: """Move down in the page navigation.""" - self.query_one("#toc", ListView).action_cursor_down() + self._move_toc(1) def action_toc_up(self) -> None: """Move up in the page navigation.""" - self.query_one("#toc", ListView).action_cursor_up() + self._move_toc(-1) + + def _move_toc(self, step: int) -> None: + """Move to the next visible table-of-contents item.""" + toc = self.query_one("#toc", ListView) + visible_indices = self._visible_toc_indices() + if not visible_indices: + return + + current = toc.index + if current is None or current not in visible_indices: + toc.index = visible_indices[0 if step > 0 else -1] + return + + current_position = visible_indices.index(current) + next_position = max( + 0, + min(len(visible_indices) - 1, current_position + step), + ) + toc.index = visible_indices[next_position] def action_search_table(self) -> None: """Focus the table search input.""" @@ -480,14 +498,16 @@ def action_clear_search(self) -> None: search = self.query_one("#table-search", Input) search.value = "" search.display = False - if table := self._current_table(): - table.filter_rows("") + self._filter_all_tables("") self.query_one("#toc", ListView).focus() - def on_input_changed(self, event: Input.Changed) -> None: + async def on_input_changed(self, event: Input.Changed) -> None: """Filter the current page table while typing in the search input.""" - if event.input.id == "table-search" and (table := self._current_table()): - table.filter_rows(event.value) + if event.input.id == "table-search": + visible_pages = self._filter_all_tables(event.value) + if visible_pages and self._current_page_index() not in visible_pages: + self.query_one("#toc", ListView).index = visible_pages[0] + await self._show_page(visible_pages[0]) def on_input_submitted(self, event: Input.Submitted) -> None: """Return focus to navigation after submitting table search.""" @@ -509,6 +529,41 @@ async def _show_page(self, index: int) -> None: if table := self._current_table(): table.filter_rows(search.value) + def _filter_all_tables(self, query: str) -> list[int]: + """Filter all mounted tables and return visible page indices.""" + for table in self.query(SearchableDataTable): + table.filter_rows(query) + + visible_pages = [] + toc = self.query_one("#toc", ListView) + for index, item in enumerate(toc.children): + page = self.query_one(f"#{self.pages[index].id}", PageView) + tables = page.query(SearchableDataTable) + is_visible = ( + not query or index == 0 or any(table.row_count > 0 for table in tables) + ) + item.display = is_visible + if is_visible: + visible_pages.append(index) + return visible_pages + + def _visible_toc_indices(self) -> list[int]: + """Return indices for visible table-of-contents items.""" + toc = self.query_one("#toc", ListView) + return [ + index + for index, item in enumerate(toc.children) + if item.display is not False + ] + + def _current_page_index(self) -> int | None: + """Return the index of the currently visible page.""" + current = self.query_one("#content-switcher", ContentSwitcher).current + for index, page in enumerate(self.pages): + if page.id == current: + return index + return None + def _current_table(self) -> "SearchableDataTable | None": """Return the first table on the currently visible page.""" switcher = self.query_one("#content-switcher", ContentSwitcher) diff --git a/tests/test_tui.py b/tests/test_tui.py index 730e5724..f0ed6214 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -329,6 +329,7 @@ async def run_test() -> None: table = app.query_one(SearchableDataTable) app.action_search_table() + assert app.query_one("#table-search").placeholder == "Search all tables" await pilot.press("s", "p") assert table.row_count == 1 @@ -341,6 +342,64 @@ async def run_test() -> None: asyncio.run(run_test()) +def test_flower_view_app_filters_sidebar_to_matching_resource_tables(): + async def run_test() -> None: + app = FlowerViewApp( + [ + ViewPage( + label="Package", + id="page-1", + blocks=[TextBlock("Package")], + ), + ViewPage( + label="species_catalog", + id="page-2", + blocks=[ + TableBlock( + headers=["Name"], + rows=[["species"]], + ) + ], + ), + ViewPage( + label="location_catalog", + id="page-3", + blocks=[ + TableBlock( + headers=["Name"], + rows=[["location"]], + ) + ], + ), + ] + ) + + async with app.run_test() as pilot: + app.action_search_table() + await pilot.press("s", "p") + + toc_items = app.query_one("#toc").children + assert toc_items[0].display is True + assert toc_items[1].display is True + assert toc_items[2].display is False + + toc = app.query_one("#toc") + app.action_toc_down() + assert toc.index == 1 + + app.action_toc_down() + assert toc.index == 1 + + app.action_toc_up() + assert toc.index == 0 + + app.action_clear_search() + + assert all(item.display is True for item in toc_items) + + asyncio.run(run_test()) + + def test_flower_view_app_sorts_current_page_table(): async def run_test() -> None: app = FlowerViewApp( @@ -363,11 +422,14 @@ async def run_test() -> None: async with app.run_test(): table = app.query_one(SearchableDataTable) + initial_width = table.columns["Name"].width app.action_sort_table() assert table.get_row_at(0) == ["plot_id", "integer"] assert table.columns["Name"].label.plain == "Name ↑" + assert table.columns["Name"].width >= initial_width + assert str(table.columns["Name"].label.spans[0].style) == "color(3) bold" app.action_sort_table() assert table.get_row_at(0) == ["species", "string"] From dac29edaca582318067efeae08a41424a763264a Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Tue, 5 May 2026 18:31:00 +0200 Subject: [PATCH 17/28] =?UTF-8?q?style:=20=F0=9F=92=84=20make=20search=20i?= =?UTF-8?q?con=20yellow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seedcase_flower/tui.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index f41ecc7f..8442751f 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -664,13 +664,14 @@ def _sort_by_column(self, column_index: int) -> None: def _refresh_sort_indicators(self) -> None: """Show the active sort column and direction in table headers.""" for column_index, header in enumerate(self.headers): - label = header + label = Text(header) if column_index == self._sort_column_index: - label = f"{header} {'↓' if self._sort_reverse else '↑'}" - text = Text(label) - column = self.columns[ColumnKey(header)] - column.label = text - column.content_width = measure(self.app.console, text, 1) + label.append(" ") + label.append("↓" if self._sort_reverse else "↑", style=RICH_YELLOW) + column = self.columns[header] + column.label = label + label_width = len(label.plain) + column.content_width = max(column.content_width, label_width) if column.auto_width: - column.width = column.content_width + column.width = max(column.width, label_width) self.refresh_column(column_index) From 75eba594e006d0b5203a22687ee3cf39ca2ee50b Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Tue, 5 May 2026 19:21:18 +0200 Subject: [PATCH 18/28] =?UTF-8?q?style:=20=F0=9F=92=84=20display=20shortcu?= =?UTF-8?q?ts=20and=20selections=20more=20clearly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guide/cli.qmd | 12 +-- src/seedcase_flower/tui.py | 89 ++++++++++++++---- tests/test_tui.py | 179 +++++++++++++++++++++++++++++++++++-- 3 files changed, 252 insertions(+), 28 deletions(-) diff --git a/docs/guide/cli.qmd b/docs/guide/cli.qmd index d8d6942a..98372cfe 100644 --- a/docs/guide/cli.qmd +++ b/docs/guide/cli.qmd @@ -143,11 +143,13 @@ seedcase-flower view --style quarto-resource-listing --viewer textual ``` In the Textual viewer, use `j` and `k` or the arrow keys to move through -the table of contents. For tables, use `s` to cycle sorting through the -columns and directions, `/` to search all tables, and `Esc` to clear the -search. The sorted column shows an arrow for ascending or descending order, -and the table of contents hides resources whose tables have no search -matches. +the table of contents. Press `l` or the right arrow to focus the current +page's table, and `h` or the left arrow to return to the table of contents. +Use `Ctrl+d` and `Ctrl+u` to move six items down or up. For tables, use `s` +to cycle sorting through the columns and directions, `/` to search all +tables, and `Esc` to clear the search. The sorted column shows an arrow for +ascending or descending order, and the table of contents hides resources +whose tables have no search matches. ::: callout-important `view` is only configurable via command line flags. This means that any diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index 8442751f..04175d30 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -5,6 +5,7 @@ from rich.text import Text from textual.app import App, ComposeResult +from textual.binding import Binding from textual.containers import Horizontal, VerticalScroll from textual.timer import Timer from textual.widgets import ( @@ -309,13 +310,13 @@ class FlowerViewApp(App[None]): } #toc > ListItem.-highlight { - background: ansi_yellow; - color: #1A1B26; + background: #292E42; + color: ansi_default; } #toc > ListItem.-highlight > Label { - background: ansi_yellow; - color: #1A1B26; + background: #292E42; + color: ansi_default; } #toc:focus > ListItem.-highlight { @@ -381,8 +382,8 @@ class FlowerViewApp(App[None]): } .field-table > .datatable--cursor { - background: #292E42; - color: ansi_default; + background: ansi_yellow; + color: #1A1B26; } .table-caption { @@ -401,12 +402,16 @@ class FlowerViewApp(App[None]): } """ BINDINGS = [ - ("/", "search_table", "Search table"), - ("s", "sort_table", "Sort table"), - ("escape", "clear_search", "Clear search"), - ("j", "toc_down", "Down"), - ("k", "toc_up", "Up"), - ("q", "quit", "Quit"), + Binding("j", "toc_down", "Down"), + Binding("k", "toc_up", "Up"), + Binding("l,right", "focus_table", "Select", key_display="l/right"), + Binding("h,left", "focus_toc", "Back", key_display="h/left"), + Binding("s", "sort_table", "Sort table"), + Binding("/", "search_table", "Search"), + Binding("escape", "clear_search", "Clear search"), + Binding("q", "quit", "Quit"), + Binding("ctrl+d", "jump_down", show=False), + Binding("ctrl+u", "jump_up", show=False), ] TITLE = "Flower" @@ -429,7 +434,7 @@ def compose(self) -> ComposeResult: with ContentSwitcher(id="content-switcher", initial=initial_page.id): for page in self.pages: yield PageView(page.blocks, id=page.id, classes="content-page") - yield Input(placeholder="Search all tables", id="table-search") + yield SearchInput(placeholder="Search all tables", id="table-search") yield Footer() def on_mount(self) -> None: @@ -456,12 +461,33 @@ async def on_list_view_selected(self, event: ListView.Selected) -> None: await self._show_page(event.index) def action_toc_down(self) -> None: - """Move down in the page navigation.""" - self._move_toc(1) + """Move down in the focused table or page navigation.""" + self._move_focused(1) def action_toc_up(self) -> None: - """Move up in the page navigation.""" - self._move_toc(-1) + """Move up in the focused table or page navigation.""" + self._move_focused(-1) + + def action_jump_down(self) -> None: + """Move down six rows or table-of-contents items.""" + self._move_focused(6) + + def action_jump_up(self) -> None: + """Move up six rows or table-of-contents items.""" + self._move_focused(-6) + + def _move_focused(self, step: int) -> None: + """Move the focused table or sidebar by one or more rows.""" + if isinstance(self.focused, SearchableDataTable): + action = ( + self.focused.action_cursor_down + if step > 0 + else self.focused.action_cursor_up + ) + for _ in range(abs(step)): + action() + return + self._move_toc(step) def _move_toc(self, step: int) -> None: """Move to the next visible table-of-contents item.""" @@ -470,7 +496,7 @@ def _move_toc(self, step: int) -> None: if not visible_indices: return - current = toc.index + current = toc.index if toc.index is not None else self._current_page_index() if current is None or current not in visible_indices: toc.index = visible_indices[0 if step > 0 else -1] return @@ -493,6 +519,15 @@ def action_sort_table(self) -> None: if table := self._current_table(): table.sort_next_column() + def action_focus_table(self) -> None: + """Focus the current page table when one is available.""" + if table := self._current_table(): + table.focus() + + def action_focus_toc(self) -> None: + """Focus the table of contents.""" + self.query_one("#toc", ListView).focus() + def action_clear_search(self) -> None: """Clear table filtering and hide the search input.""" search = self.query_one("#table-search", Input) @@ -579,6 +614,15 @@ def run_textual_viewer(properties: dict[str, Any]) -> None: FlowerViewApp(prepare_view_pages(properties)).run() +class SearchInput(Input): + """Search input with common terminal word-delete bindings.""" + + BINDINGS = [ + *Input.BINDINGS, + Binding("ctrl+backspace", "delete_left_word", show=False), + ] + + class PageView(VerticalScroll): """A cached Textual page composed from text and native tables.""" @@ -609,6 +653,7 @@ def __init__(self, block: TableBlock) -> None: """Initialize a table from prepared table data.""" super().__init__( show_row_labels=False, + show_cursor=False, zebra_stripes=True, cursor_type="row", classes="field-table", @@ -624,6 +669,14 @@ def on_mount(self) -> None: self.add_column(header, key=header) self.filter_rows("") + def on_focus(self) -> None: + """Show row selection only while the table is active.""" + self.show_cursor = True + + def on_blur(self) -> None: + """Hide row selection when focus returns to navigation or search.""" + self.show_cursor = False + def filter_rows(self, query: str) -> None: """Show only rows that contain the query text.""" normalized_query = query.casefold() diff --git a/tests/test_tui.py b/tests/test_tui.py index f0ed6214..cd6e7bb8 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -7,6 +7,7 @@ FlowerViewApp, PageView, SearchableDataTable, + SearchInput, TableBlock, TextBlock, ViewPage, @@ -15,11 +16,28 @@ def test_flower_view_app_has_vim_navigation_bindings(): - assert ("j", "toc_down", "Down") in FlowerViewApp.BINDINGS - assert ("k", "toc_up", "Up") in FlowerViewApp.BINDINGS - assert ("/", "search_table", "Search table") in FlowerViewApp.BINDINGS - assert ("s", "sort_table", "Sort table") in FlowerViewApp.BINDINGS - assert ("escape", "clear_search", "Clear search") in FlowerViewApp.BINDINGS + visible_bindings = [binding for binding in FlowerViewApp.BINDINGS if binding.show] + assert [binding.key for binding in visible_bindings] == [ + "j", + "k", + "l,right", + "h,left", + "s", + "/", + "escape", + "q", + ] + assert visible_bindings[2].key_display == "l/right" + assert visible_bindings[2].description == "Select" + assert visible_bindings[3].key_display == "h/left" + assert visible_bindings[3].description == "Back" + assert visible_bindings[5].description == "Search" + assert any(binding.key == "ctrl+d" for binding in FlowerViewApp.BINDINGS) + assert any(binding.key == "ctrl+u" for binding in FlowerViewApp.BINDINGS) + + +def test_search_input_supports_ctrl_backspace_word_delete(): + assert any(binding.key == "ctrl+backspace" for binding in SearchInput.BINDINGS) def test_flower_view_app_pre_mounts_pages(): @@ -48,6 +66,7 @@ def test_flower_view_app_themes_chrome_and_full_width_toc_rows(): assert "color: ansi_default" in FlowerViewApp.CSS assert "ListItem" in FlowerViewApp.CSS assert "#toc > ListItem.-highlight" in FlowerViewApp.CSS + assert "#toc:focus > ListItem.-highlight" in FlowerViewApp.CSS assert "background: ansi_yellow" in FlowerViewApp.CSS assert "color: #1A1B26" in FlowerViewApp.CSS assert "color: $footer-key-foreground" not in FlowerViewApp.CSS @@ -441,3 +460,153 @@ async def run_test() -> None: assert table.columns["Type"].label.plain == "Type ↑" asyncio.run(run_test()) + + +def test_flower_view_app_moves_focus_between_toc_and_table(): + async def run_test() -> None: + app = FlowerViewApp( + [ + ViewPage( + label="Package", + id="page-1", + blocks=[ + TableBlock( + headers=["Name"], + rows=[["species"]], + ) + ], + ) + ] + ) + + async with app.run_test() as pilot: + table = app.query_one(SearchableDataTable) + toc = app.query_one("#toc") + + assert app.focused == toc + assert table.show_cursor is False + + app.action_focus_table() + await pilot.pause() + assert app.focused == table + assert table.show_cursor is True + + app.action_focus_toc() + await pilot.pause() + assert app.focused == toc + assert table.show_cursor is False + + app.action_focus_table() + await pilot.pause() + app.action_focus_toc() + await pilot.pause() + assert app.focused == toc + + asyncio.run(run_test()) + + +def test_flower_view_app_uses_vim_keys_in_focused_table(): + async def run_test() -> None: + app = FlowerViewApp( + [ + ViewPage( + label="Package", + id="page-1", + blocks=[ + TableBlock( + headers=["Name"], + rows=[["species"], ["location"]], + ) + ], + ) + ] + ) + + async with app.run_test() as pilot: + table = app.query_one(SearchableDataTable) + + app.action_focus_table() + await pilot.pause() + app.action_toc_down() + + assert table.cursor_row == 1 + + app.action_toc_up() + + assert table.cursor_row == 0 + + asyncio.run(run_test()) + + +def test_flower_view_app_jumps_in_focused_table(): + async def run_test() -> None: + app = FlowerViewApp( + [ + ViewPage( + label="Package", + id="page-1", + blocks=[ + TableBlock( + headers=["Name"], + rows=[[str(index)] for index in range(10)], + ) + ], + ) + ] + ) + + async with app.run_test() as pilot: + table = app.query_one(SearchableDataTable) + + app.action_focus_table() + await pilot.pause() + app.action_jump_down() + + assert table.cursor_row == 6 + + app.action_jump_up() + + assert table.cursor_row == 0 + + asyncio.run(run_test()) + + +def test_flower_view_app_selecting_toc_item_does_not_focus_table(): + async def run_test() -> None: + app = FlowerViewApp( + [ + ViewPage( + label="Package", + id="page-1", + blocks=[TextBlock("Package")], + ), + ViewPage( + label="species_catalog", + id="page-2", + blocks=[ + TableBlock( + headers=["Name"], + rows=[["species"]], + ) + ], + ), + ] + ) + + async with app.run_test() as pilot: + table = app.query_one(SearchableDataTable) + toc = app.query_one("#toc") + + toc.index = 1 + toc.action_select_cursor() + await pilot.pause() + + assert app.focused == toc + assert table.show_cursor is False + + app.action_focus_table() + await pilot.pause() + + assert app.focused == table + + asyncio.run(run_test()) From 196e9fbb61bfc604bb1b38fca666127ee40d0561 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Tue, 5 May 2026 19:27:13 +0200 Subject: [PATCH 19/28] =?UTF-8?q?feat:=20=E2=9C=A8=20allow=20column=20navi?= =?UTF-8?q?gation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guide/cli.qmd | 13 +++++---- src/seedcase_flower/tui.py | 41 ++++++++++++++++++++++++--- tests/test_tui.py | 57 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 99 insertions(+), 12 deletions(-) diff --git a/docs/guide/cli.qmd b/docs/guide/cli.qmd index 98372cfe..2811d41e 100644 --- a/docs/guide/cli.qmd +++ b/docs/guide/cli.qmd @@ -144,12 +144,13 @@ seedcase-flower view --style quarto-resource-listing --viewer textual In the Textual viewer, use `j` and `k` or the arrow keys to move through the table of contents. Press `l` or the right arrow to focus the current -page's table, and `h` or the left arrow to return to the table of contents. -Use `Ctrl+d` and `Ctrl+u` to move six items down or up. For tables, use `s` -to cycle sorting through the columns and directions, `/` to search all -tables, and `Esc` to clear the search. The sorted column shows an arrow for -ascending or descending order, and the table of contents hides resources -whose tables have no search matches. +page's table. Press `l` or the right arrow again to select columns, and +`h` or the left arrow to move back through selected columns, row selection, +and the table of contents. Use `Ctrl+d` and `Ctrl+u` to move six items down +or up. For tables, use `s` to cycle sorting through the columns and +directions, `/` to search all tables, and `Esc` to clear the search. The +sorted column shows an arrow for ascending or descending order, and the +table of contents hides resources whose tables have no search matches. ::: callout-important `view` is only configurable via command line flags. This means that any diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index 04175d30..ad450491 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -402,8 +402,8 @@ class FlowerViewApp(App[None]): } """ BINDINGS = [ - Binding("j", "toc_down", "Down"), - Binding("k", "toc_up", "Up"), + Binding("j,down", "toc_down", "Down", key_display="j/down"), + Binding("k,up", "toc_up", "Up", key_display="k/up"), Binding("l,right", "focus_table", "Select", key_display="l/right"), Binding("h,left", "focus_toc", "Back", key_display="h/left"), Binding("s", "sort_table", "Sort table"), @@ -520,12 +520,19 @@ def action_sort_table(self) -> None: table.sort_next_column() def action_focus_table(self) -> None: - """Focus the current page table when one is available.""" + """Focus the current page table or advance its column selection.""" + if isinstance(self.focused, SearchableDataTable): + self.focused.select_next_column() + return if table := self._current_table(): + table.focus_row_selection() table.focus() def action_focus_toc(self) -> None: - """Focus the table of contents.""" + """Move table selection back or focus the table of contents.""" + if isinstance(self.focused, SearchableDataTable): + if self.focused.select_previous_column_or_row(): + return self.query_one("#toc", ListView).focus() def action_clear_search(self) -> None: @@ -676,6 +683,7 @@ def on_focus(self) -> None: def on_blur(self) -> None: """Hide row selection when focus returns to navigation or search.""" self.show_cursor = False + self.focus_row_selection() def filter_rows(self, query: str) -> None: """Show only rows that contain the query text.""" @@ -705,6 +713,31 @@ def sort_next_column(self) -> None: self._sort_reverse = False self._sort_by_column(self._sort_column_index) + def focus_row_selection(self) -> None: + """Select rows rather than columns.""" + self.cursor_type = "row" + + def select_next_column(self) -> None: + """Select the next table column.""" + if not self.headers: + return + if self.cursor_type != "column": + next_column = 0 + else: + next_column = min(self.cursor_column + 1, len(self.headers) - 1) + self.cursor_type = "column" + self.move_cursor(column=next_column) + + def select_previous_column_or_row(self) -> bool: + """Move column selection left, returning True if table focus remains.""" + if self.cursor_type != "column": + return False + if self.cursor_column <= 0: + self.focus_row_selection() + return True + self.move_cursor(column=self.cursor_column - 1) + return True + def _sort_by_column(self, column_index: int) -> None: """Sort rows case-insensitively by one column.""" self._refresh_sort_indicators() diff --git a/tests/test_tui.py b/tests/test_tui.py index cd6e7bb8..ffba5903 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -18,8 +18,8 @@ def test_flower_view_app_has_vim_navigation_bindings(): visible_bindings = [binding for binding in FlowerViewApp.BINDINGS if binding.show] assert [binding.key for binding in visible_bindings] == [ - "j", - "k", + "j,down", + "k,up", "l,right", "h,left", "s", @@ -27,6 +27,8 @@ def test_flower_view_app_has_vim_navigation_bindings(): "escape", "q", ] + assert visible_bindings[0].key_display == "j/down" + assert visible_bindings[1].key_display == "k/up" assert visible_bindings[2].key_display == "l/right" assert visible_bindings[2].description == "Select" assert visible_bindings[3].key_display == "h/left" @@ -490,6 +492,7 @@ async def run_test() -> None: await pilot.pause() assert app.focused == table assert table.show_cursor is True + assert table.cursor_type == "row" app.action_focus_toc() await pilot.pause() @@ -505,6 +508,56 @@ async def run_test() -> None: asyncio.run(run_test()) +def test_flower_view_app_cycles_table_column_selection(): + async def run_test() -> None: + app = FlowerViewApp( + [ + ViewPage( + label="Package", + id="page-1", + blocks=[ + TableBlock( + headers=["Name", "Type"], + rows=[["species", "string"]], + ) + ], + ) + ] + ) + + async with app.run_test() as pilot: + table = app.query_one(SearchableDataTable) + toc = app.query_one("#toc") + + app.action_focus_table() + await pilot.pause() + + assert table.cursor_type == "row" + + app.action_focus_table() + assert table.cursor_type == "column" + assert table.cursor_column == 0 + + app.action_focus_table() + assert table.cursor_type == "column" + assert table.cursor_column == 1 + + app.action_focus_toc() + assert table.cursor_type == "column" + assert table.cursor_column == 0 + assert app.focused == table + + app.action_focus_toc() + assert table.cursor_type == "row" + assert app.focused == table + + app.action_focus_toc() + await pilot.pause() + assert app.focused == toc + + asyncio.run(run_test()) + + def test_flower_view_app_uses_vim_keys_in_focused_table(): async def run_test() -> None: app = FlowerViewApp( From dd65a4f605a0cf2582007b90350067b11c42c192 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Wed, 6 May 2026 09:18:04 +0200 Subject: [PATCH 20/28] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20support=20for?= =?UTF-8?q?=20copying=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guide/cli.qmd | 7 +++--- src/seedcase_flower/tui.py | 27 ++++++++++++++++++++++ tests/test_tui.py | 47 +++++++++++++++++++++++++++++++++++++- 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/docs/guide/cli.qmd b/docs/guide/cli.qmd index 2811d41e..a2dc0857 100644 --- a/docs/guide/cli.qmd +++ b/docs/guide/cli.qmd @@ -148,9 +148,10 @@ page's table. Press `l` or the right arrow again to select columns, and `h` or the left arrow to move back through selected columns, row selection, and the table of contents. Use `Ctrl+d` and `Ctrl+u` to move six items down or up. For tables, use `s` to cycle sorting through the columns and -directions, `/` to search all tables, and `Esc` to clear the search. The -sorted column shows an arrow for ascending or descending order, and the -table of contents hides resources whose tables have no search matches. +directions, `/` to search all tables, `y` or `c` to copy the selected +row or column, and `Esc` to clear the search. The sorted column shows an +arrow for ascending or descending order, and the table of contents hides +resources whose tables have no search matches. ::: callout-important `view` is only configurable via command line flags. This means that any diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index ad450491..d13bffda 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -406,6 +406,7 @@ class FlowerViewApp(App[None]): Binding("k,up", "toc_up", "Up", key_display="k/up"), Binding("l,right", "focus_table", "Select", key_display="l/right"), Binding("h,left", "focus_toc", "Back", key_display="h/left"), + Binding("y,c", "copy_selection", "Copy", key_display="y/c"), Binding("s", "sort_table", "Sort table"), Binding("/", "search_table", "Search"), Binding("escape", "clear_search", "Clear search"), @@ -514,6 +515,14 @@ def action_search_table(self) -> None: search.display = True search.focus() + def action_copy_selection(self) -> None: + """Copy the selected row or column and show a toast notification.""" + if isinstance(self.focused, SearchableDataTable): + text = self.focused.selected_text() + if text: + self.copy_to_clipboard(text) + self.notify("Selection copied", markup=False) + def action_sort_table(self) -> None: """Sort the current page table by the next column.""" if table := self._current_table(): @@ -656,6 +665,10 @@ def compose(self) -> ComposeResult: class SearchableDataTable(DataTable[str]): """DataTable with simple current-page sorting and row filtering.""" + BINDINGS = [ + *DataTable.BINDINGS, + ] + def __init__(self, block: TableBlock) -> None: """Initialize a table from prepared table data.""" super().__init__( @@ -685,6 +698,20 @@ def on_blur(self) -> None: self.show_cursor = False self.focus_row_selection() + def action_copy_selection(self) -> None: + """Copy the selected row or column to the clipboard with a toast.""" + if not self.row_count: + return + self.app.copy_to_clipboard(self.selected_text()) + self.app.notify("Selection copied", markup=False) + + def selected_text(self) -> str: + """Return selected row or column as tabular plain text.""" + if self.cursor_type == "column" and self.headers: + column = self.headers[self.cursor_column] + return "\n".join(str(value) for value in self.get_column(column)) + return "\t".join(str(value) for value in self.get_row_at(self.cursor_row)) + def filter_rows(self, query: str) -> None: """Show only rows that contain the query text.""" normalized_query = query.casefold() diff --git a/tests/test_tui.py b/tests/test_tui.py index ffba5903..a62d5ac7 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -22,6 +22,7 @@ def test_flower_view_app_has_vim_navigation_bindings(): "k,up", "l,right", "h,left", + "y,c", "s", "/", "escape", @@ -33,7 +34,9 @@ def test_flower_view_app_has_vim_navigation_bindings(): assert visible_bindings[2].description == "Select" assert visible_bindings[3].key_display == "h/left" assert visible_bindings[3].description == "Back" - assert visible_bindings[5].description == "Search" + assert visible_bindings[4].key_display == "y/c" + assert visible_bindings[4].description == "Copy" + assert visible_bindings[6].description == "Search" assert any(binding.key == "ctrl+d" for binding in FlowerViewApp.BINDINGS) assert any(binding.key == "ctrl+u" for binding in FlowerViewApp.BINDINGS) @@ -624,6 +627,48 @@ async def run_test() -> None: asyncio.run(run_test()) +def test_searchable_data_table_copies_selected_row_or_column(): + async def run_test() -> None: + copied = [] + app = FlowerViewApp( + [ + ViewPage( + label="Package", + id="page-1", + blocks=[ + TableBlock( + headers=["Name", "Type"], + rows=[ + ["species", "string"], + ["plot_id", "integer"], + ], + ) + ], + ) + ] + ) + app.copy_to_clipboard = copied.append # type: ignore[method-assign] + + async with app.run_test() as pilot: + table = app.query_one(SearchableDataTable) + + app.action_focus_table() + await pilot.pause() + table.move_cursor(row=1) + table.action_copy_selection() + await pilot.pause() + + assert copied[-1] == "plot_id\tinteger" + + table.select_next_column() + app.action_copy_selection() + await pilot.pause() + + assert copied[-1] == "species\nplot_id" + + asyncio.run(run_test()) + + def test_flower_view_app_selecting_toc_item_does_not_focus_table(): async def run_test() -> None: app = FlowerViewApp( From bdf4cba5ac351ed1056577294b853a6ecc463b5b Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Wed, 6 May 2026 09:58:05 +0200 Subject: [PATCH 21/28] =?UTF-8?q?fix:=20=F0=9F=90=9B=20tweak=20keyboard=20?= =?UTF-8?q?shortcuts=20and=20their=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guide/cli.qmd | 11 +++--- src/seedcase_flower/tui.py | 52 ++++++++++++++++++++++------ tests/test_tui.py | 69 ++++++++++++++++++++++++++------------ 3 files changed, 94 insertions(+), 38 deletions(-) diff --git a/docs/guide/cli.qmd b/docs/guide/cli.qmd index a2dc0857..3a0d8451 100644 --- a/docs/guide/cli.qmd +++ b/docs/guide/cli.qmd @@ -146,12 +146,13 @@ In the Textual viewer, use `j` and `k` or the arrow keys to move through the table of contents. Press `l` or the right arrow to focus the current page's table. Press `l` or the right arrow again to select columns, and `h` or the left arrow to move back through selected columns, row selection, -and the table of contents. Use `Ctrl+d` and `Ctrl+u` to move six items down +and the table of contents. Use `ctrl+d` and `ctrl+u` to move six items down or up. For tables, use `s` to cycle sorting through the columns and -directions, `/` to search all tables, `y` or `c` to copy the selected -row or column, and `Esc` to clear the search. The sorted column shows an -arrow for ascending or descending order, and the table of contents hides -resources whose tables have no search matches. +directions, `ctrl+f` or `/` to search all tables, `c` or `y` to copy the +selected row or column, and `q` or `Esc` to quit. Press `?` to show all +keyboard shortcuts, and press `?` again to close them. The sorted column shows an arrow for ascending or +descending order, and the table of contents hides resources whose tables +have no search matches. ::: callout-important `view` is only configurable via command line flags. This means that any diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index d13bffda..5357331b 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -401,18 +401,36 @@ class FlowerViewApp(App[None]): border: solid ansi_yellow; } """ + COMMAND_PALETTE_DISPLAY = "ctrl+p" BINDINGS = [ - Binding("j,down", "toc_down", "Down", key_display="j/down"), - Binding("k,up", "toc_up", "Up", key_display="k/up"), - Binding("l,right", "focus_table", "Select", key_display="l/right"), - Binding("h,left", "focus_toc", "Back", key_display="h/left"), - Binding("y,c", "copy_selection", "Copy", key_display="y/c"), - Binding("s", "sort_table", "Sort table"), - Binding("/", "search_table", "Search"), - Binding("escape", "clear_search", "Clear search"), - Binding("q", "quit", "Quit"), - Binding("ctrl+d", "jump_down", show=False), - Binding("ctrl+u", "jump_up", show=False), + Binding("escape", "quit", "Quit", key_display="esc"), + Binding("q", "quit", "Quit", key_display="| q", show=False), + Binding("ctrl+q", "quit", show=False, system=True), + Binding("?", "toggle_help_panel", "Keyboard shortcuts"), + Binding( + "ctrl+p", + "command_palette", + "Command Palette", + key_display="ctrl+p", + show=False, + ), + Binding("j,down", "toc_down", "Down", key_display="down | j", show=False), + Binding("k,up", "toc_up", "Up", key_display="up | k", show=False), + Binding( + "l,right", "focus_table", "Select", key_display="right | l", show=False + ), + Binding("h,left", "focus_toc", "Back", key_display="left | h", show=False), + Binding("y,c", "copy_selection", "Copy", key_display="c | y", show=False), + Binding("s", "sort_table", "Sort table", show=False), + Binding( + "/,ctrl+f", + "search_table", + "Search", + key_display="ctrl+f | /", + show=False, + ), + Binding("ctrl+d", "jump_down", "Jump down", key_display="ctrl+d", show=False), + Binding("ctrl+u", "jump_up", "Jump up", key_display="ctrl+u", show=False), ] TITLE = "Flower" @@ -423,6 +441,18 @@ def __init__(self, pages: list[ViewPage]) -> None: self._pending_page_index: int | None = None self._highlight_timer: Timer | None = None + def get_key_display(self, binding: Binding) -> str: + """Format key labels with explicit ctrl names instead of caret syntax.""" + key_display = super().get_key_display(binding) + return key_display.replace("^", "ctrl+") + + def action_toggle_help_panel(self) -> None: + """Toggle the keyboard shortcuts help panel.""" + if self.screen.query("HelpPanel"): + self.action_hide_help_panel() + else: + self.action_show_help_panel() + def compose(self) -> ComposeResult: """Compose the page navigation and content widgets.""" initial_page = self.pages[0] diff --git a/tests/test_tui.py b/tests/test_tui.py index a62d5ac7..894c20cd 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -17,28 +17,53 @@ def test_flower_view_app_has_vim_navigation_bindings(): visible_bindings = [binding for binding in FlowerViewApp.BINDINGS if binding.show] - assert [binding.key for binding in visible_bindings] == [ - "j,down", - "k,up", - "l,right", - "h,left", - "y,c", - "s", - "/", - "escape", - "q", - ] - assert visible_bindings[0].key_display == "j/down" - assert visible_bindings[1].key_display == "k/up" - assert visible_bindings[2].key_display == "l/right" - assert visible_bindings[2].description == "Select" - assert visible_bindings[3].key_display == "h/left" - assert visible_bindings[3].description == "Back" - assert visible_bindings[4].key_display == "y/c" - assert visible_bindings[4].description == "Copy" - assert visible_bindings[6].description == "Search" - assert any(binding.key == "ctrl+d" for binding in FlowerViewApp.BINDINGS) - assert any(binding.key == "ctrl+u" for binding in FlowerViewApp.BINDINGS) + assert [binding.key for binding in visible_bindings] == ["escape", "?"] + assert visible_bindings[0].key_display == "esc" + assert visible_bindings[1].description == "Keyboard shortcuts" + assert FlowerViewApp.COMMAND_PALETTE_DISPLAY == "ctrl+p" + + bindings = {binding.key: binding for binding in FlowerViewApp.BINDINGS} + assert bindings["q"].key_display == "| q" + assert bindings["q"].description == "Quit" + assert bindings["ctrl+q"].system is True + assert bindings["ctrl+p"].description == "Command Palette" + assert bindings["ctrl+p"].key_display == "ctrl+p" + assert bindings["j,down"].key_display == "down | j" + assert bindings["k,up"].key_display == "up | k" + assert bindings["l,right"].key_display == "right | l" + assert bindings["l,right"].description == "Select" + assert bindings["h,left"].key_display == "left | h" + assert bindings["h,left"].description == "Back" + assert bindings["y,c"].key_display == "c | y" + assert bindings["y,c"].description == "Copy" + assert bindings["/,ctrl+f"].key_display == "ctrl+f | /" + assert bindings["/,ctrl+f"].description == "Search" + assert bindings["ctrl+d"].description == "Jump down" + assert bindings["ctrl+u"].description == "Jump up" + + +def test_flower_view_app_uses_ctrl_not_caret_in_key_displays(): + app = FlowerViewApp([]) + + assert app.get_key_display(FlowerViewApp.BINDINGS[-2]) == "ctrl+d" + + +def test_flower_view_app_toggles_keyboard_shortcuts_panel(): + async def run_test() -> None: + app = FlowerViewApp([ViewPage(label="Package", id="page-1", blocks=[])]) + + async with app.run_test() as pilot: + app.action_toggle_help_panel() + await pilot.pause() + + assert app.screen.query("HelpPanel") + + app.action_toggle_help_panel() + await pilot.pause() + + assert not app.screen.query("HelpPanel") + + asyncio.run(run_test()) def test_search_input_supports_ctrl_backspace_word_delete(): From e4cf5457ba8ef6f5b45356b55c03409173226300 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Wed, 6 May 2026 10:13:52 +0200 Subject: [PATCH 22/28] =?UTF-8?q?style:=20=F0=9F=92=84=20style=20toasts=20?= =?UTF-8?q?more=20nicely?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seedcase_flower/tui.py | 14 ++++++++++++++ tests/test_tui.py | 3 +++ 2 files changed, 17 insertions(+) diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index 5357331b..1d696eec 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -400,6 +400,20 @@ class FlowerViewApp(App[None]): text-style: bold; border: solid ansi_yellow; } + + Toast { + background: #292E42; + color: ansi_default; + border-left: outer ansi_yellow; + } + + Toast:ansi { + background: #292E42; + } + + ToastRack { + margin-bottom: 2; + } """ COMMAND_PALETTE_DISPLAY = "ctrl+p" BINDINGS = [ diff --git a/tests/test_tui.py b/tests/test_tui.py index 894c20cd..31f569d4 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -94,6 +94,9 @@ def test_flower_view_app_themes_chrome_and_full_width_toc_rows(): assert "background: #292E42" in FlowerViewApp.CSS assert "background-tint: ansi_default 0%" in FlowerViewApp.CSS assert "color: ansi_default" in FlowerViewApp.CSS + assert "Toast" in FlowerViewApp.CSS + assert "Toast:ansi" in FlowerViewApp.CSS + assert "ToastRack" in FlowerViewApp.CSS assert "ListItem" in FlowerViewApp.CSS assert "#toc > ListItem.-highlight" in FlowerViewApp.CSS assert "#toc:focus > ListItem.-highlight" in FlowerViewApp.CSS From 753dcec6caaef2de228cb5468c07ce92dc9e5b18 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Wed, 6 May 2026 11:21:17 +0200 Subject: [PATCH 23/28] =?UTF-8?q?fix:=20=F0=9F=90=9B=20remove=20redundant?= =?UTF-8?q?=20caption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seedcase_flower/tui.py | 10 ---------- tests/test_tui.py | 3 +-- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index 1d696eec..9c8a12fb 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -50,7 +50,6 @@ class TableBlock: headers: list[str] rows: list[list[str]] - caption: str = "" type ViewBlock = TextBlock | TableBlock @@ -186,7 +185,6 @@ def _resource_blocks(resource: dict[str, Any]) -> list[ViewBlock]: ] for field in fields ], - caption=f"Fields in the {resource.get('name', 'resource')} resource.", ) ) return blocks @@ -386,12 +384,6 @@ class FlowerViewApp(App[None]): color: #1A1B26; } - .table-caption { - color: ansi_default; - text-style: italic; - margin: 0 0 1 0; - } - #table-search { display: none; height: 3; @@ -702,8 +694,6 @@ def compose(self) -> ComposeResult: else: table = SearchableDataTable(block) yield table - if block.caption: - yield Label(block.caption, classes="table-caption") class SearchableDataTable(DataTable[str]): diff --git a/tests/test_tui.py b/tests/test_tui.py index 31f569d4..c6823dd9 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -80,7 +80,7 @@ def test_flower_view_app_uses_native_datatables_for_tables(): assert "DataTable" in inspect.getsource(PageView.compose) assert "SearchableDataTable" in inspect.getsource(PageView.compose) assert "field-table" in FlowerViewApp.CSS - assert "table-caption" in FlowerViewApp.CSS + assert "table-caption" not in FlowerViewApp.CSS assert "#table-search" in FlowerViewApp.CSS assert "color: ansi_yellow" in FlowerViewApp.CSS assert "border: solid ansi_yellow" in FlowerViewApp.CSS @@ -206,7 +206,6 @@ def test_prepare_view_pages_builds_resource_page_from_properties(): ["id", "Identifier", "integer", "Stable identifier."], ["species", "Species", "string", "Scientific name."], ], - caption="Fields in the species_catalog resource.", ), ] From 5aa4bab02732e769ea5dbbae0c1096f9d48807b3 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Wed, 6 May 2026 13:34:20 +0200 Subject: [PATCH 24/28] =?UTF-8?q?fix:=20=F0=9F=90=9B=20remove=20esc=20for?= =?UTF-8?q?=20quitting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Could lead to unintentional quitting when trying to get out of search --- docs/guide/cli.qmd | 2 +- src/seedcase_flower/tui.py | 5 ++--- tests/test_tui.py | 10 +++++----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/guide/cli.qmd b/docs/guide/cli.qmd index 3a0d8451..10a22ae0 100644 --- a/docs/guide/cli.qmd +++ b/docs/guide/cli.qmd @@ -149,7 +149,7 @@ page's table. Press `l` or the right arrow again to select columns, and and the table of contents. Use `ctrl+d` and `ctrl+u` to move six items down or up. For tables, use `s` to cycle sorting through the columns and directions, `ctrl+f` or `/` to search all tables, `c` or `y` to copy the -selected row or column, and `q` or `Esc` to quit. Press `?` to show all +selected row or column, and `q` or `ctrl+q` to quit. Press `?` to show all keyboard shortcuts, and press `?` again to close them. The sorted column shows an arrow for ascending or descending order, and the table of contents hides resources whose tables have no search matches. diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index 9c8a12fb..28f3453f 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -409,9 +409,8 @@ class FlowerViewApp(App[None]): """ COMMAND_PALETTE_DISPLAY = "ctrl+p" BINDINGS = [ - Binding("escape", "quit", "Quit", key_display="esc"), - Binding("q", "quit", "Quit", key_display="| q", show=False), - Binding("ctrl+q", "quit", show=False, system=True), + Binding("ctrl+q", "quit", "Quit", key_display="ctrl+q |", show=False), + Binding("q", "quit", "Quit"), Binding("?", "toggle_help_panel", "Keyboard shortcuts"), Binding( "ctrl+p", diff --git a/tests/test_tui.py b/tests/test_tui.py index c6823dd9..3b56daec 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -17,15 +17,15 @@ def test_flower_view_app_has_vim_navigation_bindings(): visible_bindings = [binding for binding in FlowerViewApp.BINDINGS if binding.show] - assert [binding.key for binding in visible_bindings] == ["escape", "?"] - assert visible_bindings[0].key_display == "esc" + assert [binding.key for binding in visible_bindings] == ["q", "?"] + assert visible_bindings[0].description == "Quit" assert visible_bindings[1].description == "Keyboard shortcuts" assert FlowerViewApp.COMMAND_PALETTE_DISPLAY == "ctrl+p" bindings = {binding.key: binding for binding in FlowerViewApp.BINDINGS} - assert bindings["q"].key_display == "| q" - assert bindings["q"].description == "Quit" - assert bindings["ctrl+q"].system is True + assert "escape" not in bindings + assert bindings["ctrl+q"].description == "Quit" + assert bindings["ctrl+q"].key_display == "ctrl+q |" assert bindings["ctrl+p"].description == "Command Palette" assert bindings["ctrl+p"].key_display == "ctrl+p" assert bindings["j,down"].key_display == "down | j" From d9bd63ebdc68d7a2192fb52b8e30ba29305ce9ed Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Wed, 6 May 2026 14:02:51 +0200 Subject: [PATCH 25/28] =?UTF-8?q?refactor:=20=E2=99=BB=EF=B8=8F=20Clean=20?= =?UTF-8?q?up=20imlementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replaced --viewer with --mode. - New modes: - tui - stdout - view now defaults to TUI. - Removed pager mode. - Removed --style from view. - Removed ViewStyle. - view --mode stdout now renders directly from raw Data Package properties with one terminal-oriented format. - Stdout output includes resource separators with horizontal rules. - Updated CLI tests and docs/design docs. --- docs/design/interface/cli.qmd | 25 ++-- docs/design/interface/python.qmd | 13 +- docs/guide/cli.qmd | 40 ++---- docs/guide/contribute-style.qmd | 18 +-- src/seedcase_flower/__init__.py | 3 +- src/seedcase_flower/cli.py | 234 ++++++++++++++++++++++--------- src/seedcase_flower/styles.py | 8 -- tests/test_cli.py | 159 +++++++-------------- 8 files changed, 249 insertions(+), 251 deletions(-) diff --git a/docs/design/interface/cli.qmd b/docs/design/interface/cli.qmd index f9219f57..9e3b026e 100644 --- a/docs/design/interface/cli.qmd +++ b/docs/design/interface/cli.qmd @@ -182,13 +182,13 @@ human-friendly way in the terminal. It has the following signature that is positional as well as keyword-based: ``` {.bash filename="Terminal"} -seedcase-flower view [SOURCE] [STYLE] +seedcase-flower view [SOURCE] [MODE] ``` For example: ``` {.bash filename="Terminal"} -seedcase-flower view SOURCE --style STYLE +seedcase-flower view SOURCE --mode stdout ``` The diagram below shows the flow of inputs and outputs for `view`. @@ -199,27 +199,20 @@ The diagram below shows the flow of inputs and outputs for `view`. flowchart LR source("datapackage.json
[SOURCE: file, https,
gh/github]") view("view") - style_opt("--style
[option]") - output("Output
[Terminal pager]") + mode_opt("--mode
[option]") + output("Output
[TUI or stdout]") source --> view - style_opt --> view + mode_opt --> view view --> output ``` -`view` takes a `SOURCE` and `--style` argument like `build`, with two -differences: +`view` takes a `SOURCE` and an optional `--mode` argument, with two modes: -- No output files are generated---`view` only displays the metadata in - the terminal pager. -- No configuration file is used. The only way to change the output is - via `--style` with one of the built-in styles. +- `tui` opens an interactive terminal interface. +- `stdout` prints a plain terminal representation that can be piped to + other tools. `view` intentionally has no configuration file support; it's meant for quick, lightweight inspection of a Data Package's metadata and terminal display also limits customisation options. - -::: callout-tip -Want a different style for viewing the metadata? Check out our -[contribute a style](/docs/guide/contribute-style.qmd) guide. -::: diff --git a/docs/design/interface/python.qmd b/docs/design/interface/python.qmd index 33c684aa..54482da0 100644 --- a/docs/design/interface/python.qmd +++ b/docs/design/interface/python.qmd @@ -82,10 +82,10 @@ flowchart TD ### {{< var done >}} `view()` -`view()` takes the same parameters as `build()`: `source` and `style`. -Unlike `build()`, `view()` only accepts built-in styles and ignores -custom styles and the configuration file. The rendered metadata is shown -in a terminal pager. For details about the parameters, see +`view()` takes a `source` and a display `mode`. Unlike `build()`, `view()` +does not use styles, custom templates, output files, or the configuration +file. The metadata is shown in an interactive TUI by default, or printed to +stdout when `mode="stdout"`. For details about the parameters, see [`view()`](/docs/reference/view.qmd). The internal flow of `view()` is shown in the diagram below. @@ -95,10 +95,9 @@ The internal flow of `view()` is shown in the diagram below. %%| fig-cap: "Diagram of the internal flow of functions and objects in the `view()` CLI function." flowchart TD source:::input --> parse_source{{"parse_source()"}} --> address - style_cfg[style]:::input --> Config + mode[mode]:::input address --> read_properties{{"read_properties()"}} --> properties - properties & Config --> build_sections{{"build_sections()"}} --> output["list[BuiltSection]"] - output --> pager{{"pager"}} + properties & mode --> output{{"TUI or stdout"}} classDef input fill:#FFF ``` diff --git a/docs/guide/cli.qmd b/docs/guide/cli.qmd index 10a22ae0..04bd14ee 100644 --- a/docs/guide/cli.qmd +++ b/docs/guide/cli.qmd @@ -96,24 +96,19 @@ terminal, so some aspects of it might not display as expected here. ```{python} #| echo: false -!uv run seedcase-flower view gh:seedcase-project/example-seed-beetle +!uv run seedcase-flower view --mode stdout gh:seedcase-project/example-seed-beetle ``` ::: -### Styling terminal output +### Plain terminal output -Use the `--style` flag to format the terminal output with any of the -[built-in styles](/docs/reference/Style.qmd). For example: +By default, `view` opens an interactive TUI. Use `--mode stdout` to print a +plain terminal representation that can be piped to tools like `less`: ```{.bash filename="Terminal"} -seedcase-flower view --style quarto-one-page gh:seedcase-project/example-seed-beetle +seedcase-flower view --mode stdout gh:seedcase-project/example-seed-beetle | less -R ``` - - -This output will look identical to the output above, since -`quarto-one-page` is the default style for `view`. - ::: {.callout-tip collapse="true" icon="false"} ### Output @@ -122,27 +117,20 @@ terminal, so some aspects of it might not display as expected here. ```{python} #| echo: false -!uv run seedcase-flower view --style quarto-one-page gh:seedcase-project/example-seed-beetle +!uv run seedcase-flower view --mode stdout gh:seedcase-project/example-seed-beetle ``` ::: -For long output, `view` opens the rendered metadata in a terminal pager. -Multi-section styles, such as `quarto-resource-listing`, are shown as a -single paged document. +### Interactive TUI -Flower preserves terminal colors in the pager. If colors do not display, -set your pager to one that supports ANSI styles, such as -`PAGER="less -R"`. - -To try the interactive Textual viewer, use `--viewer textual`. This shows -the package and resources in a left-hand table of contents and the -selected section in a scrollable content pane: +The default `view` mode shows the package and resources in a left-hand table +of contents and the selected section in a scrollable content pane: ```{.bash filename="Terminal"} -seedcase-flower view --style quarto-resource-listing --viewer textual +seedcase-flower view gh:seedcase-project/example-seed-beetle ``` -In the Textual viewer, use `j` and `k` or the arrow keys to move through +In the TUI, use `j` and `k` or the arrow keys to move through the table of contents. Press `l` or the right arrow to focus the current page's table. Press `l` or the right arrow again to select columns, and `h` or the left arrow to move back through selected columns, row selection, @@ -150,9 +138,9 @@ and the table of contents. Use `ctrl+d` and `ctrl+u` to move six items down or up. For tables, use `s` to cycle sorting through the columns and directions, `ctrl+f` or `/` to search all tables, `c` or `y` to copy the selected row or column, and `q` or `ctrl+q` to quit. Press `?` to show all -keyboard shortcuts, and press `?` again to close them. The sorted column shows an arrow for ascending or -descending order, and the table of contents hides resources whose tables -have no search matches. +keyboard shortcuts, and press `?` again to close them. The sorted column +shows an arrow for ascending or descending order, and the table of contents +hides resources whose tables have no search matches. ::: callout-important `view` is only configurable via command line flags. This means that any diff --git a/docs/guide/contribute-style.qmd b/docs/guide/contribute-style.qmd index b653123a..3fcfb349 100644 --- a/docs/guide/contribute-style.qmd +++ b/docs/guide/contribute-style.qmd @@ -62,11 +62,10 @@ license, or schema fields. Remember to give your templates descriptive names. The `.qmd` extension serves as an example; Jinja2 can produce any plain text format (e.g., `.html`, `.md`, `.yml`). -Finally, add your style to the appropriate style enum(s) in -`src/seedcase_flower/styles.py`. All styles are defined in the `Style` -enum, while styles for terminal output with `view` are also added to -`ViewStyle`. For example, if `your_new_style` is your new style, you -would include it in `Style` like so: +Finally, add your style to the style enum in +`src/seedcase_flower/styles.py`. Build styles are defined in the `Style` +enum. For example, if `your_new_style` is your new style, you would include +it in `Style` like so: ``` {.python filename="src/seedcase_flower/styles.py"} class Style(Enum): @@ -74,15 +73,6 @@ class Style(Enum): your_new_style = "your_new_style" ``` -If your style is also a terminal style for the `view` command, add it -below the existing styles in the `ViewStyle` enum as well: - -``` {.python filename="src/seedcase_flower/styles.py"} -class ViewStyle(Enum): - # ... - your_new_style = "your_new_style" -``` - ::: callout-important For the new style to work properly, the string value of the enum member (e.g., `"your_new_style"`) must exactly match the folder name. diff --git a/src/seedcase_flower/__init__.py b/src/seedcase_flower/__init__.py index 955f9e8c..83fcf259 100644 --- a/src/seedcase_flower/__init__.py +++ b/src/seedcase_flower/__init__.py @@ -3,14 +3,13 @@ from .cli import build, view from .config import Config from .sections import Content, Many, ManyContent, One -from .styles import Style, ViewStyle +from .styles import Style __all__ = [ "build", "view", "Config", "Style", - "ViewStyle", "Content", "One", "Many", diff --git a/src/seedcase_flower/cli.py b/src/seedcase_flower/cli.py index c427439b..aee69bfd 100644 --- a/src/seedcase_flower/cli.py +++ b/src/seedcase_flower/cli.py @@ -6,8 +6,8 @@ from check_datapackage import check from rich.console import Console, Group, RenderableType -from rich.markdown import Markdown from rich.rule import Rule +from rich.table import Table from rich.text import Text from seedcase_soil import ( CONSOLE_THEME, @@ -20,7 +20,7 @@ setup_cli, ) -from seedcase_flower.build_sections import BuiltSection, build_sections +from seedcase_flower.build_sections import build_sections from seedcase_flower.config import Config from seedcase_flower.internals import _number from seedcase_flower.styles import Style @@ -33,11 +33,11 @@ ) -class Viewer(Enum): +class ViewMode(Enum): """Ways to display `view` output in the terminal.""" - pager = "pager" - textual = "textual" + tui = "tui" + stdout = "stdout" @app.command() @@ -102,8 +102,7 @@ def view( source: str = "datapackage.json", /, # End of positional-only args *, # Start of keyword-only params - style: Style = Style.quarto_one_page, - viewer: Viewer = Viewer.pager, + mode: ViewMode = ViewMode.tui, ) -> None: """Display the contents of a `datapackage.json` in a human-friendly way. @@ -114,78 +113,179 @@ def view( in the repo root (in the format `gh:org/repo`, which can also include reference to a tag or branch, such as `gh:org/repo@main` or `gh:org/repo@1.0.1`). - style: The built-in style used to display the output in the terminal. - viewer: The terminal viewer to use. Use `pager` for plain scrolling output - or `textual` for an interactive interface with section navigation. + mode: The terminal display mode. Use `tui` for an interactive interface + or `stdout` for plain output that can be piped to other tools. """ address: Address = parse_source(source) properties: dict[str, Any] = read_properties(address) check(properties, error=True) - if viewer == Viewer.textual: + if mode == ViewMode.tui: from seedcase_flower.tui import run_textual_viewer run_textual_viewer(properties) return - built_sections = build_sections(properties, Config(style=style)) console = Console(theme=CONSOLE_THEME) - # TODO move back console theme? will it be used in CDP? - print() # One line separation between the command and the datapackage title - with console.pager(styles=True): - console.print(_format_view_sections(built_sections)) - - -def _format_view_sections(built_sections: list[BuiltSection]) -> RenderableType: - if len(built_sections) == 1: - return _format_view_section_content(built_sections[0].content) - - sections: list[RenderableType] = [] - for index, section in enumerate(built_sections): - if index > 0: - sections.append(Text("")) - sections.append(Rule(style="dim")) - sections.append(Text("")) - sections.append(_format_view_section_content(section.content)) - return Group(*sections) - - -def _format_view_section_content(content: str) -> RenderableType: - front_matter, body = _split_front_matter(content) - if not front_matter: - return Markdown(body) - - headings = [] - title = _front_matter_value(front_matter, "title") - subtitle = _front_matter_value(front_matter, "subtitle") - if title: - headings.append(Text(title, style="markdown.h1")) - if subtitle: - headings.append(Text(subtitle.strip("`"), style="yellow bold")) - - if not headings: - return Markdown(body.lstrip()) - return Group(*headings, Markdown(body.lstrip())) - - -def _split_front_matter(content: str) -> tuple[list[str], str]: - lines = content.splitlines() - if not lines or lines[0].strip() != "---": - return [], content + console.print(_format_view_properties(properties)) - for index, line in enumerate(lines[1:], start=1): - if line.strip() == "---": - return lines[1:index], "\n".join(lines[index + 1 :]) - return [], content - - -def _front_matter_value(front_matter: list[str], key: str) -> str: - prefix = f"{key}:" - for line in front_matter: - if not line.startswith(prefix): - continue - return line.removeprefix(prefix).strip().strip("\"'") - return "" +def _format_view_properties(properties: dict[str, Any]) -> RenderableType: + """Format Data Package properties for plain terminal output.""" + renderables: list[RenderableType] = [] + title = _package_title(properties) + if title: + renderables.append(Text(title, style="markdown.h1")) + if description := properties.get("description"): + renderables.append(Text(description)) + if version := properties.get("version"): + renderables.extend(_metadata_list("Version", [version])) + if licenses := properties.get("licenses"): + renderables.extend(_metadata_list("Licenses", _license_labels(licenses))) + if contributors := properties.get("contributors"): + renderables.extend( + _metadata_list("Contributors", _contributor_labels(contributors)) + ) + if resources := properties.get("resources"): + renderables.append(Text("Resources", style="yellow bold")) + renderables.append(_resources_table(resources)) + + for resource in properties.get("resources", []): + renderables.extend([Text(""), Rule(style="dim"), Text("")]) + renderables.extend(_format_resource(resource)) + + return Group(*renderables) + + +def _format_resource(resource: dict[str, Any]) -> list[RenderableType]: + renderables: list[RenderableType] = [] + resource_name = resource.get("name", "") + title = resource.get("title") or resource_name + if title: + renderables.append(Text(title, style="markdown.h1")) + if description := _resource_description(resource): + renderables.append(Text(description)) + + schema = resource.get("schema") or {} + if path := resource.get("path"): + renderables.extend(_metadata_list("Path", [path])) + if primary_key := schema.get("primaryKey"): + renderables.extend(_metadata_list("Primary key", [_as_list_text(primary_key)])) + if foreign_keys := schema.get("foreignKeys"): + renderables.extend( + _metadata_list("Foreign keys", _foreign_key_text(foreign_keys, resource)) + ) + if fields := schema.get("fields"): + renderables.append(Text("Fields", style="yellow bold")) + renderables.append(_fields_table(fields)) + return renderables + + +def _package_title(properties: dict[str, Any]) -> str: + name = properties.get("name") + title = properties.get("title") + if name and title: + return f"{name}: {title}" + return name or title or "" + + +def _metadata_list(label: str, values: list[str]) -> list[RenderableType]: + if not values: + return [] + text = Text(label, style="yellow bold") + for value in values: + text.append(f"\n• {value}") + return [text] + + +def _license_labels(licenses: list[dict[str, Any]]) -> list[str]: + return [ + label + for license in licenses + if (label := license.get("title") or license.get("name")) + ] + + +def _contributor_labels(contributors: list[dict[str, Any]]) -> list[str]: + return [ + label + for contributor in contributors + if (label := _single_contributor_label(contributor)) + ] + + +def _single_contributor_label(contributor: dict[str, Any]) -> str: + full_name = ( + f"{contributor.get('firstName', '')} {contributor.get('lastName', '')}" + ).strip() + label = ( + contributor.get("title") + or full_name + or contributor.get("organization") + or contributor.get("email") + or "" + ) + roles = ", ".join(contributor.get("roles", [])) + return f"{label}{': ' + roles if roles else ''}" if label else "" + + +def _resource_description(resource: dict[str, Any]) -> str: + description = resource.get("description", "") + resource_name = resource.get("name", "") + title = resource.get("title", "") + labels = {_metadata_label(resource_name), _metadata_label(title)} + return "" if _metadata_label(description) in labels else description + + +def _metadata_label(value: str) -> str: + return value.strip().strip("`") + + +def _as_list_text(value: str | list[str]) -> str: + return value if isinstance(value, str) else ", ".join(value) + + +def _foreign_key_text( + foreign_keys: list[dict[str, Any]], resource: dict[str, Any] +) -> list[str]: + lines = [] + for foreign_key in foreign_keys: + reference = foreign_key.get("reference", {}) + reference_resource = reference.get("resource") or resource.get("name", "") + lines.append( + f"{_as_list_text(foreign_key.get('fields', []))} -> " + f"{reference_resource}.{_as_list_text(reference.get('fields', []))}" + ) + return lines + + +def _resources_table(resources: list[dict[str, Any]]) -> Table: + table = Table(show_lines=False) + table.add_column("Name", style="markdown.h1") + table.add_column("Title") + table.add_column("Description") + for resource in resources: + table.add_row( + resource.get("name", ""), + resource.get("title", ""), + resource.get("description", ""), + ) + return table + + +def _fields_table(fields: list[dict[str, Any]]) -> Table: + table = Table(show_lines=False) + table.add_column("Name", style="markdown.h1") + table.add_column("Title") + table.add_column("Type") + table.add_column("Description") + for field in fields: + table.add_row( + field.get("name", ""), + field.get("title", ""), + field.get("type", "any"), + field.get("description", ""), + ) + return table def main() -> None: diff --git a/src/seedcase_flower/styles.py b/src/seedcase_flower/styles.py index 78c21972..420cfe4b 100644 --- a/src/seedcase_flower/styles.py +++ b/src/seedcase_flower/styles.py @@ -7,11 +7,3 @@ class Style(Enum): quarto_one_page = "quarto_one_page" quarto_resource_listing = "quarto_resource_listing" quarto_resource_tables = "quarto_resource_tables" - - -class ViewStyle(Enum): - """Built-in styles suitable for terminal output.""" - - quarto_one_page = "quarto_one_page" - quarto_resource_listing = "quarto_resource_listing" - quarto_resource_tables = "quarto_resource_tables" diff --git a/tests/test_cli.py b/tests/test_cli.py index b909b3c4..c318ccfe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,17 +7,14 @@ from rich.console import Console from seedcase_soil import Address -from seedcase_flower.build_sections import ( - BuiltSection, -) -from seedcase_flower.cli import _format_view_sections, app +from seedcase_flower.cli import _format_view_properties, app from seedcase_flower.config import Config -from seedcase_flower.styles import Style, ViewStyle +from seedcase_flower.styles import Style -def _render_view_sections(sections: list[BuiltSection]) -> str: +def _render_view_properties(properties: dict) -> str: console = Console(record=True, width=80, color_system=None) - console.print(_format_view_sections(sections)) + console.print(_format_view_properties(properties)) return console.export_text() @@ -147,32 +144,20 @@ def test_view_ignores_flower_toml(tmp_path, monkeypatch): _, bound, _ = app.parse_args(["view"]) assert "source" not in bound.arguments - assert "style" not in bound.arguments - assert "viewer" not in bound.arguments + assert "mode" not in bound.arguments # view ==== -def test_view_styles_map_to_builtin_styles(): - """Every ViewStyle member must map to a built-in style.""" - for member in ViewStyle: - style = Style[member.name] - assert style.value == member.value - - def test_view_with_mocked_internals(mocker): - """view should parse source, build sections, and render via Console.""" + """view should parse source and route properties to the TUI by default.""" mock_parse_source = mocker.patch("seedcase_flower.cli.parse_source") mock_read_properties = mocker.patch("seedcase_flower.cli.read_properties") mock_check = mocker.patch("seedcase_flower.cli.check") mock_build_sections = mocker.patch("seedcase_flower.cli.build_sections") - mock_console_cls = mocker.patch("seedcase_flower.cli.Console") - mock_console = mock_console_cls.return_value - - mock_build_sections.return_value = [ - BuiltSection(content="# Test", output_path=None) - ] + mock_tui = mocker.patch("seedcase_flower.tui.run_textual_viewer") + mocker.patch("seedcase_flower.cli.Console") fake_source = Address(value="file:///datapackage.json", local=True) mock_parse_source.return_value = fake_source @@ -182,16 +167,12 @@ def test_view_with_mocked_internals(mocker): mock_parse_source.assert_called_once_with("datapackage.json") mock_read_properties.assert_called_once_with(fake_source) mock_check.assert_called_once_with(mock_read_properties.return_value, error=True) - mock_build_sections.assert_called_once_with( - mock_read_properties.return_value, - Config(style=Style.quarto_one_page), - ) - mock_console.pager.assert_called_once_with(styles=True) - assert mock_console.print.called + mock_build_sections.assert_not_called() + mock_tui.assert_called_once_with(mock_read_properties.return_value) -def test_view_with_multi_section_style(mocker): - """view should allow multi-section styles and render via a pager.""" +def test_view_with_stdout_mode(mocker): + """view --mode stdout should print one terminal-oriented representation.""" mock_parse_source = mocker.patch("seedcase_flower.cli.parse_source") mock_read_properties = mocker.patch("seedcase_flower.cli.read_properties") mocker.patch("seedcase_flower.cli.check") @@ -199,32 +180,21 @@ def test_view_with_multi_section_style(mocker): mock_console_cls = mocker.patch("seedcase_flower.cli.Console") mock_console = mock_console_cls.return_value - mock_build_sections.return_value = [ - BuiltSection(content="# Package", output_path=Path("index.qmd")), - BuiltSection( - content="Resource details", output_path=Path("resources/data.qmd") - ), - ] - fake_source = Address(value="file:///datapackage.json", local=True) mock_parse_source.return_value = fake_source app( - ["view", "datapackage.json", "--style", "quarto-resource-listing"], + ["view", "datapackage.json", "--mode", "stdout"], result_action="return_value", ) mock_read_properties.assert_called_once_with(fake_source) - mock_build_sections.assert_called_once_with( - mock_read_properties.return_value, - Config(style=Style.quarto_resource_listing), - ) - mock_console.pager.assert_called_once_with(styles=True) + mock_build_sections.assert_not_called() assert mock_console.print.called -def test_view_with_textual_viewer(mocker): - """view should route Data Package properties to the Textual viewer.""" +def test_view_with_tui_mode(mocker): + """view should route Data Package properties to the TUI viewer.""" mock_parse_source = mocker.patch("seedcase_flower.cli.parse_source") mock_read_properties = mocker.patch("seedcase_flower.cli.read_properties") mocker.patch("seedcase_flower.cli.check") @@ -236,7 +206,7 @@ def test_view_with_textual_viewer(mocker): mock_parse_source.return_value = fake_source app( - ["view", "datapackage.json", "--viewer", "textual"], + ["view", "datapackage.json", "--mode", "tui"], result_action="return_value", ) @@ -246,78 +216,45 @@ def test_view_with_textual_viewer(mocker): mock_console_cls.assert_not_called() -def test_format_view_sections_separates_sections_with_rule(): - """Multi-section view output should separate sections without file labels.""" - output = _render_view_sections( - [ - BuiltSection(content="# Package", output_path=Path("index.qmd")), - BuiltSection( - content="Resource details", output_path=Path("resources/data.qmd") - ), - ] +def test_format_view_properties_separates_resources_with_rule(): + """Plain stdout output should separate resources with rules.""" + output = _render_view_properties( + { + "name": "test-package", + "resources": [ + { + "name": "species_catalog", + "title": "Species Catalog", + "description": "Species metadata.", + "path": "data/species.csv", + "schema": { + "fields": [ + { + "name": "id", + "type": "integer", + "description": "Stable identifier.", + } + ] + }, + } + ], + } ) - assert "index.qmd" not in output - assert "resources/data.qmd" not in output assert "─" in output - assert "Package" in output - assert "Resource details" in output + assert "test-package" in output + assert "Species Catalog" in output + assert "Species metadata." in output + assert "Path" in output + assert "data/species.csv" in output + assert "Fields" in output + assert "integer" in output output_lines = output.splitlines() rule_index = next(index for index, line in enumerate(output_lines) if "─" in line) - assert output_lines[rule_index - 1] == "" assert output_lines[rule_index + 1] == "" -def test_format_view_sections_removes_listing_front_matter(): - """Index page listing front matter should not be displayed in the pager.""" - output = _render_view_sections( - [ - BuiltSection( - content=( - "---\n" - "listing:\n" - " type: default\n" - " contents: resources\n" - "---\n\n" - "# Package" - ), - output_path=Path("index.qmd"), - ) - ] - ) - - assert "listing:" not in output - assert "contents: resources" not in output - assert "Package" in output - - -def test_format_view_sections_converts_resource_front_matter_to_headings(): - """Resource title and subtitle front matter should become Markdown headings.""" - output = _render_view_sections( - [ - BuiltSection( - content=( - "---\n" - 'title: "Species Catalog"\n' - 'subtitle: "`species_catalog`"\n' - 'description: "Resource description"\n' - "---\n\n" - "- Path: `data/species.csv`" - ), - output_path=Path("resources/species_catalog.qmd"), - ) - ] - ) - - assert "title:" not in output - assert "subtitle:" not in output - assert "description:" not in output - assert "Species Catalog" in output.splitlines() - assert "species_catalog" in output.splitlines() - assert "Path: data/species.csv" in output - - def test_build_raises_on_invalid_datapackage(tmp_path): """build should check datapackage content and fail for malformed metadata.""" json_file = tmp_path / "datapackage.json" @@ -340,7 +277,7 @@ def test_view_raises_on_invalid_datapackage(tmp_path): # instead of the exact full output def test_view_renders_datapackage(capsys, datapackage_path): """view should render all key datapackage fields to the terminal.""" - app(["view", datapackage_path], result_action="return_value") + app(["view", datapackage_path, "--mode", "stdout"], result_action="return_value") output = capsys.readouterr().out # Package metadata From ff55ece3284e5f4ad3172afb3d1ca73c6628c62b Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Wed, 6 May 2026 14:56:51 +0200 Subject: [PATCH 26/28] =?UTF-8?q?refactor:=20=E2=99=BB=EF=B8=8F=20reuse=20?= =?UTF-8?q?existing=20markdown=20rendering=20path=20for=20stdout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seedcase_flower/cli.py | 168 +------------------------------------ tests/test_cli.py | 59 +++---------- 2 files changed, 14 insertions(+), 213 deletions(-) diff --git a/src/seedcase_flower/cli.py b/src/seedcase_flower/cli.py index aee69bfd..8680fa1f 100644 --- a/src/seedcase_flower/cli.py +++ b/src/seedcase_flower/cli.py @@ -5,10 +5,8 @@ from typing import Any, Optional from check_datapackage import check -from rich.console import Console, Group, RenderableType -from rich.rule import Rule -from rich.table import Table -from rich.text import Text +from rich.console import Console +from rich.markdown import Markdown from seedcase_soil import ( CONSOLE_THEME, Address, @@ -126,166 +124,8 @@ def view( return console = Console(theme=CONSOLE_THEME) - console.print(_format_view_properties(properties)) - - -def _format_view_properties(properties: dict[str, Any]) -> RenderableType: - """Format Data Package properties for plain terminal output.""" - renderables: list[RenderableType] = [] - title = _package_title(properties) - if title: - renderables.append(Text(title, style="markdown.h1")) - if description := properties.get("description"): - renderables.append(Text(description)) - if version := properties.get("version"): - renderables.extend(_metadata_list("Version", [version])) - if licenses := properties.get("licenses"): - renderables.extend(_metadata_list("Licenses", _license_labels(licenses))) - if contributors := properties.get("contributors"): - renderables.extend( - _metadata_list("Contributors", _contributor_labels(contributors)) - ) - if resources := properties.get("resources"): - renderables.append(Text("Resources", style="yellow bold")) - renderables.append(_resources_table(resources)) - - for resource in properties.get("resources", []): - renderables.extend([Text(""), Rule(style="dim"), Text("")]) - renderables.extend(_format_resource(resource)) - - return Group(*renderables) - - -def _format_resource(resource: dict[str, Any]) -> list[RenderableType]: - renderables: list[RenderableType] = [] - resource_name = resource.get("name", "") - title = resource.get("title") or resource_name - if title: - renderables.append(Text(title, style="markdown.h1")) - if description := _resource_description(resource): - renderables.append(Text(description)) - - schema = resource.get("schema") or {} - if path := resource.get("path"): - renderables.extend(_metadata_list("Path", [path])) - if primary_key := schema.get("primaryKey"): - renderables.extend(_metadata_list("Primary key", [_as_list_text(primary_key)])) - if foreign_keys := schema.get("foreignKeys"): - renderables.extend( - _metadata_list("Foreign keys", _foreign_key_text(foreign_keys, resource)) - ) - if fields := schema.get("fields"): - renderables.append(Text("Fields", style="yellow bold")) - renderables.append(_fields_table(fields)) - return renderables - - -def _package_title(properties: dict[str, Any]) -> str: - name = properties.get("name") - title = properties.get("title") - if name and title: - return f"{name}: {title}" - return name or title or "" - - -def _metadata_list(label: str, values: list[str]) -> list[RenderableType]: - if not values: - return [] - text = Text(label, style="yellow bold") - for value in values: - text.append(f"\n• {value}") - return [text] - - -def _license_labels(licenses: list[dict[str, Any]]) -> list[str]: - return [ - label - for license in licenses - if (label := license.get("title") or license.get("name")) - ] - - -def _contributor_labels(contributors: list[dict[str, Any]]) -> list[str]: - return [ - label - for contributor in contributors - if (label := _single_contributor_label(contributor)) - ] - - -def _single_contributor_label(contributor: dict[str, Any]) -> str: - full_name = ( - f"{contributor.get('firstName', '')} {contributor.get('lastName', '')}" - ).strip() - label = ( - contributor.get("title") - or full_name - or contributor.get("organization") - or contributor.get("email") - or "" - ) - roles = ", ".join(contributor.get("roles", [])) - return f"{label}{': ' + roles if roles else ''}" if label else "" - - -def _resource_description(resource: dict[str, Any]) -> str: - description = resource.get("description", "") - resource_name = resource.get("name", "") - title = resource.get("title", "") - labels = {_metadata_label(resource_name), _metadata_label(title)} - return "" if _metadata_label(description) in labels else description - - -def _metadata_label(value: str) -> str: - return value.strip().strip("`") - - -def _as_list_text(value: str | list[str]) -> str: - return value if isinstance(value, str) else ", ".join(value) - - -def _foreign_key_text( - foreign_keys: list[dict[str, Any]], resource: dict[str, Any] -) -> list[str]: - lines = [] - for foreign_key in foreign_keys: - reference = foreign_key.get("reference", {}) - reference_resource = reference.get("resource") or resource.get("name", "") - lines.append( - f"{_as_list_text(foreign_key.get('fields', []))} -> " - f"{reference_resource}.{_as_list_text(reference.get('fields', []))}" - ) - return lines - - -def _resources_table(resources: list[dict[str, Any]]) -> Table: - table = Table(show_lines=False) - table.add_column("Name", style="markdown.h1") - table.add_column("Title") - table.add_column("Description") - for resource in resources: - table.add_row( - resource.get("name", ""), - resource.get("title", ""), - resource.get("description", ""), - ) - return table - - -def _fields_table(fields: list[dict[str, Any]]) -> Table: - table = Table(show_lines=False) - table.add_column("Name", style="markdown.h1") - table.add_column("Title") - table.add_column("Type") - table.add_column("Description") - for field in fields: - table.add_row( - field.get("name", ""), - field.get("title", ""), - field.get("type", "any"), - field.get("description", ""), - ) - return table + for section in build_sections(properties, Config(style=Style.quarto_one_page)): + console.print(Markdown(section.content)) def main() -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index c318ccfe..e6d16605 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,20 +4,14 @@ import pytest from check_datapackage.check import DataPackageError -from rich.console import Console from seedcase_soil import Address -from seedcase_flower.cli import _format_view_properties, app +from seedcase_flower.build_sections import BuiltSection +from seedcase_flower.cli import app from seedcase_flower.config import Config from seedcase_flower.styles import Style -def _render_view_properties(properties: dict) -> str: - console = Console(record=True, width=80, color_system=None) - console.print(_format_view_properties(properties)) - return console.export_text() - - @pytest.fixture def mock_parse_source(mocker): """Mock _parse_source to isolate CLI tests from filesystem resolution.""" @@ -172,7 +166,7 @@ def test_view_with_mocked_internals(mocker): def test_view_with_stdout_mode(mocker): - """view --mode stdout should print one terminal-oriented representation.""" + """view --mode stdout should render the Quarto one-page Markdown style.""" mock_parse_source = mocker.patch("seedcase_flower.cli.parse_source") mock_read_properties = mocker.patch("seedcase_flower.cli.read_properties") mocker.patch("seedcase_flower.cli.check") @@ -182,6 +176,9 @@ def test_view_with_stdout_mode(mocker): fake_source = Address(value="file:///datapackage.json", local=True) mock_parse_source.return_value = fake_source + mock_build_sections.return_value = [ + BuiltSection(content="# Test Package", output_path=Path("index.qmd")) + ] app( ["view", "datapackage.json", "--mode", "stdout"], @@ -189,7 +186,10 @@ def test_view_with_stdout_mode(mocker): ) mock_read_properties.assert_called_once_with(fake_source) - mock_build_sections.assert_not_called() + mock_build_sections.assert_called_once_with( + mock_read_properties.return_value, + Config(style=Style.quarto_one_page), + ) assert mock_console.print.called @@ -216,45 +216,6 @@ def test_view_with_tui_mode(mocker): mock_console_cls.assert_not_called() -def test_format_view_properties_separates_resources_with_rule(): - """Plain stdout output should separate resources with rules.""" - output = _render_view_properties( - { - "name": "test-package", - "resources": [ - { - "name": "species_catalog", - "title": "Species Catalog", - "description": "Species metadata.", - "path": "data/species.csv", - "schema": { - "fields": [ - { - "name": "id", - "type": "integer", - "description": "Stable identifier.", - } - ] - }, - } - ], - } - ) - - assert "─" in output - assert "test-package" in output - assert "Species Catalog" in output - assert "Species metadata." in output - assert "Path" in output - assert "data/species.csv" in output - assert "Fields" in output - assert "integer" in output - - output_lines = output.splitlines() - rule_index = next(index for index, line in enumerate(output_lines) if "─" in line) - assert output_lines[rule_index + 1] == "" - - def test_build_raises_on_invalid_datapackage(tmp_path): """build should check datapackage content and fail for malformed metadata.""" json_file = tmp_path / "datapackage.json" From 048ea8f2b6ef4149e97fb34d26972154e094d9a3 Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Wed, 6 May 2026 15:02:44 +0200 Subject: [PATCH 27/28] =?UTF-8?q?chore:=20=F0=9F=94=A7=20make=20mypy=20hap?= =?UTF-8?q?py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seedcase_flower/tui.py | 20 +++++++--- tests/test_tui.py | 82 +++++++++++++++++++++++--------------- 2 files changed, 64 insertions(+), 38 deletions(-) diff --git a/src/seedcase_flower/tui.py b/src/seedcase_flower/tui.py index 28f3453f..98ba616b 100644 --- a/src/seedcase_flower/tui.py +++ b/src/seedcase_flower/tui.py @@ -19,6 +19,7 @@ ListView, Static, ) +from textual.widgets._data_table import ColumnKey RICH_BLUE = "color(4) bold" RICH_YELLOW = "color(3) bold" @@ -677,9 +678,15 @@ class SearchInput(Input): class PageView(VerticalScroll): """A cached Textual page composed from text and native tables.""" - def __init__(self, blocks: list[ViewBlock], **kwargs: object) -> None: + def __init__( + self, + blocks: list[ViewBlock], + *, + id: str | None = None, + classes: str | None = None, + ) -> None: """Initialize the page with prepared content blocks.""" - super().__init__(**kwargs) + super().__init__(id=id, classes=classes) self.blocks = blocks def compose(self) -> ComposeResult: @@ -713,13 +720,14 @@ def __init__(self, block: TableBlock) -> None: ) self.headers = block.headers self.all_rows = block.rows + self.column_keys: list[ColumnKey] = [] self._sort_column_index: int | None = None self._sort_reverse = False def on_mount(self) -> None: """Populate the table once it has app context for measuring columns.""" for header in self.headers: - self.add_column(header, key=header) + self.column_keys.append(self.add_column(header, key=header)) self.filter_rows("") def on_focus(self) -> None: @@ -741,7 +749,7 @@ def action_copy_selection(self) -> None: def selected_text(self) -> str: """Return selected row or column as tabular plain text.""" if self.cursor_type == "column" and self.headers: - column = self.headers[self.cursor_column] + column = self.column_keys[self.cursor_column] return "\n".join(str(value) for value in self.get_column(column)) return "\t".join(str(value) for value in self.get_row_at(self.cursor_row)) @@ -802,7 +810,7 @@ def _sort_by_column(self, column_index: int) -> None: """Sort rows case-insensitively by one column.""" self._refresh_sort_indicators() self.sort( - self.headers[column_index], + self.column_keys[column_index], key=lambda value: str(value).casefold(), reverse=self._sort_reverse, ) @@ -814,7 +822,7 @@ def _refresh_sort_indicators(self) -> None: if column_index == self._sort_column_index: label.append(" ") label.append("↓" if self._sort_reverse else "↑", style=RICH_YELLOW) - column = self.columns[header] + column = self.columns[self.column_keys[column_index]] column.label = label label_width = len(label.plain) column.content_width = max(column.content_width, label_width) diff --git a/tests/test_tui.py b/tests/test_tui.py index 3b56daec..db04e958 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -2,6 +2,10 @@ import asyncio import inspect +from typing import cast + +from textual.binding import Binding +from textual.widgets import ContentSwitcher, Input, ListView from seedcase_flower.tui import ( FlowerViewApp, @@ -15,14 +19,22 @@ ) +def _flower_bindings() -> list[Binding]: + return cast(list[Binding], FlowerViewApp.BINDINGS) + + +def _search_bindings() -> list[Binding]: + return cast(list[Binding], SearchInput.BINDINGS) + + def test_flower_view_app_has_vim_navigation_bindings(): - visible_bindings = [binding for binding in FlowerViewApp.BINDINGS if binding.show] + visible_bindings = [binding for binding in _flower_bindings() if binding.show] assert [binding.key for binding in visible_bindings] == ["q", "?"] assert visible_bindings[0].description == "Quit" assert visible_bindings[1].description == "Keyboard shortcuts" assert FlowerViewApp.COMMAND_PALETTE_DISPLAY == "ctrl+p" - bindings = {binding.key: binding for binding in FlowerViewApp.BINDINGS} + bindings = {binding.key: binding for binding in _flower_bindings()} assert "escape" not in bindings assert bindings["ctrl+q"].description == "Quit" assert bindings["ctrl+q"].key_display == "ctrl+q |" @@ -45,7 +57,7 @@ def test_flower_view_app_has_vim_navigation_bindings(): def test_flower_view_app_uses_ctrl_not_caret_in_key_displays(): app = FlowerViewApp([]) - assert app.get_key_display(FlowerViewApp.BINDINGS[-2]) == "ctrl+d" + assert app.get_key_display(_flower_bindings()[-2]) == "ctrl+d" def test_flower_view_app_toggles_keyboard_shortcuts_panel(): @@ -67,7 +79,7 @@ async def run_test() -> None: def test_search_input_supports_ctrl_backspace_word_delete(): - assert any(binding.key == "ctrl+backspace" for binding in SearchInput.BINDINGS) + assert any(binding.key == "ctrl+backspace" for binding in _search_bindings()) def test_flower_view_app_pre_mounts_pages(): @@ -288,7 +300,8 @@ async def run_test() -> None: await app._show_page(1) assert app.sub_title == "species_catalog" - assert app.query_one("#content-switcher").current == "page-2" + switcher = app.query_one("#content-switcher", ContentSwitcher) + assert switcher.current == "page-2" asyncio.run(run_test()) @@ -319,12 +332,13 @@ async def run_test() -> None: app.action_toc_down() app.action_toc_down() - assert app.query_one("#content-switcher").current == "page-1" + switcher = app.query_one("#content-switcher", ContentSwitcher) + assert switcher.current == "page-1" await pilot.pause(0.15) assert app.sub_title == "location_catalog" - assert app.query_one("#content-switcher").current == "page-3" + assert switcher.current == "page-3" asyncio.run(run_test()) @@ -347,11 +361,12 @@ async def run_test() -> None: ) async with app.run_test(): - page = app.query_one("#page-2") - page.remove = None # type: ignore[method-assign] + page = app.query_one("#page-2", PageView) + page.remove = None # type: ignore[assignment, method-assign] await app._show_page(1) - assert app.query_one("#content-switcher").current == "page-2" + switcher = app.query_one("#content-switcher", ContentSwitcher) + assert switcher.current == "page-2" asyncio.run(run_test()) @@ -380,7 +395,8 @@ async def run_test() -> None: table = app.query_one(SearchableDataTable) app.action_search_table() - assert app.query_one("#table-search").placeholder == "Search all tables" + search = app.query_one("#table-search", Input) + assert search.placeholder == "Search all tables" await pilot.press("s", "p") assert table.row_count == 1 @@ -429,12 +445,12 @@ async def run_test() -> None: app.action_search_table() await pilot.press("s", "p") - toc_items = app.query_one("#toc").children + toc = app.query_one("#toc", ListView) + toc_items = toc.children assert toc_items[0].display is True assert toc_items[1].display is True assert toc_items[2].display is False - toc = app.query_one("#toc") app.action_toc_down() assert toc.index == 1 @@ -473,23 +489,25 @@ async def run_test() -> None: async with app.run_test(): table = app.query_one(SearchableDataTable) - initial_width = table.columns["Name"].width + name_key = table.column_keys[0] + type_key = table.column_keys[1] + initial_width = table.columns[name_key].width app.action_sort_table() assert table.get_row_at(0) == ["plot_id", "integer"] - assert table.columns["Name"].label.plain == "Name ↑" - assert table.columns["Name"].width >= initial_width - assert str(table.columns["Name"].label.spans[0].style) == "color(3) bold" + assert table.columns[name_key].label.plain == "Name ↑" + assert table.columns[name_key].width >= initial_width + assert str(table.columns[name_key].label.spans[0].style) == "color(3) bold" app.action_sort_table() assert table.get_row_at(0) == ["species", "string"] - assert table.columns["Name"].label.plain == "Name ↓" + assert table.columns[name_key].label.plain == "Name ↓" app.action_sort_table() assert table.get_row_at(0) == ["plot_id", "integer"] - assert table.columns["Name"].label.plain == "Name" - assert table.columns["Type"].label.plain == "Type ↑" + assert table.columns[name_key].label.plain == "Name" + assert table.columns[type_key].label.plain == "Type ↑" asyncio.run(run_test()) @@ -513,14 +531,14 @@ async def run_test() -> None: async with app.run_test() as pilot: table = app.query_one(SearchableDataTable) - toc = app.query_one("#toc") + toc = app.query_one("#toc", ListView) assert app.focused == toc assert table.show_cursor is False app.action_focus_table() await pilot.pause() - assert app.focused == table + assert app.focused == cast(object, table) assert table.show_cursor is True assert table.cursor_type == "row" @@ -557,7 +575,7 @@ async def run_test() -> None: async with app.run_test() as pilot: table = app.query_one(SearchableDataTable) - toc = app.query_one("#toc") + toc = app.query_one("#toc", ListView) app.action_focus_table() await pilot.pause() @@ -565,21 +583,21 @@ async def run_test() -> None: assert table.cursor_type == "row" app.action_focus_table() - assert table.cursor_type == "column" + assert str(table.cursor_type) == "column" assert table.cursor_column == 0 app.action_focus_table() - assert table.cursor_type == "column" + assert str(table.cursor_type) == "column" assert table.cursor_column == 1 app.action_focus_toc() - assert table.cursor_type == "column" + assert str(table.cursor_type) == "column" assert table.cursor_column == 0 - assert app.focused == table + assert app.focused == cast(object, table) app.action_focus_toc() assert table.cursor_type == "row" - assert app.focused == table + assert app.focused == cast(object, table) app.action_focus_toc() await pilot.pause() @@ -656,7 +674,7 @@ async def run_test() -> None: def test_searchable_data_table_copies_selected_row_or_column(): async def run_test() -> None: - copied = [] + copied: list[str] = [] app = FlowerViewApp( [ ViewPage( @@ -674,7 +692,7 @@ async def run_test() -> None: ) ] ) - app.copy_to_clipboard = copied.append # type: ignore[method-assign] + app.copy_to_clipboard = copied.append # type: ignore[assignment, method-assign] async with app.run_test() as pilot: table = app.query_one(SearchableDataTable) @@ -720,7 +738,7 @@ async def run_test() -> None: async with app.run_test() as pilot: table = app.query_one(SearchableDataTable) - toc = app.query_one("#toc") + toc = app.query_one("#toc", ListView) toc.index = 1 toc.action_select_cursor() @@ -732,6 +750,6 @@ async def run_test() -> None: app.action_focus_table() await pilot.pause() - assert app.focused == table + assert app.focused == cast(object, table) asyncio.run(run_test()) From 523fa6e8419f427f28a633b6cf641f653f1d29ab Mon Sep 17 00:00:00 2001 From: Joel Ostblom Date: Wed, 6 May 2026 15:02:51 +0200 Subject: [PATCH 28/28] =?UTF-8?q?chore:=20=F0=9F=94=A7=20just=20run=20all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/interface/python.qmd | 10 +- docs/guide/cli.qmd | 27 +- docs/guide/contribute-style.qmd | 10 +- uv.lock | 443 ++++++++++++++++--------------- 4 files changed, 246 insertions(+), 244 deletions(-) diff --git a/docs/design/interface/python.qmd b/docs/design/interface/python.qmd index 54482da0..a29bbaf1 100644 --- a/docs/design/interface/python.qmd +++ b/docs/design/interface/python.qmd @@ -82,11 +82,11 @@ flowchart TD ### {{< var done >}} `view()` -`view()` takes a `source` and a display `mode`. Unlike `build()`, `view()` -does not use styles, custom templates, output files, or the configuration -file. The metadata is shown in an interactive TUI by default, or printed to -stdout when `mode="stdout"`. For details about the parameters, see -[`view()`](/docs/reference/view.qmd). +`view()` takes a `source` and a display `mode`. Unlike `build()`, +`view()` does not use styles, custom templates, output files, or the +configuration file. The metadata is shown in an interactive TUI by +default, or printed to stdout when `mode="stdout"`. For details about +the parameters, see [`view()`](/docs/reference/view.qmd). The internal flow of `view()` is shown in the diagram below. diff --git a/docs/guide/cli.qmd b/docs/guide/cli.qmd index 04bd14ee..c2363683 100644 --- a/docs/guide/cli.qmd +++ b/docs/guide/cli.qmd @@ -102,8 +102,9 @@ terminal, so some aspects of it might not display as expected here. ### Plain terminal output -By default, `view` opens an interactive TUI. Use `--mode stdout` to print a -plain terminal representation that can be piped to tools like `less`: +By default, `view` opens an interactive TUI. Use `--mode stdout` to +print a plain terminal representation that can be piped to tools like +`less`: ```{.bash filename="Terminal"} seedcase-flower view --mode stdout gh:seedcase-project/example-seed-beetle | less -R @@ -123,24 +124,24 @@ terminal, so some aspects of it might not display as expected here. ### Interactive TUI -The default `view` mode shows the package and resources in a left-hand table -of contents and the selected section in a scrollable content pane: +The default `view` mode shows the package and resources in a left-hand +table of contents and the selected section in a scrollable content pane: ```{.bash filename="Terminal"} seedcase-flower view gh:seedcase-project/example-seed-beetle ``` -In the TUI, use `j` and `k` or the arrow keys to move through -the table of contents. Press `l` or the right arrow to focus the current -page's table. Press `l` or the right arrow again to select columns, and -`h` or the left arrow to move back through selected columns, row selection, -and the table of contents. Use `ctrl+d` and `ctrl+u` to move six items down +In the TUI, use `j` and `k` or the arrow keys to move through the table +of contents. Press `l` or the right arrow to focus the current page's +table. Press `l` or the right arrow again to select columns, and `h` or +the left arrow to move back through selected columns, row selection, and +the table of contents. Use `ctrl+d` and `ctrl+u` to move six items down or up. For tables, use `s` to cycle sorting through the columns and directions, `ctrl+f` or `/` to search all tables, `c` or `y` to copy the -selected row or column, and `q` or `ctrl+q` to quit. Press `?` to show all -keyboard shortcuts, and press `?` again to close them. The sorted column -shows an arrow for ascending or descending order, and the table of contents -hides resources whose tables have no search matches. +selected row or column, and `q` or `ctrl+q` to quit. Press `?` to show +all keyboard shortcuts, and press `?` again to close them. The sorted +column shows an arrow for ascending or descending order, and the table +of contents hides resources whose tables have no search matches. ::: callout-important `view` is only configurable via command line flags. This means that any diff --git a/docs/guide/contribute-style.qmd b/docs/guide/contribute-style.qmd index 3fcfb349..cdf4e937 100644 --- a/docs/guide/contribute-style.qmd +++ b/docs/guide/contribute-style.qmd @@ -34,9 +34,9 @@ should be short, descriptive, and distinct from existing built-in styles. It should be given in snake case (e.g., `my_new_style`). ::: callout-note -The `view` command can display built-in styles with one or more sections. -Multi-section styles are rendered as one paged terminal document, with -each section labelled by its `output-path`. +The `view` command can display built-in styles with one or more +sections. Multi-section styles are rendered as one paged terminal +document, with each section labelled by its `output-path`. ::: ## Adding the style as built-in @@ -64,8 +64,8 @@ plain text format (e.g., `.html`, `.md`, `.yml`). Finally, add your style to the style enum in `src/seedcase_flower/styles.py`. Build styles are defined in the `Style` -enum. For example, if `your_new_style` is your new style, you would include -it in `Style` like so: +enum. For example, if `your_new_style` is your new style, you would +include it in `Style` like so: ``` {.python filename="src/seedcase_flower/styles.py"} class Style(Enum): diff --git a/uv.lock b/uv.lock index 50742e75..a8cddf19 100644 --- a/uv.lock +++ b/uv.lock @@ -227,11 +227,11 @@ css = [ [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.4.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] [[package]] @@ -375,7 +375,7 @@ wheels = [ [[package]] name = "check-datapackage" -version = "0.34.0" +version = "0.35.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonschema" }, @@ -385,21 +385,21 @@ dependencies = [ { name = "seedcase-soil" }, { name = "types-jsonschema" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/ac/77af7c0c6ebc5dde15da31891365b6c0ec9427b9a6b1786a53f1024972a3/check_datapackage-0.34.0.tar.gz", hash = "sha256:494f8c5c3df6d2f54ef8a41a497ea19a0acfb5d2d77ca3b16a0d06ce26c09a58", size = 1086921, upload-time = "2026-04-30T08:28:47.958Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/20/49504c0d5e51a88e313db9ea31b97982e67a71781369b7121201111f6722/check_datapackage-0.35.0.tar.gz", hash = "sha256:48e4cb97530b13c87f06bac76933a98e9e4591fdde8056b8570bee8b377393bd", size = 1087041, upload-time = "2026-05-04T08:31:05.629Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/4d/2ac332e5de86c049a67f9a95ada7cd4ca559a4f38c7e66603f4b6c8cbe16/check_datapackage-0.34.0-py3-none-any.whl", hash = "sha256:f63c19b2cf1d9be03574620c3819f4e4bafb898efa269a140c401a4d89ce70b0", size = 36206, upload-time = "2026-04-30T08:28:46.698Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d9/7b555152eb7fa88a47d6f900df6b126e54fea7dd0fc34aa46872d0473bf5/check_datapackage-0.35.0-py3-none-any.whl", hash = "sha256:5261b629f574d39671dd31c0053da7ca829fb6692e4f299e8d51c13130714a06", size = 36206, upload-time = "2026-05-04T08:31:04.438Z" }, ] [[package]] name = "click" -version = "8.3.2" +version = "8.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] [[package]] @@ -422,7 +422,7 @@ wheels = [ [[package]] name = "commitizen" -version = "4.13.10" +version = "4.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argcomplete" }, @@ -438,9 +438,9 @@ dependencies = [ { name = "termcolor" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/95/da2c71ed6a1c06836cdd4eb60a8b9e1bf05f4ce7029ab508081745171be9/commitizen-4.13.10.tar.gz", hash = "sha256:402b5bcd466be69ba79a3f380be6ba5b55ac658c7d2a93e82fc99668a6eb2673", size = 64106, upload-time = "2026-04-11T06:49:12.907Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/2a/ac219a23e89210aa3ee9244dc076e39bc532bc3f352024f3d478f3e79b85/commitizen-4.15.1.tar.gz", hash = "sha256:cca192e07b2f9d77734044c631da294b3007ef9aa10cc8f0600290aa662e9fa3", size = 65718, upload-time = "2026-05-06T04:14:45.159Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/3a/ad70b3c7dc3da1255668a9396429b1d820c15b74a501668158e4574c1edd/commitizen-4.13.10-py3-none-any.whl", hash = "sha256:95a281317990ac613501fdfe65745cec1fa4042bc5d003a72d332a74926e3039", size = 85746, upload-time = "2026-04-11T06:49:11.167Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5d/12e74d633a622474cf3af6b27656c7da8b96caf87e1c6672436fcff85138/commitizen-4.15.1-py3-none-any.whl", hash = "sha256:8207ee3e0344b483c5e59103d59ce19b96eb7204ccf28c72b65596fa524b06e2", size = 87876, upload-time = "2026-05-06T04:14:43.754Z" }, ] [[package]] @@ -529,7 +529,7 @@ wheels = [ [[package]] name = "cyclopts" -version = "4.11.0" +version = "4.11.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -537,9 +537,9 @@ dependencies = [ { name = "rich" }, { name = "rich-rst" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/fa/eff8f1abae783bade9b5e9bafafd0040d4dbf51988f9384bfdc0326ba1fc/cyclopts-4.11.0.tar.gz", hash = "sha256:1ffcb9990dbd56b90da19980d31596de9e99019980a215a5d76cf88fe452e94d", size = 170690, upload-time = "2026-04-23T00:23:36.858Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/f7/3ee212c1bc314551094fc8fda7b4b63c647ac5c32d06daa285d04d33edfc/cyclopts-4.11.2.tar.gz", hash = "sha256:8c9b77921660fa1ee52c150e2217ced672323efb3434e9b338077de1bc551ff4", size = 175935, upload-time = "2026-05-04T00:11:57.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/37/197db187c260d24d4be1f09d427f59f3fb9a89bcf1354e23865c7bff7607/cyclopts-4.11.0-py3-none-any.whl", hash = "sha256:34318e3823b44b5baa754a5e37ec70a5c17dc81c65e4295ed70e17bc1aeae50d", size = 208494, upload-time = "2026-04-23T00:23:34.948Z" }, + { url = "https://files.pythonhosted.org/packages/23/18/4cedda786e7da429e7489549a9e5461530d4133130e541f25fb94f015776/cyclopts-4.11.2-py3-none-any.whl", hash = "sha256:838020120b939549ff7c8423aca29c86764b5dd1d8a5d7f3753a6327861f537b", size = 213537, upload-time = "2026-05-04T00:11:56.103Z" }, ] [[package]] @@ -649,11 +649,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.25.2" +version = "3.29.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] [[package]] @@ -754,20 +754,20 @@ wheels = [ [[package]] name = "identify" -version = "2.6.18" +version = "2.6.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] [[package]] name = "idna" -version = "3.11" +version = "3.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] [[package]] @@ -826,7 +826,7 @@ wheels = [ [[package]] name = "ipython" -version = "9.12.0" +version = "9.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -836,13 +836,14 @@ dependencies = [ { name = "matplotlib-inline" }, { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "prompt-toolkit" }, + { name = "psutil" }, { name = "pygments" }, { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/73/7114f80a8f9cabdb13c27732dce24af945b2923dcab80723602f7c8bc2d8/ipython-9.12.0.tar.gz", hash = "sha256:01daa83f504b693ba523b5a407246cabde4eb4513285a3c6acaff11a66735ee4", size = 4428879, upload-time = "2026-03-27T09:42:45.312Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/22/906c8108974c673ebef6356c506cebb6870d48cedea3c41e949e2dd556bb/ipython-9.12.0-py3-none-any.whl", hash = "sha256:0f2701e8ee86e117e37f50563205d36feaa259d2e08d4a6bc6b6d74b18ce128d", size = 625661, upload-time = "2026-03-27T09:42:42.831Z" }, + { url = "https://files.pythonhosted.org/packages/b9/86/3060e8029b7cc505cce9a0137431dda81d0a3fde93a8f0f50ee0bf37a795/ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", size = 627274, upload-time = "2026-04-24T12:24:53.038Z" }, ] [[package]] @@ -887,14 +888,14 @@ wheels = [ [[package]] name = "jedi" -version = "0.19.2" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parso" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, ] [[package]] @@ -1034,7 +1035,7 @@ wheels = [ [[package]] name = "jupyter-events" -version = "0.12.0" +version = "0.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonschema", extra = ["format-nongpl"] }, @@ -1046,9 +1047,9 @@ dependencies = [ { name = "rfc3986-validator" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/c3/306d090461e4cf3cd91eceaff84bede12a8e52cd821c2d20c9a4fd728385/jupyter_events-0.12.0.tar.gz", hash = "sha256:fc3fce98865f6784c9cd0a56a20644fc6098f21c8c33834a8d9fe383c17e554b", size = 62196, upload-time = "2025-02-03T17:23:41.485Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/f8/475c4241b2b75af0deaae453ed003c6c851766dbc44d332d8baf245dc931/jupyter_events-0.12.1.tar.gz", hash = "sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3", size = 62854, upload-time = "2026-04-20T23:17:50.66Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/48/577993f1f99c552f18a0428731a755e06171f9902fa118c379eb7c04ea22/jupyter_events-0.12.0-py3-none-any.whl", hash = "sha256:6464b2fa5ad10451c3d35fabc75eab39556ae1e2853ad0c0cc31b656731a97fb", size = 19430, upload-time = "2025-02-03T17:23:38.643Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl", hash = "sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf", size = 19512, upload-time = "2026-04-20T23:17:48.927Z" }, ] [[package]] @@ -1065,7 +1066,7 @@ wheels = [ [[package]] name = "jupyter-server" -version = "2.17.0" +version = "2.18.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1087,9 +1088,9 @@ dependencies = [ { name = "traitlets" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/ac/e040ec363d7b6b1f11304cc9f209dac4517ece5d5e01821366b924a64a50/jupyter_server-2.17.0.tar.gz", hash = "sha256:c38ea898566964c888b4772ae1ed58eca84592e88251d2cfc4d171f81f7e99d5", size = 731949, upload-time = "2025-08-21T14:42:54.042Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/15/1eacb0fcb79ef86e8a0a79a708e6ad7435f6f223097dd29a4ce861fabc44/jupyter_server-2.18.2.tar.gz", hash = "sha256:06b4f40d8a7a00bb39d5216859c81374a0e7cfefe6d8a5a7facc5a5c37c679a7", size = 753177, upload-time = "2026-05-06T07:04:36.274Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/80/a24767e6ca280f5a49525d987bf3e4d7552bf67c8be07e8ccf20271f8568/jupyter_server-2.17.0-py3-none-any.whl", hash = "sha256:e8cb9c7db4251f51ed307e329b81b72ccf2056ff82d50524debde1ee1870e13f", size = 388221, upload-time = "2025-08-21T14:42:52.034Z" }, + { url = "https://files.pythonhosted.org/packages/e2/50/ecf4f70d65bdb7519b28a33d1b2fee8a4b4ba1ae1a92f15d97e877c5de21/jupyter_server-2.18.2-py3-none-any.whl", hash = "sha256:fa5e46539ded65791838035a2b6001f13e54d5f64b8b3752eb1e91fdd641a5b8", size = 391907, upload-time = "2026-05-06T07:04:34.014Z" }, ] [[package]] @@ -1107,7 +1108,7 @@ wheels = [ [[package]] name = "jupyterlab" -version = "4.5.6" +version = "4.5.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-lru" }, @@ -1124,9 +1125,9 @@ dependencies = [ { name = "tornado" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/d5/730628e03fff2e8a8e8ccdaedde1489ab1309f9a4fa2536248884e30b7c7/jupyterlab-4.5.6.tar.gz", hash = "sha256:642fe2cfe7f0f5922a8a558ba7a0d246c7bc133b708dfe43f7b3a826d163cf42", size = 23970670, upload-time = "2026-03-11T14:17:04.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/22/8440ec827762146e7cdecf04335bd348795899d29dc6ae82238707353a2c/jupyterlab-4.5.7.tar.gz", hash = "sha256:55a9822c4754da305f41e113452c68383e214dcf96de760146af89ce5d5117b0", size = 23992763, upload-time = "2026-04-29T16:43:51.328Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/1b/dad6fdcc658ed7af26fdf3841e7394072c9549a8b896c381ab49dd11e2d9/jupyterlab-4.5.6-py3-none-any.whl", hash = "sha256:d6b3dac883aa4d9993348e0f8e95b24624f75099aed64eab6a4351a9cdd1e580", size = 12447124, upload-time = "2026-03-11T14:17:00.229Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/537b8f7d80e799af19af35fb3ddfc970b951088a13c57dd9387dcfbb7f61/jupyterlab-4.5.7-py3-none-any.whl", hash = "sha256:fba4cb0e2c44a52859669d8c98b45de029d5e515f8407bf8534d2a8fc5f0964d", size = 12450123, upload-time = "2026-04-29T16:43:46.639Z" }, ] [[package]] @@ -1176,62 +1177,62 @@ wheels = [ [[package]] name = "librt" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/90/89ddba8e1c20b0922783cd93ed8e64f34dc05ab59c38a9c7e313632e20ff/librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4", size = 68332, upload-time = "2026-04-09T16:05:00.09Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/7aa4da1fb08bdeeb540cb07bfc8207cb32c5c41642f2594dbd0098a0662d/librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d", size = 70581, upload-time = "2026-04-09T16:05:01.213Z" }, - { url = "https://files.pythonhosted.org/packages/48/ac/73a2187e1031041e93b7e3a25aae37aa6f13b838c550f7e0f06f66766212/librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f", size = 203984, upload-time = "2026-04-09T16:05:02.542Z" }, - { url = "https://files.pythonhosted.org/packages/5e/3d/23460d571e9cbddb405b017681df04c142fb1b04cbfce77c54b08e28b108/librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27", size = 215762, upload-time = "2026-04-09T16:05:04.127Z" }, - { url = "https://files.pythonhosted.org/packages/de/1e/42dc7f8ab63e65b20640d058e63e97fd3e482c1edbda3570d813b4d0b927/librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2", size = 230288, upload-time = "2026-04-09T16:05:05.883Z" }, - { url = "https://files.pythonhosted.org/packages/dc/08/ca812b6d8259ad9ece703397f8ad5c03af5b5fedfce64279693d3ce4087c/librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b", size = 224103, upload-time = "2026-04-09T16:05:07.148Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3f/620490fb2fa66ffd44e7f900254bc110ebec8dac6c1b7514d64662570e6f/librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265", size = 232122, upload-time = "2026-04-09T16:05:08.386Z" }, - { url = "https://files.pythonhosted.org/packages/e9/83/12864700a1b6a8be458cf5d05db209b0d8e94ae281e7ec261dbe616597b4/librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084", size = 225045, upload-time = "2026-04-09T16:05:09.707Z" }, - { url = "https://files.pythonhosted.org/packages/fd/1b/845d339c29dc7dbc87a2e992a1ba8d28d25d0e0372f9a0a2ecebde298186/librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8", size = 227372, upload-time = "2026-04-09T16:05:10.942Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fe/277985610269d926a64c606f761d58d3db67b956dbbf40024921e95e7fcb/librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f", size = 248224, upload-time = "2026-04-09T16:05:12.254Z" }, - { url = "https://files.pythonhosted.org/packages/92/1b/ee486d244b8de6b8b5dbaefabe6bfdd4a72e08f6353edf7d16d27114da8d/librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f", size = 55986, upload-time = "2026-04-09T16:05:13.529Z" }, - { url = "https://files.pythonhosted.org/packages/89/7a/ba1737012308c17dc6d5516143b5dce9a2c7ba3474afd54e11f44a4d1ef3/librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745", size = 63260, upload-time = "2026-04-09T16:05:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/36/e4/01752c113da15127f18f7bf11142f5640038f062407a611c059d0036c6aa/librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9", size = 53694, upload-time = "2026-04-09T16:05:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d7/1b3e26fffde1452d82f5666164858a81c26ebe808e7ae8c9c88628981540/librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e", size = 68367, upload-time = "2026-04-09T16:05:17.243Z" }, - { url = "https://files.pythonhosted.org/packages/a5/5b/c61b043ad2e091fbe1f2d35d14795e545d0b56b03edaa390fa1dcee3d160/librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22", size = 70595, upload-time = "2026-04-09T16:05:18.471Z" }, - { url = "https://files.pythonhosted.org/packages/a3/22/2448471196d8a73370aa2f23445455dc42712c21404081fcd7a03b9e0749/librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a", size = 204354, upload-time = "2026-04-09T16:05:19.593Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5e/39fc4b153c78cfd2c8a2dcb32700f2d41d2312aa1050513183be4540930d/librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5", size = 216238, upload-time = "2026-04-09T16:05:20.868Z" }, - { url = "https://files.pythonhosted.org/packages/d7/42/bc2d02d0fa7badfa63aa8d6dcd8793a9f7ef5a94396801684a51ed8d8287/librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11", size = 230589, upload-time = "2026-04-09T16:05:22.305Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7b/e2d95cc513866373692aa5edf98080d5602dd07cabfb9e5d2f70df2f25f7/librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858", size = 224610, upload-time = "2026-04-09T16:05:23.647Z" }, - { url = "https://files.pythonhosted.org/packages/31/d5/6cec4607e998eaba57564d06a1295c21b0a0c8de76e4e74d699e627bd98c/librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e", size = 232558, upload-time = "2026-04-09T16:05:25.025Z" }, - { url = "https://files.pythonhosted.org/packages/95/8c/27f1d8d3aaf079d3eb26439bf0b32f1482340c3552e324f7db9dca858671/librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0", size = 225521, upload-time = "2026-04-09T16:05:26.311Z" }, - { url = "https://files.pythonhosted.org/packages/6b/d8/1e0d43b1c329b416017619469b3c3801a25a6a4ef4a1c68332aeaa6f72ca/librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2", size = 227789, upload-time = "2026-04-09T16:05:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/2c/b4/d3d842e88610fcd4c8eec7067b0c23ef2d7d3bff31496eded6a83b0f99be/librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d", size = 248616, upload-time = "2026-04-09T16:05:29.181Z" }, - { url = "https://files.pythonhosted.org/packages/ec/28/527df8ad0d1eb6c8bdfa82fc190f1f7c4cca5a1b6d7b36aeabf95b52d74d/librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd", size = 56039, upload-time = "2026-04-09T16:05:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a7/413652ad0d92273ee5e30c000fc494b361171177c83e57c060ecd3c21538/librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519", size = 63264, upload-time = "2026-04-09T16:05:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/a4/0a/92c244309b774e290ddb15e93363846ae7aa753d9586b8aad511c5e6145b/librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5", size = 53728, upload-time = "2026-04-09T16:05:33.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c1/184e539543f06ea2912f4b92a5ffaede4f9b392689e3f00acbf8134bee92/librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb", size = 67830, upload-time = "2026-04-09T16:05:34.517Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/23399bdcb7afca819acacdef31b37ee59de261bd66b503a7995c03c4b0dc/librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499", size = 70280, upload-time = "2026-04-09T16:05:35.649Z" }, - { url = "https://files.pythonhosted.org/packages/9f/0b/4542dc5a2b8772dbf92cafb9194701230157e73c14b017b6961a23598b03/librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f", size = 201925, upload-time = "2026-04-09T16:05:36.739Z" }, - { url = "https://files.pythonhosted.org/packages/31/d4/8ee7358b08fd0cfce051ef96695380f09b3c2c11b77c9bfbc367c921cce5/librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1", size = 212381, upload-time = "2026-04-09T16:05:38.043Z" }, - { url = "https://files.pythonhosted.org/packages/f2/94/a2025fe442abedf8b038038dab3dba942009ad42b38ea064a1a9e6094241/librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f", size = 227065, upload-time = "2026-04-09T16:05:39.394Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e9/b9fcf6afa909f957cfbbf918802f9dada1bd5d3c1da43d722fd6a310dc3f/librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a", size = 221333, upload-time = "2026-04-09T16:05:40.999Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7c/ba54cd6aa6a3c8cd12757a6870e0c79a64b1e6327f5248dcff98423f4d43/librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f", size = 229051, upload-time = "2026-04-09T16:05:42.605Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4b/8cfdbad314c8677a0148bf0b70591d6d18587f9884d930276098a235461b/librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845", size = 222492, upload-time = "2026-04-09T16:05:43.842Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d1/2eda69563a1a88706808decdce035e4b32755dbfbb0d05e1a65db9547ed1/librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b", size = 223849, upload-time = "2026-04-09T16:05:45.054Z" }, - { url = "https://files.pythonhosted.org/packages/04/44/b2ed37df6be5b3d42cfe36318e0598e80843d5c6308dd63d0bf4e0ce5028/librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b", size = 245001, upload-time = "2026-04-09T16:05:46.34Z" }, - { url = "https://files.pythonhosted.org/packages/47/e7/617e412426df89169dd2a9ed0cc8752d5763336252c65dbf945199915119/librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9", size = 51799, upload-time = "2026-04-09T16:05:47.738Z" }, - { url = "https://files.pythonhosted.org/packages/24/ed/c22ca4db0ca3cbc285e4d9206108746beda561a9792289c3c31281d7e9df/librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e", size = 59165, upload-time = "2026-04-09T16:05:49.198Z" }, - { url = "https://files.pythonhosted.org/packages/24/56/875398fafa4cbc8f15b89366fc3287304ddd3314d861f182a4b87595ace0/librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f", size = 49292, upload-time = "2026-04-09T16:05:50.362Z" }, - { url = "https://files.pythonhosted.org/packages/4c/61/bc448ecbf9b2d69c5cff88fe41496b19ab2a1cbda0065e47d4d0d51c0867/librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4", size = 70175, upload-time = "2026-04-09T16:05:51.564Z" }, - { url = "https://files.pythonhosted.org/packages/60/f2/c47bb71069a73e2f04e70acbd196c1e5cc411578ac99039a224b98920fd4/librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228", size = 72951, upload-time = "2026-04-09T16:05:52.699Z" }, - { url = "https://files.pythonhosted.org/packages/29/19/0549df59060631732df758e8886d92088da5fdbedb35b80e4643664e8412/librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54", size = 225864, upload-time = "2026-04-09T16:05:53.895Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f8/3b144396d302ac08e50f89e64452c38db84bc7b23f6c60479c5d3abd303c/librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71", size = 241155, upload-time = "2026-04-09T16:05:55.191Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ce/ee67ec14581de4043e61d05786d2aed6c9b5338816b7859bcf07455c6a9f/librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938", size = 252235, upload-time = "2026-04-09T16:05:56.549Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fa/0ead15daa2b293a54101550b08d4bafe387b7d4a9fc6d2b985602bae69b6/librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3", size = 244963, upload-time = "2026-04-09T16:05:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/29/68/9fbf9a9aa704ba87689e40017e720aced8d9a4d2b46b82451d8142f91ec9/librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283", size = 257364, upload-time = "2026-04-09T16:05:59.686Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8d/9d60869f1b6716c762e45f66ed945b1e5dd649f7377684c3b176ae424648/librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee", size = 247661, upload-time = "2026-04-09T16:06:00.938Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/a5c365093962310bfdb4f6af256f191085078ffb529b3f0cbebb5b33ebe2/librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c", size = 248238, upload-time = "2026-04-09T16:06:02.537Z" }, - { url = "https://files.pythonhosted.org/packages/a0/3c/2d34365177f412c9e19c0a29f969d70f5343f27634b76b765a54d8b27705/librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15", size = 269457, upload-time = "2026-04-09T16:06:03.833Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/de45b239ea3bdf626f982a00c14bfcf2e12d261c510ba7db62c5969a27cd/librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40", size = 52453, upload-time = "2026-04-09T16:06:05.229Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f9/bfb32ae428aa75c0c533915622176f0a17d6da7b72b5a3c6363685914f70/librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118", size = 60044, upload-time = "2026-04-09T16:06:06.398Z" }, - { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/cb/c1945e506893b5b8577fb45a60c80e3ffe4a82092a04a6f29b0b951d9a24/librt-0.10.0.tar.gz", hash = "sha256:1aba1e8aa4e3307a7be68a74149545fde7451964dc0235a8bec5704a17bdda42", size = 191799, upload-time = "2026-05-05T16:31:23.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/8e/cbb5b6f6e45e65c10a42449a69eaccc44d73e6a081ea752fbc5221c6dc1c/librt-0.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b4b58a44b407e91f633dafee008de9ddea6aa2a555ed94929c099260910bd0ba", size = 77327, upload-time = "2026-05-05T16:29:38.919Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3d/8233cbee8e99e6a8992f02bfc2dec8d787509566a511d1fde2574ee7473f/librt-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:950b79b11762531bdf45a9df909d2f9a2a8445c70c88665c01d14c8511a27dc5", size = 79971, upload-time = "2026-05-05T16:29:40.96Z" }, + { url = "https://files.pythonhosted.org/packages/87/6f/5264b298cef2b72fc97d2dde56c66181eda35204bf5dcd1ed0c3d0a0a782/librt-0.10.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4538453f51be197633b425912c150e25b0667252d3741c53e8368176d98d9d37", size = 246559, upload-time = "2026-05-05T16:29:42.701Z" }, + { url = "https://files.pythonhosted.org/packages/07/7b/19b1b859cc60d5f99276cc2b3144d91556c6d1b1e4ebb50359696bebf7a8/librt-0.10.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:70b955f091beac93e994a0b7ec616934f63b3ea5c3d6d7af847562f935aceca7", size = 235216, upload-time = "2026-05-05T16:29:44.193Z" }, + { url = "https://files.pythonhosted.org/packages/6e/56/a2f40717142a8af46289f57874ef914353d8faccd5e4f8e594ab1e16e8c7/librt-0.10.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:483e685e06b6163728ba6c85d74315176be7190f432ec2a41226e5e14355d5f0", size = 263108, upload-time = "2026-05-05T16:29:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/ca/15c625c3bdc0167c01e04ef8878317e9713f3bfa788438342f7a94c7b22c/librt-0.10.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ac53d946a009d1a38c44a60812708c9458fb2a239a5f630d8e625571386650f", size = 255280, upload-time = "2026-05-05T16:29:48.087Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/ba301d571d9e05844e2435b73aba30bee77bb75ce155c9affcfd2173dd03/librt-0.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc8771c9fcf0ea894ca41fdc2abd83572c2fbda221f232d86e718614e57ff513", size = 268829, upload-time = "2026-05-05T16:29:49.628Z" }, + { url = "https://files.pythonhosted.org/packages/8b/60/af70e135bc1f1fe15dd3894b1e4bbefc7ecdf911749a925a39eb86ceb2a1/librt-0.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:70805dbc5257892ac572f86290a61e3c8d90224ecce1a8b2d1f7ed51965417f4", size = 262051, upload-time = "2026-05-05T16:29:51.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/c2/c8236eb8b421bac5a172ba208f965abaa89805da2a3fa112bdf1764caf8f/librt-0.10.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d3b4f300f7bcba6e2ff73fb8bef1898479e9772bfa2682998c636391633ec826", size = 264347, upload-time = "2026-05-05T16:29:53.013Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/15b6d32bc25dacd4a60886a683d8128d6219910c122202b995a40dd4f8d2/librt-0.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:943bc943f92f4fb3408fae62485c6a3ad68ce4f2ee205643a39641525c19a276", size = 286482, upload-time = "2026-05-05T16:29:54.675Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8e/b1b959bacd323eb4360579db992513e1406d1c6ef7edb57b5511fd0666fd/librt-0.10.0-cp312-cp312-win32.whl", hash = "sha256:6065c1a758fba1010b41401013903d3d5d2750eab425ddedd584abac31d0630e", size = 62955, upload-time = "2026-05-05T16:29:56.39Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4c/d4cd6e4b9fc24098e63cc85537d1b6689682aee96809c38f08072067cc2b/librt-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:d788ecbe208ab352dab0e105cc06057bf9a2fc7e58cabb0d751ad9e30062b9e2", size = 71191, upload-time = "2026-05-05T16:29:57.682Z" }, + { url = "https://files.pythonhosted.org/packages/2b/19/8641da1f63d24b92354a492f893c022d6b3a0df44e70c8eff49364613983/librt-0.10.0-cp312-cp312-win_arm64.whl", hash = "sha256:6003d1f295bdba02656dc81308208fc060d0a51d8c0d0a6db70f7f3c57b9ba0a", size = 61432, upload-time = "2026-05-05T16:29:58.971Z" }, + { url = "https://files.pythonhosted.org/packages/e5/29/681a75c82f4cc90d29e4b257a3299b79fe13fe927a04c57b8109d70b6957/librt-0.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0ede79d682e73f91c1b599a76d78b7464b9b5d213754cedb13372d9df36e596", size = 77299, upload-time = "2026-05-05T16:30:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/62/24/0c7ca445a55d04be79cac19819437fd094782347fa116f6681844fa6143e/librt-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0ba0b131fdb336c8b9c948e397f4a7e649d0f783b529f07b647bf4961df392e", size = 79930, upload-time = "2026-05-05T16:30:01.555Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/1e2b8f6443ef9e9a81e89486ca70e22f3684f93db003ce6eaefc3d0839b9/librt-0.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2728117da2afb96fb957768725ee43dc9a2d73b031e02da424b818a3cdd3a275", size = 246195, upload-time = "2026-05-05T16:30:03.261Z" }, + { url = "https://files.pythonhosted.org/packages/74/61/9dc9e03de0439ad84c1c240aac8b747f12c90cb797ea6042f7bdb8d3410f/librt-0.10.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:723ba80594c49cdf0584196fc430752262605dc9449902fc9bd3d9b79976cb77", size = 234951, upload-time = "2026-05-05T16:30:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/635223117d7590875bca441275065a3bf491203ad4208bd1cc3ffd90c5a1/librt-0.10.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7292edaaca294a61a978c53a3c7d6130d099b0dfbc8f0a65916cdc6b891b9852", size = 262768, upload-time = "2026-05-05T16:30:06.638Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/b04152d0cd8b6ca2b428a8bd3230343230c35ed304a932f35b5375f2f828/librt-0.10.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89fe9d539f2c10a1666633eeeac507ce95dd06d9ecc58de3c6390dba156a3d3a", size = 255075, upload-time = "2026-05-05T16:30:08.216Z" }, + { url = "https://files.pythonhosted.org/packages/35/1e/25bac4c7f2ca36f0e612cade186970683cf79153d96beccc3a11a9e19b97/librt-0.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4efa7b9587503fa5b67f40593302b9c8836d211d222ff9f7cafe67be5f8f0b10", size = 268559, upload-time = "2026-05-05T16:30:10.1Z" }, + { url = "https://files.pythonhosted.org/packages/18/54/4601faab35b6632a13200faa146ca62bfd111ffbe2568be430d65c89493a/librt-0.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:22dc982ef59df0136df36092ccbdbb570ced8aafb33e49585739b2f1de1c13b6", size = 261753, upload-time = "2026-05-05T16:30:11.912Z" }, + { url = "https://files.pythonhosted.org/packages/1b/cf/39f4023509e94fade8b074666fa3292db9cb6b34ea5dcbe7af53df9fca1d/librt-0.10.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6f2e5f3606253a84cea719c94a3bb1c54487b5d617d0254d46e0920d8a06be3f", size = 264055, upload-time = "2026-05-05T16:30:13.465Z" }, + { url = "https://files.pythonhosted.org/packages/8e/00/40247209fc46a8e308a91412d5206aedf8efb667ee89eb625820106a5c2f/librt-0.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:40884bfaa1e29f6b6a9be255007d8f359bfc9e61d68bdef8ed3158bfcbc95df9", size = 286190, upload-time = "2026-05-05T16:30:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/d8/6e/5566beb94431a985abe1787af5ef86e087750172ff9d0bbf20f93e88132d/librt-0.10.0-cp313-cp313-win32.whl", hash = "sha256:3cd34cd8254eba756660bff6c2da91278248184301054fe3e4feb073bdd49b14", size = 62949, upload-time = "2026-05-05T16:30:16.503Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c2/3ea3301d6c8dff51d39dbe8ed75db3dc92896947d4afb5eeadf821c1e67f/librt-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:7baac5313e2d8dce1386f97777a8d03ab28f5fe1e780b3b9ac2ee7544551fedc", size = 71152, upload-time = "2026-05-05T16:30:17.766Z" }, + { url = "https://files.pythonhosted.org/packages/3c/de/5d49cb92cadcbc77d3abc27b93fd6030ed8437487dde2eae38cab5e6704d/librt-0.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:afc5b4406c8e2515698d922a5c7823a009312835ea58196671fff40e35cb8166", size = 61336, upload-time = "2026-05-05T16:30:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/7165e08108cc185a13a9c069f0685e6ef92e70e07fddf7edf5e7348c6316/librt-0.10.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f09588a30e6a22ec624090d72a3ab1a6d4d5485c3ed739603e76aa3c16efa688", size = 76794, upload-time = "2026-05-05T16:30:20.392Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ef/bf8613febf651b90c5222ee79dea5ae58d4cc2b544df69d3033424448934/librt-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:131ade118d12bd7a0adc4e655474a553f1b76cf78385868885944d21d51e45e0", size = 79662, upload-time = "2026-05-05T16:30:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/b6/67/9eddd165c1d8397bdf99b38bf12b5a55b3def5035b49eedb49f2775d1430/librt-0.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8b9ab28e40d011c373a189eae900c916e66d6fbecf7983e9e4883089ee085ef", size = 242390, upload-time = "2026-05-05T16:30:23.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/d1/d95da80334501866cd37004ab5d7483220d05862fab4b5405394f0264f0d/librt-0.10.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:67c39bb30da73bae1f293d1ed8bc2f8f6642649dd0928d3600aeff3041ac23d6", size = 232603, upload-time = "2026-05-05T16:30:25.198Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fa/e6d64d28718bc1be4e1736fcb037ca1c4dfca927e7167df75a7d5215665e/librt-0.10.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c3273c6b774614f093c8927c2bf1b077d0fefde988fe98f46a333734e5597ab", size = 259187, upload-time = "2026-05-05T16:30:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/72/3f/3fdb77e7f937dad59cfd76b720be7e7643400ec76b2da35befab8d66ba30/librt-0.10.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9dd7c1b86a4baa583ab5db977484b93a2c474e69e96ef3e9538387ea54229cb9", size = 251846, upload-time = "2026-05-05T16:30:28.56Z" }, + { url = "https://files.pythonhosted.org/packages/18/ca/f4d49133dd86a6f55d79eca30bf412fa722f511a9abe67f62f57aa64e66a/librt-0.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a77385c5a202e831149f7ad03be9e67cf80e957e52c614e83dcb822c95222eb8", size = 264936, upload-time = "2026-05-05T16:30:30.491Z" }, + { url = "https://files.pythonhosted.org/packages/de/66/a8df2fbadc1f6c1827a096d11c40175bd526133480bd3bc88ec64a03d257/librt-0.10.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c6a5eafa74b5655bad59886138ed68426f098a6beb8cb95a71f2cc3cd8bb33fe", size = 258699, upload-time = "2026-05-05T16:30:32.002Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/1e3c83613fe05451bb969e27b68a573d177f08d5f63533cc29fec0989658/librt-0.10.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1fc93d0439204c50ab4d1512611ce2c206f1b369b419f69c7c27c761561e3291", size = 259825, upload-time = "2026-05-05T16:30:35.077Z" }, + { url = "https://files.pythonhosted.org/packages/09/24/5e2f926ee9d3ef348d9339526d7062abb5c44d8419e3179528c01d78c102/librt-0.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:79e713c178bc7a744adfbee6b4619a288eecc0c914da2a9313a20255abe2f0cf", size = 282548, upload-time = "2026-05-05T16:30:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7d/3e89ed6ad0162561fa8bef9df3195e24263104c955713cd0237d3711fad2/librt-0.10.0-cp314-cp314-win32.whl", hash = "sha256:2eba9d955a68c41d9f326be3da42f163ec3518b7ab20f1c826224e7bed71e0bf", size = 58970, upload-time = "2026-05-05T16:30:38.183Z" }, + { url = "https://files.pythonhosted.org/packages/76/25/579e731c94a7086a268bfa3e7a4945cd47836bebd3cbf3faeafd2e7eaef9/librt-0.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbfaf7f5145e9917f5d18bffa298eff6a19d74e7b8b11dabdca95785befe8dbf", size = 67260, upload-time = "2026-05-05T16:30:39.804Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f8/235822b7ae0b2334f12ee18bcf2476d07924077a5efeea57dbe927704be2/librt-0.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:8d6d385d1969849a6b1397114df22714b6ded917bada98668e3e974dc663477e", size = 57156, upload-time = "2026-05-05T16:30:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e3/9b919cbf1e8eb770bf91bb7df28125e0f1daf4587169afefd95402636e9a/librt-0.10.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:6c3a82d3bd32631ef5c79922dfc028520c9ad840255979ab4d908271818039ee", size = 79150, upload-time = "2026-05-05T16:30:42.761Z" }, + { url = "https://files.pythonhosted.org/packages/6a/f5/72a944aa3bc3498169a168087eff58ca48b58bf1b704e59d091fd30739f3/librt-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d64cc66005dc324c9bb1fa3fc2841f529002f6eb15966d55e46d430f56955a6a", size = 82304, upload-time = "2026-05-05T16:30:44.082Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e3/fcc290a33e295019759472dfa794d204e43504b276ac65eab7fd9da20ea3/librt-0.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb562cd28c88cd2c6a9a6c78f99dc39348d6b16c94adc25de0e574acf1176e9", size = 272556, upload-time = "2026-05-05T16:30:45.497Z" }, + { url = "https://files.pythonhosted.org/packages/fd/54/546975e4c997573885e7f040a05012f8838e06fb12b0c3c1fbb76254e9d7/librt-0.10.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b809aa2854d019c28773b03605df22adc675ee4f3f4402d673581313e8906119", size = 256941, upload-time = "2026-05-05T16:30:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f1d03401571b331653acddbd4e8cd955c06d945241dd08b25192fac0d04b/librt-0.10.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc15acabdd519bd4176fdadc2119e5e3093485d86f89138daf47e5b4cedb983a", size = 285855, upload-time = "2026-05-05T16:30:48.86Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/62cf80ff046c339faf56718b3a940244d4beb70f1c6407289b5830ec11e9/librt-0.10.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b1b2d835307d08ddadd94568e2369648ec9173bd3eea6d7f52a1abe717c81f98", size = 275321, upload-time = "2026-05-05T16:30:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ea/da5918d4070362e9a4d2ee9cd34f9dc84902daad8fd4275f8504a727ff4e/librt-0.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d261c6a2f93335a5167887fb0223e8b98ffce20ee3fde242e8e58a37ece6d0e5", size = 293993, upload-time = "2026-05-05T16:30:52.577Z" }, + { url = "https://files.pythonhosted.org/packages/c9/8d/68b6086bed1fcdc314c640ea04e31e52d18052e08059fa595409d66a51a9/librt-0.10.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e2ffd44963f8e7f68995504d90f9881d64e94dc1d8e310039b9526108fc0c0f7", size = 284254, upload-time = "2026-05-05T16:30:55.086Z" }, + { url = "https://files.pythonhosted.org/packages/06/c8/b810f1d84ec34a5a7ed93d7b510ab04164d75fbdf23088d5c3fbe6b08357/librt-0.10.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f285f6455ed495791c4d8630e5af732960adea93cac4c893d15619f2eae53e8", size = 284925, upload-time = "2026-05-05T16:30:56.728Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/3c82d4158c5a2c62528b8fccce65a8c9ad700e480e86f9389387435089a5/librt-0.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6034ff52e663d34c7b82ef2aa2f94ad7c1d939e2368e63b06844bc4d127d2e1", size = 307830, upload-time = "2026-05-05T16:30:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/99/3a/9c635ac3e8a00383ff689161d3eac8a30b3b2ddc711b40471e6b8983ea29/librt-0.10.0-cp314-cp314t-win32.whl", hash = "sha256:657860fd877fba6a241ea088ef99f63ca819945d3c715265da670bad56c37ebe", size = 60147, upload-time = "2026-05-05T16:31:00.293Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e8/6f65f3e565d4ac212cddddd552eacc8035ffdf941ca0ad6fe945a211d41f/librt-0.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:56ded2d66010203a0cb5af063b609e3f079531a0e5e576d618dece859fd2e1af", size = 68649, upload-time = "2026-05-05T16:31:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/51/78/a0705a67cacd81e5fa01a5035b3adbdfbb43a7b8d4bd27e2b282ae61baf2/librt-0.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1ee63f30abf18ed4830fdbaf87b2b6f4bba1e198d46085c314edde4045e56715", size = 58247, upload-time = "2026-05-05T16:31:03.191Z" }, ] [[package]] @@ -1361,11 +1362,11 @@ wheels = [ [[package]] name = "mistune" -version = "3.2.0" +version = "3.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/84/620cc3f7e3adf6f5067e10f4dbae71295d8f9e16d5d3f9ef97c40f2f592c/mistune-3.2.1.tar.gz", hash = "sha256:7c8e5501d38bac1582e067e46c8343f17d57ea1aaa735823f3aba1fd59c88a28", size = 98003, upload-time = "2026-05-03T14:33:22.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" }, ] [[package]] @@ -1495,7 +1496,7 @@ wheels = [ [[package]] name = "notebook" -version = "7.5.5" +version = "7.5.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jupyter-server" }, @@ -1504,9 +1505,9 @@ dependencies = [ { name = "notebook-shim" }, { name = "tornado" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/6d/41052c48d6f6349ca0a7c4d1f6a78464de135e6d18f5829ba2510e62184c/notebook-7.5.5.tar.gz", hash = "sha256:dc0bfab0f2372c8278c457423d3256c34154ac2cc76bf20e9925260c461013c3", size = 14169167, upload-time = "2026-03-11T16:32:51.922Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/c2/cf59bd2e6f2c8b976b52477e3e53bf6f97bc714ed046a51821afb428eaee/notebook-7.5.6.tar.gz", hash = "sha256:621174aade80108f0020b0f00738000b215f75fa3cd90771ad7aa0f24536a4e1", size = 14170814, upload-time = "2026-04-30T11:46:26.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/aa/cbd1deb9f07446241e88f8d5fecccd95b249bca0b4e5482214a4d1714c49/notebook-7.5.5-py3-none-any.whl", hash = "sha256:a7c14dbeefa6592e87f72290ca982e0c10f5bbf3786be2a600fda9da2764a2b8", size = 14578929, upload-time = "2026-03-11T16:32:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d6/1fd0646b9bbd9efbb0b8ae21b2325fbef515769a5621c03e31d8eb8da587/notebook-7.5.6-py3-none-any.whl", hash = "sha256:4dde3f8fb55fa8fb7946d58c6e869ce9baf46d00fc070664f62604569d0faca0", size = 14581730, upload-time = "2026-04-30T11:46:22.342Z" }, ] [[package]] @@ -1523,11 +1524,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.0" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] @@ -1541,20 +1542,20 @@ wheels = [ [[package]] name = "parso" -version = "0.8.6" +version = "0.8.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, ] [[package]] name = "pathspec" -version = "1.0.4" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] @@ -1658,27 +1659,27 @@ wheels = [ [[package]] name = "plum-dispatch" -version = "2.8.0" +version = "2.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beartype" }, { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/cf/2f2b0dd84edbb2951a845ba98d29f651bbbd467039c368fcfa22904905cd/plum_dispatch-2.8.0.tar.gz", hash = "sha256:453fc7bc67d2a39492c834b00c94d816871d148b5a0a5d3f49e2bbf2391e2619", size = 241272, upload-time = "2026-03-17T21:24:41.338Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/b7/84146ae5ff6c40d11357acdb36aafe3db7e104de01c1026d8e1b0ce3e7f1/plum_dispatch-2.9.0.tar.gz", hash = "sha256:fb45c5b2dd4dadd57def51bcf321dfa3a258df5c725f43adea7e7f6db3b79b52", size = 244502, upload-time = "2026-04-28T08:38:34.442Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/a3/cdb8130cc05a13c75237d2be4821f38c0b99d1a64980b6529d788a43a501/plum_dispatch-2.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fca6b6dbed8c686f6f827e9c95f60334ba70ee27cccef1f44a50d42a30221748", size = 165288, upload-time = "2026-03-17T21:24:31.203Z" }, - { url = "https://files.pythonhosted.org/packages/25/fe/b1dbbdd1864d4ca825b3b5079fca852ea5e5a4f223aa866702f751b02dba/plum_dispatch-2.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:599025e7bd7e16f198e5ccaad0ca51036fcbeb43eb41525a5ef705b9a25bb192", size = 194360, upload-time = "2026-03-17T21:24:32.531Z" }, - { url = "https://files.pythonhosted.org/packages/6b/01/a8947d5c92824a6bc019696e5d9f150178736ecd8e6766db38b38e0f0fda/plum_dispatch-2.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0e6146881b71eb6a6355d18476bed356071d1dff45a7cf4ae31f7f4bffb01b9", size = 146826, upload-time = "2026-03-17T21:24:34.076Z" }, - { url = "https://files.pythonhosted.org/packages/68/25/380300b3468417999ca0db0522eeae1017798519fe28753e8ad110718174/plum_dispatch-2.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d60c0040207495afddab3f80c498a5e8f9dab29f8d143468c51d668fe70ed291", size = 165256, upload-time = "2026-03-17T21:24:35.341Z" }, - { url = "https://files.pythonhosted.org/packages/59/2f/94e4e62ad437996341d03471bd398f3f6bf4c5f3a1fa524ba53f02b6896d/plum_dispatch-2.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88aaf08a562e7a559851679a051b32306b67fd403ed6070915a966f048a83e9", size = 193632, upload-time = "2026-03-17T21:24:37.035Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d2/772d1e3071971a30e70b78df421f51788354337a7f94957d910ba3cd8a5a/plum_dispatch-2.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:53bc084b335e5071b72353830a9f2cdacd58c9e959089be5778b6d638b5062f9", size = 146931, upload-time = "2026-03-17T21:24:38.8Z" }, - { url = "https://files.pythonhosted.org/packages/4b/7d/6d1a0a5851fb590564045471bdd6f405426ae4214cafe530ceba297e5c0d/plum_dispatch-2.8.0-py3-none-any.whl", hash = "sha256:f5dfffc46de1d8208ba281fdb0f41d6065c7341c1fde76fc83b4b78810c30c7e", size = 44547, upload-time = "2026-03-17T21:24:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/82/d6/fc6591336731b5e291319ec3c43d81cd6cc7940c9079183b333b85ed4d77/plum_dispatch-2.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:39a325b0b041fad687271d71abc11b5271d5a3b62af2c6b1271f4ececc3b59de", size = 175166, upload-time = "2026-04-28T08:38:24.467Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4d/5418649397b477ae08839564c97596ede5ef36523147899bb913bbe959d1/plum_dispatch-2.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d39b20a1af776a39e4a0833e97c6258d938e5d874c7f3537bb7d772c39718c1e", size = 205988, upload-time = "2026-04-28T08:38:25.838Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a5/28a6c4e45c4465a6e1a30d65206e913c217690752dc3c9261d78a9187aed/plum_dispatch-2.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:beb9b92c6994404a0d2dc83c857263f5da49519575e5cbb8a13060652746c44c", size = 152563, upload-time = "2026-04-28T08:38:27.37Z" }, + { url = "https://files.pythonhosted.org/packages/cb/18/19e01dbbaececb75815abb37bc71e10d87a7561829b4eae47ec3b342c94f/plum_dispatch-2.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78987caf558fbdd675a7a4a5c144069f1f9524610195123fb7d0f0234d0af01a", size = 174490, upload-time = "2026-04-28T08:38:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/fc/29/7b3de57cd86430fd00d71bc5a8a078bf2c63d4ada7fdfb30853fe976a19f/plum_dispatch-2.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36662d76ae6d87aaa041fade036d2242f0b88b68c9fb4c96b7ba0026320065d3", size = 204734, upload-time = "2026-04-28T08:38:30.398Z" }, + { url = "https://files.pythonhosted.org/packages/ba/98/23d19326af0da251bb98a39f517ab6da983e5aad5eb9cc398b60933df2cc/plum_dispatch-2.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0d16daf09482a6588025e9f133a338d99474e8dd3c3dbbc4c5282a4fffe5a0e", size = 152624, upload-time = "2026-04-28T08:38:31.886Z" }, + { url = "https://files.pythonhosted.org/packages/64/a7/ee4d01d26032b060d379f44a29124180f756d907e3840d3bc450f8a0d2a7/plum_dispatch-2.9.0-py3-none-any.whl", hash = "sha256:5a516cdac5460343937a2b813562ac00b0eeac973ac023916e538610bdf34397", size = 45328, upload-time = "2026-04-28T08:38:33.022Z" }, ] [[package]] name = "pre-commit" -version = "4.5.1" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -1687,9 +1688,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] @@ -1770,7 +1771,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.0" +version = "2.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1778,84 +1779,84 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/6b/69fd5c7194b21ebde0f8637e2a4ddc766ada29d472bfa6a5ca533d79549a/pydantic-2.13.0.tar.gz", hash = "sha256:b89b575b6e670ebf6e7448c01b41b244f471edd276cd0b6fe02e7e7aca320070", size = 843468, upload-time = "2026-04-13T10:51:35.571Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/d7/c3a52c61f5b7be648e919005820fbac33028c6149994cd64453f49951c17/pydantic-2.13.0-py3-none-any.whl", hash = "sha256:ab0078b90da5f3e2fd2e71e3d9b457ddcb35d0350854fbda93b451e28d56baaf", size = 471872, upload-time = "2026-04-13T10:51:33.343Z" }, + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, ] [[package]] name = "pydantic-core" -version = "2.46.0" +version = "2.46.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/0a/9414cddf82eda3976b14048cc0fa8f5b5d1aecb0b22e1dcd2dbfe0e139b1/pydantic_core-2.46.0.tar.gz", hash = "sha256:82d2498c96be47b47e903e1378d1d0f770097ec56ea953322f39936a7cf34977", size = 471441, upload-time = "2026-04-13T09:06:33.813Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/d2/206c72ad47071559142a35f71efc29eb16448a4a5ae9487230ab8e4e292b/pydantic_core-2.46.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:66ccedb02c934622612448489824955838a221b3a35875458970521ef17b2f9c", size = 2117060, upload-time = "2026-04-13T09:04:47.443Z" }, - { url = "https://files.pythonhosted.org/packages/17/2c/7a53b33f91c8b77e696b1a6aa3bed609bf9374bdc0f8dcda681bc7d922b8/pydantic_core-2.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a44f27f4d2788ef9876ec47a43739b118c5904d74f418f53398f6ced3bbcacf2", size = 1951802, upload-time = "2026-04-13T09:05:34.591Z" }, - { url = "https://files.pythonhosted.org/packages/fc/20/90e548c1f6d38800ef11c915881525770ce270d8e5e887563ff046a08674/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26a1032bcce6ca4b4670eb3f7d8195bd0a8b8f255f1307823e217ca3cfa7c27", size = 1976621, upload-time = "2026-04-13T09:04:03.909Z" }, - { url = "https://files.pythonhosted.org/packages/20/3c/9c5810ca70b60c623488cdd80f7e9ee1a0812df81e97098b64788719860f/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b8d1412f725060527e56675904b17a2d421dddcf861eecf7c75b9dda47921a4", size = 2056721, upload-time = "2026-04-13T09:04:40.992Z" }, - { url = "https://files.pythonhosted.org/packages/1a/a3/d6e5f4cdec84278431c75540f90838c9d0a4dfe9402a8f3902073660ff28/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc3d1569edd859cabaa476cabce9eecd05049a7966af7b4a33b541bfd4ca1104", size = 2239634, upload-time = "2026-04-13T09:03:52.478Z" }, - { url = "https://files.pythonhosted.org/packages/46/42/ef58aacf330d8de6e309d62469aa1f80e945eaf665929b4037ac1bfcebc1/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38108976f2d8afaa8f5067fd1390a8c9f5cc580175407cda636e76bc76e88054", size = 2315739, upload-time = "2026-04-13T09:05:04.971Z" }, - { url = "https://files.pythonhosted.org/packages/8b/86/c63b12fafa2d86a515bfd1840b39c23a49302f02b653161bf9c3a0566c50/pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5a06d8ed01dad5575056b5187e5959b336793c6047920a3441ee5b03533836", size = 2098169, upload-time = "2026-04-13T09:07:27.151Z" }, - { url = "https://files.pythonhosted.org/packages/76/19/b5b33a2f6be4755b21a20434293c4364be255f4c1a108f125d101d4cc4ee/pydantic_core-2.46.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:04017ace142da9ce27cafd423a480872571b5c7e80382aec22f7d715ca8eb870", size = 2170830, upload-time = "2026-04-13T09:04:39.448Z" }, - { url = "https://files.pythonhosted.org/packages/99/ae/7559f99a29b7d440012ddb4da897359304988a881efaca912fd2f655652e/pydantic_core-2.46.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2629ad992ed1b1c012e6067f5ffafd3336fcb9b54569449fabb85621f1444ed3", size = 2203901, upload-time = "2026-04-13T09:04:01.048Z" }, - { url = "https://files.pythonhosted.org/packages/dd/0e/b0ef945a39aeb4ac58da316813e1106b7fbdfbf20ac141c1c27904355ac5/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3068b1e7bd986aebc88f6859f8353e72072538dcf92a7fb9cf511a0f61c5e729", size = 2191789, upload-time = "2026-04-13T09:06:39.915Z" }, - { url = "https://files.pythonhosted.org/packages/90/f4/830484e07188c1236b013995818888ab93bab8fd88aa9689b1d8fd22220d/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:1e366916ff69ff700aa9326601634e688581bc24c5b6b4f8738d809ec7d72611", size = 2344423, upload-time = "2026-04-13T09:05:12.252Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ba/e455c18cbdc333177af754e740be4fe9d1de173d65bbe534daf88da02ac0/pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:485a23e8f4618a1b8e23ac744180acde283fffe617f96923d25507d5cade62ec", size = 2384037, upload-time = "2026-04-13T09:06:24.503Z" }, - { url = "https://files.pythonhosted.org/packages/78/1f/b35d20d73144a41e78de0ae398e60fdd8bed91667daa1a5a92ab958551ba/pydantic_core-2.46.0-cp312-cp312-win32.whl", hash = "sha256:520940e1b702fe3b33525d0351777f25e9924f1818ca7956447dabacf2d339fd", size = 1967068, upload-time = "2026-04-13T09:05:23.374Z" }, - { url = "https://files.pythonhosted.org/packages/d1/84/4b6252e9606e8295647b848233cc4137ee0a04ebba8f0f9fb2977655b38c/pydantic_core-2.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:90d2048e0339fa365e5a66aefe760ddd3b3d0a45501e088bc5bc7f4ed9ff9571", size = 2071008, upload-time = "2026-04-13T09:05:21.392Z" }, - { url = "https://files.pythonhosted.org/packages/39/95/d08eb508d4d5560ccbd226ee5971e5ef9b749aba9b413c0c4ed6e406d4f6/pydantic_core-2.46.0-cp312-cp312-win_arm64.whl", hash = "sha256:a70247649b7dffe36648e8f34be5ce8c5fa0a27ff07b071ea780c20a738c05ce", size = 2036634, upload-time = "2026-04-13T09:05:48.299Z" }, - { url = "https://files.pythonhosted.org/packages/df/05/ab3b0742bad1d51822f1af0c4232208408902bdcfc47601f3b812e09e6c2/pydantic_core-2.46.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a05900c37264c070c683c650cbca8f83d7cbb549719e645fcd81a24592eac788", size = 2116814, upload-time = "2026-04-13T09:04:12.41Z" }, - { url = "https://files.pythonhosted.org/packages/98/08/30b43d9569d69094a0899a199711c43aa58fce6ce80f6a8f7693673eb995/pydantic_core-2.46.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de8e482fd4f1e3f36c50c6aac46d044462615d8f12cfafc6bebeaa0909eea22", size = 1951867, upload-time = "2026-04-13T09:04:02.364Z" }, - { url = "https://files.pythonhosted.org/packages/db/a0/bf9a1ba34537c2ed3872a48195291138fdec8fe26c4009776f00d63cf0c8/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c525ecf8a4cdf198327b65030a7d081867ad8e60acb01a7214fff95cf9832d47", size = 1977040, upload-time = "2026-04-13T09:06:16.088Z" }, - { url = "https://files.pythonhosted.org/packages/71/70/0ba03c20e1e118219fc18c5417b008b7e880f0e3fb38560ec4465984d471/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f14581aeb12e61542ce73b9bfef2bca5439d65d9ab3efe1a4d8e346b61838f9b", size = 2055284, upload-time = "2026-04-13T09:05:25.125Z" }, - { url = "https://files.pythonhosted.org/packages/58/cf/1e320acefbde7fb7158a9e5def55e0adf9a4634636098ce28dc6b978e0d3/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c108067f2f7e190d0dbd81247d789ec41f9ea50ccd9265a3a46710796ac60530", size = 2238896, upload-time = "2026-04-13T09:05:01.345Z" }, - { url = "https://files.pythonhosted.org/packages/df/f5/ea8ba209756abe9eba891bb0ef3772b4c59a894eb9ad86cd5bd0dd4e3e52/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ac10967e9a7bb1b96697374513f9a1a90a59e2fb41566b5e00ee45392beac59", size = 2314353, upload-time = "2026-04-13T09:06:07.942Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f8/5885350203b72e96438eee7f94de0d8f0442f4627237ca8ef75de34db1cd/pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7897078fe8a13b73623c0955dfb2b3d2c9acb7177aac25144758c9e5a5265aaa", size = 2098522, upload-time = "2026-04-13T09:04:23.239Z" }, - { url = "https://files.pythonhosted.org/packages/bf/88/5930b0e828e371db5a556dd3189565417ddc3d8316bb001058168aadcf5f/pydantic_core-2.46.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:e69ce405510a419a082a78faed65bb4249cfb51232293cc675645c12f7379bf7", size = 2168757, upload-time = "2026-04-13T09:07:12.46Z" }, - { url = "https://files.pythonhosted.org/packages/da/75/63d563d3035a0548e721c38b5b69fd5626fdd51da0f09ff4467503915b82/pydantic_core-2.46.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd28d13eea0d8cf351dc1fe274b5070cc8e1cca2644381dee5f99de629e77cf3", size = 2202518, upload-time = "2026-04-13T09:05:44.418Z" }, - { url = "https://files.pythonhosted.org/packages/a7/53/1958eacbfddc41aadf5ae86dd85041bf054b675f34a2fa76385935f96070/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ee1547a6b8243e73dd10f585555e5a263395e55ce6dea618a078570a1e889aef", size = 2190148, upload-time = "2026-04-13T09:06:56.151Z" }, - { url = "https://files.pythonhosted.org/packages/c7/17/098cc6d3595e4623186f2bc6604a6195eb182e126702a90517236391e9ce/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c3dc68dcf62db22a18ddfc3ad4960038f72b75908edc48ae014d7ac8b391d57a", size = 2342925, upload-time = "2026-04-13T09:04:17.286Z" }, - { url = "https://files.pythonhosted.org/packages/71/a7/abdb924620b1ac535c690b36ad5b8871f376104090f8842c08625cecf1d3/pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:004a2081c881abfcc6854a4623da6a09090a0d7c1398a6ae7133ca1256cee70b", size = 2383167, upload-time = "2026-04-13T09:04:52.643Z" }, - { url = "https://files.pythonhosted.org/packages/d7/c9/2ddd10f50e4b7350d2574629a0f53d8d4eb6573f9c19a6b43e6b1487a31d/pydantic_core-2.46.0-cp313-cp313-win32.whl", hash = "sha256:59d24ec8d5eaabad93097525a69d0f00f2667cb353eb6cda578b1cfff203ceef", size = 1965660, upload-time = "2026-04-13T09:06:05.877Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e7/1efc38ed6f2680c032bcefa0e3ebd496a8c77e92dfdb86b07d0f2fc632b1/pydantic_core-2.46.0-cp313-cp313-win_amd64.whl", hash = "sha256:71186dad5ac325c64d68fe0e654e15fd79802e7cc42bc6f0ff822d5ad8b1ab25", size = 2069563, upload-time = "2026-04-13T09:07:14.738Z" }, - { url = "https://files.pythonhosted.org/packages/c3/1e/a325b4989e742bf7e72ed35fa124bc611fd76539c9f8cd2a9a7854473533/pydantic_core-2.46.0-cp313-cp313-win_arm64.whl", hash = "sha256:8e4503f3213f723842c9a3b53955c88a9cfbd0b288cbd1c1ae933aebeec4a1b4", size = 2034966, upload-time = "2026-04-13T09:04:21.629Z" }, - { url = "https://files.pythonhosted.org/packages/36/3b/914891d384cdbf9a6f464eb13713baa22ea1e453d4da80fb7da522079370/pydantic_core-2.46.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4fc801c290342350ffc82d77872054a934b2e24163727263362170c1db5416ca", size = 2113349, upload-time = "2026-04-13T09:04:59.407Z" }, - { url = "https://files.pythonhosted.org/packages/35/95/3a0c6f65e231709fb3463e32943c69d10285cb50203a2130a4732053a06d/pydantic_core-2.46.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0a36f2cc88170cc177930afcc633a8c15907ea68b59ac16bd180c2999d714940", size = 1949170, upload-time = "2026-04-13T09:06:09.935Z" }, - { url = "https://files.pythonhosted.org/packages/d1/63/d845c36a608469fe7bee226edeff0984c33dbfe7aecd755b0e7ab5a275c4/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a3912e0c568a1f99d4d6d3e41def40179d61424c0ca1c8c87c4877d7f6fd7fb", size = 1977914, upload-time = "2026-04-13T09:04:56.16Z" }, - { url = "https://files.pythonhosted.org/packages/08/6f/f2e7a7f85931fb31671f5378d1c7fc70606e4b36d59b1b48e1bd1ef5d916/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3534c3415ed1a19ab23096b628916a827f7858ec8db49ad5d7d1e44dc13c0d7b", size = 2050538, upload-time = "2026-04-13T09:05:06.789Z" }, - { url = "https://files.pythonhosted.org/packages/8c/97/f4aa7181dd9a16dd9059a99fc48fdab0c2aab68307283a5c04cf56de68c4/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21067396fc285609323a4db2f63a87570044abe0acddfcca8b135fc7948e3db7", size = 2236294, upload-time = "2026-04-13T09:07:03.2Z" }, - { url = "https://files.pythonhosted.org/packages/24/c1/6a5042fc32765c87101b500f394702890af04239c318b6002cfd627b710d/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2afd85b7be186e2fe7cdbb09a3d964bcc2042f65bbcc64ad800b3c7915032655", size = 2312954, upload-time = "2026-04-13T09:06:11.919Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e4/566101a561492ce8454f0844ca29c3b675a6b3a7b3ff577db85ed05c8c50/pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67e2c2e171b78db8154da602de72ffdc473c6ee51de8a9d80c0f1cd4051abfc7", size = 2102533, upload-time = "2026-04-13T09:06:58.664Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ac/adc11ee1646a5c4dd9abb09a00e7909e6dc25beddc0b1310ca734bb9b48e/pydantic_core-2.46.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c16ae1f3170267b1a37e16dba5c297bdf60c8b5657b147909ca8774ce7366644", size = 2169447, upload-time = "2026-04-13T09:04:11.143Z" }, - { url = "https://files.pythonhosted.org/packages/26/73/408e686b45b82d28ac19e8229e07282254dbee6a5d24c5c7cf3cf3716613/pydantic_core-2.46.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:133b69e1c1ba34d3702eed73f19f7f966928f9aa16663b55c2ebce0893cca42e", size = 2200672, upload-time = "2026-04-13T09:03:54.056Z" }, - { url = "https://files.pythonhosted.org/packages/0a/3b/807d5b035ec891b57b9079ce881f48263936c37bd0d154a056e7fd152afb/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:15ed8e5bde505133d96b41702f31f06829c46b05488211a5b1c7877e11de5eb5", size = 2188293, upload-time = "2026-04-13T09:07:07.614Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ed/719b307516285099d1196c52769fdbe676fd677da007b9c349ae70b7226d/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:8cfc29a1c66a7f0fcb36262e92f353dd0b9c4061d558fceb022e698a801cb8ae", size = 2335023, upload-time = "2026-04-13T09:04:05.176Z" }, - { url = "https://files.pythonhosted.org/packages/8d/90/8718e4ae98c4e8a7325afdc079be82be1e131d7a47cb6c098844a9531ffe/pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e1155708540f13845bf68d5ac511a55c76cfe2e057ed12b4bf3adac1581fc5c2", size = 2377155, upload-time = "2026-04-13T09:06:18.081Z" }, - { url = "https://files.pythonhosted.org/packages/dd/dc/7172789283b963f81da2fc92b186e22de55687019079f71c4d570822502b/pydantic_core-2.46.0-cp314-cp314-win32.whl", hash = "sha256:de5635a48df6b2eef161d10ea1bc2626153197333662ba4cd700ee7ec1aba7f5", size = 1963078, upload-time = "2026-04-13T09:05:30.615Z" }, - { url = "https://files.pythonhosted.org/packages/e0/69/03a7ea4b6264def3a44eabf577528bcec2f49468c5698b2044dea54dc07e/pydantic_core-2.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:f07a5af60c5e7cf53dd1ff734228bd72d0dc9938e64a75b5bb308ca350d9681e", size = 2068439, upload-time = "2026-04-13T09:04:57.729Z" }, - { url = "https://files.pythonhosted.org/packages/f5/eb/1c3afcfdee2ab6634b802ab0a0f1966df4c8b630028ec56a1cb0a710dc58/pydantic_core-2.46.0-cp314-cp314-win_arm64.whl", hash = "sha256:e7a77eca3c7d5108ff509db20aae6f80d47c7ed7516d8b96c387aacc42f3ce0f", size = 2026470, upload-time = "2026-04-13T09:05:08.654Z" }, - { url = "https://files.pythonhosted.org/packages/5c/30/1177dde61b200785c4739665e3aa03a9d4b2c25d2d0408b07d585e633965/pydantic_core-2.46.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5e7cdd4398bee1aaeafe049ac366b0f887451d9ae418fd8785219c13fea2f928", size = 2107447, upload-time = "2026-04-13T09:05:46.314Z" }, - { url = "https://files.pythonhosted.org/packages/b1/60/4e0f61f99bdabbbc309d364a2791e1ba31e778a4935bc43391a7bdec0744/pydantic_core-2.46.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5c2c92d82808e27cef3f7ab3ed63d657d0c755e0dbe5b8a58342e37bdf09bd2e", size = 1926927, upload-time = "2026-04-13T09:06:20.371Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d0/67f89a8269152c1d6eaa81f04e75a507372ebd8ca7382855a065222caa80/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bab80af91cd7014b45d1089303b5f844a9d91d7da60eabf3d5f9694b32a6655", size = 1966613, upload-time = "2026-04-13T09:07:05.389Z" }, - { url = "https://files.pythonhosted.org/packages/cd/07/8dfdc3edc78f29a80fb31f366c50203ec904cff6a4c923599bf50ac0d0ff/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e49ffdb714bc990f00b39d1ad1d683033875b5af15582f60c1f34ad3eeccfaa", size = 2032902, upload-time = "2026-04-13T09:06:42.47Z" }, - { url = "https://files.pythonhosted.org/packages/b0/2a/111c5e8fe24f99c46bcad7d3a82a8f6dbc738066e2c72c04c71f827d8c78/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca877240e8dbdeef3a66f751dc41e5a74893767d510c22a22fc5c0199844f0ce", size = 2244456, upload-time = "2026-04-13T09:05:36.484Z" }, - { url = "https://files.pythonhosted.org/packages/6b/7c/cfc5d11c15a63ece26e148572c77cfbb2c7f08d315a7b63ef0fe0711d753/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87e6843f89ecd2f596d7294e33196c61343186255b9880c4f1b725fde8b0e20d", size = 2294535, upload-time = "2026-04-13T09:06:01.689Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2c/f0d744e3dab7bd026a3f4670a97a295157cff923a2666d30a15a70a7e3d0/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e20bc5add1dd9bc3b9a3600d40632e679376569098345500799a6ad7c5d46c72", size = 2104621, upload-time = "2026-04-13T09:04:34.388Z" }, - { url = "https://files.pythonhosted.org/packages/a7/64/e7cc4698dc024264d214b51d5a47a2404221b12060dd537d76f831b2120a/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:ee6ff79a5f0289d64a9d6696a3ce1f98f925b803dd538335a118231e26d6d827", size = 2130718, upload-time = "2026-04-13T09:04:26.23Z" }, - { url = "https://files.pythonhosted.org/packages/0b/a8/224e655fec21f7d4441438ad2ecaccb33b5a3876ce7bb2098c74a49efc14/pydantic_core-2.46.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:52d35cfb58c26323101c7065508d7bb69bb56338cda9ea47a7b32be581af055d", size = 2180738, upload-time = "2026-04-13T09:05:50.253Z" }, - { url = "https://files.pythonhosted.org/packages/32/7b/b3025618ed4c4e4cbaa9882731c19625db6669896b621760ea95bc1125ef/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d14cc5a6f260fa78e124061eebc5769af6534fc837e9a62a47f09a2c341fa4ea", size = 2171222, upload-time = "2026-04-13T09:07:29.929Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e3/68170aa1d891920af09c1f2f34df61dc5ff3a746400027155523e3400e89/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:4f7ff859d663b6635f6307a10803d07f0d09487e16c3d36b1744af51dbf948b2", size = 2320040, upload-time = "2026-04-13T09:06:35.732Z" }, - { url = "https://files.pythonhosted.org/packages/67/1b/5e65807001b84972476300c1f49aea2b4971b7e9fffb5c2654877dadd274/pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8ef749be6ed0d69dba31902aaa8255a9bb269ae50c93888c4df242d8bb7acd9e", size = 2377062, upload-time = "2026-04-13T09:07:39.945Z" }, - { url = "https://files.pythonhosted.org/packages/75/03/48caa9dd5f28f7662bd52bff454d9a451f6b7e5e4af95e289e5e170749c9/pydantic_core-2.46.0-cp314-cp314t-win32.whl", hash = "sha256:d93ca72870133f86360e4bb0c78cd4e6ba2a0f9f3738a6486909ffc031463b32", size = 1951028, upload-time = "2026-04-13T09:04:20.224Z" }, - { url = "https://files.pythonhosted.org/packages/87/ed/e97ff55fe28c0e6e3cba641d622b15e071370b70e5f07c496b07b65db7c9/pydantic_core-2.46.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ebb2668afd657e2127cb40f2ceb627dd78e74e9dfde14d9bf6cdd532a29ff59", size = 2048519, upload-time = "2026-04-13T09:05:10.464Z" }, - { url = "https://files.pythonhosted.org/packages/b6/51/e0db8267a287994546925f252e329eeae4121b1e77e76353418da5a3adf0/pydantic_core-2.46.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4864f5bbb7993845baf9209bae1669a8a76769296a018cb569ebda9dcb4241f5", size = 2026791, upload-time = "2026-04-13T09:04:37.724Z" }, - { url = "https://files.pythonhosted.org/packages/74/0c/106ed5cc50393d90523f09adcc50d05e42e748eb107dc06aea971137f02d/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:bc0e2fefe384152d7da85b5c2fe8ce2bf24752f68a58e3f3ea42e28a29dfdeb2", size = 2104968, upload-time = "2026-04-13T09:06:26.967Z" }, - { url = "https://files.pythonhosted.org/packages/f5/71/b494cef3165e3413ee9bbbb5a9eedc9af0ea7b88d8638beef6c2061b110e/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a2ab0e785548be1b4362a62c4004f9217598b7ee465f1f420fc2123e2a5b5b02", size = 1940442, upload-time = "2026-04-13T09:06:29.332Z" }, - { url = "https://files.pythonhosted.org/packages/7e/3e/a4d578c8216c443e26a1124f8c1e07c0654264ce5651143d3883d85ff140/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16d45aecb18b8cba1c68eeb17c2bb2d38627ceed04c5b30b882fc9134e01f187", size = 1999672, upload-time = "2026-04-13T09:04:42.798Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c1/9114560468685525a21770138382fd0cb849aaf351ff2c7b97f760d121e0/pydantic_core-2.46.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5078f6c377b002428e984259ac327ef8902aacae6c14b7de740dd4869a491501", size = 2154533, upload-time = "2026-04-13T09:04:50.868Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, + { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, + { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, ] [[package]] @@ -1923,15 +1924,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.2.2" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/e0/cc5a8653e9a24f6cf84768f05064aa8ed5a83dcefd5e2a043db14a1c5f44/python_discovery-1.3.0.tar.gz", hash = "sha256:d098f1e86be5d45fe4d14bf1029294aabbd332f4321179dec85e76cddce834b0", size = 63925, upload-time = "2026-05-05T14:38:39.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/30/d4/24d543ab8b8158b7f5a97113c831205f5c900c92c8762b1e7f44b7ea0405/python_discovery-1.3.0-py3-none-any.whl", hash = "sha256:441d9ced3dfce36e113beb35ca302c71c7ef06f3c0f9c227a0b9bb3bd49b9e9f", size = 33124, upload-time = "2026-05-05T14:38:38.539Z" }, ] [[package]] @@ -2383,15 +2384,15 @@ dev = [ [[package]] name = "seedcase-soil" -version = "0.12.0" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cyclopts" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/0d/12a0eb4f8973aabe9236390f2640844a514a7369f99eea8f0a072295f1fb/seedcase_soil-0.12.0.tar.gz", hash = "sha256:8be8d78c7251acfd86d75bb248527ba8b78e04263285e0c7a9c659f999be4ebc", size = 1044701, upload-time = "2026-04-30T08:27:54.556Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/4b/fbad245a6d83c6f135a33674be528b94597cd48c61d2a1260a131f7fa642/seedcase_soil-0.13.0.tar.gz", hash = "sha256:6c69b35b35f26979a0e6e950a9b9a95345128fb8d967e54fdddc940a3744a80a", size = 1044803, upload-time = "2026-05-04T08:31:23.583Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/5c/619b9b60ce1c41e8dd38c4030fbeeedcab38cec48cad9c6cf8037204261b/seedcase_soil-0.12.0-py3-none-any.whl", hash = "sha256:fae6691931779ed52a8c9f791b99746ba40bc94bec1cc7efbaa988c2239a6773", size = 17801, upload-time = "2026-04-30T08:27:53.344Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ca/d131b714cfa764c4134e7ea8438b9164da6e91c5d662244ed5ae58ecf213/seedcase_soil-0.13.0-py3-none-any.whl", hash = "sha256:a4ac906a228389b7e99a80ea97db556762e92c49d7bad7d05dcf21655dfb9ec0", size = 17803, upload-time = "2026-05-04T08:31:22.704Z" }, ] [[package]] @@ -2556,11 +2557,11 @@ wheels = [ [[package]] name = "traitlets" -version = "5.14.3" +version = "5.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/22/40f55b26baeab80c2d7b3f1db0682f8954e4617fee7d90ce634022ef05c6/traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", size = 163197, upload-time = "2026-05-06T08:05:58.016Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, + { url = "https://files.pythonhosted.org/packages/da/98/a9937a969d018a23badfea0b381f66783649d48e0ea6c41923265c3cbeb3/traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40", size = 85877, upload-time = "2026-05-06T08:05:55.853Z" }, ] [[package]] @@ -2607,28 +2608,28 @@ wheels = [ [[package]] name = "typos" -version = "1.45.1" +version = "1.46.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/c2/0cd9200d030f8e3c71ac30bc0ee86736d9d7d0a9b02c1b050f40138c19c0/typos-1.45.1.tar.gz", hash = "sha256:a1ac7ab02e74d4c4a2f8525b1529e1ce6261051df3229701836175fb91bb0583", size = 1820481, upload-time = "2026-04-13T15:03:01.549Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/87/8769161e958d715ed00b6cc110452c1a3921009a239944b66279acb63898/typos-1.46.0.tar.gz", hash = "sha256:afcc399b3ef9aedc0b037de935ebdef60654a3cc616bc160aac14de7fb4d5592", size = 1822140, upload-time = "2026-04-30T16:20:18.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/49/e0e0614d635f4847de1c3464ee068be579dfe14a81dd9051fa6fc3653305/typos-1.45.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3cd3d7e7e35f971e04974c7b34563dc1efb101841be3a39fec36c51f3d6ca2d", size = 3480310, upload-time = "2026-04-13T15:02:43.19Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f5/b0fc73ab073eb844e888cf47c8fc1fab3b59eaf8becbb901b58e46ded7e2/typos-1.45.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:be6f26c580915e63df107f88bc766f131efe5f7d01d41c7bad83e6f9e5fe42be", size = 3381483, upload-time = "2026-04-13T15:02:45.415Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e1/1b1c8ff64d61143206e700fe4ae6732749053e44d10a33d9da1eceecbadc/typos-1.45.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cd6a6ccbb1fc4fb8f0d9fee0201642d7a7560bd1661ebbefb9eac2da1ae4a5c", size = 8247207, upload-time = "2026-04-13T15:02:47.658Z" }, - { url = "https://files.pythonhosted.org/packages/8b/6b/79ccb79cab37c04a10759709042476b8534f3f2f6a89180f67821f396482/typos-1.45.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d33c7750a29524dff020a17f356ed079227f36f43ec57f193e9681606a35749b", size = 7361395, upload-time = "2026-04-13T15:02:49.564Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4c/b97fbabf0413edda31e1e830befb267f934e990a417a11e1f6f6b2e1235e/typos-1.45.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:745b0584eeead4593858671113fceed3c28b8ca67bdc7a517120127aa509c6a6", size = 7757620, upload-time = "2026-04-13T15:02:51.664Z" }, - { url = "https://files.pythonhosted.org/packages/69/f8/1b757c9b83750100b6c2f09fb6d84d4521ca158c74d9a437495329fca58e/typos-1.45.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e962d414fb92ad31dc4c930fc5d07ac9e4b55fdd4f42688468040fc5649d92da", size = 7111197, upload-time = "2026-04-13T15:02:53.831Z" }, - { url = "https://files.pythonhosted.org/packages/63/76/65c1d4248d9cb1b12fb4bad81b829bd5cc3da7564ffe0a794fac3126f1f4/typos-1.45.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f39afdfcc2d159705f3ffb11162e13e8affd994d07836738c8d2a592194604ab", size = 8180810, upload-time = "2026-04-13T15:02:55.901Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b5/65b3e0509c3d698faf1a965a9afcfff3ebff50c850e789891945735f8066/typos-1.45.1-py3-none-win32.whl", hash = "sha256:212fdbb7b90d40522fe77efb69c15f7063c146812df01d5605e5d7816a3f37d3", size = 3135956, upload-time = "2026-04-13T15:02:57.934Z" }, - { url = "https://files.pythonhosted.org/packages/ea/50/a9ec45215912d8e8600ba903b09382144071b450ad2f7adebc63e7699b99/typos-1.45.1-py3-none-win_amd64.whl", hash = "sha256:67a56bd1f06184f3761883f4f75dd3cc196f939180de595d0980164d4a19d363", size = 3318717, upload-time = "2026-04-13T15:02:59.829Z" }, + { url = "https://files.pythonhosted.org/packages/85/61/241cc5a28e7daf78e9f00345776a06d34b43a7901ec127875c18d5e864c4/typos-1.46.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21709d52d62c32010151edb1ae4b2c9278f93f6d3a57cd6ab1a5ff3425de1e1c", size = 3473633, upload-time = "2026-04-30T16:20:01.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/a8/5c465e2f09fb2aead5af646bb1027d9c86eb2def7fe110a8e2a42a986562/typos-1.46.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7d9811d112420cb866d2ed2b23a7eb4c97ea5fecfa81f9bb6ab5b57e59bd70b6", size = 3378427, upload-time = "2026-04-30T16:20:04.337Z" }, + { url = "https://files.pythonhosted.org/packages/49/ec/38b5b2edbb88b5a610bb7ba35b991558ed7df3584091f48546d2fe26dcf6/typos-1.46.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d612b6ace64f2219da5a18c9ac008042b37079a5fbaabcd6a20f060686205ac", size = 8234098, upload-time = "2026-04-30T16:20:05.981Z" }, + { url = "https://files.pythonhosted.org/packages/a4/63/ec68ddb454e56dcecc8c7f57efab4a1d527881fd5121a1c926cd914abc39/typos-1.46.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c3b0859517d73d13537a3f4a3662ceadb988e6bb5bdb8ba53bc6de86302d5154", size = 7325493, upload-time = "2026-04-30T16:20:08.508Z" }, + { url = "https://files.pythonhosted.org/packages/36/4e/a6eb51e56dd80468086543aa84f554cffb99942d31b49f452e6999d377dd/typos-1.46.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:696b94e7d73cc74ee47d05d8aab1f1a3d50ee427b7e2a30337430eaa613b8f9c", size = 7741777, upload-time = "2026-04-30T16:20:10.297Z" }, + { url = "https://files.pythonhosted.org/packages/89/31/7cf4a2b59797284feb81933ef6c793db6e5a9150a5ca76eb7cfd39a5657f/typos-1.46.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7b9b32bca025049d68f6c571baccb2dbaded8e32299b91bd184ef1b0ec2755ae", size = 7093146, upload-time = "2026-04-30T16:20:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3a/cab42c900888a49049b8bb33acea64f8b0c76cbb1a6e69a2771a6f11feae/typos-1.46.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7284909bd27e487de31c5eb3b53817df9e705e14b96f226315dca8a8c653363f", size = 8128222, upload-time = "2026-04-30T16:20:13.587Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e3/f0e2a727af4b1ac6f231d454f7ba85565039c930c9a4197c6345326ac1f9/typos-1.46.0-py3-none-win32.whl", hash = "sha256:563c54404f253450f195e2ec1cd4ca5e0d659e78c5d23b4062cea5cf2150fc9f", size = 3135787, upload-time = "2026-04-30T16:20:15.285Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/cf00d7df41e910b7a5bd7d83ac2fa0d771aa2ca3a2fde17388725ee1c20f/typos-1.46.0-py3-none-win_amd64.whl", hash = "sha256:595e614b9b9967247a68c2cb3a4712ced7a5cc434f2cabcf945afbebaccf885b", size = 3310337, upload-time = "2026-04-30T16:20:17.131Z" }, ] [[package]] name = "tzdata" -version = "2026.1" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] [[package]] @@ -2660,7 +2661,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.2.3" +version = "21.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -2668,9 +2669,9 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/8c/bdd9f89f89e4a787ac61bb2da4d884bc45e0c287ec694dfa3170dddd5cfe/virtualenv-21.2.3.tar.gz", hash = "sha256:9bb6d1414ab55ca624371e30c7719c32f183ef44da544ef8aa44a456de7ac191", size = 5844776, upload-time = "2026-04-14T01:10:36.692Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/0d/915c02c94d207b85580eb09bffab54438a709e7288524094fe781da526c2/virtualenv-21.3.1.tar.gz", hash = "sha256:c2305bc1fddeec40699b8370d13f8d431b0701f00ce895061ce493aeded4426b", size = 7613791, upload-time = "2026-05-05T01:34:31.402Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/19/bc7c4e05f42532863cf2ae7e7e847beab25835934e0410160b47eeff1e35/virtualenv-21.2.3-py3-none-any.whl", hash = "sha256:486652347ea8526d91e9807c0274583cb7ba31dd4942ff10fb5621402f0fe0d8", size = 5828329, upload-time = "2026-04-14T01:10:34.809Z" }, + { url = "https://files.pythonhosted.org/packages/b1/4f/f71e641e504111a5a74e3a20bc52d01bd86788b22699dd3fee1c63253cf6/virtualenv-21.3.1-py3-none-any.whl", hash = "sha256:d1a71cf58f2f9228fff23a1f6ec15d39785c6b32e03658d104974247145edd35", size = 7594539, upload-time = "2026-05-05T01:34:28.98Z" }, ] [[package]] @@ -2708,11 +2709,11 @@ wheels = [ [[package]] name = "wcwidth" -version = "0.6.0" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, ] [[package]]