From e5dc4e073640afa93d3690c83aa941e56648dbd1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 13:57:49 +0200 Subject: [PATCH 1/8] Added: a web layout read from the application's themes, with the calibration board on it --- .../ui/themes/loader.py | 110 ++------- src/sampletones_application/ui/themes/spec.py | 41 --- .../utils/theme/__init__.py | 0 .../utils/theme/catalog.py | 198 +++++++++++++++ .../utils/theme/spec.py | 63 +++++ .../calibration/board/composition.py | 7 +- .../calibration/board/fonts.py | 73 ------ .../calibration/board/layout.py | 4 +- .../calibration/board/model.py | 4 +- .../calibration/board/palette.py | 52 ---- .../calibration/board/paths.py | 2 - .../calibration/board/session.py | 28 +-- .../calibration/board/static/index.html | 33 ++- .../calibration/board/static/page.js | 25 +- .../calibration/board/static/style.css | 233 +++--------------- src/sampletones_tools/calibration/command.py | 8 +- src/sampletones_tools/calibration/session.py | 4 +- src/sampletones_tools/web/__init__.py | 0 src/sampletones_tools/web/colors.py | 22 ++ src/sampletones_tools/web/design.py | 64 +++++ src/sampletones_tools/web/fonts.py | 107 ++++++++ src/sampletones_tools/web/paths.py | 13 + src/sampletones_tools/web/roles/__init__.py | 0 src/sampletones_tools/web/roles/resolver.py | 26 ++ src/sampletones_tools/web/roles/sources.py | 70 ++++++ src/sampletones_tools/web/roles/table.py | 114 +++++++++ src/sampletones_tools/web/static/__init__.py | 0 src/sampletones_tools/web/static/base.css | 97 ++++++++ src/sampletones_tools/web/static/card.css | 89 +++++++ src/sampletones_tools/web/static/controls.css | 61 +++++ src/sampletones_tools/web/static/print.css | 29 +++ src/sampletones_tools/web/static/prose.css | 98 ++++++++ src/sampletones_tools/web/static/shell.css | 124 ++++++++++ src/sampletones_tools/web/static/table.css | 51 ++++ src/sampletones_tools/web/stylesheets.py | 31 +++ .../web/typography/__init__.py | 0 .../web/typography/metrics.py | 58 +++++ src/sampletones_tools/web/typography/scale.py | 39 +++ src/sampletones_tools/web/writer.py | 62 +++++ .../ui/themes/test_loader.py | 20 +- .../utils/theme/__init__.py | 0 .../utils/theme/test_catalog.py | 162 ++++++++++++ .../utils/theme/test_spec.py | 18 ++ .../calibration/board/test_composition.py | 9 +- .../calibration/board/test_data.py | 8 +- .../calibration/board/test_fonts.py | 37 --- .../calibration/board/test_palette.py | 46 ---- .../calibration/board/test_session.py | 14 +- .../calibration/board/test_static.py | 31 +-- .../calibration/test_command.py | 4 +- .../calibration/test_session.py | 4 +- tests/unit/sampletones_tools/web/__init__.py | 0 tests/unit/sampletones_tools/web/conftest.py | 9 + .../unit/sampletones_tools/web/test_colors.py | 17 ++ .../unit/sampletones_tools/web/test_design.py | 21 ++ .../unit/sampletones_tools/web/test_fonts.py | 47 ++++ .../unit/sampletones_tools/web/test_layout.py | 70 ++++++ .../sampletones_tools/web/test_metrics.py | 43 ++++ .../unit/sampletones_tools/web/test_roles.py | 32 +++ .../unit/sampletones_tools/web/test_scale.py | 33 +++ 60 files changed, 2032 insertions(+), 633 deletions(-) delete mode 100644 src/sampletones_application/ui/themes/spec.py create mode 100644 src/sampletones_application/utils/theme/__init__.py create mode 100644 src/sampletones_application/utils/theme/catalog.py create mode 100644 src/sampletones_application/utils/theme/spec.py delete mode 100644 src/sampletones_tools/calibration/board/fonts.py delete mode 100644 src/sampletones_tools/calibration/board/palette.py create mode 100644 src/sampletones_tools/web/__init__.py create mode 100644 src/sampletones_tools/web/colors.py create mode 100644 src/sampletones_tools/web/design.py create mode 100644 src/sampletones_tools/web/fonts.py create mode 100644 src/sampletones_tools/web/paths.py create mode 100644 src/sampletones_tools/web/roles/__init__.py create mode 100644 src/sampletones_tools/web/roles/resolver.py create mode 100644 src/sampletones_tools/web/roles/sources.py create mode 100644 src/sampletones_tools/web/roles/table.py create mode 100644 src/sampletones_tools/web/static/__init__.py create mode 100644 src/sampletones_tools/web/static/base.css create mode 100644 src/sampletones_tools/web/static/card.css create mode 100644 src/sampletones_tools/web/static/controls.css create mode 100644 src/sampletones_tools/web/static/print.css create mode 100644 src/sampletones_tools/web/static/prose.css create mode 100644 src/sampletones_tools/web/static/shell.css create mode 100644 src/sampletones_tools/web/static/table.css create mode 100644 src/sampletones_tools/web/stylesheets.py create mode 100644 src/sampletones_tools/web/typography/__init__.py create mode 100644 src/sampletones_tools/web/typography/metrics.py create mode 100644 src/sampletones_tools/web/typography/scale.py create mode 100644 src/sampletones_tools/web/writer.py create mode 100644 tests/unit/sampletones_application/utils/theme/__init__.py create mode 100644 tests/unit/sampletones_application/utils/theme/test_catalog.py create mode 100644 tests/unit/sampletones_application/utils/theme/test_spec.py delete mode 100644 tests/unit/sampletones_tools/calibration/board/test_fonts.py delete mode 100644 tests/unit/sampletones_tools/calibration/board/test_palette.py create mode 100644 tests/unit/sampletones_tools/web/__init__.py create mode 100644 tests/unit/sampletones_tools/web/conftest.py create mode 100644 tests/unit/sampletones_tools/web/test_colors.py create mode 100644 tests/unit/sampletones_tools/web/test_design.py create mode 100644 tests/unit/sampletones_tools/web/test_fonts.py create mode 100644 tests/unit/sampletones_tools/web/test_layout.py create mode 100644 tests/unit/sampletones_tools/web/test_metrics.py create mode 100644 tests/unit/sampletones_tools/web/test_roles.py create mode 100644 tests/unit/sampletones_tools/web/test_scale.py diff --git a/src/sampletones_application/ui/themes/loader.py b/src/sampletones_application/ui/themes/loader.py index 5949fa7ab..ef61eb2b5 100644 --- a/src/sampletones_application/ui/themes/loader.py +++ b/src/sampletones_application/ui/themes/loader.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, Final, List, Optional, Set +from typing import Dict, List from sampletones_application.ui.themes.dpg_constants import ( CATEGORY_MAP, @@ -16,11 +16,6 @@ ThemeEntryKey, ThemeItems, ) -from sampletones_application.ui.themes.spec import ( - ThemeColorEntrySpec, - ThemeEntrySpec, - ThemeSpec, -) from sampletones_application.ui.themes.style import ( ThemeColor, ThemeParameter, @@ -28,37 +23,35 @@ ThemeValue, ) from sampletones_application.ui.themes.theme import Theme -from sampletones_application.utils.palette.colors.written import PALETTE_SOURCE_CONTEXT_KEY from sampletones_application.utils.palette.source import PaletteSource -from sampletones_shared.paths.extensions import EXT_FILE_YAML -from sampletones_shared.utils.serialization import load_yaml - -_BASE_THEME_NAME: Final[str] = "default" +from sampletones_application.utils.theme.catalog import ThemeSpecCatalog +from sampletones_application.utils.theme.spec import ( + ThemeColorEntrySpec, + ThemeEntrySpec, + ThemeSpec, +) class ThemeLoader: """Resolves the theme YAML directory into complete runtime themes. - Each spec composes on top of its parent (the base theme by default), and every - enabled-state entry gains a disabled-state counterpart, so a loaded theme fully - describes both item states and stays authoritative wherever it is bound. + The specs come from :class:`ThemeSpecCatalog`, parents first. Each spec composes on top of + its parent (the base theme by default), and every enabled-state entry gains a disabled-state + counterpart, so a loaded theme fully describes both item states and stays authoritative + wherever it is bound. """ def __init__(self, theme_directory: Path, palette_source: PaletteSource) -> None: self._directory = theme_directory - self._context: Dict[str, PaletteSource] = {PALETTE_SOURCE_CONTEXT_KEY: palette_source} + self._palette_source = palette_source def load_all(self) -> List[Theme]: - specs = self._load_specs() - name_index = {spec.name: spec for spec in specs} - self._check_for_cycles(name_index) - ordered = self._topological_sort(specs, name_index) - + catalog = ThemeSpecCatalog.load(self._directory, self._palette_source) resolved: Dict[str, ThemeEntries] = {} themes: List[Theme] = [] - for spec in ordered: + for spec in catalog.ordered: entries = self._spec_to_entries(spec) - parent = self._effective_parent(spec) + parent = spec.parent merged = {**resolved[parent], **entries} if parent is not None else entries resolved[spec.name] = merged items = self._entries_to_items(self._mirror_disabled_entries(merged)) @@ -66,36 +59,6 @@ def load_all(self) -> List[Theme]: return themes - def _load_specs(self) -> List[ThemeSpec]: - specs: List[ThemeSpec] = [] - for path in sorted(self._directory.rglob(f"*{EXT_FILE_YAML}")): - raw = load_yaml(path) - if not isinstance(raw, dict): - raise TypeError(f"Theme file {path} must contain a mapping, got {type(raw)}") - - specs.append(ThemeSpec.model_validate(raw, context=self._context)) - - return specs - - @staticmethod - def _effective_parent(spec: ThemeSpec) -> Optional[str]: - """The theme a spec composes on top of. - - A spec inherits from its explicit ``extends`` when given, and otherwise from - the base theme, so every theme carries the shared base's complete component - set and states only its own overrides. The base theme itself stands alone. - This makes inheritance the default: a bound item theme always resolves to - the global base plus its overrides, so every color it omits keeps the - base's value. - """ - if spec.extends is not None: - return spec.extends - - if spec.name != _BASE_THEME_NAME: - return _BASE_THEME_NAME - - return None - @staticmethod def _resolve_color_key(key: str, category: str) -> int: color_map = PLOTS_COLOR_MAP if category == "Plots" else CORE_COLOR_MAP @@ -238,46 +201,3 @@ def _entries_to_items(entries: ThemeEntries) -> ThemeItems: ordered = sorted(grouped, key=lambda parameter: parameter.item_type != EVERY_ITEM_TYPE) return ThemeItems(items={parameter: grouped[parameter] for parameter in ordered}) - - @classmethod - def _check_for_cycles(cls, name_index: Dict[str, ThemeSpec]) -> None: - for name in name_index: - visited: Set[str] = set() - current: Optional[str] = name - while current is not None: - if current in visited: - raise ValueError(f"Cycle detected in theme inheritance: {name!r} → {current!r}") - - visited.add(current) - spec = name_index.get(current) - if spec is None: - raise ValueError( - f"Theme {name!r} extends unknown parent {current!r}. Known themes: {sorted(name_index)}" - ) - - current = cls._effective_parent(spec) - - @classmethod - def _topological_sort( - cls, - specs: List[ThemeSpec], - name_index: Dict[str, ThemeSpec], - ) -> List[ThemeSpec]: - ordered: List[ThemeSpec] = [] - visited: Set[str] = set() - - def visit(spec: ThemeSpec) -> None: - if spec.name in visited: - return - - parent = cls._effective_parent(spec) - if parent is not None: - visit(name_index[parent]) - - visited.add(spec.name) - ordered.append(spec) - - for spec in specs: - visit(spec) - - return ordered diff --git a/src/sampletones_application/ui/themes/spec.py b/src/sampletones_application/ui/themes/spec.py deleted file mode 100644 index 3eeb63711..000000000 --- a/src/sampletones_application/ui/themes/spec.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from typing import Annotated, List, Literal, Optional, Union - -from pydantic import BaseModel, Field - -from sampletones_application.utils.palette.colors.written import WrittenColor - - -class ThemeColorEntrySpec(BaseModel, frozen=True): - type: Literal["color"] - key: str - value: WrittenColor - category: str = "Core" - - -class ThemeStyleEntrySpec(BaseModel, frozen=True): - type: Literal["style"] - key: str - x: float - y: float = -1.0 - category: str = "Core" - - -ThemeEntrySpec = Annotated[ - Union[ThemeColorEntrySpec, ThemeStyleEntrySpec], - Field(discriminator="type"), -] - - -class ThemeComponentSpec(BaseModel, frozen=True): - item_type: str - enabled: bool = True - entries: List[ThemeEntrySpec] - - -class ThemeSpec(BaseModel, frozen=True): - name: str - tag: str - extends: Optional[str] = None - components: List[ThemeComponentSpec] diff --git a/src/sampletones_application/utils/theme/__init__.py b/src/sampletones_application/utils/theme/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/utils/theme/catalog.py b/src/sampletones_application/utils/theme/catalog.py new file mode 100644 index 000000000..9d3719f38 --- /dev/null +++ b/src/sampletones_application/utils/theme/catalog.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Dict, Iterator, List, Optional, Set, Tuple + +from sampletones_application.utils.palette.colors.base import BaseColor +from sampletones_application.utils.palette.colors.written import PALETTE_SOURCE_CONTEXT_KEY +from sampletones_application.utils.palette.source import PaletteSource +from sampletones_application.utils.theme.spec import ( + EVERY_ITEM_TYPE_NAME, + ThemeColorEntrySpec, + ThemeEntrySpec, + ThemeSpec, + ThemeStyleEntrySpec, +) +from sampletones_shared.paths.extensions import EXT_FILE_YAML +from sampletones_shared.utils.serialization import load_yaml + + +@dataclass(frozen=True) +class ThemeEntryAddress: + """Where one entry of a theme sits: the item type it styles, the state it applies in, its + category and its key, all as the theme files spell them.""" + + item_type: str + enabled: bool + category: str + key: str + + +ThemeEntries = Dict[ThemeEntryAddress, ThemeEntrySpec] + + +class ThemeSpecCatalog: + """Every theme the configuration states, each with the entries it wears once composed. + + A theme composes on top of its parent (:attr:`ThemeSpec.parent`), so a theme's entries are + its parent's with its own laid over them, and the specs are kept parents first. A lookup + answers what an item of a given type and state draws with: the entry its own component + states, the enabled state's entry where the disabled state states none, and the component + naming every item type where the item's own type states nothing. That is how DearPyGui + resolves a bound theme, so a reader of this catalog sees the value the application draws. + """ + + def __init__(self, specs: List[ThemeSpec]) -> None: + self._index = {spec.name: spec for spec in specs} + self._check_parents() + self._ordered = self._parents_first(specs) + self._entries = self._compose() + + @classmethod + def load( + cls, + directory: Path, + palette_source: PaletteSource, + ) -> ThemeSpecCatalog: + """Every theme file under ``directory``, its colors bound to ``palette_source``. + + Raises: + TypeError: If a theme file holds something other than a mapping. + ValueError: If a theme extends an unknown theme or the inheritance forms a cycle. + """ + context = {PALETTE_SOURCE_CONTEXT_KEY: palette_source} + specs: List[ThemeSpec] = [] + for path in sorted(directory.rglob(f"*{EXT_FILE_YAML}")): + raw = load_yaml(path) + if not isinstance(raw, dict): + raise TypeError(f"Theme file {path} must contain a mapping, got {type(raw)}") + + specs.append(ThemeSpec.model_validate(raw, context=context)) + + return cls(specs) + + @property + def ordered(self) -> List[ThemeSpec]: + """Every theme, each after the theme it composes on.""" + return list(self._ordered) + + def entries(self, name: str) -> ThemeEntries: + """The entries theme ``name`` wears: its parent's, with its own laid over them. + + Raises: + KeyError: If no theme carries that name. + """ + return dict(self._entries[name]) + + def color( + self, + name: str, + address: ThemeEntryAddress, + ) -> BaseColor: + """The color theme ``name`` gives the entry at ``address``. + + Raises: + KeyError: If the theme states no color there, for that item type or every item type. + """ + entry = self._resolve(name, address) + if not isinstance(entry, ThemeColorEntrySpec): + raise KeyError(f"Theme {name!r} states a style, not a color, at {address}") + + return entry.value + + def style( + self, + name: str, + address: ThemeEntryAddress, + ) -> Tuple[float, float]: + """The style value theme ``name`` gives the entry at ``address``, as its two components. + + Raises: + KeyError: If the theme states no style there, for that item type or every item type. + """ + entry = self._resolve(name, address) + if not isinstance(entry, ThemeStyleEntrySpec): + raise KeyError(f"Theme {name!r} states a color, not a style, at {address}") + + return entry.x, entry.y + + def _resolve( + self, + name: str, + address: ThemeEntryAddress, + ) -> ThemeEntrySpec: + entries = self._entries[name] + for candidate in self._candidates(address): + if candidate in entries: + return entries[candidate] + + raise KeyError(f"Theme {name!r} states nothing at {address}") + + @staticmethod + def _candidates(address: ThemeEntryAddress) -> Iterator[ThemeEntryAddress]: + for item_type in dict.fromkeys((address.item_type, EVERY_ITEM_TYPE_NAME)): + yield replace(address, item_type=item_type) + if not address.enabled: + yield replace(address, item_type=item_type, enabled=True) + + def _compose(self) -> Dict[str, ThemeEntries]: + composed: Dict[str, ThemeEntries] = {} + for spec in self._ordered: + own = self._own_entries(spec) + parent = spec.parent + composed[spec.name] = {**composed[parent], **own} if parent is not None else own + + return composed + + @staticmethod + def _own_entries(spec: ThemeSpec) -> ThemeEntries: + entries: ThemeEntries = {} + for component in spec.components: + for entry in component.entries: + address = ThemeEntryAddress( + item_type=component.item_type, + enabled=component.enabled, + category=entry.category, + key=entry.key, + ) + entries[address] = entry + + return entries + + def _check_parents(self) -> None: + for name in self._index: + visited: Set[str] = set() + current: Optional[str] = name + while current is not None: + if current in visited: + raise ValueError(f"Cycle detected in theme inheritance: {name!r} → {current!r}") + + visited.add(current) + spec = self._index.get(current) + if spec is None: + raise ValueError( + f"Theme {name!r} extends unknown parent {current!r}. Known themes: {sorted(self._index)}" + ) + + current = spec.parent + + def _parents_first(self, specs: List[ThemeSpec]) -> List[ThemeSpec]: + ordered: List[ThemeSpec] = [] + visited: Set[str] = set() + + def visit(spec: ThemeSpec) -> None: + if spec.name in visited: + return + + parent = spec.parent + if parent is not None: + visit(self._index[parent]) + + visited.add(spec.name) + ordered.append(spec) + + for spec in specs: + visit(spec) + + return ordered diff --git a/src/sampletones_application/utils/theme/spec.py b/src/sampletones_application/utils/theme/spec.py new file mode 100644 index 000000000..7421f2ff8 --- /dev/null +++ b/src/sampletones_application/utils/theme/spec.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import Annotated, Final, List, Literal, Optional, Union + +from pydantic import BaseModel, Field + +from sampletones_application.utils.palette.colors.written import WrittenColor + +BASE_THEME_NAME: Final[str] = "default" +EVERY_ITEM_TYPE_NAME: Final[str] = "All" +CORE_CATEGORY: Final[str] = "Core" + + +class ThemeColorEntrySpec(BaseModel, frozen=True): + type: Literal["color"] + key: str + value: WrittenColor + category: str = CORE_CATEGORY + + +class ThemeStyleEntrySpec(BaseModel, frozen=True): + type: Literal["style"] + key: str + x: float + y: float = -1.0 + category: str = CORE_CATEGORY + + +ThemeEntrySpec = Annotated[ + Union[ThemeColorEntrySpec, ThemeStyleEntrySpec], + Field(discriminator="type"), +] + + +class ThemeComponentSpec(BaseModel, frozen=True): + item_type: str + enabled: bool = True + entries: List[ThemeEntrySpec] + + +class ThemeSpec(BaseModel, frozen=True): + name: str + tag: str + extends: Optional[str] = None + components: List[ThemeComponentSpec] + + @property + def parent(self) -> Optional[str]: + """The theme this one composes on top of. + + A theme inherits from its explicit ``extends`` when given, and otherwise from the base + theme, so every theme carries the shared base's complete component set and states only + its own overrides. The base theme itself stands alone. This makes inheritance the + default: a bound item theme always resolves to the global base plus its overrides, so + every color it omits keeps the base's value. + """ + if self.extends is not None: + return self.extends + + if self.name != BASE_THEME_NAME: + return BASE_THEME_NAME + + return None diff --git a/src/sampletones_tools/calibration/board/composition.py b/src/sampletones_tools/calibration/board/composition.py index 298a3ba9b..a2bb804dd 100644 --- a/src/sampletones_tools/calibration/board/composition.py +++ b/src/sampletones_tools/calibration/board/composition.py @@ -6,10 +6,10 @@ from sampletones_shared.utils.system.paths import get_filename from sampletones_tools.calibration.layout import RECORDINGS_DIRECTORY, combination_name from sampletones_tools.calibration.renders import RenderRecord, item_directory, variant_directory +from sampletones_tools.web.roles.table import channel_role from .clips import ClipStore from .model import BoardCell, BoardChannel, BoardGroup, BoardPage, BoardRow -from .palette import channel_token from .reading import RunReading NO_CLIP: Final[str] = "" @@ -19,6 +19,7 @@ def compose_page( runs: Sequence[RunReading], store: ClipStore, title: str, + glyph: str, ) -> BoardPage: """ Assemble everything a page draws from the runs it reports. @@ -31,6 +32,7 @@ def compose_page( runs: The runs the page reports, in the order the page holds them. store: Where the page finds each clip it plays. title: The heading the page carries. + glyph: The purpose glyph each table's card header carries. Returns: The page, ready to be written. @@ -45,7 +47,8 @@ def compose_page( return BoardPage( title=title, referee=runs[0].referee, - colors={channel.value: channel_token(channel.value) for channel in ChannelName}, + glyph=glyph, + colors={channel.value: channel_role(channel.value) for channel in ChannelName}, groups=groups, ) diff --git a/src/sampletones_tools/calibration/board/fonts.py b/src/sampletones_tools/calibration/board/fonts.py deleted file mode 100644 index c6cb82861..000000000 --- a/src/sampletones_tools/calibration/board/fonts.py +++ /dev/null @@ -1,73 +0,0 @@ -from base64 import b64encode -from dataclasses import dataclass -from pathlib import Path -from typing import Final, Tuple - -from sampletones_shared.paths.resources import ( - FONT_MONO_REGULAR, - FONT_SANS_BOLD, - FONT_SANS_REGULAR, -) - -from .paths import FONTS_DIRECTORY - -SANS_FAMILY: Final[str] = "SampleToNES Sans" -MONO_FAMILY: Final[str] = "SampleToNES Mono" - -REGULAR_WEIGHT: Final[int] = 400 -BOLD_WEIGHT: Final[int] = 600 - -FONT_MEDIA_TYPE: Final[str] = "font/ttf" - - -@dataclass(frozen=True) -class PageFont: - """One face the page draws with, and the file it is read from. - - Attributes: - family: The family name the stylesheet asks for. - weight: The weight the face answers at. - filename: The face's file in the assets package. - """ - - family: str - weight: int - filename: str - - -PAGE_FONTS: Final[Tuple[PageFont, ...]] = ( - PageFont(family=SANS_FAMILY, weight=REGULAR_WEIGHT, filename=FONT_SANS_REGULAR), - PageFont(family=SANS_FAMILY, weight=BOLD_WEIGHT, filename=FONT_SANS_BOLD), - PageFont(family=MONO_FAMILY, weight=REGULAR_WEIGHT, filename=FONT_MONO_REGULAR), -) - - -def fonts_stylesheet(directory: Path = FONTS_DIRECTORY) -> str: - """The faces of the application, embedded so the page draws with them wherever it is opened. - - A page opened from a file has an origin of its own, and a face carried inside the stylesheet - reaches it there as surely as it does over the web. - - Args: - directory: The directory the face files are read from. - - Returns: - The stylesheet, one ``@font-face`` rule per face. - - Raises: - FileNotFoundError: If the directory holds no file of a face's name. - """ - return "\n".join(_face(font, directory) for font in PAGE_FONTS) + "\n" - - -def _face(font: PageFont, directory: Path) -> str: - encoded = b64encode((directory / font.filename).read_bytes()).decode("ascii") - return ( - "@font-face {\n" - f' font-family: "{font.family}";\n' - f" font-weight: {font.weight};\n" - " font-style: normal;\n" - " font-display: block;\n" - f' src: url("data:{FONT_MEDIA_TYPE};base64,{encoded}") format("truetype");\n' - "}" - ) diff --git a/src/sampletones_tools/calibration/board/layout.py b/src/sampletones_tools/calibration/board/layout.py index e0bb3d52f..baf340c8c 100644 --- a/src/sampletones_tools/calibration/board/layout.py +++ b/src/sampletones_tools/calibration/board/layout.py @@ -3,12 +3,10 @@ PAGE_FILE: Final[str] = "index.html" STYLE_FILE: Final[str] = "style.css" SCRIPT_FILE: Final[str] = "page.js" -PALETTE_FILE: Final[str] = "palette.css" -FONTS_FILE: Final[str] = "fonts.css" DATA_FILE: Final[str] = "data.js" PAGE_DIRECTORY: Final[str] = "page" AUDIO_DIRECTORY: Final[str] = "audio" SHIPPED_FILES: Final[Tuple[str, ...]] = (PAGE_FILE, STYLE_FILE, SCRIPT_FILE) -GENERATED_FILES: Final[Tuple[str, ...]] = (PALETTE_FILE, FONTS_FILE, DATA_FILE) +GENERATED_FILES: Final[Tuple[str, ...]] = (DATA_FILE,) diff --git a/src/sampletones_tools/calibration/board/model.py b/src/sampletones_tools/calibration/board/model.py index b8387e5b1..7e82665c8 100644 --- a/src/sampletones_tools/calibration/board/model.py +++ b/src/sampletones_tools/calibration/board/model.py @@ -79,7 +79,8 @@ class BoardPage(BaseModel): Attributes: title: The heading the page carries. referee: The name of the referee whose scores the cells report. - colors: The palette token each channel's color stands under, by channel name. + glyph: The purpose glyph each table's card header carries. + colors: The role each channel's color stands under, by channel name. groups: The tables the page holds, one per tab. """ @@ -87,5 +88,6 @@ class BoardPage(BaseModel): title: str referee: str + glyph: str colors: Dict[str, str] groups: Tuple[BoardGroup, ...] diff --git a/src/sampletones_tools/calibration/board/palette.py b/src/sampletones_tools/calibration/board/palette.py deleted file mode 100644 index 238d0284a..000000000 --- a/src/sampletones_tools/calibration/board/palette.py +++ /dev/null @@ -1,52 +0,0 @@ -from typing import Final, Tuple - -from sampletones_application.paths import PALETTES_DIRECTORY -from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME, PaletteCatalog -from sampletones_application.utils.palette.palette import Palette -from sampletones_shared.types.application import ColorRGBA - -DEFAULT_PALETTE: Final[str] = DEFAULT_PALETTE_NAME -OPAQUE_ALPHA: Final[int] = 255 -CHANNEL_TOKEN_PREFIX: Final[str] = "channel_" - - -def board_palette(name: str) -> Palette: - """The palette a page is drawn in, by the name the application knows it under. - - Raises: - KeyError: If the build ships no palette of that name. - """ - return PaletteCatalog.load(PALETTES_DIRECTORY).get(name) - - -def palette_names() -> Tuple[str, ...]: - """Every palette name a page may be drawn in, the application's own default among them.""" - return PaletteCatalog.load(PALETTES_DIRECTORY).names - - -def palette_stylesheet(palette: Palette) -> str: - """The palette's tokens as custom properties, under the names the application states them by. - - A token keeps its name from the palette file to the page, so a color the application draws with - and the color the page draws with are one value read from one place. - - Args: - palette: The palette the page is drawn in. - - Returns: - The stylesheet, one ``:root`` block holding every token. - """ - declarations = "".join(f" --{token}: {css_color(color)};\n" for token, color in sorted(palette.colors.items())) - return f":root {{\n{declarations}}}\n" - - -def channel_token(channel: str) -> str: - """The palette token a channel's color stands under.""" - return f"{CHANNEL_TOKEN_PREFIX}{channel}" - - -def css_color(color: ColorRGBA) -> str: - """A palette color as CSS writes it: six hexadecimal digits, eight where it is translucent.""" - red, green, blue, alpha = color - opaque = f"#{red:02x}{green:02x}{blue:02x}" - return opaque if alpha == OPAQUE_ALPHA else f"{opaque}{alpha:02x}" diff --git a/src/sampletones_tools/calibration/board/paths.py b/src/sampletones_tools/calibration/board/paths.py index fcd858834..8c56729ef 100644 --- a/src/sampletones_tools/calibration/board/paths.py +++ b/src/sampletones_tools/calibration/board/paths.py @@ -4,7 +4,5 @@ from sampletones_shared.paths.package import package_directory STATIC_PACKAGE: Final[str] = "sampletones_tools.calibration.board.static" -FONTS_PACKAGE: Final[str] = "sampletones_assets.fonts" STATIC_DIRECTORY: Final[Path] = package_directory(STATIC_PACKAGE) -FONTS_DIRECTORY: Final[Path] = package_directory(FONTS_PACKAGE) diff --git a/src/sampletones_tools/calibration/board/session.py b/src/sampletones_tools/calibration/board/session.py index d822c6164..03ad5b3fb 100644 --- a/src/sampletones_tools/calibration/board/session.py +++ b/src/sampletones_tools/calibration/board/session.py @@ -3,13 +3,15 @@ from pydantic import BaseModel, ConfigDict, Field +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from sampletones_tools.web.design import ApplicationDesign +from sampletones_tools.web.writer import write_layout + from .assets import write_assets from .clips import clip_store from .composition import compose_page from .data import data_script -from .fonts import fonts_stylesheet -from .layout import DATA_FILE, FONTS_FILE, PALETTE_FILE -from .palette import DEFAULT_PALETTE, board_palette, palette_stylesheet +from .layout import DATA_FILE, PAGE_DIRECTORY from .reading import read_runs PAGE_TITLE: Final[str] = "Calibration" @@ -29,7 +31,7 @@ class BoardRequest(BaseModel): runs: List[Path] = Field(min_length=1) output: Path - palette: str = DEFAULT_PALETTE + palette: str = DEFAULT_PALETTE_NAME title: str = PAGE_TITLE @@ -37,8 +39,9 @@ def build_board(request: BoardRequest) -> Path: """ Build the page a person listens to a run on, from what the runs already wrote. - Everything the page needs travels with it: the palette as custom properties, the application's - own faces inside the stylesheet, and the run's contents as data. A page written into the run it + Everything the page needs travels with it: the web layout drawn from the application's own + configuration in the requested palette, the application's faces inside a stylesheet, and the + run's contents as data. A page written into the run it reports plays that run's clips where they lie; anywhere else it carries copies, so either page opens straight from the folder it was written into. @@ -53,13 +56,8 @@ def build_board(request: BoardRequest) -> Path: KeyError: If the build ships no palette of the requested name. """ runs = read_runs(request.runs) + design = ApplicationDesign.load(request.palette) request.output.mkdir(parents=True, exist_ok=True) - page = compose_page(runs, clip_store(runs, request.output), request.title) - return write_assets( - request.output, - { - PALETTE_FILE: palette_stylesheet(board_palette(request.palette)), - FONTS_FILE: fonts_stylesheet(), - DATA_FILE: data_script(page), - }, - ) + page = compose_page(runs, clip_store(runs, request.output), request.title, design.glyphs.headers.reconstruction) + write_layout(request.output / PAGE_DIRECTORY, design) + return write_assets(request.output, {DATA_FILE: data_script(page)}) diff --git a/src/sampletones_tools/calibration/board/static/index.html b/src/sampletones_tools/calibration/board/static/index.html index 8d283fe49..5e0c97440 100644 --- a/src/sampletones_tools/calibration/board/static/index.html +++ b/src/sampletones_tools/calibration/board/static/index.html @@ -4,23 +4,42 @@ Calibration - + + + + + + + + -
-

Calibration

-

- +
+ -
-
+ +
+
+
+

Calibration

+

+
+
+
+
+

Click a clip to play it from its beginning. Space pauses and resumes, ← → move across a sound's versions, ↑ ↓ move to another sound. S plays one channel alone, M plays every other channel. A bar shows the frames its channel sounds.

+
diff --git a/src/sampletones_tools/calibration/board/static/page.js b/src/sampletones_tools/calibration/board/static/page.js index 0e1fb2b80..17c1c62c3 100644 --- a/src/sampletones_tools/calibration/board/static/page.js +++ b/src/sampletones_tools/calibration/board/static/page.js @@ -36,8 +36,8 @@ function channelRow(channel, color) { swatch.style.background = `var(--${color})`; swatch.title = channel.name; row.append(swatch); - if (channel.solo) { row.append(clip("S", channel.solo, "clip part")); } - if (channel.mute) { row.append(clip("M", channel.mute, "clip part")); } + if (channel.solo) { row.append(clip("S", channel.solo, "button secondary compact clip part")); } + if (channel.mute) { row.append(clip("M", channel.mute, "button secondary compact clip part")); } const bar = element("div", "bar"); bar.style.backgroundImage = gradient(channel.timeline, color); bar.title = channel.name; @@ -53,7 +53,7 @@ function closed(cell) { function cellNode(cell, colors) { const node = element("div", "cell"); - node.append(clip("play", cell.render, "clip")); + node.append(clip("play", cell.render, "button clip")); const reading = element("div", "reading"); reading.append(element("b", null, cell.score.toFixed(2))); reading.append(element("span", null, closed(cell))); @@ -73,7 +73,7 @@ function rowNode(row, colors) { sound.append(element("span", null, row.category)); node.append(sound); const recording = element("td"); - recording.append(clip("recording", row.recording, "clip")); + recording.append(clip("recording", row.recording, "button clip")); node.append(recording); for (const cell of row.cells) { const holder = element("td"); @@ -112,14 +112,14 @@ function legendNode(group, colors) { return legend; } -function groupNode(group, colors) { +function groupNode(group, glyph, colors) { const card = element("section", "card"); card.dataset.group = group.name; - const heading = element("h2"); - heading.append(element("span", null, group.name), legendNode(group, colors)); - card.append(heading); - const scroll = element("div", "scroll"); - const table = element("table"); + const header = element("div", "card-header"); + header.append(element("span", "card-glyph", glyph), element("span", "card-title", group.name), legendNode(group, colors)); + card.append(header); + const scroll = element("div", "table-scroll"); + const table = element("table", "table"); const head = element("tr"); for (const heading of ["sound", "recording", ...group.columns, "change"]) { head.append(element("th", null, heading)); @@ -207,13 +207,14 @@ function build(board) { `Scored by ${board.referee}; lower is closer to the recording.`; const groups = document.getElementById("groups"); for (const group of board.groups) { - groups.append(groupNode(group, board.colors)); + groups.append(groupNode(group, board.glyph, board.colors)); } if (board.groups.length > 1) { const tabs = document.getElementById("tabs"); tabs.hidden = false; for (const group of board.groups) { - const tab = element("button", null, group.name); + const tab = element("button", "tab", group.name); + tab.setAttribute("role", "tab"); tab.dataset.group = group.name; tab.addEventListener("click", () => selectGroup(group.name)); tabs.append(tab); diff --git a/src/sampletones_tools/calibration/board/static/style.css b/src/sampletones_tools/calibration/board/static/style.css index 2b5069e05..b94443dfb 100644 --- a/src/sampletones_tools/calibration/board/static/style.css +++ b/src/sampletones_tools/calibration/board/static/style.css @@ -1,113 +1,44 @@ :root { - --page-gap: 8px; - --card-radius: 8px; - --frame-radius: 6px; - --header-height: 35px; --bar-height: 6px; } -* { - box-sizing: border-box; -} - -[hidden] { - display: none !important; -} - -body { - margin: 0; - padding: 16px 16px 32px; - background: var(--ground); - color: var(--text); - font: 400 15px/1.45 "SampleToNES Sans", system-ui, sans-serif; -} - -.head, -main, -.foot { +.board { + display: flex; + flex-direction: column; + gap: var(--panel-gap); max-width: 1400px; margin: 0 auto; } -h1 { - margin: 0 0 4px; - font-size: 22px; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--accent); -} - -.lead { - margin: 0 0 var(--page-gap); - color: var(--text_muted); -} - -.tabs { +.groups { display: flex; - flex-wrap: wrap; - gap: 2px; - margin-bottom: var(--page-gap); - padding: 4px; - background: var(--tab_strip); - border: 1px solid var(--border); - border-radius: var(--card-radius); -} - -.tabs button { - font: inherit; - color: var(--text_muted); - background: var(--tab); - border: 1px solid transparent; - border-radius: var(--frame-radius); - padding: 6px 16px; - cursor: pointer; -} - -.tabs button:hover { - background: var(--tab_hovered); - color: var(--text); + flex-direction: column; + gap: var(--panel-gap); } -.tabs button[aria-selected="true"] { - background: var(--tab_active); - border-color: var(--border); - color: var(--text); +.status-bar p { + margin: 0; + padding-block: var(--item-spacing-y); } -.card { - margin-bottom: var(--page-gap); - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--card-radius); - overflow: hidden; +.card-header { + flex-wrap: wrap; } -.card > h2 { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - height: var(--header-height); - margin: 0; - padding: 0 12px; - background: var(--surface_accent); - border-bottom: 1px solid var(--border); - color: var(--accent); - font-size: 15px; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; +.card-header .card-title { + flex: 1 0 auto; } .legend { display: flex; - gap: 12px; - color: var(--text_muted); - font-family: "SampleToNES Mono", ui-monospace, monospace; - font-size: 12px; + gap: calc(1.5 * var(--item-spacing-x)); + margin-left: auto; + padding-left: var(--glyph-indent); + margin-right: var(--chevron-offset); + font-family: var(--font-mono); + font-size: var(--size-mono-small); font-weight: 400; - letter-spacing: 0; + color: var(--text-muted); text-transform: none; } @@ -117,40 +48,8 @@ h1 { gap: 4px; } -.scroll { - overflow-x: auto; -} - -table { - width: 100%; - border-collapse: collapse; -} - -th, -td { - padding: 8px; - text-align: left; - vertical-align: top; - border-bottom: 1px solid var(--table_border); -} - -thead th { - background: var(--table_header); - color: var(--text); - font-weight: 600; - white-space: nowrap; -} - -tbody tr:nth-child(odd) { - background: var(--table_row); -} - -tbody tr:nth-child(even) { - background: var(--table_row_alt); -} - -tbody tr.playing { - outline: 1px solid var(--accent); +.table tbody tr.playing { + outline: 1px solid var(--heading); outline-offset: -1px; } @@ -159,67 +58,42 @@ tbody tr.playing { } .sound b { - font-family: "SampleToNES Mono", ui-monospace, monospace; + font-family: var(--font-mono); font-weight: 400; } .sound span { display: block; - color: var(--text_muted); - font-size: 13px; -} - -button.clip { - font: 400 14px/1 "SampleToNES Sans", system-ui, sans-serif; - color: var(--text); - background: var(--button); - border: 1px solid var(--border); - border-radius: var(--frame-radius); - padding: 7px 12px; - cursor: pointer; -} - -button.clip:hover { - background: var(--button_hovered); + font-size: var(--size-sans-small); + color: var(--text-muted); } -button.clip[aria-pressed="true"] { - background: var(--button_active); - border-color: var(--accent); -} - -button.part { - padding: 2px 6px; - font-family: "SampleToNES Mono", ui-monospace, monospace; - font-size: 12px; - background: var(--secondary); -} - -button.part:hover { - background: var(--secondary_hover); +.sound, +td.change { + width: 1%; } .cell { display: inline-flex; align-items: flex-start; - gap: 10px; + gap: var(--item-spacing-x); } .reading { min-width: 104px; white-space: nowrap; - font-family: "SampleToNES Mono", ui-monospace, monospace; + font-family: var(--font-mono); } .reading b { + font-size: var(--size-mono-medium); font-weight: 400; - font-size: 15px; } .reading span { display: block; - color: var(--text_muted); - font-size: 12px; + font-size: var(--size-mono-small); + color: var(--text-muted); } .channels { @@ -240,46 +114,17 @@ button.part:hover { flex: 1; min-width: 0; height: var(--bar-height); - border-radius: 2px; background: var(--recess); -} - -.swatch { - width: 8px; - height: 8px; border-radius: 2px; - flex: none; -} - -.sound, -td.change { - width: 1%; } td.change { - text-align: right; white-space: nowrap; - font-family: "SampleToNES Mono", ui-monospace, monospace; -} - -.better { - color: var(--accent); -} - -.worse { - color: var(--danger); -} - -.foot { - margin-top: var(--page-gap); - color: var(--text_muted); - font-size: 13px; + text-align: right; + font-family: var(--font-mono); } -kbd { - padding: 0 5px; - border: 1px solid var(--border); - border-radius: 3px; - color: var(--text); - font-family: "SampleToNES Mono", ui-monospace, monospace; +.button.part { + font-family: var(--font-mono); + font-size: var(--size-mono-small); } diff --git a/src/sampletones_tools/calibration/command.py b/src/sampletones_tools/calibration/command.py index 74efa9684..36b6343b6 100644 --- a/src/sampletones_tools/calibration/command.py +++ b/src/sampletones_tools/calibration/command.py @@ -89,9 +89,9 @@ def run(arguments: Namespace) -> int: def _measure(given: CalibrationArguments) -> int: + from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME from sampletones_core.headless.conversion.request import channels_named from sampletones_shared.utils.validation import describe_failure - from sampletones_tools.calibration.board.palette import DEFAULT_PALETTE from sampletones_tools.calibration.config.suite import SuiteConfig from sampletones_tools.calibration.session import ( CalibrationRequest, @@ -111,7 +111,7 @@ def _measure(given: CalibrationArguments) -> int: perceptual_exponents=floats_named(given.perceptual_exponents, suite.perceptual_exponents), temporal_weights=floats_named(given.temporal_weights, suite.temporal_weights), channels=channels_named(given.channels) if given.channels is not None else list(suite.channels), - palette=given.palette if given.palette is not None else DEFAULT_PALETTE, + palette=given.palette if given.palette is not None else DEFAULT_PALETTE_NAME, ) except ValueError as error: raise SystemExit(describe_failure(error)) from error @@ -122,7 +122,7 @@ def _measure(given: CalibrationArguments) -> int: def _compare(given: CalibrationArguments) -> int: - from sampletones_tools.calibration.board.palette import DEFAULT_PALETTE + from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME from sampletones_tools.calibration.board.session import BoardRequest, build_board from sampletones_tools.calibration.session import default_page_output @@ -130,7 +130,7 @@ def _compare(given: CalibrationArguments) -> int: request = BoardRequest( runs=list(given.board or []), output=given.output if given.output is not None else default_page_output(), - palette=given.palette if given.palette is not None else DEFAULT_PALETTE, + palette=given.palette if given.palette is not None else DEFAULT_PALETTE_NAME, ) try: page = build_board(request) diff --git a/src/sampletones_tools/calibration/session.py b/src/sampletones_tools/calibration/session.py index ad47d7d4d..9afe5d047 100644 --- a/src/sampletones_tools/calibration/session.py +++ b/src/sampletones_tools/calibration/session.py @@ -3,12 +3,12 @@ from pydantic import BaseModel, ConfigDict, Field +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, SpectrumMethod from sampletones_shared.logger import logger from sampletones_shared.paths.user import USER_PATH_DOCUMENTS from sampletones_shared.utils.text import listed_items -from sampletones_tools.calibration.board.palette import DEFAULT_PALETTE from sampletones_tools.calibration.board.session import BoardRequest, build_board from sampletones_tools.calibration.config.corpus import CorpusConfig from sampletones_tools.calibration.corpus.synthesis import build_corpus @@ -103,7 +103,7 @@ class CalibrationRequest(BaseModel): perceptual_exponents: List[float] = Field(min_length=1) temporal_weights: List[float] channels: List[ChannelName] = Field(min_length=1) - palette: str = DEFAULT_PALETTE + palette: str = DEFAULT_PALETTE_NAME class CalibrationOutcome(BaseModel): diff --git a/src/sampletones_tools/web/__init__.py b/src/sampletones_tools/web/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/web/colors.py b/src/sampletones_tools/web/colors.py new file mode 100644 index 000000000..f173075b1 --- /dev/null +++ b/src/sampletones_tools/web/colors.py @@ -0,0 +1,22 @@ +from typing import Final + +from sampletones_shared.types.application import ColorRGBA +from sampletones_shared.utils.color import relative_luminance + +OPAQUE_ALPHA: Final[int] = 255 +DARK_SCHEME: Final[str] = "dark" +LIGHT_SCHEME: Final[str] = "light" +LIGHT_GROUND_LUMINANCE: Final[float] = 0.5 + + +def css_color(color: ColorRGBA) -> str: + """A color as CSS writes it: six hexadecimal digits, eight where it is translucent.""" + red, green, blue, alpha = color + opaque = f"#{red:02x}{green:02x}{blue:02x}" + return opaque if alpha == OPAQUE_ALPHA else f"{opaque}{alpha:02x}" + + +def color_scheme(ground: ColorRGBA) -> str: + """The CSS color scheme a page standing on ``ground`` asks for, so form controls and + scrollbars match it: ``light`` on a light ground, ``dark`` otherwise.""" + return LIGHT_SCHEME if relative_luminance(ground) >= LIGHT_GROUND_LUMINANCE else DARK_SCHEME diff --git a/src/sampletones_tools/web/design.py b/src/sampletones_tools/web/design.py new file mode 100644 index 000000000..785bc563e --- /dev/null +++ b/src/sampletones_tools/web/design.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Tuple + +from sampletones_application.layout.fonts import FontsLayout +from sampletones_application.layout.general import GeneralLayout +from sampletones_application.layout.glyphs.glyphs import Glyphs +from sampletones_application.paths import LAYOUT_DIRECTORY, PALETTES_DIRECTORY, THEME_DIRECTORY +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.colors.written import PALETTE_SOURCE_CONTEXT_KEY +from sampletones_application.utils.palette.palette import Palette +from sampletones_application.utils.palette.source import PaletteSource +from sampletones_application.utils.theme.catalog import ThemeSpecCatalog +from sampletones_shared.utils.serialization import load_yaml_model, load_yaml_model_dir + + +@dataclass(frozen=True) +class ApplicationDesign: + """The application's design in one palette, as the configuration states it: the palette, the + themes its widgets are bound to, the general layout, the font sizes and the glyphs. + + A page drawn from it wears what the application wears, read from the same files, so a + change to the application's look reaches every page that is built afterwards. + """ + + palette: Palette + themes: ThemeSpecCatalog + general: GeneralLayout + fonts: FontsLayout + glyphs: Glyphs + + @classmethod + def load(cls, palette_name: str) -> ApplicationDesign: + """The design in the palette the application knows by ``palette_name``. + + Raises: + KeyError: If the build ships no palette of that name. + """ + palette = PaletteCatalog.load(PALETTES_DIRECTORY).get(palette_name) + source = PaletteSource(palette) + context: Dict[str, object] = {PALETTE_SOURCE_CONTEXT_KEY: source} + return cls( + palette=palette, + themes=ThemeSpecCatalog.load(THEME_DIRECTORY, source), + general=load_yaml_model_dir(LAYOUT_DIRECTORY / "general", GeneralLayout, context=context), + fonts=load_yaml_model(LAYOUT_DIRECTORY / "fonts.yaml", FontsLayout, context=context), + glyphs=load_yaml_model(LAYOUT_DIRECTORY / "glyphs.yaml", Glyphs, context=context), + ) + + @property + def glyph_characters(self) -> str: + """Every character the glyph catalog holds, each once, in a stable order.""" + characters: Dict[str, None] = {} + for group in self.glyphs.model_dump().values(): + for glyph in group.values(): + characters.update(dict.fromkeys(glyph)) + + return "".join(sorted(characters)) + + +def palette_names() -> Tuple[str, ...]: + """Every palette name a page may be drawn in, the application's own default among them.""" + return PaletteCatalog.load(PALETTES_DIRECTORY).names diff --git a/src/sampletones_tools/web/fonts.py b/src/sampletones_tools/web/fonts.py new file mode 100644 index 000000000..9288107f3 --- /dev/null +++ b/src/sampletones_tools/web/fonts.py @@ -0,0 +1,107 @@ +from base64 import b64encode +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, List, Optional, Tuple + +from sampletones_application.layout.fonts import Typeface +from sampletones_shared.paths.resources import ( + FONT_ICON, + FONT_MONO_BOLD, + FONT_MONO_REGULAR, + FONT_SANS_BOLD, + FONT_SANS_ITALIC, + FONT_SANS_REGULAR, +) + +SANS_FAMILY: Final[str] = "SampleToNES Sans" +MONO_FAMILY: Final[str] = "SampleToNES Mono" +ICON_FAMILY: Final[str] = "SampleToNES Glyphs" + +REGULAR_WEIGHT: Final[int] = 400 +BOLD_WEIGHT: Final[int] = 700 +NORMAL_STYLE: Final[str] = "normal" +ITALIC_STYLE: Final[str] = "italic" + +FONT_MEDIA_TYPE: Final[str] = "font/ttf" + +FONT_STACKS: Final[Dict[Typeface, str]] = { + Typeface.SANS: f'"{SANS_FAMILY}", "{ICON_FAMILY}", system-ui, sans-serif', + Typeface.MONO: f'"{MONO_FAMILY}", "{ICON_FAMILY}", ui-monospace, monospace', + Typeface.ICON: f'"{ICON_FAMILY}", sans-serif', +} + + +@dataclass(frozen=True) +class PageFont: + """One face a page draws with, and the file it is read from.""" + + family: str + weight: int + style: str + filename: str + + +PAGE_FONTS: Final[Tuple[PageFont, ...]] = ( + PageFont(family=SANS_FAMILY, weight=REGULAR_WEIGHT, style=NORMAL_STYLE, filename=FONT_SANS_REGULAR), + PageFont(family=SANS_FAMILY, weight=BOLD_WEIGHT, style=NORMAL_STYLE, filename=FONT_SANS_BOLD), + PageFont(family=SANS_FAMILY, weight=REGULAR_WEIGHT, style=ITALIC_STYLE, filename=FONT_SANS_ITALIC), + PageFont(family=MONO_FAMILY, weight=REGULAR_WEIGHT, style=NORMAL_STYLE, filename=FONT_MONO_REGULAR), + PageFont(family=MONO_FAMILY, weight=BOLD_WEIGHT, style=NORMAL_STYLE, filename=FONT_MONO_BOLD), +) +ICON_FONT: Final[PageFont] = PageFont( + family=ICON_FAMILY, + weight=REGULAR_WEIGHT, + style=NORMAL_STYLE, + filename=FONT_ICON, +) + + +def fonts_stylesheet( + directory: Path, + glyph_characters: str, +) -> str: + """The application's faces, embedded so a page draws with them wherever it is opened. + + A page opened from a file has an origin of its own, and browsers refuse a face read from a + folder other than the page's, so each face travels inside the stylesheet. The glyph face + answers only for the glyph characters, which keeps every other character in the text faces. + + Raises: + FileNotFoundError: If ``directory`` holds no file of a face's name. + """ + faces = [_face(font, directory, None) for font in PAGE_FONTS] + faces.append(_face(ICON_FONT, directory, unicode_range(glyph_characters))) + return "\n".join(faces) + "\n" + + +def unicode_range(characters: str) -> str: + """The characters as a CSS ``unicode-range``, consecutive code points joined into spans.""" + codepoints = sorted({ord(character) for character in characters}) + spans: List[Tuple[int, int]] = [] + for codepoint in codepoints: + if spans and codepoint == spans[-1][1] + 1: + spans[-1] = (spans[-1][0], codepoint) + else: + spans.append((codepoint, codepoint)) + + return ", ".join(f"U+{start:04X}" if start == end else f"U+{start:04X}-{end:04X}" for start, end in spans) + + +def _face( + font: PageFont, + directory: Path, + characters: Optional[str], +) -> str: + encoded = b64encode((directory / font.filename).read_bytes()).decode("ascii") + lines = [ + "@font-face {", + f' font-family: "{font.family}";', + f" font-weight: {font.weight};", + f" font-style: {font.style};", + " font-display: block;", + f' src: url("data:{FONT_MEDIA_TYPE};base64,{encoded}") format("truetype");', + ] + if characters is not None: + lines.append(f" unicode-range: {characters};") + lines.append("}") + return "\n".join(lines) diff --git a/src/sampletones_tools/web/paths.py b/src/sampletones_tools/web/paths.py new file mode 100644 index 000000000..624fd58e9 --- /dev/null +++ b/src/sampletones_tools/web/paths.py @@ -0,0 +1,13 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.package import package_directory +from sampletones_shared.paths.resources import ICON_VECTOR_FILENAME + +STATIC_PACKAGE: Final[str] = "sampletones_tools.web.static" +FONTS_PACKAGE: Final[str] = "sampletones_assets.fonts" +ICONS_PACKAGE: Final[str] = "sampletones_assets.icons" + +STATIC_DIRECTORY: Final[Path] = package_directory(STATIC_PACKAGE) +FONTS_DIRECTORY: Final[Path] = package_directory(FONTS_PACKAGE) +MARK_SOURCE: Final[Path] = package_directory(ICONS_PACKAGE) / ICON_VECTOR_FILENAME diff --git a/src/sampletones_tools/web/roles/__init__.py b/src/sampletones_tools/web/roles/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/web/roles/resolver.py b/src/sampletones_tools/web/roles/resolver.py new file mode 100644 index 000000000..55bbfd659 --- /dev/null +++ b/src/sampletones_tools/web/roles/resolver.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from typing import Dict + +from sampletones_shared.types.application import ColorRGBA +from sampletones_tools.web.design import ApplicationDesign +from sampletones_tools.web.roles.table import COLOR_ROLES, DIMENSION_ROLES + + +@dataclass(frozen=True) +class RoleSheet: + """Every role resolved in one design: colors, and sizes in application pixels.""" + + colors: Dict[str, ColorRGBA] + dimensions: Dict[str, float] + + +def resolve_roles(design: ApplicationDesign) -> RoleSheet: + """Every role of the table, read from ``design``. + + Raises: + KeyError: If a role names a theme entry the configuration does not state. + """ + return RoleSheet( + colors={name: role.resolve(design) for name, role in COLOR_ROLES.items()}, + dimensions={name: role.resolve(design) for name, role in DIMENSION_ROLES.items()}, + ) diff --git a/src/sampletones_tools/web/roles/sources.py b/src/sampletones_tools/web/roles/sources.py new file mode 100644 index 000000000..93047cad3 --- /dev/null +++ b/src/sampletones_tools/web/roles/sources.py @@ -0,0 +1,70 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Callable, Protocol + +from sampletones_application.layout.general import GeneralLayout +from sampletones_application.utils.palette.colors.base import BaseColor +from sampletones_application.utils.theme.catalog import ThemeEntryAddress +from sampletones_shared.types.application import ColorRGBA +from sampletones_tools.web.design import ApplicationDesign + + +class Axis(Enum): + X = "x" + Y = "y" + + +class ColorRole(Protocol): + """Where a page's color comes from in the application's configuration.""" + + def resolve(self, design: ApplicationDesign) -> ColorRGBA: ... + + +class DimensionRole(Protocol): + """Where a page's size comes from in the application's configuration, in application pixels.""" + + def resolve(self, design: ApplicationDesign) -> float: ... + + +@dataclass(frozen=True) +class ThemeColorRole: + """The color a theme gives one entry, the way the widget bound to that theme draws it.""" + + theme: str + address: ThemeEntryAddress + + def resolve(self, design: ApplicationDesign) -> ColorRGBA: + return design.themes.color(self.theme, self.address).rgba + + +@dataclass(frozen=True) +class LayoutColorRole: + """A color the general layout states, such as a channel's or a link's.""" + + read: Callable[[GeneralLayout], BaseColor] + + def resolve(self, design: ApplicationDesign) -> ColorRGBA: + return self.read(design.general).rgba + + +@dataclass(frozen=True) +class ThemeDimensionRole: + """One component of a style a theme states, such as a rounding or a padding.""" + + theme: str + address: ThemeEntryAddress + axis: Axis + + def resolve(self, design: ApplicationDesign) -> float: + x, y = design.themes.style(self.theme, self.address) + return x if self.axis is Axis.X or y < 0 else y + + +@dataclass(frozen=True) +class LayoutDimensionRole: + """A size the general layout states, such as the card header's height.""" + + read: Callable[[GeneralLayout], float] + + def resolve(self, design: ApplicationDesign) -> float: + return self.read(design.general) diff --git a/src/sampletones_tools/web/roles/table.py b/src/sampletones_tools/web/roles/table.py new file mode 100644 index 000000000..15204a119 --- /dev/null +++ b/src/sampletones_tools/web/roles/table.py @@ -0,0 +1,114 @@ +from typing import Dict, Final + +from sampletones_application.utils.theme.catalog import ThemeEntryAddress +from sampletones_application.utils.theme.spec import CORE_CATEGORY, EVERY_ITEM_TYPE_NAME +from sampletones_tools.web.roles.sources import ( + Axis, + ColorRole, + DimensionRole, + LayoutColorRole, + LayoutDimensionRole, + ThemeColorRole, + ThemeDimensionRole, +) + +BASE_THEME: Final[str] = "default" +CHANNEL_ROLE_PREFIX: Final[str] = "channel-" +ENABLED: Final[bool] = True +DISABLED: Final[bool] = False + + +def channel_role(channel: str) -> str: + """The role a channel's color stands under, by the channel's name.""" + return f"{CHANNEL_ROLE_PREFIX}{channel}" + + +def _color(theme: str, item_type: str, key: str, enabled: bool) -> ThemeColorRole: + return ThemeColorRole( + theme=theme, + address=ThemeEntryAddress(item_type=item_type, enabled=enabled, category=CORE_CATEGORY, key=key), + ) + + +def _style(theme: str, item_type: str, key: str, axis: Axis) -> ThemeDimensionRole: + return ThemeDimensionRole( + theme=theme, + address=ThemeEntryAddress(item_type=item_type, enabled=ENABLED, category=CORE_CATEGORY, key=key), + axis=axis, + ) + + +COLOR_ROLES: Final[Dict[str, ColorRole]] = { + "ground": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "WindowBg", ENABLED), + "recess": _color("panel_ground", EVERY_ITEM_TYPE_NAME, "ChildBg", ENABLED), + "surface": _color("panel_surface", EVERY_ITEM_TYPE_NAME, "ChildBg", ENABLED), + "well": _color("tree_window", EVERY_ITEM_TYPE_NAME, "ChildBg", ENABLED), + "card-header": _color("collapse_header", EVERY_ITEM_TYPE_NAME, "ChildBg", ENABLED), + "card-header-hover": _color("collapse_header_hovered", EVERY_ITEM_TYPE_NAME, "ChildBg", ENABLED), + "heading": _color("section_header", "Text", "Text", ENABLED), + "emphasis-surface": _color("panel_emphasis", EVERY_ITEM_TYPE_NAME, "ChildBg", ENABLED), + "emphasis-border": _color("panel_emphasis", EVERY_ITEM_TYPE_NAME, "Border", ENABLED), + "border": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "Border", ENABLED), + "separator": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "Separator", ENABLED), + "text": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "Text", ENABLED), + "text-muted": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "TextDisabled", ENABLED), + "text-disabled": _color(BASE_THEME, "Button", "Text", DISABLED), + "text-strong": _color("table_pattern", EVERY_ITEM_TYPE_NAME, "Text", ENABLED), + "text-highlight": LayoutColorRole(read=lambda general: general.colors.text.highlight), + "text-error": LayoutColorRole(read=lambda general: general.colors.text.error), + "link": LayoutColorRole(read=lambda general: general.colors.paths.default), + "link-hover": LayoutColorRole(read=lambda general: general.colors.paths.hover), + "menu": _color(BASE_THEME, "MenuBar", "MenuBarBg", ENABLED), + "status": _color("status", EVERY_ITEM_TYPE_NAME, "WindowBg", ENABLED), + "status-text": _color("status", EVERY_ITEM_TYPE_NAME, "Text", ENABLED), + "tab-strip": _color("tab_strip", EVERY_ITEM_TYPE_NAME, "ChildBg", ENABLED), + "tab": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "Tab", ENABLED), + "tab-hover": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "TabHovered", ENABLED), + "tab-selected": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "TabSelected", ENABLED), + "button": _color(BASE_THEME, "Button", "Button", ENABLED), + "button-hover": _color(BASE_THEME, "Button", "ButtonHovered", ENABLED), + "button-active": _color(BASE_THEME, "Button", "ButtonActive", ENABLED), + "button-disabled": _color(BASE_THEME, "Button", "Button", DISABLED), + "secondary": _color("secondary_button", "Button", "Button", ENABLED), + "secondary-hover": _color("secondary_button", "Button", "ButtonHovered", ENABLED), + "secondary-active": _color("secondary_button", "Button", "ButtonActive", ENABLED), + "secondary-text": _color("secondary_button", "Button", "Text", ENABLED), + "secondary-border": _color("secondary_button", "Button", "Border", ENABLED), + "focus": _color("focused_button", "Button", "Border", ENABLED), + "table-header": _color(BASE_THEME, "Table", "TableHeaderBg", ENABLED), + "table-row": _color(BASE_THEME, "Table", "TableRowBg", ENABLED), + "table-row-alt": _color(BASE_THEME, "Table", "TableRowBgAlt", ENABLED), + "table-border": _color(BASE_THEME, "Table", "TableBorderStrong", ENABLED), + "selection": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "Header", ENABLED), + "block-selection": _color("table_pattern", "Selectable", "Header", ENABLED), + "scrollbar-track": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "ScrollbarBg", ENABLED), + "scrollbar-grab": _color(BASE_THEME, EVERY_ITEM_TYPE_NAME, "ScrollbarGrab", ENABLED), + channel_role("pulse1"): LayoutColorRole(read=lambda general: general.colors.channels.pulse1), + channel_role("pulse2"): LayoutColorRole(read=lambda general: general.colors.channels.pulse2), + channel_role("triangle"): LayoutColorRole(read=lambda general: general.colors.channels.triangle), + channel_role("noise"): LayoutColorRole(read=lambda general: general.colors.channels.noise), +} + +DIMENSION_ROLES: Final[Dict[str, DimensionRole]] = { + "card-radius": _style(BASE_THEME, EVERY_ITEM_TYPE_NAME, "ChildRounding", Axis.X), + "card-border": _style(BASE_THEME, EVERY_ITEM_TYPE_NAME, "ChildBorderSize", Axis.X), + "card-padding-x": _style(BASE_THEME, EVERY_ITEM_TYPE_NAME, "WindowPadding", Axis.X), + "card-padding-y": _style(BASE_THEME, EVERY_ITEM_TYPE_NAME, "WindowPadding", Axis.Y), + "frame-radius": _style(BASE_THEME, EVERY_ITEM_TYPE_NAME, "FrameRounding", Axis.X), + "tab-radius": _style(BASE_THEME, EVERY_ITEM_TYPE_NAME, "TabRounding", Axis.X), + "item-spacing-x": _style(BASE_THEME, EVERY_ITEM_TYPE_NAME, "ItemSpacing", Axis.X), + "item-spacing-y": _style(BASE_THEME, EVERY_ITEM_TYPE_NAME, "ItemSpacing", Axis.Y), + "button-padding-x": _style(BASE_THEME, "Button", "FramePadding", Axis.X), + "button-padding-y": _style(BASE_THEME, "Button", "FramePadding", Axis.Y), + "menu-padding-x": _style(BASE_THEME, "MenuBar", "FramePadding", Axis.X), + "cell-padding-x": _style("table", "Table", "CellPadding", Axis.X), + "cell-padding-y": _style("table", "Table", "CellPadding", Axis.Y), + "header-height": LayoutDimensionRole(read=lambda general: general.collapse.header_bar_height), + "glyph-indent": LayoutDimensionRole(read=lambda general: general.section_header.glyph.indent), + "glyph-width": LayoutDimensionRole(read=lambda general: general.section_header.glyph.width), + "glyph-top": LayoutDimensionRole(read=lambda general: general.section_header.glyph.top_offset), + "chevron-offset": LayoutDimensionRole(read=lambda general: general.section_header.chevron_offset), + "panel-gap": LayoutDimensionRole(read=lambda general: general.panel_gap), + "side-column": LayoutDimensionRole(read=lambda general: general.columns.side.width), + "status-height": LayoutDimensionRole(read=lambda general: general.status_bar.height), +} diff --git a/src/sampletones_tools/web/static/__init__.py b/src/sampletones_tools/web/static/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/web/static/base.css b/src/sampletones_tools/web/static/base.css new file mode 100644 index 000000000..548f2ff1d --- /dev/null +++ b/src/sampletones_tools/web/static/base.css @@ -0,0 +1,97 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +:root { + --page-gutter: 16px; +} + +html { + scrollbar-color: var(--scrollbar-grab) var(--scrollbar-track); +} + +body { + margin: 0; + background: var(--ground); + color: var(--text); + font-family: var(--font-sans); + font-size: var(--size-sans-medium); + line-height: normal; +} + +::selection { + background: var(--block-selection); +} + +a { + color: var(--link); + text-decoration: none; +} + +a:hover { + color: var(--link-hover); + text-decoration: underline; +} + +b, +strong { + font-weight: 700; +} + +code, +kbd, +samp, +pre, +.mono { + font-family: var(--font-mono); + font-size: var(--size-mono-medium); +} + +.small { + font-size: var(--size-sans-small); +} + +.mono.small { + font-size: var(--size-mono-small); +} + +.muted { + color: var(--text-muted); +} + +.strong { + color: var(--text-strong); +} + +.highlight { + color: var(--text-highlight); +} + +.error, +.worse { + color: var(--text-error); +} + +.better { + color: var(--heading); +} + +.glyph { + font-family: var(--font-icon); +} + +kbd { + padding: 0 5px; + font-size: var(--size-mono-small); + color: var(--text); + background: var(--recess); + border: 1px solid var(--border); + border-bottom-width: 2px; + border-radius: calc(var(--frame-radius) / 2); +} diff --git a/src/sampletones_tools/web/static/card.css b/src/sampletones_tools/web/static/card.css new file mode 100644 index 000000000..018ab1041 --- /dev/null +++ b/src/sampletones_tools/web/static/card.css @@ -0,0 +1,89 @@ +.card { + display: flex; + flex-direction: column; + gap: var(--item-spacing-y); + min-width: 0; + padding: var(--card-padding-y) var(--card-padding-x); + background: var(--surface); + border: var(--card-border) solid var(--border); + border-radius: var(--card-radius); +} + +.card.emphasis { + background: var(--emphasis-surface); + border-color: var(--emphasis-border); +} + +.card-header { + display: flex; + flex: none; + align-items: center; + gap: var(--item-spacing-x); + min-height: var(--header-height); + margin: 0; + color: var(--heading); + background: var(--card-header); + border-radius: var(--card-radius); +} + +summary.card-header { + list-style: none; + cursor: pointer; +} + +summary.card-header::-webkit-details-marker { + display: none; +} + +summary.card-header:hover { + background: var(--card-header-hover); +} + +.card-glyph { + flex: none; + width: calc(var(--glyph-indent) + var(--glyph-width)); + padding: var(--glyph-top) 0 0 var(--glyph-indent); + font-family: var(--font-icon); + font-size: var(--size-icon-small); +} + +.card-title { + flex: 1; + min-width: 0; + padding-block: 2px; + font-size: var(--size-sans-large); + font-weight: 700; + overflow-wrap: anywhere; + text-transform: uppercase; +} + +.card-chevron { + flex: none; + margin-right: var(--chevron-offset); + font-family: var(--font-icon); + font-size: var(--size-icon-small); +} + +.separator { + width: 100%; + margin: var(--item-spacing-y) 0; + border: 0; + border-top: 1px solid var(--separator); +} + +.subheader { + margin: var(--item-spacing-y) 0 0; + font-size: inherit; + font-weight: 700; + color: var(--text); +} + +.well { + padding: var(--card-padding-y) var(--card-padding-x); + background: var(--recess); + border-radius: var(--card-radius); +} + +.well.deep { + background: var(--well); +} diff --git a/src/sampletones_tools/web/static/controls.css b/src/sampletones_tools/web/static/controls.css new file mode 100644 index 000000000..50b9c08d0 --- /dev/null +++ b/src/sampletones_tools/web/static/controls.css @@ -0,0 +1,61 @@ +.button { + appearance: none; + margin: 0; + padding: var(--button-padding-y) var(--button-padding-x); + font: inherit; + line-height: normal; + color: var(--text); + text-align: center; + background: var(--button); + border: 0; + border-radius: var(--frame-radius); + cursor: pointer; +} + +.button:hover { + background: var(--button-hover); +} + +.button:active, +.button[aria-pressed="true"] { + background: var(--button-active); +} + +.button:focus-visible { + outline: 1px solid var(--focus); + outline-offset: 0; +} + +.button:disabled { + color: var(--text-disabled); + background: var(--button-disabled); + cursor: default; +} + +.button.secondary { + color: var(--secondary-text); + background: var(--secondary); + box-shadow: inset 0 0 0 1px var(--secondary-border); +} + +.button.secondary:hover { + background: var(--secondary-hover); +} + +.button.secondary:active, +.button.secondary[aria-pressed="true"] { + background: var(--secondary-active); +} + +.button.compact { + padding: 0 var(--button-padding-y); + min-width: 1.6em; +} + +.swatch { + display: inline-block; + flex: none; + width: 0.6em; + height: 0.6em; + border-radius: 2px; +} diff --git a/src/sampletones_tools/web/static/print.css b/src/sampletones_tools/web/static/print.css new file mode 100644 index 000000000..68a6aaa8d --- /dev/null +++ b/src/sampletones_tools/web/static/print.css @@ -0,0 +1,29 @@ +@media print { + .menu-bar, + .tab-bar, + .status-bar { + display: none; + } + + .card, + .card-header, + .table, + .prose pre { + print-color-adjust: exact; + -webkit-print-color-adjust: exact; + } + + .card-header { + break-after: avoid; + } + + .prose pre, + .figure, + tr { + break-inside: avoid; + } + + .prose pre { + white-space: pre-wrap; + } +} diff --git a/src/sampletones_tools/web/static/prose.css b/src/sampletones_tools/web/static/prose.css new file mode 100644 index 000000000..44fded04f --- /dev/null +++ b/src/sampletones_tools/web/static/prose.css @@ -0,0 +1,98 @@ +.prose { + min-width: 0; + padding: 0 var(--card-padding-x) var(--card-padding-y); + font-size: var(--size-sans-large); + line-height: 1.55; +} + +.prose > :first-child { + margin-top: var(--card-padding-y); +} + +.prose > :last-child { + margin-bottom: 0; +} + +.prose p, +.prose ul, +.prose ol, +.prose blockquote, +.prose pre, +.prose .table-scroll, +.prose .figure { + margin: 0.7em 0; +} + +.prose ul, +.prose ol { + padding-left: 1.5em; +} + +.prose li + li, +.prose li > ul, +.prose li > ol { + margin-top: 0.25em; +} + +.prose h3, +.prose h4 { + margin: 1.4em 0 0.4em; + line-height: 1.3; + text-wrap: balance; +} + +.prose h3 { + font-size: 1.15em; + color: var(--text); +} + +.prose h4 { + font-size: 1em; + color: var(--text-muted); +} + +.prose strong { + color: var(--text-strong); +} + +.prose :not(pre) > code { + padding: 1px 5px; + font-size: 0.86em; + background: var(--recess); + border-radius: calc(var(--frame-radius) / 2); +} + +.prose pre { + padding: calc(1.5 * var(--card-padding-y)) var(--card-padding-x); + overflow-x: auto; + font-size: var(--size-mono-small); + line-height: 1.5; + background: var(--recess); + border-radius: var(--frame-radius); +} + +.prose pre code { + font-size: inherit; +} + +.prose blockquote { + padding: 0 var(--card-padding-x); + color: var(--text-muted); + border-left: 3px solid var(--separator); +} + +.prose hr { + margin: 1.2em 0; + border: 0; + border-top: 1px solid var(--separator); +} + +.prose img { + max-width: 100%; + height: auto; +} + +.prose .table { + font-size: var(--size-sans-medium); + line-height: normal; +} diff --git a/src/sampletones_tools/web/static/shell.css b/src/sampletones_tools/web/static/shell.css new file mode 100644 index 000000000..2f1f73931 --- /dev/null +++ b/src/sampletones_tools/web/static/shell.css @@ -0,0 +1,124 @@ +.shell { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.menu-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + column-gap: calc(2 * var(--menu-padding-x)); + padding: 2px var(--page-gutter); + background: var(--menu); +} + +.menu-bar .title { + font-weight: 700; +} + +.menu-bar .brand-mark { + display: block; + width: 20px; + height: 20px; +} + +.menu-bar .spacer { + flex: 1; +} + +.tab-bar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 4px; + padding: var(--panel-gap) var(--page-gutter) 0; + background: var(--tab-strip); +} + +.tab { + appearance: none; + margin: 0; + padding: 3px 11px; + font: inherit; + font-size: var(--size-sans-large); + color: var(--text); + background: var(--tab); + border: 0; + border-radius: var(--tab-radius) var(--tab-radius) 0 0; + cursor: pointer; +} + +.tab:hover { + background: var(--tab-hover); +} + +.tab[aria-selected="true"] { + background: var(--tab-selected); +} + +.workspace { + flex: 1; + min-width: 0; + padding-block: var(--panel-gap); + padding-inline: var(--page-gutter); + background: var(--recess); +} + +.columns { + display: flex; + align-items: flex-start; + gap: var(--panel-gap); +} + +.column { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--panel-gap); + min-width: 0; +} + +.column.side { + flex: 0 0 var(--side-column); +} + +.status-bar { + display: flex; + align-items: center; + gap: var(--item-spacing-x); + min-height: var(--status-height); + padding: 0 var(--page-gutter); + color: var(--status-text); + background: var(--status); + border-top: 1px solid var(--border); +} + +.masthead { + padding: var(--page-gutter) var(--card-padding-x) var(--card-padding-y); +} + +.masthead h1 { + margin: 0; + font-size: var(--size-sans-title); + font-weight: 700; + line-height: 1.2; + text-wrap: balance; +} + +.masthead .lead { + margin: 0.4em 0 0; + font-size: var(--size-sans-large); + color: var(--text-muted); +} + +@media (max-width: 900px) { + .columns { + flex-direction: column; + align-items: stretch; + } + + .column.side { + flex-basis: auto; + } +} diff --git a/src/sampletones_tools/web/static/table.css b/src/sampletones_tools/web/static/table.css new file mode 100644 index 000000000..faaca1a18 --- /dev/null +++ b/src/sampletones_tools/web/static/table.css @@ -0,0 +1,51 @@ +.table-scroll { + max-width: 100%; + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +.table th, +.table td { + padding: var(--cell-padding-y) var(--cell-padding-x); + text-align: left; + vertical-align: middle; + border-bottom: 1px solid var(--table-border); +} + +.table thead th { + font-weight: 700; + white-space: nowrap; + background: var(--table-header); +} + +.table tbody tr:nth-child(odd) { + background: var(--table-row); +} + +.table tbody tr:nth-child(even) { + background: var(--table-row-alt); +} + +.table tbody tr.selected { + background: var(--selection); +} + +.table .number { + font-family: var(--font-mono); + font-size: var(--size-mono-medium); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.table .center { + text-align: center; +} + +.table .right { + text-align: right; +} diff --git a/src/sampletones_tools/web/stylesheets.py b/src/sampletones_tools/web/stylesheets.py new file mode 100644 index 000000000..072037d1a --- /dev/null +++ b/src/sampletones_tools/web/stylesheets.py @@ -0,0 +1,31 @@ +from typing import Dict, Final, List + +from sampletones_tools.web.colors import color_scheme, css_color +from sampletones_tools.web.fonts import FONT_STACKS +from sampletones_tools.web.roles.resolver import RoleSheet + +GROUND_ROLE: Final[str] = "ground" +SIZE_PREFIX: Final[str] = "size-" +FONT_PREFIX: Final[str] = "font-" + + +def roles_stylesheet( + sheet: RoleSheet, + sizes: Dict[str, float], +) -> str: + """The roles, sizes and font stacks as custom properties on ``:root``. + + Colors are written as hexadecimal, sizes in pixels, and the color scheme follows the ground, + so a page's form controls and scrollbars stand on it as the rest does. + """ + declarations: List[str] = [f"color-scheme: {color_scheme(sheet.colors[GROUND_ROLE])};"] + declarations.extend(f"--{name}: {css_color(color)};" for name, color in sorted(sheet.colors.items())) + declarations.extend(f"--{name}: {_pixels(value)};" for name, value in sorted(sheet.dimensions.items())) + declarations.extend(f"--{SIZE_PREFIX}{name}: {_pixels(value)};" for name, value in sorted(sizes.items())) + declarations.extend(f"--{FONT_PREFIX}{typeface.value}: {stack};" for typeface, stack in FONT_STACKS.items()) + body = "".join(f" {declaration}\n" for declaration in declarations) + return f":root {{\n{body}}}\n" + + +def _pixels(value: float) -> str: + return f"{value:g}px" diff --git a/src/sampletones_tools/web/typography/__init__.py b/src/sampletones_tools/web/typography/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/web/typography/metrics.py b/src/sampletones_tools/web/typography/metrics.py new file mode 100644 index 000000000..bf05c2660 --- /dev/null +++ b/src/sampletones_tools/web/typography/metrics.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final + +TABLE_COUNT_OFFSET: Final[int] = 4 +TABLE_RECORDS_OFFSET: Final[int] = 12 +TABLE_RECORD_SIZE: Final[int] = 16 +TABLE_RECORD_FORMAT: Final[str] = ">4sIII" +HEAD_TAG: Final[bytes] = b"head" +HHEA_TAG: Final[bytes] = b"hhea" +UNITS_PER_EM_OFFSET: Final[int] = 18 +ASCENT_OFFSET: Final[int] = 4 + + +@dataclass(frozen=True) +class FontMetrics: + """A TrueType face's vertical metrics, as its ``head`` and ``hhea`` tables state them.""" + + units_per_em: int + ascent: int + descent: int + + @classmethod + def read(cls, path: Path) -> FontMetrics: + """The metrics of the TrueType file at ``path``. + + Raises: + KeyError: If the file lacks a ``head`` or an ``hhea`` table. + """ + data = path.read_bytes() + tables = cls._table_offsets(data) + (units_per_em,) = struct.unpack_from(">H", data, tables[HEAD_TAG] + UNITS_PER_EM_OFFSET) + ascent, descent = struct.unpack_from(">hh", data, tables[HHEA_TAG] + ASCENT_OFFSET) + return cls(units_per_em=units_per_em, ascent=ascent, descent=descent) + + @property + def em_per_pixel_height(self) -> float: + """Ems in one unit of the height the application sizes a font by. + + DearPyGui sizes a font by the span from its ascent to its descent, and CSS by its em, so a + CSS size equals the application's size times this factor. + """ + return self.units_per_em / (self.ascent - self.descent) + + @staticmethod + def _table_offsets(data: bytes) -> Dict[bytes, int]: + (count,) = struct.unpack_from(">H", data, TABLE_COUNT_OFFSET) + offsets: Dict[bytes, int] = {} + for index in range(count): + tag, _, offset, _ = struct.unpack_from( + TABLE_RECORD_FORMAT, data, TABLE_RECORDS_OFFSET + index * TABLE_RECORD_SIZE + ) + offsets[tag] = offset + + return offsets diff --git a/src/sampletones_tools/web/typography/scale.py b/src/sampletones_tools/web/typography/scale.py new file mode 100644 index 000000000..2b3043000 --- /dev/null +++ b/src/sampletones_tools/web/typography/scale.py @@ -0,0 +1,39 @@ +from pathlib import Path +from typing import Dict, Final + +from sampletones_application.layout.fonts import FontsLayout, Step, Typeface +from sampletones_shared.paths.resources import FONT_ICON, FONT_MONO_REGULAR, FONT_SANS_REGULAR +from sampletones_tools.web.typography.metrics import FontMetrics + +SIZE_PRECISION: Final[int] = 2 +TYPEFACE_FILES: Final[Dict[Typeface, str]] = { + Typeface.SANS: FONT_SANS_REGULAR, + Typeface.MONO: FONT_MONO_REGULAR, + Typeface.ICON: FONT_ICON, +} + + +def size_name(typeface: Typeface, step: Step) -> str: + """The name a page knows a size by, such as ``sans-medium``.""" + return f"{typeface.value}-{step.value}" + + +def type_sizes( + fonts: FontsLayout, + directory: Path, +) -> Dict[str, float]: + """Every size the application draws text at, in CSS pixels, keyed by :func:`size_name`. + + Each typeface's sizes are converted with the metrics of its regular face, so a page at device + scale 1 sets text exactly as large as the application draws it. + + Raises: + FileNotFoundError: If ``directory`` lacks a typeface's regular face. + """ + sizes: Dict[str, float] = {} + for typeface in Typeface: + factor = FontMetrics.read(directory / TYPEFACE_FILES[typeface]).em_per_pixel_height * fonts.scale + for step in Step: + sizes[size_name(typeface, step)] = round(fonts.size_for(typeface, step) * factor, SIZE_PRECISION) + + return sizes diff --git a/src/sampletones_tools/web/writer.py b/src/sampletones_tools/web/writer.py new file mode 100644 index 000000000..b030750cb --- /dev/null +++ b/src/sampletones_tools/web/writer.py @@ -0,0 +1,62 @@ +from pathlib import Path +from shutil import copyfile +from typing import Final, Tuple + +from sampletones_tools.web.design import ApplicationDesign +from sampletones_tools.web.fonts import fonts_stylesheet +from sampletones_tools.web.paths import FONTS_DIRECTORY, MARK_SOURCE, STATIC_DIRECTORY +from sampletones_tools.web.roles.resolver import resolve_roles +from sampletones_tools.web.stylesheets import roles_stylesheet +from sampletones_tools.web.typography.scale import type_sizes + +FONTS_STYLESHEET: Final[str] = "fonts.css" +MARK_FILE: Final[str] = "mark.svg" +ROLES_STYLESHEET: Final[str] = "roles.css" +LAYOUT_STYLESHEETS: Final[Tuple[str, ...]] = ( + "base.css", + "shell.css", + "card.css", + "controls.css", + "table.css", + "prose.css", + "print.css", +) +TEXT_NEWLINE: Final[str] = "\n" + + +def write_layout( + directory: Path, + design: ApplicationDesign, +) -> Tuple[str, ...]: + """Write the web layout into ``directory``: the faces, the roles of ``design``, the shared + stylesheets, and the application's mark as :data:`MARK_FILE`. + + Every page the project writes links these files, so a board and the documentation wear one + layout, drawn from the application's own configuration. + + Returns: + The stylesheet file names, in the order a page links them. + + Raises: + FileNotFoundError: If the package lacks a shared stylesheet or a face. + """ + directory.mkdir(parents=True, exist_ok=True) + (directory / FONTS_STYLESHEET).write_text( + fonts_stylesheet(FONTS_DIRECTORY, design.glyph_characters), + encoding="utf-8", + newline=TEXT_NEWLINE, + ) + (directory / ROLES_STYLESHEET).write_text( + roles_stylesheet(resolve_roles(design), type_sizes(design.fonts, FONTS_DIRECTORY)), + encoding="utf-8", + newline=TEXT_NEWLINE, + ) + for name in LAYOUT_STYLESHEETS: + source = STATIC_DIRECTORY / name + if not source.is_file(): + raise FileNotFoundError(f"The package ships no layout stylesheet '{name}' at '{source}'") + + copyfile(source, directory / name) + + copyfile(MARK_SOURCE, directory / MARK_FILE) + return (FONTS_STYLESHEET, ROLES_STYLESHEET, *LAYOUT_STYLESHEETS) diff --git a/tests/unit/sampletones_application/ui/themes/test_loader.py b/tests/unit/sampletones_application/ui/themes/test_loader.py index 205a3c464..13e5dfae3 100644 --- a/tests/unit/sampletones_application/ui/themes/test_loader.py +++ b/tests/unit/sampletones_application/ui/themes/test_loader.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, Generator, Optional +from typing import Dict, Generator import dearpygui.dearpygui as dpg import pytest @@ -11,15 +11,12 @@ ) from sampletones_application.ui.themes.dpg_constants import EVERY_ITEM_TYPE from sampletones_application.ui.themes.loader import ThemeLoader -from sampletones_application.ui.themes.spec import ThemeSpec from sampletones_application.ui.themes.style import ThemeParameter from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.palette import Palette from sampletones_application.utils.palette.source import PaletteSource -_BASE_NAME = "default" - _SYNTHETIC_BASE = """ name: default tag: synthetic.default @@ -52,21 +49,6 @@ """ -def _spec(name: str, *, extends: Optional[str] = None) -> ThemeSpec: - return ThemeSpec(name=name, tag=f"tag.{name}", extends=extends, components=[]) - - -class TestEffectiveParent: - def test_a_theme_inherits_the_base_by_default(self) -> None: - assert ThemeLoader._effective_parent(_spec("converter")) == _BASE_NAME - - def test_the_base_theme_stands_alone(self) -> None: - assert ThemeLoader._effective_parent(_spec(_BASE_NAME)) is None - - def test_an_explicit_parent_is_respected(self) -> None: - assert ThemeLoader._effective_parent(_spec("child", extends="table")) == "table" - - class TestLoadedInheritance: """The real theme set: every theme resolves to the base plus its own overrides, so a bound item theme keeps the base's colors instead of dropping to DearPyGui diff --git a/tests/unit/sampletones_application/utils/theme/__init__.py b/tests/unit/sampletones_application/utils/theme/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/utils/theme/test_catalog.py b/tests/unit/sampletones_application/utils/theme/test_catalog.py new file mode 100644 index 000000000..39887ca15 --- /dev/null +++ b/tests/unit/sampletones_application/utils/theme/test_catalog.py @@ -0,0 +1,162 @@ +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_application.utils.palette.palette import Palette +from sampletones_application.utils.palette.source import PaletteSource +from sampletones_application.utils.theme.catalog import ThemeEntryAddress, ThemeSpecCatalog +from sampletones_application.utils.theme.spec import BASE_THEME_NAME, CORE_CATEGORY, EVERY_ITEM_TYPE_NAME + +_BASE: Final[str] = """ +name: default +tag: synthetic.default +components: + - item_type: All + entries: + - type: color + key: Text + value: .ink + - type: color + key: Border + value: "#101010" + - type: style + key: ChildRounding + x: 8 + - item_type: Button + entries: + - type: color + key: Button + value: .ink + - item_type: Button + enabled: false + entries: + - type: color + key: Button + value: "#2e2e3c" +""" +_PANEL: Final[str] = """ +name: panel +tag: synthetic.panel +components: + - item_type: All + entries: + - type: color + key: Text + value: .paper +""" +_INNER: Final[str] = """ +name: inner +tag: synthetic.inner +extends: panel +components: + - item_type: All + entries: + - type: color + key: ChildBg + value: .paper +""" +_PALETTE: Final[str] = """ +name: synthetic +colors: + ink: "#e0e0e0" + paper: "#202020" +""" + + +def _address(key: str, *, item_type: str, enabled: bool) -> ThemeEntryAddress: + return ThemeEntryAddress(item_type=item_type, enabled=enabled, category=CORE_CATEGORY, key=key) + + +@pytest.fixture +def source(tmp_path: Path) -> PaletteSource: + palette_file = tmp_path / "synthetic.yaml" + palette_file.write_text(_PALETTE) + return PaletteSource(Palette.load(palette_file)) + + +@pytest.fixture +def catalog(tmp_path: Path, source: PaletteSource) -> ThemeSpecCatalog: + themes = tmp_path / "themes" + themes.mkdir() + (themes / "default.yaml").write_text(_BASE) + (themes / "panel.yaml").write_text(_PANEL) + (themes / "inner.yaml").write_text(_INNER) + return ThemeSpecCatalog.load(themes, source) + + +class TestComposition: + def test_parents_come_before_the_themes_built_on_them(self, catalog: ThemeSpecCatalog) -> None: + names = [spec.name for spec in catalog.ordered] + + assert names.index(BASE_THEME_NAME) < names.index("panel") < names.index("inner") + + def test_a_theme_keeps_its_own_override(self, catalog: ThemeSpecCatalog, source: PaletteSource) -> None: + color = catalog.color("panel", _address("Text", item_type=EVERY_ITEM_TYPE_NAME, enabled=True)) + + assert color.rgba == source.palette.colors["paper"] + + def test_a_theme_inherits_what_its_parents_state(self, catalog: ThemeSpecCatalog) -> None: + inherited = catalog.color("inner", _address("Border", item_type=EVERY_ITEM_TYPE_NAME, enabled=True)) + base = catalog.color(BASE_THEME_NAME, _address("Border", item_type=EVERY_ITEM_TYPE_NAME, enabled=True)) + + assert inherited.rgba == base.rgba + + +class TestLookup: + def test_an_item_type_reads_the_every_type_component_where_its_own_states_nothing( + self, + catalog: ThemeSpecCatalog, + ) -> None: + button_text = catalog.color(BASE_THEME_NAME, _address("Text", item_type="Button", enabled=True)) + every_text = catalog.color(BASE_THEME_NAME, _address("Text", item_type=EVERY_ITEM_TYPE_NAME, enabled=True)) + + assert button_text.rgba == every_text.rgba + + def test_the_disabled_state_reads_the_enabled_entry_where_it_states_none( + self, + catalog: ThemeSpecCatalog, + ) -> None: + disabled = catalog.color(BASE_THEME_NAME, _address("Text", item_type=EVERY_ITEM_TYPE_NAME, enabled=False)) + enabled = catalog.color(BASE_THEME_NAME, _address("Text", item_type=EVERY_ITEM_TYPE_NAME, enabled=True)) + + assert disabled.rgba == enabled.rgba + + def test_an_explicit_disabled_entry_wins(self, catalog: ThemeSpecCatalog) -> None: + disabled = catalog.color(BASE_THEME_NAME, _address("Button", item_type="Button", enabled=False)) + enabled = catalog.color(BASE_THEME_NAME, _address("Button", item_type="Button", enabled=True)) + + assert disabled.rgba != enabled.rgba + + def test_a_style_answers_with_its_components(self, catalog: ThemeSpecCatalog) -> None: + assert catalog.style(BASE_THEME_NAME, _address("ChildRounding", item_type="Button", enabled=True)) == ( + 8.0, + -1.0, + ) + + def test_an_entry_no_component_states_is_refused(self, catalog: ThemeSpecCatalog) -> None: + with pytest.raises(KeyError): + catalog.color(BASE_THEME_NAME, _address("WindowBg", item_type=EVERY_ITEM_TYPE_NAME, enabled=True)) + + def test_a_style_read_as_a_color_is_refused(self, catalog: ThemeSpecCatalog) -> None: + with pytest.raises(KeyError): + catalog.color(BASE_THEME_NAME, _address("ChildRounding", item_type=EVERY_ITEM_TYPE_NAME, enabled=True)) + + +class TestInheritanceErrors: + def test_an_unknown_parent_is_refused(self, tmp_path: Path, source: PaletteSource) -> None: + (tmp_path / "default.yaml").write_text(_BASE) + (tmp_path / "inner.yaml").write_text(_INNER) + + with pytest.raises(ValueError): + ThemeSpecCatalog.load(tmp_path, source) + + def test_a_cycle_is_refused(self, tmp_path: Path, source: PaletteSource) -> None: + (tmp_path / "default.yaml").write_text(_BASE) + (tmp_path / "panel.yaml").write_text( + _PANEL.replace("tag: synthetic.panel", "tag: synthetic.panel\nextends: inner") + ) + (tmp_path / "inner.yaml").write_text(_INNER) + + with pytest.raises(ValueError): + ThemeSpecCatalog.load(tmp_path, source) diff --git a/tests/unit/sampletones_application/utils/theme/test_spec.py b/tests/unit/sampletones_application/utils/theme/test_spec.py new file mode 100644 index 000000000..87a17da3c --- /dev/null +++ b/tests/unit/sampletones_application/utils/theme/test_spec.py @@ -0,0 +1,18 @@ +from typing import Optional + +from sampletones_application.utils.theme.spec import BASE_THEME_NAME, ThemeSpec + + +def _spec(name: str, *, extends: Optional[str]) -> ThemeSpec: + return ThemeSpec(name=name, tag=f"tag.{name}", extends=extends, components=[]) + + +class TestParent: + def test_a_theme_inherits_the_base_by_default(self) -> None: + assert _spec("converter", extends=None).parent == BASE_THEME_NAME + + def test_the_base_theme_stands_alone(self) -> None: + assert _spec(BASE_THEME_NAME, extends=None).parent is None + + def test_an_explicit_parent_is_respected(self) -> None: + assert _spec("child", extends="table").parent == "table" diff --git a/tests/unit/sampletones_tools/calibration/board/test_composition.py b/tests/unit/sampletones_tools/calibration/board/test_composition.py index 497a9d930..779394464 100644 --- a/tests/unit/sampletones_tools/calibration/board/test_composition.py +++ b/tests/unit/sampletones_tools/calibration/board/test_composition.py @@ -9,13 +9,15 @@ from sampletones_tools.calibration.board.model import BoardPage from sampletones_tools.calibration.board.reading import read_runs from sampletones_tools.calibration.layout import combination_name +from sampletones_tools.web.roles.table import channel_role TITLE: Final[str] = "Calibration" +GLYPH: Final[str] = "≈" def page_of(directories: List[Path], output: Path) -> BoardPage: runs = read_runs(directories) - return compose_page(runs, clip_store(runs, output), TITLE) + return compose_page(runs, clip_store(runs, output), TITLE, GLYPH) class TestOneRun: @@ -41,7 +43,8 @@ def test_the_referee_and_the_channel_colors_travel_with_the_page(self, run: Path page = page_of([run], run) assert page.referee == "mr-auditory-dB" - assert page.colors == {channel.value: f"channel_{channel.value}" for channel in ChannelName} + assert page.glyph == GLYPH + assert page.colors == {channel.value: channel_role(channel.value) for channel in ChannelName} class TestChannels: @@ -83,4 +86,4 @@ def test_a_row_holds_one_cell_per_run(self, runs: List[Path], tmp_path: Path) -> class TestNoRun: def test_a_page_without_a_run_is_refused(self) -> None: with pytest.raises(ValueError, match="at least one run"): - compose_page([], InPlaceClips(), TITLE) + compose_page([], InPlaceClips(), TITLE, GLYPH) diff --git a/tests/unit/sampletones_tools/calibration/board/test_data.py b/tests/unit/sampletones_tools/calibration/board/test_data.py index 21cefddbc..0cf4f89cf 100644 --- a/tests/unit/sampletones_tools/calibration/board/test_data.py +++ b/tests/unit/sampletones_tools/calibration/board/test_data.py @@ -12,7 +12,13 @@ def page() -> BoardPage: cell = BoardCell(render="renders/cqt-pe1/tone-a.flac", score=9.5, silence=20.0, channels=(channel,)) row = BoardRow(item="tone-a", category="tone", recording="recordings/tone-a.flac", cells=(cell,)) group = BoardGroup(name="run-a", columns=("cqt-pe1",), rows=(row,)) - return BoardPage(title="Calibration", referee="mr-auditory-dB", colors={"noise": "channel_noise"}, groups=(group,)) + return BoardPage( + title="Calibration", + referee="mr-auditory-dB", + glyph="≈", + colors={"noise": "channel-noise"}, + groups=(group,), + ) class TestDataScript: diff --git a/tests/unit/sampletones_tools/calibration/board/test_fonts.py b/tests/unit/sampletones_tools/calibration/board/test_fonts.py deleted file mode 100644 index 3044906db..000000000 --- a/tests/unit/sampletones_tools/calibration/board/test_fonts.py +++ /dev/null @@ -1,37 +0,0 @@ -from pathlib import Path -from typing import Final - -import pytest - -from sampletones_tools.calibration.board.fonts import ( - FONT_MEDIA_TYPE, - PAGE_FONTS, - PageFont, - fonts_stylesheet, -) -from sampletones_tools.calibration.board.paths import FONTS_DIRECTORY - -FACE_RULE: Final[str] = "@font-face" - - -class TestFontsStylesheet: - def test_every_face_carries_its_own_data(self) -> None: - stylesheet = fonts_stylesheet() - - assert stylesheet.count(FACE_RULE) == len(PAGE_FONTS) - assert stylesheet.count(f"data:{FONT_MEDIA_TYPE};base64,") == len(PAGE_FONTS) - - @pytest.mark.parametrize("font", PAGE_FONTS, ids=lambda font: font.filename) - def test_a_face_names_the_family_and_weight_it_answers_at(self, font: PageFont) -> None: - stylesheet = fonts_stylesheet() - - assert f'font-family: "{font.family}";' in stylesheet - assert f"font-weight: {font.weight};" in stylesheet - - @pytest.mark.parametrize("font", PAGE_FONTS, ids=lambda font: font.filename) - def test_the_assets_hold_the_face_it_reads(self, font: PageFont) -> None: - assert (FONTS_DIRECTORY / font.filename).is_file() - - def test_a_missing_face_is_reported(self, tmp_path: Path) -> None: - with pytest.raises(FileNotFoundError): - fonts_stylesheet(tmp_path) diff --git a/tests/unit/sampletones_tools/calibration/board/test_palette.py b/tests/unit/sampletones_tools/calibration/board/test_palette.py deleted file mode 100644 index ecc74de25..000000000 --- a/tests/unit/sampletones_tools/calibration/board/test_palette.py +++ /dev/null @@ -1,46 +0,0 @@ -import re -from typing import Dict, Final - -import pytest - -from sampletones_tools.calibration.board.palette import ( - CHANNEL_TOKEN_PREFIX, - board_palette, - channel_token, - css_color, - palette_names, - palette_stylesheet, -) - -DECLARATION: Final[re.Pattern[str]] = re.compile(r"--([a-z0-9_]+): (#[0-9a-f]{6}(?:[0-9a-f]{2})?);") - - -def declared(stylesheet: str) -> Dict[str, str]: - return dict(DECLARATION.findall(stylesheet)) - - -class TestPaletteStylesheet: - @pytest.mark.parametrize("name", palette_names()) - def test_every_token_reaches_the_page_under_its_own_name(self, name: str) -> None: - palette = board_palette(name) - - written = declared(palette_stylesheet(palette)) - - assert written == {token: css_color(color) for token, color in palette.colors.items()} - - def test_an_unknown_palette_is_refused(self) -> None: - with pytest.raises(KeyError): - board_palette("sunset") - - -class TestCssColor: - def test_an_opaque_color_is_six_digits(self) -> None: - assert css_color((26, 27, 38, 255)) == "#1a1b26" - - def test_a_translucent_color_carries_its_alpha(self) -> None: - assert css_color((26, 27, 38, 170)) == "#1a1b26aa" - - -class TestChannelToken: - def test_a_channel_stands_under_the_prefix_the_palette_uses(self) -> None: - assert channel_token("noise") == f"{CHANNEL_TOKEN_PREFIX}noise" diff --git a/tests/unit/sampletones_tools/calibration/board/test_session.py b/tests/unit/sampletones_tools/calibration/board/test_session.py index 781d00e01..8564ba556 100644 --- a/tests/unit/sampletones_tools/calibration/board/test_session.py +++ b/tests/unit/sampletones_tools/calibration/board/test_session.py @@ -6,16 +6,17 @@ from sampletones_tools.calibration.board.layout import ( DATA_FILE, - FONTS_FILE, GENERATED_FILES, PAGE_DIRECTORY, PAGE_FILE, - PALETTE_FILE, SHIPPED_FILES, ) -from sampletones_tools.calibration.board.palette import board_palette, palette_names, palette_stylesheet from sampletones_tools.calibration.board.paths import STATIC_DIRECTORY from sampletones_tools.calibration.board.session import BoardRequest, build_board +from sampletones_tools.web.colors import css_color +from sampletones_tools.web.design import ApplicationDesign, palette_names +from sampletones_tools.web.roles.table import COLOR_ROLES +from sampletones_tools.web.writer import FONTS_STYLESHEET, ROLES_STYLESHEET MARKUP_REFERENCE: Final[re.Pattern[str]] = re.compile(r'(?:src|href)="([^"]+)"') CLIP_REFERENCE: Final[re.Pattern[str]] = re.compile(r'"((?:[\w.+-]+/)+[\w.+-]+\.flac)"') @@ -28,7 +29,7 @@ def referenced(page: Path) -> Set[str]: directory = page.parent / PAGE_DIRECTORY data = (directory / DATA_FILE).read_text(encoding="utf-8") references.update(CLIP_REFERENCE.findall(data)) - for name in (PALETTE_FILE, FONTS_FILE): + for name in (ROLES_STYLESHEET, FONTS_STYLESHEET): references.update( f"{PAGE_DIRECTORY}/{reference}" for reference in STYLE_REFERENCE.findall((directory / name).read_text(encoding="utf-8")) @@ -74,8 +75,9 @@ def test_the_shipped_files_travel_unchanged(self, run: Path) -> None: def test_a_page_is_drawn_in_the_palette_it_is_asked_for(self, run: Path, name: str) -> None: build_board(BoardRequest(runs=[run], output=run, palette=name)) - stylesheet = (run / PAGE_DIRECTORY / PALETTE_FILE).read_text(encoding="utf-8") - assert stylesheet == palette_stylesheet(board_palette(name)) + stylesheet = (run / PAGE_DIRECTORY / ROLES_STYLESHEET).read_text(encoding="utf-8") + ground = COLOR_ROLES["ground"].resolve(ApplicationDesign.load(name)) + assert f"--ground: {css_color(ground)};" in stylesheet def test_an_unknown_palette_is_refused(self, run: Path) -> None: with pytest.raises(KeyError): diff --git a/tests/unit/sampletones_tools/calibration/board/test_static.py b/tests/unit/sampletones_tools/calibration/board/test_static.py index e57d35777..5f4c69e53 100644 --- a/tests/unit/sampletones_tools/calibration/board/test_static.py +++ b/tests/unit/sampletones_tools/calibration/board/test_static.py @@ -4,19 +4,19 @@ import pytest +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME from sampletones_core.constants.enums import ChannelName from sampletones_tools.calibration.board.layout import ( DATA_FILE, - FONTS_FILE, PAGE_DIRECTORY, PAGE_FILE, - PALETTE_FILE, SCRIPT_FILE, SHIPPED_FILES, STYLE_FILE, ) -from sampletones_tools.calibration.board.palette import board_palette, palette_names from sampletones_tools.calibration.board.paths import STATIC_DIRECTORY +from sampletones_tools.web.design import ApplicationDesign +from sampletones_tools.web.writer import FONTS_STYLESHEET, LAYOUT_STYLESHEETS, MARK_FILE, ROLES_STYLESHEET, write_layout REACHING_OUT: Final[Tuple[str, ...]] = ( "fetch(", @@ -31,7 +31,8 @@ ) REFERENCE: Final[re.Pattern[str]] = re.compile(r'(?:src|href)="([^"]+)"') COLOR_LITERAL: Final[re.Pattern[str]] = re.compile(r"#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(") -TOKEN: Final[re.Pattern[str]] = re.compile(r"var\(--([a-z0-9_]+)\)") +USED: Final[re.Pattern[str]] = re.compile(r"var\(--([a-z0-9-]+)\)") +DECLARED: Final[re.Pattern[str]] = re.compile(r"--([a-z0-9-]+):") REFEREE_NAMES: Final[Tuple[str, ...]] = ("mr-auditory", "mr-loudness", "zimtohrli") @@ -59,24 +60,26 @@ def test_every_reference_is_relative(self) -> None: assert [reference for reference in references if Path(reference).is_absolute()] == [] def test_the_page_names_exactly_the_files_the_layout_declares(self) -> None: - expected = { - f"{PAGE_DIRECTORY}/{name}" for name in (PALETTE_FILE, FONTS_FILE, STYLE_FILE, DATA_FILE, SCRIPT_FILE) - } + names = (FONTS_STYLESHEET, ROLES_STYLESHEET, *LAYOUT_STYLESHEETS, MARK_FILE, STYLE_FILE, DATA_FILE, SCRIPT_FILE) + expected = {f"{PAGE_DIRECTORY}/{name}" for name in names} assert set(REFERENCE.findall(shipped(PAGE_FILE))) == expected -class TestStylesheetDrawsFromThePalette: +class TestStylesheetDrawsFromTheLayout: def test_no_color_is_written_into_the_stylesheet(self) -> None: assert COLOR_LITERAL.findall(shipped(STYLE_FILE)) == [] - @pytest.mark.parametrize("name", palette_names()) - def test_every_token_it_names_exists_in_every_palette(self, name: str) -> None: - colors = board_palette(name).colors - named = set(TOKEN.findall(shipped(STYLE_FILE))) + def test_every_variable_it_reads_is_declared(self, tmp_path: Path) -> None: + write_layout(tmp_path, ApplicationDesign.load(DEFAULT_PALETTE_NAME)) + declared = set(DECLARED.findall(shipped(STYLE_FILE))) + for stylesheet in tmp_path.glob("*.css"): + declared.update(DECLARED.findall(stylesheet.read_text(encoding="utf-8"))) - assert named - assert [token for token in sorted(named) if token not in colors] == [] + used = set(USED.findall(shipped(STYLE_FILE))) + + assert used + assert sorted(used - declared) == [] class TestScriptStatesNoMeasurement: diff --git a/tests/unit/sampletones_tools/calibration/test_command.py b/tests/unit/sampletones_tools/calibration/test_command.py index d0383e8a5..44a9464c1 100644 --- a/tests/unit/sampletones_tools/calibration/test_command.py +++ b/tests/unit/sampletones_tools/calibration/test_command.py @@ -5,10 +5,10 @@ from sampletones.commands.registry import COMMANDS from sampletones.dispatcher import dispatch +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, SpectrumMethod from sampletones_tools.calibration.board.layout import PAGE_FILE -from sampletones_tools.calibration.board.palette import DEFAULT_PALETTE from sampletones_tools.calibration.board.session import BoardRequest from sampletones_tools.calibration.config.suite import SuiteConfig from sampletones_tools.calibration.layout import MARKDOWN_REPORT @@ -142,7 +142,7 @@ def test_without_a_palette_the_page_takes_the_application_default( ) -> None: assert dispatch(COMMANDS, ["calibration"]) == 0 - assert calibration.requests[0].palette == DEFAULT_PALETTE + assert calibration.requests[0].palette == DEFAULT_PALETTE_NAME class TestBoard: diff --git a/tests/unit/sampletones_tools/calibration/test_session.py b/tests/unit/sampletones_tools/calibration/test_session.py index c04852089..96ea83817 100644 --- a/tests/unit/sampletones_tools/calibration/test_session.py +++ b/tests/unit/sampletones_tools/calibration/test_session.py @@ -4,10 +4,10 @@ import pytest +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, SpectrumMethod from sampletones_tools.calibration.board.layout import PAGE_FILE -from sampletones_tools.calibration.board.palette import DEFAULT_PALETTE from sampletones_tools.calibration.board.session import BoardRequest from sampletones_tools.calibration.corpus.item import CorpusItem from sampletones_tools.calibration.layout import CSV_REPORT, MARKDOWN_REPORT @@ -166,7 +166,7 @@ def test_a_run_asked_for_no_palette_draws_its_page_in_the_application_default( calibrate(request) - assert drawn == [DEFAULT_PALETTE] + assert drawn == [DEFAULT_PALETTE_NAME] def test_a_run_evaluates_with_the_channels_it_is_asked_for( self, diff --git a/tests/unit/sampletones_tools/web/__init__.py b/tests/unit/sampletones_tools/web/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/web/conftest.py b/tests/unit/sampletones_tools/web/conftest.py new file mode 100644 index 000000000..baf2ea87b --- /dev/null +++ b/tests/unit/sampletones_tools/web/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from sampletones_tools.web.design import ApplicationDesign + + +@pytest.fixture(scope="module") +def design() -> ApplicationDesign: + return ApplicationDesign.load(DEFAULT_PALETTE_NAME) diff --git a/tests/unit/sampletones_tools/web/test_colors.py b/tests/unit/sampletones_tools/web/test_colors.py new file mode 100644 index 000000000..c0c51d9a6 --- /dev/null +++ b/tests/unit/sampletones_tools/web/test_colors.py @@ -0,0 +1,17 @@ +from sampletones_tools.web.colors import DARK_SCHEME, LIGHT_SCHEME, color_scheme, css_color + + +class TestCssColor: + def test_an_opaque_color_is_six_digits(self) -> None: + assert css_color((26, 27, 38, 255)) == "#1a1b26" + + def test_a_translucent_color_carries_its_alpha(self) -> None: + assert css_color((26, 27, 38, 170)) == "#1a1b26aa" + + +class TestColorScheme: + def test_a_dark_ground_asks_for_the_dark_scheme(self) -> None: + assert color_scheme((28, 27, 38, 255)) == DARK_SCHEME + + def test_a_light_ground_asks_for_the_light_scheme(self) -> None: + assert color_scheme((230, 232, 236, 255)) == LIGHT_SCHEME diff --git a/tests/unit/sampletones_tools/web/test_design.py b/tests/unit/sampletones_tools/web/test_design.py new file mode 100644 index 000000000..a3d78e74f --- /dev/null +++ b/tests/unit/sampletones_tools/web/test_design.py @@ -0,0 +1,21 @@ +import pytest + +from sampletones_tools.web.design import ApplicationDesign, palette_names + + +class TestApplicationDesign: + def test_an_unknown_palette_is_refused(self) -> None: + with pytest.raises(KeyError): + ApplicationDesign.load("sunset") + + def test_the_glyph_characters_hold_every_glyph_once(self, design: ApplicationDesign) -> None: + characters = design.glyph_characters + + assert design.glyphs.common.tick in characters + assert design.glyphs.headers.reconstruction in characters + assert len(set(characters)) == len(characters) + + +class TestPaletteNames: + def test_the_shipped_palettes_are_listed(self) -> None: + assert palette_names() diff --git a/tests/unit/sampletones_tools/web/test_fonts.py b/tests/unit/sampletones_tools/web/test_fonts.py new file mode 100644 index 000000000..e9bf467f7 --- /dev/null +++ b/tests/unit/sampletones_tools/web/test_fonts.py @@ -0,0 +1,47 @@ +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_tools.web.fonts import ( + FONT_MEDIA_TYPE, + ICON_FONT, + PAGE_FONTS, + PageFont, + fonts_stylesheet, + unicode_range, +) +from sampletones_tools.web.paths import FONTS_DIRECTORY + +FACE_RULE: Final[str] = "@font-face" +GLYPHS: Final[str] = "▍▾▸" + + +class TestFontsStylesheet: + def test_every_face_carries_its_own_data(self) -> None: + stylesheet = fonts_stylesheet(FONTS_DIRECTORY, GLYPHS) + + assert stylesheet.count(FACE_RULE) == len(PAGE_FONTS) + 1 + assert stylesheet.count(f"data:{FONT_MEDIA_TYPE};base64,") == len(PAGE_FONTS) + 1 + + @pytest.mark.parametrize("font", (*PAGE_FONTS, ICON_FONT), ids=lambda font: f"{font.filename}-{font.style}") + def test_the_assets_hold_the_face_it_reads(self, font: PageFont) -> None: + assert (FONTS_DIRECTORY / font.filename).is_file() + + def test_only_the_glyph_face_is_limited_to_the_glyphs(self) -> None: + stylesheet = fonts_stylesheet(FONTS_DIRECTORY, GLYPHS) + + assert stylesheet.count("unicode-range:") == 1 + assert f"unicode-range: {unicode_range(GLYPHS)};" in stylesheet + + def test_a_missing_face_is_reported(self, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + fonts_stylesheet(tmp_path, GLYPHS) + + +class TestUnicodeRange: + def test_consecutive_code_points_join_into_a_span(self) -> None: + assert unicode_range("cab") == "U+0061-0063" + + def test_apart_code_points_stay_apart(self) -> None: + assert unicode_range("▸▍") == "U+258D, U+25B8" diff --git a/tests/unit/sampletones_tools/web/test_layout.py b/tests/unit/sampletones_tools/web/test_layout.py new file mode 100644 index 000000000..831994084 --- /dev/null +++ b/tests/unit/sampletones_tools/web/test_layout.py @@ -0,0 +1,70 @@ +import re +from pathlib import Path +from typing import Final, Set + +import pytest + +from sampletones_tools.web.design import ApplicationDesign +from sampletones_tools.web.paths import STATIC_DIRECTORY +from sampletones_tools.web.writer import FONTS_STYLESHEET, LAYOUT_STYLESHEETS, MARK_FILE, ROLES_STYLESHEET, write_layout + +COLOR_LITERAL: Final[re.Pattern[str]] = re.compile(r"#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(") +USED: Final[re.Pattern[str]] = re.compile(r"var\(--([a-z0-9-]+)\)") +DECLARED: Final[re.Pattern[str]] = re.compile(r"--([a-z0-9-]+):") +URL: Final[re.Pattern[str]] = re.compile(r'url\("?([^")]+)') + + +@pytest.fixture(scope="module") +def written(tmp_path_factory: pytest.TempPathFactory, design: ApplicationDesign) -> Path: + directory = tmp_path_factory.mktemp("layout") + write_layout(directory, design) + return directory + + +def declared_names(directory: Path) -> Set[str]: + names: Set[str] = set() + for stylesheet in directory.glob("*.css"): + names.update(DECLARED.findall(stylesheet.read_text(encoding="utf-8"))) + return names + + +class TestStaticStylesheets: + @pytest.mark.parametrize("name", LAYOUT_STYLESHEETS) + def test_no_color_is_written_into_a_shared_stylesheet(self, name: str) -> None: + assert COLOR_LITERAL.findall((STATIC_DIRECTORY / name).read_text(encoding="utf-8")) == [] + + @pytest.mark.parametrize("name", LAYOUT_STYLESHEETS) + def test_every_variable_a_stylesheet_reads_is_declared(self, name: str, written: Path) -> None: + used = set(USED.findall((STATIC_DIRECTORY / name).read_text(encoding="utf-8"))) + + assert sorted(used - declared_names(written)) == [] + + +class TestWriteLayout: + def test_returns_the_stylesheets_in_link_order(self, tmp_path: Path, design: ApplicationDesign) -> None: + assert write_layout(tmp_path, design) == (FONTS_STYLESHEET, ROLES_STYLESHEET, *LAYOUT_STYLESHEETS) + + def test_every_file_lands_beside_the_mark(self, written: Path) -> None: + for name in (FONTS_STYLESHEET, ROLES_STYLESHEET, *LAYOUT_STYLESHEETS, MARK_FILE): + assert (written / name).is_file() + + def test_the_faces_travel_inside_the_stylesheet(self, written: Path) -> None: + urls = URL.findall((written / FONTS_STYLESHEET).read_text(encoding="utf-8")) + + assert urls + assert [url for url in urls if not url.startswith("data:")] == [] + + def test_the_roles_carry_the_ground_of_the_design(self, written: Path, design: ApplicationDesign) -> None: + roles = (written / ROLES_STYLESHEET).read_text(encoding="utf-8") + + assert "--ground: #" in roles + assert "--size-sans-medium:" in roles + assert "--font-icon:" in roles + + def test_the_output_is_the_same_on_every_write(self, tmp_path: Path, design: ApplicationDesign) -> None: + first, second = tmp_path / "first", tmp_path / "second" + write_layout(first, design) + write_layout(second, design) + + for path in first.iterdir(): + assert path.read_bytes() == (second / path.name).read_bytes() diff --git a/tests/unit/sampletones_tools/web/test_metrics.py b/tests/unit/sampletones_tools/web/test_metrics.py new file mode 100644 index 000000000..0fe23e783 --- /dev/null +++ b/tests/unit/sampletones_tools/web/test_metrics.py @@ -0,0 +1,43 @@ +import struct +from pathlib import Path + +import pytest + +from sampletones_tools.web.paths import FONTS_DIRECTORY +from sampletones_tools.web.typography.metrics import FontMetrics +from sampletones_tools.web.typography.scale import TYPEFACE_FILES + + +def _font(path: Path, *, units_per_em: int, ascent: int, descent: int) -> Path: + """A TrueType file holding only the two tables the reader needs.""" + head = bytearray(54) + struct.pack_into(">H", head, 18, units_per_em) + hhea = bytearray(36) + struct.pack_into(">hh", hhea, 4, ascent, descent) + tables = [(b"head", bytes(head)), (b"hhea", bytes(hhea))] + offset = 12 + 16 * len(tables) + directory = struct.pack(">IHHHH", 0x00010000, len(tables), 0, 0, 0) + body = b"" + for tag, data in tables: + directory += struct.pack(">4sIII", tag, 0, offset + len(body), len(data)) + body += data + path.write_bytes(directory + body) + return path + + +class TestFontMetrics: + def test_reads_the_head_and_hhea_tables(self, tmp_path: Path) -> None: + metrics = FontMetrics.read(_font(tmp_path / "face.ttf", units_per_em=1000, ascent=1100, descent=-300)) + + assert (metrics.units_per_em, metrics.ascent, metrics.descent) == (1000, 1100, -300) + + def test_the_pixel_factor_spans_the_ascent_to_the_descent(self) -> None: + metrics = FontMetrics(units_per_em=1000, ascent=1100, descent=-300) + + assert metrics.em_per_pixel_height == pytest.approx(1000 / 1400) + + @pytest.mark.parametrize("filename", sorted(set(TYPEFACE_FILES.values()))) + def test_every_shipped_regular_face_reads(self, filename: str) -> None: + metrics = FontMetrics.read(FONTS_DIRECTORY / filename) + + assert 0 < metrics.em_per_pixel_height < 1 diff --git a/tests/unit/sampletones_tools/web/test_roles.py b/tests/unit/sampletones_tools/web/test_roles.py new file mode 100644 index 000000000..dd3874a72 --- /dev/null +++ b/tests/unit/sampletones_tools/web/test_roles.py @@ -0,0 +1,32 @@ +import pytest + +from sampletones_tools.web.design import ApplicationDesign, palette_names +from sampletones_tools.web.roles.resolver import resolve_roles +from sampletones_tools.web.roles.table import CHANNEL_ROLE_PREFIX, COLOR_ROLES, DIMENSION_ROLES, channel_role + + +class TestRoleTable: + @pytest.mark.parametrize("name", palette_names()) + def test_every_role_resolves_in_every_palette(self, name: str) -> None: + sheet = resolve_roles(ApplicationDesign.load(name)) + + assert set(sheet.colors) == set(COLOR_ROLES) + assert set(sheet.dimensions) == set(DIMENSION_ROLES) + + def test_a_role_reads_what_the_bound_theme_states(self, design: ApplicationDesign) -> None: + sheet = resolve_roles(design) + + assert sheet.colors["recess"] == COLOR_ROLES["recess"].resolve(design) + assert sheet.colors["heading"] != sheet.colors["text"] + + def test_a_dimension_reads_the_layout(self, design: ApplicationDesign) -> None: + assert resolve_roles(design).dimensions["header-height"] == design.general.collapse.header_bar_height + + +class TestChannelRole: + def test_a_channel_stands_under_the_prefix(self) -> None: + assert channel_role("noise") == f"{CHANNEL_ROLE_PREFIX}noise" + + def test_every_channel_role_is_in_the_table(self) -> None: + for channel in ("pulse1", "pulse2", "triangle", "noise"): + assert channel_role(channel) in COLOR_ROLES diff --git a/tests/unit/sampletones_tools/web/test_scale.py b/tests/unit/sampletones_tools/web/test_scale.py new file mode 100644 index 000000000..725767890 --- /dev/null +++ b/tests/unit/sampletones_tools/web/test_scale.py @@ -0,0 +1,33 @@ +from pathlib import Path + +import pytest + +from sampletones_application.layout.fonts import Step, Typeface +from sampletones_tools.web.design import ApplicationDesign +from sampletones_tools.web.paths import FONTS_DIRECTORY +from sampletones_tools.web.typography.metrics import FontMetrics +from sampletones_tools.web.typography.scale import TYPEFACE_FILES, size_name, type_sizes + + +class TestTypeSizes: + def test_every_typeface_and_step_has_a_size(self, design: ApplicationDesign) -> None: + sizes = type_sizes(design.fonts, FONTS_DIRECTORY) + + assert set(sizes) == {size_name(typeface, step) for typeface in Typeface for step in Step} + + @pytest.mark.parametrize("typeface", list(Typeface)) + def test_a_size_is_the_application_size_in_the_face_s_ems( + self, + design: ApplicationDesign, + typeface: Typeface, + ) -> None: + factor = FontMetrics.read(FONTS_DIRECTORY / TYPEFACE_FILES[typeface]).em_per_pixel_height + expected = design.fonts.size_for(typeface, Step.MEDIUM) * design.fonts.scale * factor + + assert type_sizes(design.fonts, FONTS_DIRECTORY)[size_name(typeface, Step.MEDIUM)] == pytest.approx( + expected, abs=0.01 + ) + + def test_a_missing_face_is_reported(self, design: ApplicationDesign, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + type_sizes(design.fonts, tmp_path) From f5e5c57d1676f8f177d5d709903d78e75ba4e2a6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 14:12:18 +0200 Subject: [PATCH 2/8] Added: the documentation maker in assets, writing the user docs as pages in the application's look --- .gitignore | 1 + Makefile | 6 +- assets/documentation/__init__.py | 0 assets/documentation/__main__.py | 48 +++++ assets/documentation/build.py | 172 ++++++++++++++++++ assets/documentation/links/__init__.py | 0 assets/documentation/links/resolver.py | 111 +++++++++++ assets/documentation/markdown/__init__.py | 0 assets/documentation/markdown/anchors.py | 28 +++ assets/documentation/markdown/parser.py | 40 ++++ assets/documentation/navigation.py | 89 +++++++++ assets/documentation/pages/__init__.py | 0 assets/documentation/pages/model.py | 37 ++++ assets/documentation/pages/reader.py | 51 ++++++ assets/documentation/pages/sections.py | 76 ++++++++ assets/documentation/paths.py | 14 ++ assets/documentation/project.py | 32 ++++ assets/documentation/published.py | 34 ++++ assets/documentation/rendering/__init__.py | 0 assets/documentation/rendering/cards.py | 22 +++ assets/documentation/rendering/navigation.py | 64 +++++++ assets/documentation/rendering/page.py | 54 ++++++ assets/documentation/static/__init__.py | 0 assets/documentation/static/documentation.css | 121 ++++++++++++ assets/documentation/static/page.html | 41 +++++ pyproject.toml | 2 +- scripts/bootstrap/layout.py | 11 +- .../ui/resources/loader.py | 4 +- .../documentation/__init__.py | 0 src/sampletones_shared/documentation/site.py | 61 +++++++ src/sampletones_shared/paths/resources.py | 1 + .../assets/documentation/__init__.py | 0 .../assets/documentation/test_site.py | 155 ++++++++++++++++ tests/integration/docs/test_documentation.py | 9 +- tests/unit/assets/documentation/__init__.py | 0 tests/unit/assets/documentation/conftest.py | 121 ++++++++++++ .../assets/documentation/links/__init__.py | 0 .../documentation/links/test_resolver.py | 124 +++++++++++++ .../assets/documentation/markdown/__init__.py | 0 .../documentation/markdown/test_anchors.py | 45 +++++ .../documentation/markdown/test_parser.py | 19 ++ .../assets/documentation/pages/__init__.py | 0 .../assets/documentation/pages/test_reader.py | 24 +++ .../documentation/pages/test_sections.py | 62 +++++++ .../documentation/rendering/__init__.py | 0 .../documentation/rendering/test_cards.py | 22 +++ .../rendering/test_navigation.py | 41 +++++ .../documentation/rendering/test_page.py | 40 ++++ tests/unit/assets/documentation/test_build.py | 92 ++++++++++ tests/unit/assets/documentation/test_main.py | 43 +++++ .../assets/documentation/test_navigation.py | 20 ++ .../unit/assets/documentation/test_project.py | 18 ++ .../assets/documentation/test_published.py | 46 +++++ .../unit/assets/documentation/test_static.py | 49 +++++ .../documentation/__init__.py | 0 .../documentation/test_site.py | 47 +++++ uv.lock | 8 +- 57 files changed, 2091 insertions(+), 14 deletions(-) create mode 100644 assets/documentation/__init__.py create mode 100644 assets/documentation/__main__.py create mode 100644 assets/documentation/build.py create mode 100644 assets/documentation/links/__init__.py create mode 100644 assets/documentation/links/resolver.py create mode 100644 assets/documentation/markdown/__init__.py create mode 100644 assets/documentation/markdown/anchors.py create mode 100644 assets/documentation/markdown/parser.py create mode 100644 assets/documentation/navigation.py create mode 100644 assets/documentation/pages/__init__.py create mode 100644 assets/documentation/pages/model.py create mode 100644 assets/documentation/pages/reader.py create mode 100644 assets/documentation/pages/sections.py create mode 100644 assets/documentation/paths.py create mode 100644 assets/documentation/project.py create mode 100644 assets/documentation/published.py create mode 100644 assets/documentation/rendering/__init__.py create mode 100644 assets/documentation/rendering/cards.py create mode 100644 assets/documentation/rendering/navigation.py create mode 100644 assets/documentation/rendering/page.py create mode 100644 assets/documentation/static/__init__.py create mode 100644 assets/documentation/static/documentation.css create mode 100644 assets/documentation/static/page.html create mode 100644 src/sampletones_shared/documentation/__init__.py create mode 100644 src/sampletones_shared/documentation/site.py create mode 100644 tests/integration/assets/documentation/__init__.py create mode 100644 tests/integration/assets/documentation/test_site.py create mode 100644 tests/unit/assets/documentation/__init__.py create mode 100644 tests/unit/assets/documentation/conftest.py create mode 100644 tests/unit/assets/documentation/links/__init__.py create mode 100644 tests/unit/assets/documentation/links/test_resolver.py create mode 100644 tests/unit/assets/documentation/markdown/__init__.py create mode 100644 tests/unit/assets/documentation/markdown/test_anchors.py create mode 100644 tests/unit/assets/documentation/markdown/test_parser.py create mode 100644 tests/unit/assets/documentation/pages/__init__.py create mode 100644 tests/unit/assets/documentation/pages/test_reader.py create mode 100644 tests/unit/assets/documentation/pages/test_sections.py create mode 100644 tests/unit/assets/documentation/rendering/__init__.py create mode 100644 tests/unit/assets/documentation/rendering/test_cards.py create mode 100644 tests/unit/assets/documentation/rendering/test_navigation.py create mode 100644 tests/unit/assets/documentation/rendering/test_page.py create mode 100644 tests/unit/assets/documentation/test_build.py create mode 100644 tests/unit/assets/documentation/test_main.py create mode 100644 tests/unit/assets/documentation/test_navigation.py create mode 100644 tests/unit/assets/documentation/test_project.py create mode 100644 tests/unit/assets/documentation/test_published.py create mode 100644 tests/unit/assets/documentation/test_static.py create mode 100644 tests/unit/sampletones_shared/documentation/__init__.py create mode 100644 tests/unit/sampletones_shared/documentation/test_site.py diff --git a/.gitignore b/.gitignore index f9ba5ecf8..8c984bc08 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ wheels/ /bin/ /build/ /bundles/ +/src/sampletones_assets/documentation/ sampletones !src/sampletones diff --git a/Makefile b/Makefile index d5b3d77ec..c71627598 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help setup install system-deps build release run calibration tracker-playback icons demo pictures clean pre-commit test test-docs benchmarks screens lint format +.PHONY: help setup install system-deps build release run calibration tracker-playback icons docs demo pictures clean pre-commit test test-docs benchmarks screens lint format ifeq ($(OS),Windows_NT) PYTHON := python @@ -30,6 +30,7 @@ help: @echo $(Q) make calibration - Measure reconstruction on the reference sounds; writes the renders and a report$(Q) @echo $(Q) make tracker-playback BITPHASE=folder FAMITRACKER=FamiTracker.exe [PROJECT=song.stp] - Play exported projects (the corpus by default) in Bitphase, FamiTracker or both; reports every tick that differs from the app$(Q) @echo $(Q) make icons - Write the application icon suite from the mark into the package$(Q) + @echo $(Q) make docs - Write the user documentation as HTML pages into the package$(Q) @echo $(Q) make demo - Make the demo tree (recordings, a library, reconstructions, a project) in DEMO=build/demo$(Q) @echo $(Q) make pictures - Draw the guide's and the README's pictures of the application from the demo tree (needs Xvfb)$(Q) @echo $(Q) make clean - Remove build artifacts and cache files$(Q) @@ -67,6 +68,9 @@ tracker-playback: icons: uv run python -m assets.icons +docs: + uv run python -m assets.documentation + demo: uv run python -m assets.demo -o $(DEMO) diff --git a/assets/documentation/__init__.py b/assets/documentation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/assets/documentation/__main__.py b/assets/documentation/__main__.py new file mode 100644 index 000000000..5d15c9268 --- /dev/null +++ b/assets/documentation/__main__.py @@ -0,0 +1,48 @@ +import sys +from argparse import ArgumentParser +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Optional, Sequence + +from assets.documentation.build import SiteRequest, build_site +from assets.documentation.paths import DOCS_DIRECTORY, PROJECT_FILE, SITE_DIRECTORY + +PROGRAM: Final[str] = "python -m assets.documentation" +DESCRIPTION: Final[str] = "write the user's documentation as HTML pages in the application's look" +OUTPUT_HELP: Final[str] = "the folder receiving the site; without it, the folder the package ships it from" + + +@dataclass(frozen=True) +class DocumentationArguments: + """What a documentation run is given: where the site goes, if anywhere but the package.""" + + output: Optional[Path] + + @classmethod + def parse(cls, argv: Sequence[str]) -> "DocumentationArguments": + """The arguments ``argv`` states, read by the maker's own parser.""" + arguments = cls._parser().parse_args(list(argv)) + return cls(output=arguments.output) + + @staticmethod + def _parser() -> ArgumentParser: + parser = ArgumentParser(prog=PROGRAM, description=DESCRIPTION) + parser.add_argument("--output", "-o", type=Path, default=None, help=OUTPUT_HELP) + return parser + + @property + def directory(self) -> Path: + """The folder the site is written into: the one given, or the one the package ships it from.""" + return self.output if self.output is not None else SITE_DIRECTORY + + +def main(argv: Sequence[str]) -> int: + """Writes the documentation site and reports its index.""" + given = DocumentationArguments.parse(argv) + site = build_site(SiteRequest(docs=DOCS_DIRECTORY, project=PROJECT_FILE, output=given.directory)) + print(f"Wrote {site.index}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/assets/documentation/build.py b/assets/documentation/build.py new file mode 100644 index 000000000..bcd3a4b69 --- /dev/null +++ b/assets/documentation/build.py @@ -0,0 +1,172 @@ +import posixpath +import shutil +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from shutil import copyfile +from string import Template +from typing import Final, Iterable, List, Set + +from markdown_it import MarkdownIt +from markdown_it.utils import EnvType + +from assets.documentation.links.resolver import EXTERNAL_PREFIXES, BrokenLinkError, LinkResolver +from assets.documentation.markdown.parser import markdown_parser +from assets.documentation.navigation import Navigation +from assets.documentation.pages.model import ParsedPage +from assets.documentation.pages.reader import parse_page +from assets.documentation.pages.sections import image_sources, split_sections +from assets.documentation.paths import STATIC_DIRECTORY +from assets.documentation.project import ProjectFacts +from assets.documentation.published import INDEX_PAGE, page_path, published_pages +from assets.documentation.rendering.cards import section_card +from assets.documentation.rendering.navigation import navigation_html +from assets.documentation.rendering.page import LAYOUT_DIRECTORY, PageChrome, PageContent, render_page +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from sampletones_shared.documentation.site import DocumentationSite +from sampletones_tools.web.design import ApplicationDesign +from sampletones_tools.web.writer import write_layout + +TEMPLATE_FILE: Final[str] = "page.html" +DOCUMENTATION_STYLESHEET: Final[str] = "documentation.css" +TEXT_NEWLINE: Final[str] = "\n" +PARENT: Final[str] = ".." + + +class OutputOccupiedError(ValueError): + pass + + +@dataclass(frozen=True) +class SiteRequest: + """Where the documentation is read from and where its site is written. + + Attributes: + docs: The docs folder of a checkout. + project: The checkout's ``pyproject.toml``, for the version and the online address. + output: The folder the site is written into; empty, or holding an earlier site. + """ + + docs: Path + project: Path + output: Path + + +def build_site(request: SiteRequest) -> DocumentationSite: + """Write the user's documentation as HTML pages in the application's look. + + Every published document becomes a page at the same place in the site, wearing the web + layout in the application's default palette, with the navigation the docs index lays out. The + pictures the pages show are copied beside them, and the site is sealed with its stamp last. + + Raises: + OutputOccupiedError: If the output holds files other than an earlier site. + BrokenLinkError: If a page links to a missing document, heading or picture. + """ + _prepare(request.output) + facts = ProjectFacts.read(request.project) + parser = markdown_parser() + pages = {relative: parse_page(parser, request.docs, relative) for relative in published_pages(request.docs)} + navigation = Navigation.read(pages[INDEX_PAGE], pages) + resolver = LinkResolver( + request.docs, + {relative: page.anchors for relative, page in pages.items()}, + navigation.folder_pages, + facts.documentation_address, + ) + design = ApplicationDesign.load(DEFAULT_PALETTE_NAME) + layout = request.output / LAYOUT_DIRECTORY + stylesheets = write_layout(layout, design) + copyfile(STATIC_DIRECTORY / DOCUMENTATION_STYLESHEET, layout / DOCUMENTATION_STYLESHEET) + chrome = PageChrome( + version=facts.version, + address=facts.documentation_address, + stylesheets=(*stylesheets, DOCUMENTATION_STYLESHEET), + ) + template = Template((STATIC_DIRECTORY / TEMPLATE_FILE).read_text(encoding="utf-8")) + pictures: Set[PurePosixPath] = set() + for page in pages.values(): + _resolve_links(page, resolver) + html = _page_html(parser, template, chrome, navigation, page, design.glyphs.common.tick) + target = request.output / page_path(page.relative) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(html, encoding="utf-8", newline=TEXT_NEWLINE) + pictures.update(_pictures(request.docs, page)) + + _copy_pictures(request.docs, request.output, pictures) + site = DocumentationSite(request.output) + site.write_stamp() + return site + + +def _prepare(output: Path) -> None: + site = DocumentationSite(output) + if output.exists() and any(output.iterdir()): + if site.read_stamp() is None: + raise OutputOccupiedError(f"{output} holds files other than a documentation site; give an empty folder") + + shutil.rmtree(output) + + output.mkdir(parents=True, exist_ok=True) + + +def _resolve_links( + page: ParsedPage, + resolver: LinkResolver, +) -> None: + for token in page.tokens: + for child in token.children or []: + href = child.attrGet("href") if child.type == "link_open" else None + if href is not None: + child.attrSet("href", resolver.resolve(str(href), page.relative)) + + +def _page_html( + parser: MarkdownIt, + template: Template, + chrome: PageChrome, + navigation: Navigation, + page: ParsedPage, + glyph: str, +) -> str: + env: EnvType = {} + + def inline(text: str) -> str: + return str(parser.renderInline(text, env)) + + sections = split_sections(parser, page.tokens, env) + content = PageContent( + relative=page.relative, + title_html=inline(page.title), + navigation_html=navigation_html(navigation, page.relative, sections, inline), + cards_html="\n".join(section_card(section, glyph) for section in sections), + ) + return render_page(template, chrome, content) + + +def _pictures( + docs: Path, + page: ParsedPage, +) -> List[PurePosixPath]: + pictures: List[PurePosixPath] = [] + for source in image_sources(page.tokens): + if source.startswith(EXTERNAL_PREFIXES): + continue + + target = posixpath.normpath(posixpath.join(page.relative.parent.as_posix(), source)) + if target.startswith(PARENT) or not (docs / target).is_file(): + raise BrokenLinkError(f"{page.relative} shows a picture outside the docs or missing: {source}") + + pictures.append(PurePosixPath(target)) + + return pictures + + +def _copy_pictures( + docs: Path, + output: Path, + pictures: Iterable[PurePosixPath], +) -> None: + for picture in sorted(pictures): + target = output / picture + target.parent.mkdir(parents=True, exist_ok=True) + copyfile(docs / picture, target) diff --git a/assets/documentation/links/__init__.py b/assets/documentation/links/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/assets/documentation/links/resolver.py b/assets/documentation/links/resolver.py new file mode 100644 index 000000000..704915ddc --- /dev/null +++ b/assets/documentation/links/resolver.py @@ -0,0 +1,111 @@ +import posixpath +from pathlib import Path, PurePosixPath +from typing import Dict, Final, FrozenSet, Optional, Tuple +from urllib.parse import urljoin + +from assets.documentation.published import MARKDOWN_SUFFIX, is_published, page_path + +EXTERNAL_PREFIXES: Final[Tuple[str, ...]] = ("http://", "https://", "mailto:") +FRAGMENT_SEPARATOR: Final[str] = "#" +PARENT: Final[str] = ".." +DIRECTORY_INDEX: Final[str] = "index.md" + + +class BrokenLinkError(ValueError): + pass + + +class LinkResolver: + """Turns a link a document writes into the link its page holds. + + A link to another published document becomes a link to its page, and a link to a folder + becomes a link to the folder's index or to the first of its pages the navigation lists. A link + to anything the site leaves out, the development pages among them, becomes a link to the online + copy, so it still leads somewhere. A link to a missing file or heading fails the build. + """ + + def __init__( + self, + docs: Path, + anchors: Dict[PurePosixPath, FrozenSet[str]], + folder_pages: Dict[str, PurePosixPath], + address: str, + ) -> None: + self._docs = docs + self._anchors = anchors + self._folder_pages = folder_pages + self._address = address.rstrip("/") + "/" + + def resolve( + self, + href: str, + page: PurePosixPath, + ) -> str: + """The link ``href``, written in the document ``page``, as the page's link. + + Raises: + BrokenLinkError: If the link names a missing file or a missing heading. + """ + if href.startswith(EXTERNAL_PREFIXES): + return href + + path, _, fragment = href.partition(FRAGMENT_SEPARATOR) + if not path: + self._check_fragment(page, fragment, page, href) + return href + + target = posixpath.normpath(posixpath.join(page.parent.as_posix(), path)) + if target == PARENT or target.startswith(f"{PARENT}/") or not is_published(PurePosixPath(target)): + return self._online(target, fragment, page, href) + + relative = PurePosixPath(target) + if relative not in self._anchors: + raise BrokenLinkError(f"{page} links to a missing document: {href}") + + self._check_fragment(relative, fragment, page, href) + return self._page_link(page, relative, fragment) + + def _online( + self, + target: str, + fragment: str, + page: PurePosixPath, + href: str, + ) -> str: + source = (self._docs / target).resolve() + if not source.exists(): + raise BrokenLinkError(f"{page} links to a missing file: {href}") + + folder = self._folder_page(target) if source.is_dir() else None + if folder is not None: + self._check_fragment(folder, fragment, page, href) + return self._page_link(page, folder, fragment) + + address = urljoin(self._address, f"{target}/" if source.is_dir() else target) + return f"{address}{FRAGMENT_SEPARATOR}{fragment}" if fragment else address + + def _folder_page(self, target: str) -> Optional[PurePosixPath]: + index = PurePosixPath(target) / DIRECTORY_INDEX + if index in self._anchors: + return index + + return self._folder_pages.get(target) + + def _check_fragment( + self, + target: PurePosixPath, + fragment: str, + page: PurePosixPath, + href: str, + ) -> None: + if fragment and target.suffix == MARKDOWN_SUFFIX and fragment not in self._anchors[target]: + raise BrokenLinkError(f"{page} links to a missing heading: {href}") + + @staticmethod + def _page_link( + page: PurePosixPath, + target: PurePosixPath, + fragment: str, + ) -> str: + link = posixpath.relpath(page_path(target).as_posix(), page.parent.as_posix()) + return f"{link}{FRAGMENT_SEPARATOR}{fragment}" if fragment else link diff --git a/assets/documentation/markdown/__init__.py b/assets/documentation/markdown/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/assets/documentation/markdown/anchors.py b/assets/documentation/markdown/anchors.py new file mode 100644 index 000000000..1d931dca5 --- /dev/null +++ b/assets/documentation/markdown/anchors.py @@ -0,0 +1,28 @@ +import re +from typing import Dict, Final + +FORMATTING: Final[re.Pattern[str]] = re.compile(r"[`*]") +PUNCTUATION: Final[re.Pattern[str]] = re.compile(r"[^\w\s-]") + + +def heading_anchor(heading: str) -> str: + """The fragment GitHub gives a heading: punctuation dropped, every remaining space a hyphen.""" + text = FORMATTING.sub("", heading).strip().lower() + return PUNCTUATION.sub("", text).replace(" ", "-") + + +class HeadingAnchors: + """The anchors of one page's headings, each unique, as GitHub numbers a repeated one. + + A heading whose anchor an earlier heading already took gains ``-1``, then ``-2``, so every + heading on a page has a fragment of its own. + """ + + def __init__(self) -> None: + self._taken: Dict[str, int] = {} + + def claim(self, heading: str) -> str: + anchor = heading_anchor(heading) + repeats = self._taken.get(anchor) + self._taken[anchor] = 0 if repeats is None else repeats + 1 + return anchor if repeats is None else f"{anchor}-{repeats + 1}" diff --git a/assets/documentation/markdown/parser.py b/assets/documentation/markdown/parser.py new file mode 100644 index 000000000..a841b3ba6 --- /dev/null +++ b/assets/documentation/markdown/parser.py @@ -0,0 +1,40 @@ +from typing import Final, Sequence + +from markdown_it import MarkdownIt +from markdown_it.renderer import RendererHTML +from markdown_it.token import Token +from markdown_it.utils import EnvType, OptionsDict + +COMMONMARK: Final[str] = "commonmark" +TABLE_RULE: Final[str] = "table" +TABLE_OPEN: Final[str] = '
\n' +TABLE_CLOSE: Final[str] = "
\n" + + +def markdown_parser() -> MarkdownIt: + """The parser the documentation is read with: CommonMark, the pipe tables GitHub draws, and + the raw HTML the pages hold for their pictures, with every table in the application's style.""" + parser = MarkdownIt(COMMONMARK, {"html": True}).enable(TABLE_RULE) + parser.add_render_rule("table_open", _table_open) + parser.add_render_rule("table_close", _table_close) + return parser + + +def _table_open( + _renderer: RendererHTML, + _tokens: Sequence[Token], + _index: int, + _options: OptionsDict, + _env: EnvType, +) -> str: + return TABLE_OPEN + + +def _table_close( + _renderer: RendererHTML, + _tokens: Sequence[Token], + _index: int, + _options: OptionsDict, + _env: EnvType, +) -> str: + return TABLE_CLOSE diff --git a/assets/documentation/navigation.py b/assets/documentation/navigation.py new file mode 100644 index 000000000..5f431dfbd --- /dev/null +++ b/assets/documentation/navigation.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import posixpath +from dataclasses import dataclass +from pathlib import PurePosixPath +from typing import Dict, Final, List, Mapping, Optional, Tuple + +from assets.documentation.pages.model import ParsedPage + +GROUP_LEVEL: Final[str] = "h2" +FRAGMENT_SEPARATOR: Final[str] = "#" + + +@dataclass(frozen=True) +class NavigationEntry: + page: PurePosixPath + title: str + + +@dataclass(frozen=True) +class NavigationGroup: + title: str + entries: Tuple[NavigationEntry, ...] + + +@dataclass(frozen=True) +class Navigation: + """The site's contents as the docs index lays them out: each of its sections a group, and each + published document it links an entry, in the order the index names them. + + A section that links no published document, such as the development one, makes no group. + """ + + groups: Tuple[NavigationGroup, ...] + + @classmethod + def read( + cls, + index: ParsedPage, + pages: Mapping[PurePosixPath, ParsedPage], + ) -> Navigation: + groups: List[NavigationGroup] = [] + title: Optional[str] = None + entries: Dict[PurePosixPath, NavigationEntry] = {} + tokens = index.tokens + for position, token in enumerate(tokens): + if token.type == "heading_open" and token.tag == GROUP_LEVEL: + cls._close(groups, title, entries) + title, entries = tokens[position + 1].content, {} + elif token.type == "inline" and title is not None: + for child in token.children or []: + href = child.attrGet("href") if child.type == "link_open" else None + target = cls._target(index, str(href)) if href is not None else None + if target is not None and target in pages and target != index.relative: + entries.setdefault(target, NavigationEntry(page=target, title=pages[target].title)) + + cls._close(groups, title, entries) + return cls(groups=tuple(groups)) + + @property + def folder_pages(self) -> Dict[str, PurePosixPath]: + """The first page the navigation lists in each folder, where a link to the folder leads.""" + firsts: Dict[str, PurePosixPath] = {} + for group in self.groups: + for entry in group.entries: + if len(entry.page.parts) > 1: + firsts.setdefault(entry.page.parts[0], entry.page) + + return firsts + + @staticmethod + def _target( + index: ParsedPage, + href: str, + ) -> Optional[PurePosixPath]: + path = href.partition(FRAGMENT_SEPARATOR)[0] + if not path: + return None + + return PurePosixPath(posixpath.normpath(posixpath.join(index.relative.parent.as_posix(), path))) + + @staticmethod + def _close( + groups: List[NavigationGroup], + title: Optional[str], + entries: Dict[PurePosixPath, NavigationEntry], + ) -> None: + if title is not None and entries: + groups.append(NavigationGroup(title=title, entries=tuple(entries.values()))) diff --git a/assets/documentation/pages/__init__.py b/assets/documentation/pages/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/assets/documentation/pages/model.py b/assets/documentation/pages/model.py new file mode 100644 index 000000000..d2035d0cd --- /dev/null +++ b/assets/documentation/pages/model.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass +from pathlib import PurePosixPath +from typing import FrozenSet, List, Optional + +from markdown_it.token import Token + + +@dataclass(frozen=True) +class ParsedPage: + """A document read and its headings anchored, before its links are resolved. + + Attributes: + relative: The document's path relative to the docs folder. + tokens: The document's tokens, each heading carrying its anchor as its ``id``. + anchors: Every anchor the document's headings hold. + title: The document's title, its first-level heading as written. + """ + + relative: PurePosixPath + tokens: List[Token] + anchors: FrozenSet[str] + title: str + + +@dataclass(frozen=True) +class Section: + """One card of a page: a second-level heading and what follows it, or what precedes the first. + + Attributes: + title_html: The heading as inline markup, or ``None`` for the opening card. + anchor: The heading's anchor, or ``None`` for the opening card. + body_html: Everything the section holds, as markup. + """ + + title_html: Optional[str] + anchor: Optional[str] + body_html: str diff --git a/assets/documentation/pages/reader.py b/assets/documentation/pages/reader.py new file mode 100644 index 000000000..9124bb94e --- /dev/null +++ b/assets/documentation/pages/reader.py @@ -0,0 +1,51 @@ +from pathlib import Path, PurePosixPath +from typing import Final, List, Set + +from markdown_it import MarkdownIt +from markdown_it.token import Token + +from assets.documentation.markdown.anchors import HeadingAnchors +from assets.documentation.pages.model import ParsedPage + +HEADING_OPEN: Final[str] = "heading_open" +TITLE_LEVEL: Final[str] = "h1" +ANCHOR_ATTRIBUTE: Final[str] = "id" + + +class MissingTitleError(ValueError): + pass + + +def parse_page( + parser: MarkdownIt, + docs: Path, + relative: PurePosixPath, +) -> ParsedPage: + """Read one document and give each heading its anchor, the fragment links to it name. + + Raises: + MissingTitleError: If the document holds no first-level heading. + """ + tokens = parser.parse((docs / relative).read_text(encoding="utf-8")) + anchors = HeadingAnchors() + claimed: Set[str] = set() + for index, token in enumerate(tokens): + if token.type == HEADING_OPEN: + anchor = anchors.claim(tokens[index + 1].content) + token.attrSet(ANCHOR_ATTRIBUTE, anchor) + claimed.add(anchor) + + return ParsedPage( + relative=relative, + tokens=tokens, + anchors=frozenset(claimed), + title=_title(tokens, relative), + ) + + +def _title(tokens: List[Token], relative: PurePosixPath) -> str: + for index, token in enumerate(tokens): + if token.type == HEADING_OPEN and token.tag == TITLE_LEVEL: + return tokens[index + 1].content + + raise MissingTitleError(f"{relative} holds no first-level heading to name its page by") diff --git a/assets/documentation/pages/sections.py b/assets/documentation/pages/sections.py new file mode 100644 index 000000000..5211dafbd --- /dev/null +++ b/assets/documentation/pages/sections.py @@ -0,0 +1,76 @@ +import re +from dataclasses import dataclass, field +from typing import Final, List, Optional, Tuple + +from markdown_it import MarkdownIt +from markdown_it.token import Token +from markdown_it.utils import EnvType + +from assets.documentation.pages.model import Section + +HEADING_OPEN: Final[str] = "heading_open" +HEADING_CLOSE: Final[str] = "heading_close" +TITLE_LEVEL: Final[str] = "h1" +SECTION_LEVEL: Final[str] = "h2" +ANCHOR_ATTRIBUTE: Final[str] = "id" +IMAGE_SOURCE: Final[re.Pattern[str]] = re.compile(r']*\bsrc="([^"]+)"') +HTML_TOKENS: Final[Tuple[str, ...]] = ("html_block", "html_inline") + + +@dataclass +class _Segment: + title: Optional[Token] + anchor: Optional[str] + body: List[Token] = field(default_factory=list) + + +def split_sections( + parser: MarkdownIt, + tokens: List[Token], + env: EnvType, +) -> List[Section]: + """A page as its cards: what precedes the first second-level heading, then one card per such + heading with what follows it. The first-level heading is the page's title and stands apart. + + An opening that holds nothing makes no card. + """ + segments: List[_Segment] = [_Segment(title=None, anchor=None)] + position = 0 + while position < len(tokens): + token = tokens[position] + if token.type == HEADING_OPEN and token.tag in (TITLE_LEVEL, SECTION_LEVEL): + if token.tag == SECTION_LEVEL: + anchor = token.attrGet(ANCHOR_ATTRIBUTE) + segments.append(_Segment(title=tokens[position + 1], anchor=str(anchor))) + position = _after_heading(tokens, position) + continue + + segments[-1].body.append(token) + position += 1 + + return [_section(parser, segment, env) for segment in segments if segment.title is not None or segment.body] + + +def image_sources(tokens: List[Token]) -> List[str]: + """The source of every picture the page holds as raw HTML, as the page writes it.""" + return [source for token in tokens if token.type in HTML_TOKENS for source in IMAGE_SOURCE.findall(token.content)] + + +def _after_heading(tokens: List[Token], position: int) -> int: + while tokens[position].type != HEADING_CLOSE: + position += 1 + + return position + 1 + + +def _section( + parser: MarkdownIt, + segment: _Segment, + env: EnvType, +) -> Section: + title_html = parser.renderer.render([segment.title], parser.options, env) if segment.title is not None else None + return Section( + title_html=title_html, + anchor=segment.anchor, + body_html=parser.renderer.render(segment.body, parser.options, env), + ) diff --git a/assets/documentation/paths.py b/assets/documentation/paths.py new file mode 100644 index 000000000..1f7bb30ff --- /dev/null +++ b/assets/documentation/paths.py @@ -0,0 +1,14 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.documentation.site import DOCUMENTATION_DIRECTORY +from sampletones_shared.paths.package import package_directory +from sampletones_shared.paths.resources import ASSETS_PACKAGE +from sampletones_shared.paths.source import REPOSITORY_ROOT, SOURCE_ROOT + +STATIC_PACKAGE: Final[str] = "assets.documentation.static" + +DOCS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "docs" +PROJECT_FILE: Final[Path] = REPOSITORY_ROOT / "pyproject.toml" +SITE_DIRECTORY: Final[Path] = SOURCE_ROOT / ASSETS_PACKAGE / DOCUMENTATION_DIRECTORY +STATIC_DIRECTORY: Final[Path] = package_directory(STATIC_PACKAGE) diff --git a/assets/documentation/project.py b/assets/documentation/project.py new file mode 100644 index 000000000..dfe25ac88 --- /dev/null +++ b/assets/documentation/project.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +DOCUMENTATION_URL_KEY: Final[str] = "Documentation" + + +@dataclass(frozen=True) +class ProjectFacts: + """What ``pyproject.toml`` states that the documentation shows: the version, and where the + documentation lives online.""" + + version: str + documentation_address: str + + @classmethod + def read(cls, path: Path) -> ProjectFacts: + """The facts ``path`` states. + + Raises: + KeyError: If the file names no version or no documentation address. + """ + with path.open("rb") as stream: + project = tomllib.load(stream)["project"] + + return cls( + version=str(project["version"]), + documentation_address=str(project["urls"][DOCUMENTATION_URL_KEY]), + ) diff --git a/assets/documentation/published.py b/assets/documentation/published.py new file mode 100644 index 000000000..38b816277 --- /dev/null +++ b/assets/documentation/published.py @@ -0,0 +1,34 @@ +from pathlib import Path, PurePosixPath +from typing import Final, List, Tuple + +MARKDOWN_SUFFIX: Final[str] = ".md" +PAGE_SUFFIX: Final[str] = ".html" +INDEX_PAGE: Final[PurePosixPath] = PurePosixPath("index.md") +PUBLISHED_FOLDERS: Final[Tuple[str, ...]] = ("guide", "concepts", "tools", "formats", "api") +PUBLISHED_ROOT_PAGES: Final[Tuple[PurePosixPath, ...]] = (INDEX_PAGE, PurePosixPath("glossary.md")) + + +def is_published(relative: PurePosixPath) -> bool: + """Whether a document, named relative to the docs folder, belongs to the user's documentation. + + The documentation carries what a user of the application reads: the index, the glossary, and + the guide, concepts, tools, formats and API sections. The development section stays online. + """ + if relative.suffix != MARKDOWN_SUFFIX: + return False + + if len(relative.parts) == 1: + return relative in PUBLISHED_ROOT_PAGES + + return relative.parts[0] in PUBLISHED_FOLDERS + + +def published_pages(docs: Path) -> List[PurePosixPath]: + """Every published document under ``docs``, relative to it, in a stable order.""" + relatives = (PurePosixPath(path.relative_to(docs).as_posix()) for path in docs.rglob(f"*{MARKDOWN_SUFFIX}")) + return sorted(relative for relative in relatives if is_published(relative)) + + +def page_path(relative: PurePosixPath) -> PurePosixPath: + """The page a document becomes, at the same place in the site as the document in the docs.""" + return relative.with_suffix(PAGE_SUFFIX) diff --git a/assets/documentation/rendering/__init__.py b/assets/documentation/rendering/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/assets/documentation/rendering/cards.py b/assets/documentation/rendering/cards.py new file mode 100644 index 000000000..878b9738a --- /dev/null +++ b/assets/documentation/rendering/cards.py @@ -0,0 +1,22 @@ +from html import escape + +from assets.documentation.pages.model import Section + + +def section_card( + section: Section, + glyph: str, +) -> str: + """One section as the application's card: the header strip with the purpose glyph and the + heading, then the section's prose. The opening section is a card without a header.""" + body = f'
\n{section.body_html}
' + if section.title_html is None or section.anchor is None: + return f'
\n{body}\n
' + + header = ( + '

' + f'{escape(glyph)}' + f'{section.title_html}' + "

" + ) + return f'
\n{header}\n{body}\n
' diff --git a/assets/documentation/rendering/navigation.py b/assets/documentation/rendering/navigation.py new file mode 100644 index 000000000..b7179c23b --- /dev/null +++ b/assets/documentation/rendering/navigation.py @@ -0,0 +1,64 @@ +import posixpath +from html import escape +from pathlib import PurePosixPath +from typing import Callable, Final, List, Sequence + +from assets.documentation.navigation import Navigation, NavigationEntry +from assets.documentation.pages.model import Section +from assets.documentation.published import INDEX_PAGE, page_path + +InlineRenderer = Callable[[str], str] + +HOME_TITLE: Final[str] = "Overview" + + +def navigation_html( + navigation: Navigation, + current: PurePosixPath, + sections: Sequence[Section], + inline: InlineRenderer, +) -> str: + """The navigation as seen from page ``current``: the overview, then each group, every entry a + link relative to the page, and the current page marked, with its sections listed beneath it.""" + lines: List[str] = [_home(current)] + for group in navigation.groups: + lines.append('") + + return "\n".join(lines) + + +def _home(current: PurePosixPath) -> str: + marked = ' aria-current="page"' if current == INDEX_PAGE else "" + return f'{HOME_TITLE}' + + +def _link( + page: PurePosixPath, + current: PurePosixPath, +) -> str: + return posixpath.relpath(page_path(page).as_posix(), current.parent.as_posix()) + + +def _entry( + entry: NavigationEntry, + current: PurePosixPath, + sections: Sequence[Section], + inline: InlineRenderer, +) -> str: + link = _link(entry.page, current) + title = inline(entry.title) + if entry.page != current: + return f'
  • {title}
  • ' + + anchors = "".join( + f'
  • {section.title_html}
  • ' + for section in sections + if section.anchor is not None and section.title_html is not None + ) + listed = f'' if anchors else "" + return f'
  • {title}{listed}
  • ' diff --git a/assets/documentation/rendering/page.py b/assets/documentation/rendering/page.py new file mode 100644 index 000000000..462f19b3a --- /dev/null +++ b/assets/documentation/rendering/page.py @@ -0,0 +1,54 @@ +import re +from dataclasses import dataclass +from html import escape, unescape +from pathlib import PurePosixPath +from string import Template +from typing import Final, Tuple +from urllib.parse import urljoin + +TAG: Final[re.Pattern[str]] = re.compile(r"<[^>]+>") +LAYOUT_DIRECTORY: Final[str] = "style" +PARENT_STEP: Final[str] = "../" + + +@dataclass(frozen=True) +class PageChrome: + """What every page shares around its content: the version, where the documentation lives + online, and the stylesheets the page links, in order.""" + + version: str + address: str + stylesheets: Tuple[str, ...] + + +@dataclass(frozen=True) +class PageContent: + """What one page holds: its document, its title as inline markup, its navigation and its cards.""" + + relative: PurePosixPath + title_html: str + navigation_html: str + cards_html: str + + +def render_page( + template: Template, + chrome: PageChrome, + content: PageContent, +) -> str: + """The page as a complete document, every link to the shared files relative to where it sits.""" + root = PARENT_STEP * (len(content.relative.parts) - 1) + stylesheets = "\n".join( + f'' for name in chrome.stylesheets + ) + return template.substitute( + title=escape(unescape(TAG.sub("", content.title_html))), + heading=content.title_html, + stylesheets=stylesheets, + root=root, + layout=LAYOUT_DIRECTORY, + navigation=content.navigation_html, + content=content.cards_html, + version=escape(chrome.version), + source=escape(urljoin(chrome.address.rstrip("/") + "/", content.relative.as_posix())), + ) diff --git a/assets/documentation/static/__init__.py b/assets/documentation/static/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/assets/documentation/static/documentation.css b/assets/documentation/static/documentation.css new file mode 100644 index 000000000..071d67680 --- /dev/null +++ b/assets/documentation/static/documentation.css @@ -0,0 +1,121 @@ +.documentation .menu-bar a { + color: var(--text); +} + +.documentation .menu-bar a:hover { + color: var(--link-hover); + text-decoration: none; +} + +.documentation .brand { + display: flex; + align-items: center; + gap: var(--item-spacing-x); +} + +.documentation .status-bar .spacer { + flex: 1; +} + +.documentation .column.page { + max-width: 980px; +} + +.documentation .column.side { + position: sticky; + top: var(--panel-gap); + max-height: calc(100vh - 2 * var(--panel-gap)); + overflow-y: auto; +} + +.documentation .masthead { + padding-top: 0; +} + +h2.card-header { + font-size: inherit; + font-weight: inherit; +} + +.card-header .card-title:first-child { + padding-left: var(--glyph-indent); +} + +.navigation { + gap: var(--card-padding-y); +} + +.navigation-home { + padding-inline: var(--card-padding-x); + font-weight: 700; +} + +.navigation-group { + padding-inline: var(--card-padding-x); +} + +.navigation-heading { + margin: var(--item-spacing-y) 0; + font-size: var(--size-sans-small); + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; +} + +.navigation ul { + margin: 0; + padding: 0; + list-style: none; +} + +.navigation li { + padding: 2px 0; +} + +.navigation a { + color: var(--text); +} + +.navigation a:hover { + color: var(--link-hover); +} + +.navigation a[aria-current="page"] { + font-weight: 700; + color: var(--heading); +} + +.navigation-anchors { + margin-top: 2px; + padding-left: var(--glyph-width); + border-left: 1px solid var(--separator); + font-size: var(--size-sans-small); +} + +.navigation-anchors a { + color: var(--text-muted); +} + +.page { + gap: var(--panel-gap); +} + +.prose div[align="center"] { + text-align: center; +} + +.documentation .contents-link { + display: none; +} + +@media (max-width: 900px) { + .documentation .column.side { + order: 1; + position: static; + max-height: none; + } + + .documentation .contents-link { + display: inline; + } +} diff --git a/assets/documentation/static/page.html b/assets/documentation/static/page.html new file mode 100644 index 000000000..7c1e18148 --- /dev/null +++ b/assets/documentation/static/page.html @@ -0,0 +1,41 @@ + + + + + +$title · SampleToNES documentation +$stylesheets + + +
    + +
    +
    +
    + +
    +
    +

    $heading

    +$content +
    +
    +
    +
    + SampleToNES $version + + Documentation +
    +
    + + diff --git a/pyproject.toml b/pyproject.toml index d7d900362..d09e734a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,7 +76,7 @@ gpu-cuda11 = [ ] [dependency-groups] -assets = ["pillow>=11,<13"] +assets = ["markdown-it-py>=3,<5", "pillow>=11,<13"] calibration = ["zimtohrli>=0.2.1,<0.3"] dev = [ { include-group = "assets" }, diff --git a/scripts/bootstrap/layout.py b/scripts/bootstrap/layout.py index 9542db5b3..baa2838a0 100644 --- a/scripts/bootstrap/layout.py +++ b/scripts/bootstrap/layout.py @@ -9,6 +9,7 @@ DISTRIBUTION: Final[str] = "bin" BUNDLES: Final[str] = "bundles" BUILD_ENVIRONMENT: Final[str] = ".venv-build" +DOCUMENTATION_SITE: Final[str] = "src/sampletones_assets/documentation" RELEASE_HOOK: Final[str] = "scripts/runtime_hooks/release_environment.py" GPU_HOOK: Final[str] = "scripts/runtime_hooks/cuda_bundle.py" PYINSTALLER_HOOKS: Final[str] = "scripts/pyinstaller_hooks" @@ -17,7 +18,15 @@ CUPY_PACKAGES: Final[Tuple[str, ...]] = ("cupy", "cupy_backends", "cupyx") CUDA_PACKAGE: Final[str] = "nvidia" GPU_PACKAGES: Final[Tuple[str, ...]] = (*CUPY_PACKAGES, CUDA_PACKAGE) -CLEAN_ARTIFACTS: Final[Tuple[str, ...]] = (DISTRIBUTION, BUNDLES, "build", "dist", "htmlcov", ".coverage") +CLEAN_ARTIFACTS: Final[Tuple[str, ...]] = ( + DISTRIBUTION, + BUNDLES, + DOCUMENTATION_SITE, + "build", + "dist", + "htmlcov", + ".coverage", +) CLEAN_PATTERNS: Final[Tuple[str, ...]] = ("*.spec",) CACHE_DIRECTORIES: Final[Tuple[str, ...]] = ("__pycache__",) CACHE_DIRECTORY_SUFFIXES: Final[Tuple[str, ...]] = (".egg-info",) diff --git a/src/sampletones_application/ui/resources/loader.py b/src/sampletones_application/ui/resources/loader.py index 327a1fa7c..808e588d4 100644 --- a/src/sampletones_application/ui/resources/loader.py +++ b/src/sampletones_application/ui/resources/loader.py @@ -1,11 +1,9 @@ from pathlib import Path -from typing import Final from sampletones_shared.paths.package import package_directory +from sampletones_shared.paths.resources import ASSETS_PACKAGE from sampletones_shared.types.path import Pathlike -ASSETS_PACKAGE: Final[str] = "sampletones_assets" - class ResourceLoader: """Reads the files of one directory of the assets package, where the package lies in every copy.""" diff --git a/src/sampletones_shared/documentation/__init__.py b/src/sampletones_shared/documentation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_shared/documentation/site.py b/src/sampletones_shared/documentation/site.py new file mode 100644 index 000000000..5f4fd797e --- /dev/null +++ b/src/sampletones_shared/documentation/site.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Optional + +from sampletones_shared.paths.package import package_directory +from sampletones_shared.paths.resources import ASSETS_PACKAGE +from sampletones_shared.utils.hashing import calculate_hash + +DOCUMENTATION_DIRECTORY: Final[str] = "documentation" +INDEX_FILE: Final[str] = "index.html" +STAMP_FILE: Final[str] = "stamp" +FULL_DIGEST: Final[int] = 64 +ENTRY_SEPARATOR: Final[str] = "\n" + + +@dataclass(frozen=True) +class DocumentationSite: + """The HTML documentation as a folder of pages, and the stamp that names its contents. + + The maker writes the stamp last, from the files beside it, so a site whose stamp matches its + contents was written whole. A copy of the site keyed by its stamp is a copy of these exact pages. + """ + + directory: Path + + @classmethod + def shipped(cls) -> DocumentationSite: + """The site that ships with the application, inside its assets package.""" + return cls(package_directory(ASSETS_PACKAGE) / DOCUMENTATION_DIRECTORY) + + @property + def index(self) -> Path: + return self.directory / INDEX_FILE + + @property + def stamp_path(self) -> Path: + return self.directory / STAMP_FILE + + def read_stamp(self) -> Optional[str]: + """The stamp the site was written with, or ``None`` where no complete site lies.""" + if not self.stamp_path.is_file(): + return None + + return self.stamp_path.read_text(encoding="utf-8").strip() + + def content_stamp(self) -> str: + """The stamp the site's files earn now: a hash over every file but the stamp, by path and contents.""" + entries = sorted( + f"{path.relative_to(self.directory).as_posix()}:{calculate_hash(path.read_bytes(), length=FULL_DIGEST)}" + for path in self.directory.rglob("*") + if path.is_file() and path != self.stamp_path + ) + return calculate_hash(ENTRY_SEPARATOR.join(entries)) + + def write_stamp(self) -> str: + """Seal the site: write the stamp its files earn, and return it.""" + stamp = self.content_stamp() + self.stamp_path.write_text(stamp + ENTRY_SEPARATOR, encoding="utf-8", newline=ENTRY_SEPARATOR) + return stamp diff --git a/src/sampletones_shared/paths/resources.py b/src/sampletones_shared/paths/resources.py index 2e415f2ff..00faa138e 100644 --- a/src/sampletones_shared/paths/resources.py +++ b/src/sampletones_shared/paths/resources.py @@ -3,6 +3,7 @@ from sampletones_shared.paths.package import package_directory +ASSETS_PACKAGE: Final[str] = "sampletones_assets" CONFIG_DIRECTORY: Final[Path] = package_directory("sampletones_config") ICON_DIRECTORY: Final[str] = "icons" diff --git a/tests/integration/assets/documentation/__init__.py b/tests/integration/assets/documentation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/assets/documentation/test_site.py b/tests/integration/assets/documentation/test_site.py new file mode 100644 index 000000000..f2b377295 --- /dev/null +++ b/tests/integration/assets/documentation/test_site.py @@ -0,0 +1,155 @@ +import posixpath +import re +from html.parser import HTMLParser +from pathlib import Path, PurePosixPath +from typing import Dict, Final, List, Optional, Set, Tuple + +import pytest + +from assets.documentation.build import SiteRequest, build_site +from assets.documentation.paths import DOCS_DIRECTORY, PROJECT_FILE +from assets.documentation.published import INDEX_PAGE, page_path, published_pages +from sampletones_shared.documentation.site import DocumentationSite + +EXTERNAL: Final[Tuple[str, ...]] = ("http://", "https://", "mailto:") +NETWORK_URL: Final[re.Pattern[str]] = re.compile(r"url\(\s*[\"']?(?:https?:)?//") +FRAGMENT_SEPARATOR: Final[str] = "#" +CURRENT_PAGE: Final[str] = "page" + + +class PageReader(HTMLParser): + """The ids a page holds, the links it follows, the files it loads and its marked navigation.""" + + def __init__(self) -> None: + super().__init__() + self.ids: List[str] = [] + self.links: List[str] = [] + self.loads: List[str] = [] + self.navigation: List[str] = [] + self.current: List[str] = [] + self._in_navigation = False + + def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None: + attributes = {name: value or "" for name, value in attrs} + if "id" in attributes: + self.ids.append(attributes["id"]) + if tag == "nav": + self._in_navigation = True + if tag == "a" and "href" in attributes: + self.links.append(attributes["href"]) + if self._in_navigation: + self.navigation.append(attributes["href"]) + if attributes.get("aria-current") == CURRENT_PAGE: + self.current.append(attributes["href"]) + if tag == "link": + self.loads.append(attributes["href"]) + if tag in ("img", "script") and "src" in attributes: + self.loads.append(attributes["src"]) + + def handle_endtag(self, tag: str) -> None: + if tag == "nav": + self._in_navigation = False + + +@pytest.fixture(scope="module") +def site(tmp_path_factory: pytest.TempPathFactory) -> DocumentationSite: + output = tmp_path_factory.mktemp("documentation") / "site" + return build_site(SiteRequest(docs=DOCS_DIRECTORY, project=PROJECT_FILE, output=output)) + + +@pytest.fixture(scope="module") +def readers(site: DocumentationSite) -> Dict[PurePosixPath, PageReader]: + readers: Dict[PurePosixPath, PageReader] = {} + for path in sorted(site.directory.rglob("*.html")): + reader = PageReader() + reader.feed(path.read_text(encoding="utf-8")) + readers[PurePosixPath(path.relative_to(site.directory).as_posix())] = reader + + return readers + + +def target(page: PurePosixPath, href: str) -> Tuple[PurePosixPath, str]: + path, _, fragment = href.partition(FRAGMENT_SEPARATOR) + if not path: + return page, fragment + + return PurePosixPath(posixpath.normpath(posixpath.join(page.parent.as_posix(), path))), fragment + + +class TestTheShippedDocumentation: + def test_every_published_document_is_a_page(self, readers: Dict[PurePosixPath, PageReader]) -> None: + assert sorted(readers) == sorted(page_path(relative) for relative in published_pages(DOCS_DIRECTORY)) + + def test_the_site_is_sealed(self, site: DocumentationSite) -> None: + assert site.read_stamp() == site.content_stamp() + + def test_every_page_link_reaches_a_file_and_its_heading( + self, + site: DocumentationSite, + readers: Dict[PurePosixPath, PageReader], + ) -> None: + broken: List[str] = [] + for page, reader in readers.items(): + for href in reader.links: + if href.startswith(EXTERNAL): + continue + + destination, fragment = target(page, href) + if not (site.directory / destination).is_file(): + broken.append(f"{page}: {href}") + elif fragment and fragment not in readers[destination].ids: + broken.append(f"{page}: {href}") + + assert broken == [] + + def test_every_file_a_page_loads_lies_in_the_site( + self, + site: DocumentationSite, + readers: Dict[PurePosixPath, PageReader], + ) -> None: + missing = [ + f"{page}: {source}" + for page, reader in readers.items() + for source in reader.loads + if source.startswith(EXTERNAL) or not (site.directory / target(page, source)[0]).is_file() + ] + + assert missing == [] + + def test_no_stylesheet_reaches_the_network(self, site: DocumentationSite) -> None: + reaching = [ + path.name for path in site.directory.rglob("*.css") if NETWORK_URL.search(path.read_text(encoding="utf-8")) + ] + + assert reaching == [] + + def test_no_page_links_a_document_source(self, readers: Dict[PurePosixPath, PageReader]) -> None: + sources = [ + f"{page}: {href}" + for page, reader in readers.items() + for href in reader.links + if not href.startswith(EXTERNAL) and href.partition(FRAGMENT_SEPARATOR)[0].endswith(".md") + ] + + assert sources == [] + + def test_every_id_on_a_page_is_its_own(self, readers: Dict[PurePosixPath, PageReader]) -> None: + repeated: Dict[str, Set[str]] = {} + for page, reader in readers.items(): + seen: Set[str] = set() + for identifier in reader.ids: + if identifier in seen: + repeated.setdefault(str(page), set()).add(identifier) + seen.add(identifier) + + assert repeated == {} + + def test_the_navigation_reaches_every_page(self, readers: Dict[PurePosixPath, PageReader]) -> None: + index = readers[page_path(INDEX_PAGE)] + reached = {target(page_path(INDEX_PAGE), href)[0] for href in index.navigation} + + assert set(readers) <= reached + + def test_each_page_marks_itself_once(self, readers: Dict[PurePosixPath, PageReader]) -> None: + for page, reader in readers.items(): + assert [target(page, href)[0] for href in reader.current] == [page], page diff --git a/tests/integration/docs/test_documentation.py b/tests/integration/docs/test_documentation.py index 4101300c8..7c307e7ca 100644 --- a/tests/integration/docs/test_documentation.py +++ b/tests/integration/docs/test_documentation.py @@ -5,6 +5,7 @@ import pytest from PIL import Image +from assets.documentation.markdown.anchors import heading_anchor from sampletones_shared.paths.source import REPOSITORY_ROOT ENCODING: Final[str] = "utf-8" @@ -18,19 +19,13 @@ ROOT_PAGES: Final[Tuple[str, ...]] = ("README.md", "CHANGELOG.md") -def anchor(heading: str) -> str: - """The fragment GitHub gives a heading: punctuation dropped, every remaining space a hyphen.""" - text = re.sub(r"[`*]", "", heading).strip().lower() - return re.sub(r"[^\w\s-]", "", text).replace(" ", "-") - - def text(page: Path) -> str: """The page as it is stored, which is UTF-8 whatever encoding the platform prefers.""" return page.read_text(encoding=ENCODING) def anchors(page: Path) -> Set[str]: - return {anchor(heading) for heading in HEADING.findall(text(page))} + return {heading_anchor(heading) for heading in HEADING.findall(text(page))} def images(page: Path) -> List[Dict[str, str]]: diff --git a/tests/unit/assets/documentation/__init__.py b/tests/unit/assets/documentation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/assets/documentation/conftest.py b/tests/unit/assets/documentation/conftest.py new file mode 100644 index 000000000..d06615e5c --- /dev/null +++ b/tests/unit/assets/documentation/conftest.py @@ -0,0 +1,121 @@ +from pathlib import Path, PurePosixPath +from typing import Dict, Final + +import pytest +from markdown_it import MarkdownIt + +from assets.documentation.markdown.parser import markdown_parser +from assets.documentation.navigation import Navigation +from assets.documentation.pages.model import ParsedPage +from assets.documentation.pages.reader import parse_page +from assets.documentation.published import INDEX_PAGE, published_pages + +ADDRESS: Final[str] = "https://example.com/project/docs" +VERSION: Final[str] = "1.2.3" + +INDEX: Final[str] = """# Example documentation + +An opening paragraph. + +## Using it + +- [Start here](guide/start.md) — the first steps. +- [Next steps](guide/next.md#second-part) — what follows. +- [Glossary](glossary.md) + +## Development + +- [Notes](development/notes.md) +""" + +START: Final[str] = """# Start here + +Before the first section. + +## First part + +See [the next part](next.md#second-part), [the guide](./), [the notes](../development/notes.md), +[this part](#first-part) and [the web](https://example.com). + +A picture + +## First part + +| Column | Value | +|---|---| +| one | 1 | +""" + +NEXT: Final[str] = """# Next steps + +## Second part + +Text. +""" + +GLOSSARY: Final[str] = """# Glossary + +## Term + +A word. +""" + +NOTES: Final[str] = "# Notes\n" + +PROJECT: Final[str] = f"""[project] +name = "example" +version = "{VERSION}" + +[project.urls] +Documentation = "{ADDRESS}" +""" + +DOCUMENTS: Final[Dict[str, str]] = { + "index.md": INDEX, + "glossary.md": GLOSSARY, + "guide/start.md": START, + "guide/next.md": NEXT, + "development/notes.md": NOTES, +} +PICTURES: Final[Dict[str, bytes]] = { + "images/picture.webp": b"picture", + "images/unused.webp": b"unused", +} + + +@pytest.fixture +def docs(tmp_path: Path) -> Path: + """A small docs folder: an index with a published and a development section, two guide pages, + a glossary, a development page and two pictures, one of them shown.""" + root = tmp_path / "docs" + for relative, text in DOCUMENTS.items(): + (root / relative).parent.mkdir(parents=True, exist_ok=True) + (root / relative).write_text(text, encoding="utf-8") + + for relative, data in PICTURES.items(): + (root / relative).parent.mkdir(parents=True, exist_ok=True) + (root / relative).write_bytes(data) + + return root + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + path = tmp_path / "pyproject.toml" + path.write_text(PROJECT, encoding="utf-8") + return path + + +@pytest.fixture +def parser() -> MarkdownIt: + return markdown_parser() + + +@pytest.fixture +def pages(parser: MarkdownIt, docs: Path) -> Dict[PurePosixPath, ParsedPage]: + return {relative: parse_page(parser, docs, relative) for relative in published_pages(docs)} + + +@pytest.fixture +def navigation(pages: Dict[PurePosixPath, ParsedPage]) -> Navigation: + return Navigation.read(pages[INDEX_PAGE], pages) diff --git a/tests/unit/assets/documentation/links/__init__.py b/tests/unit/assets/documentation/links/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/assets/documentation/links/test_resolver.py b/tests/unit/assets/documentation/links/test_resolver.py new file mode 100644 index 000000000..4a65c95fa --- /dev/null +++ b/tests/unit/assets/documentation/links/test_resolver.py @@ -0,0 +1,124 @@ +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Dict + +import pytest + +from assets.documentation.links.resolver import BrokenLinkError, LinkResolver +from assets.documentation.navigation import Navigation +from assets.documentation.pages.model import ParsedPage +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.unit.assets.documentation.conftest import ADDRESS + +START = PurePosixPath("guide/start.md") + + +@pytest.fixture +def resolver( + docs: Path, + pages: Dict[PurePosixPath, ParsedPage], + navigation: Navigation, +) -> LinkResolver: + return LinkResolver( + docs, + {relative: page.anchors for relative, page in pages.items()}, + navigation.folder_pages, + ADDRESS, + ) + + +class TestWhereALinkLeads(BaseTestSuite): + """A link written in a document leads to the page, the heading or the online copy it means.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + href: str + page: str + expected: str + + test_cases = ( + TestCase( + label="an external address", + href="https://example.com/a", + page="guide/start.md", + expected="https://example.com/a", + ), + TestCase( + label="a mail address", + href="mailto:someone@example.com", + page="index.md", + expected="mailto:someone@example.com", + ), + TestCase(label="a heading on the same page", href="#first-part", page="guide/start.md", expected="#first-part"), + TestCase(label="a page beside it", href="next.md", page="guide/start.md", expected="next.html"), + TestCase( + label="a heading on a page beside it", + href="next.md#second-part", + page="guide/start.md", + expected="next.html#second-part", + ), + TestCase(label="a page in a folder", href="guide/start.md", page="index.md", expected="guide/start.html"), + TestCase( + label="a page up a folder", + href="../glossary.md#term", + page="guide/start.md", + expected="../glossary.html#term", + ), + TestCase(label="a folder without an index", href="./", page="guide/start.md", expected="start.html"), + TestCase(label="a folder named from above", href="guide/", page="index.md", expected="guide/start.html"), + TestCase( + label="a development page", + href="../development/notes.md", + page="guide/start.md", + expected=f"{ADDRESS}/development/notes.md", + ), + TestCase( + label="a development folder", href="development/", page="index.md", expected=f"{ADDRESS}/development/" + ), + TestCase( + label="a picture", + href="../images/picture.webp", + page="guide/start.md", + expected=f"{ADDRESS}/images/picture.webp", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_link(self, test_case: TestCase, resolver: LinkResolver) -> None: + assert resolver.resolve(test_case.href, PurePosixPath(test_case.page)) == test_case.expected + + +class TestABrokenLink(BaseTestSuite): + """A link to a missing file or a missing heading fails the build, wherever it points.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + href: str + + test_cases = ( + TestCase(label="a missing heading here", href="#nowhere"), + TestCase(label="a missing page", href="missing.md"), + TestCase(label="a missing heading on a page", href="next.md#nowhere"), + TestCase(label="a missing development page", href="../development/missing.md"), + TestCase(label="a file above the docs", href="../../outside.md"), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_it_is_refused(self, test_case: TestCase, resolver: LinkResolver) -> None: + with pytest.raises(BrokenLinkError): + resolver.resolve(test_case.href, START) + + +class TestAFolderWithAnIndex: + def test_a_folder_leads_to_its_index( + self, + docs: Path, + pages: Dict[PurePosixPath, ParsedPage], + navigation: Navigation, + ) -> None: + anchors = {relative: page.anchors for relative, page in pages.items()} + anchors[PurePosixPath("guide/index.md")] = frozenset() + resolver = LinkResolver(docs, anchors, navigation.folder_pages, ADDRESS) + + assert resolver.resolve("guide/", PurePosixPath("index.md")) == "guide/index.html" diff --git a/tests/unit/assets/documentation/markdown/__init__.py b/tests/unit/assets/documentation/markdown/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/assets/documentation/markdown/test_anchors.py b/tests/unit/assets/documentation/markdown/test_anchors.py new file mode 100644 index 000000000..4a3c717f2 --- /dev/null +++ b/tests/unit/assets/documentation/markdown/test_anchors.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass + +import pytest + +from assets.documentation.markdown.anchors import HeadingAnchors, heading_anchor +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +class TestTheAnchorAHeadingGets(BaseTestSuite): + """A heading's anchor is the one GitHub gives it: lower case, punctuation and code marks + dropped, every space a hyphen.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + heading: str + expected: str + + test_cases = ( + TestCase(label="plain words", heading="Choosing what to convert", expected="choosing-what-to-convert"), + TestCase( + label="punctuation", heading="Voices: samples and instruments", expected="voices-samples-and-instruments" + ), + TestCase(label="a code span", heading="The `.nsf` file", expected="the-nsf-file"), + TestCase(label="emphasis", heading="An *important* part", expected="an-important-part"), + TestCase(label="a lettered heading", heading="A. The file", expected="a-the-file"), + TestCase(label="hyphens kept", heading="One-shot and loops", expected="one-shot-and-loops"), + TestCase(label="surrounding spaces", heading=" Padded ", expected="padded"), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_anchor(self, test_case: TestCase) -> None: + assert heading_anchor(test_case.heading) == test_case.expected + + +class TestHeadingAnchors: + def test_a_repeated_heading_is_numbered(self) -> None: + anchors = HeadingAnchors() + + assert [anchors.claim("Part"), anchors.claim("Part"), anchors.claim("Part")] == ["part", "part-1", "part-2"] + + def test_distinct_headings_keep_their_anchors(self) -> None: + anchors = HeadingAnchors() + + assert [anchors.claim("First"), anchors.claim("Second")] == ["first", "second"] diff --git a/tests/unit/assets/documentation/markdown/test_parser.py b/tests/unit/assets/documentation/markdown/test_parser.py new file mode 100644 index 000000000..5cea2bde9 --- /dev/null +++ b/tests/unit/assets/documentation/markdown/test_parser.py @@ -0,0 +1,19 @@ +from markdown_it import MarkdownIt + +from assets.documentation.markdown.parser import TABLE_CLOSE, TABLE_OPEN + +TABLE = "| A | B |\n|---|---|\n| 1 | 2 |\n" + + +class TestMarkdownParser: + def test_a_table_is_wrapped_to_scroll_on_its_own(self, parser: MarkdownIt) -> None: + html = parser.render(TABLE) + + assert html.startswith(TABLE_OPEN) + assert html.endswith(TABLE_CLOSE) + assert "1" in html + + def test_raw_html_passes_through(self, parser: MarkdownIt) -> None: + html = parser.render('
    A picture
    \n') + + assert 'A picture' in html diff --git a/tests/unit/assets/documentation/pages/__init__.py b/tests/unit/assets/documentation/pages/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/assets/documentation/pages/test_reader.py b/tests/unit/assets/documentation/pages/test_reader.py new file mode 100644 index 000000000..b9c1d934a --- /dev/null +++ b/tests/unit/assets/documentation/pages/test_reader.py @@ -0,0 +1,24 @@ +from pathlib import Path, PurePosixPath + +import pytest +from markdown_it import MarkdownIt + +from assets.documentation.pages.reader import MissingTitleError, parse_page + + +class TestParsePage: + def test_every_heading_carries_its_anchor(self, parser: MarkdownIt, docs: Path) -> None: + page = parse_page(parser, docs, PurePosixPath("guide/start.md")) + identifiers = [token.attrGet("id") for token in page.tokens if token.type == "heading_open"] + + assert identifiers == ["start-here", "first-part", "first-part-1"] + assert page.anchors == frozenset(identifiers) + + def test_the_title_is_the_first_level_heading(self, parser: MarkdownIt, docs: Path) -> None: + assert parse_page(parser, docs, PurePosixPath("guide/start.md")).title == "Start here" + + def test_a_document_without_a_title_is_refused(self, parser: MarkdownIt, docs: Path) -> None: + (docs / "guide" / "untitled.md").write_text("## Only a section\n", encoding="utf-8") + + with pytest.raises(MissingTitleError): + parse_page(parser, docs, PurePosixPath("guide/untitled.md")) diff --git a/tests/unit/assets/documentation/pages/test_sections.py b/tests/unit/assets/documentation/pages/test_sections.py new file mode 100644 index 000000000..7572e99ca --- /dev/null +++ b/tests/unit/assets/documentation/pages/test_sections.py @@ -0,0 +1,62 @@ +from pathlib import PurePosixPath +from typing import Dict + +from markdown_it import MarkdownIt + +from assets.documentation.pages.model import ParsedPage +from assets.documentation.pages.sections import image_sources, split_sections + +START = PurePosixPath("guide/start.md") +NEXT = PurePosixPath("guide/next.md") + + +class TestSplitSections: + def test_the_opening_comes_first_and_each_section_follows( + self, + parser: MarkdownIt, + pages: Dict[PurePosixPath, ParsedPage], + ) -> None: + sections = split_sections(parser, pages[START].tokens, {}) + + assert [section.anchor for section in sections] == [None, "first-part", "first-part-1"] + assert sections[0].title_html is None + assert "Before the first section." in sections[0].body_html + assert "Start here" not in "".join(section.body_html for section in sections) + + def test_a_section_holds_what_follows_its_heading( + self, + parser: MarkdownIt, + pages: Dict[PurePosixPath, ParsedPage], + ) -> None: + sections = split_sections(parser, pages[START].tokens, {}) + + assert sections[1].title_html == "First part" + assert " None: + sections = split_sections(parser, pages[NEXT].tokens, {}) + + assert [section.anchor for section in sections] == ["second-part"] + + def test_a_heading_keeps_its_inline_markup(self, parser: MarkdownIt) -> None: + tokens = parser.parse("# Title\n\n## The `.nsf` file\n\nText.\n") + for token in tokens: + if token.type == "heading_open": + token.attrSet("id", "anchor") + + sections = split_sections(parser, tokens, {}) + + assert sections[0].title_html == "The .nsf file" + + +class TestImageSources: + def test_every_picture_is_named_as_written(self, pages: Dict[PurePosixPath, ParsedPage]) -> None: + assert image_sources(pages[START].tokens) == ["../images/picture.webp"] + + def test_a_page_without_pictures_names_none(self, pages: Dict[PurePosixPath, ParsedPage]) -> None: + assert image_sources(pages[NEXT].tokens) == [] diff --git a/tests/unit/assets/documentation/rendering/__init__.py b/tests/unit/assets/documentation/rendering/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/assets/documentation/rendering/test_cards.py b/tests/unit/assets/documentation/rendering/test_cards.py new file mode 100644 index 000000000..eb12f66ab --- /dev/null +++ b/tests/unit/assets/documentation/rendering/test_cards.py @@ -0,0 +1,22 @@ +from assets.documentation.pages.model import Section +from assets.documentation.rendering.cards import section_card + +GLYPH = "▍" + + +class TestSectionCard: + def test_a_section_wears_the_header_strip(self) -> None: + card = section_card( + Section(title_html="The .nsf file", anchor="the-nsf-file", body_html="

    Text.

    \n"), GLYPH + ) + + assert card.startswith('
    ') + assert f'{GLYPH}' in card + assert 'The .nsf file' in card + assert '
    \n

    Text.

    \n
    ' in card + + def test_the_opening_is_a_card_without_a_header(self) -> None: + card = section_card(Section(title_html=None, anchor=None, body_html="

    Opening.

    \n"), GLYPH) + + assert card.startswith('
    ') + assert "card-header" not in card diff --git a/tests/unit/assets/documentation/rendering/test_navigation.py b/tests/unit/assets/documentation/rendering/test_navigation.py new file mode 100644 index 000000000..f8bc3c9b7 --- /dev/null +++ b/tests/unit/assets/documentation/rendering/test_navigation.py @@ -0,0 +1,41 @@ +from pathlib import PurePosixPath + +from assets.documentation.navigation import Navigation +from assets.documentation.pages.model import Section +from assets.documentation.rendering.navigation import HOME_TITLE, navigation_html + +SECTIONS = ( + Section(title_html=None, anchor=None, body_html=""), + Section(title_html="First part", anchor="first-part", body_html=""), +) + + +def inline(text: str) -> str: + return text + + +class TestNavigationHtml: + def test_links_are_relative_to_the_page(self, navigation: Navigation) -> None: + html = navigation_html(navigation, PurePosixPath("guide/next.md"), SECTIONS, inline) + + assert f'{HOME_TITLE}' in html + assert 'Start here' in html + assert 'Glossary' in html + + def test_the_current_page_is_marked_with_its_sections(self, navigation: Navigation) -> None: + html = navigation_html(navigation, PurePosixPath("guide/start.md"), SECTIONS, inline) + + assert 'Start here' in html + assert '' in html + assert html.count("aria-current") == 1 + + def test_the_index_marks_the_overview(self, navigation: Navigation) -> None: + html = navigation_html(navigation, PurePosixPath("index.md"), SECTIONS, inline) + + assert f'{HOME_TITLE}' in html + assert "navigation-anchors" not in html + + def test_each_group_is_headed_by_its_title(self, navigation: Navigation) -> None: + html = navigation_html(navigation, PurePosixPath("index.md"), SECTIONS, inline) + + assert '' in html diff --git a/tests/unit/assets/documentation/rendering/test_page.py b/tests/unit/assets/documentation/rendering/test_page.py new file mode 100644 index 000000000..ed802223e --- /dev/null +++ b/tests/unit/assets/documentation/rendering/test_page.py @@ -0,0 +1,40 @@ +from pathlib import PurePosixPath +from string import Template +from typing import List + +import pytest + +from assets.documentation.rendering.page import LAYOUT_DIRECTORY, PageChrome, PageContent, render_page + +TEMPLATE = Template("$title|$heading|$stylesheets|$root|$layout|$navigation|$content|$version|$source") +CHROME = PageChrome(version="1.2.3", address="https://example.com/docs/", stylesheets=("a.css", "b.css")) + + +@pytest.fixture +def fields() -> List[str]: + content = PageContent( + relative=PurePosixPath("guide/start.md"), + title_html="The .nsf & more", + navigation_html="NAVIGATION", + cards_html="CARDS", + ) + return render_page(TEMPLATE, CHROME, content).split("|") + + +class TestRenderPage: + def test_the_tab_title_is_the_heading_as_text(self, fields: List[str]) -> None: + assert fields[0] == "The .nsf & more" + assert fields[1] == "The .nsf & more" + + def test_shared_files_are_linked_from_where_the_page_sits(self, fields: List[str]) -> None: + assert fields[2] == ( + f'\n' + f'' + ) + assert fields[3] == "../" + + def test_the_online_copy_is_the_document_at_the_address(self, fields: List[str]) -> None: + assert fields[8] == "https://example.com/docs/guide/start.md" + + def test_the_parts_land_in_their_places(self, fields: List[str]) -> None: + assert fields[4:8] == [LAYOUT_DIRECTORY, "NAVIGATION", "CARDS", "1.2.3"] diff --git a/tests/unit/assets/documentation/test_build.py b/tests/unit/assets/documentation/test_build.py new file mode 100644 index 000000000..b6b3fd06c --- /dev/null +++ b/tests/unit/assets/documentation/test_build.py @@ -0,0 +1,92 @@ +from pathlib import Path +from typing import List + +import pytest + +from assets.documentation.build import DOCUMENTATION_STYLESHEET, OutputOccupiedError, SiteRequest, build_site +from assets.documentation.links.resolver import BrokenLinkError +from assets.documentation.rendering.page import LAYOUT_DIRECTORY +from sampletones_shared.documentation.site import DocumentationSite +from tests.unit.assets.documentation.conftest import ADDRESS, VERSION + + +@pytest.fixture +def request_for(docs: Path, project: Path, tmp_path: Path) -> SiteRequest: + return SiteRequest(docs=docs, project=project, output=tmp_path / "site") + + +@pytest.fixture +def site(request_for: SiteRequest) -> DocumentationSite: + return build_site(request_for) + + +def written(site: DocumentationSite) -> List[str]: + return sorted(path.relative_to(site.directory).as_posix() for path in site.directory.rglob("*") if path.is_file()) + + +class TestBuildSite: + def test_every_published_document_becomes_a_page(self, site: DocumentationSite) -> None: + pages = [name for name in written(site) if name.endswith(".html")] + + assert pages == ["glossary.html", "guide/next.html", "guide/start.html", "index.html"] + + def test_only_the_pictures_a_page_shows_are_copied(self, site: DocumentationSite) -> None: + assert [name for name in written(site) if name.startswith("images/")] == ["images/picture.webp"] + assert (site.directory / "images" / "picture.webp").read_bytes() == b"picture" + + def test_the_layout_lies_beside_the_pages(self, site: DocumentationSite) -> None: + assert (site.directory / LAYOUT_DIRECTORY / DOCUMENTATION_STYLESHEET).is_file() + assert (site.directory / LAYOUT_DIRECTORY / "roles.css").is_file() + + def test_the_site_is_sealed_by_its_stamp(self, site: DocumentationSite) -> None: + assert site.read_stamp() == site.content_stamp() + + def test_links_lead_to_pages_and_online_copies(self, site: DocumentationSite) -> None: + start = (site.directory / "guide" / "start.html").read_text(encoding="utf-8") + + assert 'href="next.html#second-part"' in start + assert f'href="{ADDRESS}/development/notes.md"' in start + assert 'src="../images/picture.webp"' in start + + def test_the_chrome_names_the_version_and_the_online_copy(self, site: DocumentationSite) -> None: + index = site.index.read_text(encoding="utf-8") + + assert f"SampleToNES {VERSION}" in index + assert f'href="{ADDRESS}/index.md"' in index + + def test_the_same_documents_make_the_same_site(self, site: DocumentationSite, request_for: SiteRequest) -> None: + stamp = site.read_stamp() + + assert build_site(request_for).read_stamp() == stamp + + def test_an_earlier_site_is_replaced(self, site: DocumentationSite, request_for: SiteRequest) -> None: + stale = site.directory / "guide" / "removed.html" + stale.write_text("stale", encoding="utf-8") + + build_site(request_for) + + assert not stale.exists() + + +class TestBuildSiteRefuses: + def test_a_folder_holding_other_files(self, request_for: SiteRequest) -> None: + request_for.output.mkdir() + keep = request_for.output / "notes.txt" + keep.write_text("mine", encoding="utf-8") + + with pytest.raises(OutputOccupiedError): + build_site(request_for) + + assert keep.read_text(encoding="utf-8") == "mine" + + def test_a_broken_link(self, request_for: SiteRequest) -> None: + (request_for.docs / "guide" / "next.md").write_text("# Next steps\n\n[Gone](gone.md)\n", encoding="utf-8") + + with pytest.raises(BrokenLinkError): + build_site(request_for) + + def test_a_missing_picture(self, request_for: SiteRequest) -> None: + (request_for.docs / "images" / "picture.webp").unlink() + + with pytest.raises(BrokenLinkError): + build_site(request_for) diff --git a/tests/unit/assets/documentation/test_main.py b/tests/unit/assets/documentation/test_main.py new file mode 100644 index 000000000..e72b42d36 --- /dev/null +++ b/tests/unit/assets/documentation/test_main.py @@ -0,0 +1,43 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from assets.documentation.__main__ import DocumentationArguments, main +from assets.documentation.build import SiteRequest +from assets.documentation.paths import DOCS_DIRECTORY, PROJECT_FILE, SITE_DIRECTORY +from sampletones_shared.documentation.site import DocumentationSite + +BUILDER: Final[str] = "assets.documentation.__main__.build_site" + + +class RecordedBuilds: + def __init__(self) -> None: + self.requests: List[SiteRequest] = [] + + def __call__(self, request: SiteRequest) -> DocumentationSite: + self.requests.append(request) + return DocumentationSite(request.output) + + +class TestDocumentationArguments: + def test_without_an_output_the_package_folder_is_named(self) -> None: + assert DocumentationArguments.parse([]).directory == SITE_DIRECTORY + + def test_an_output_of_its_own_is_named(self, tmp_path: Path) -> None: + assert DocumentationArguments.parse(["-o", str(tmp_path)]).directory == tmp_path + + +class TestMain: + def test_the_checkout_docs_are_written_where_asked( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, + ) -> None: + builds = RecordedBuilds() + monkeypatch.setattr(BUILDER, builds) + + assert main(["--output", str(tmp_path)]) == 0 + assert builds.requests == [SiteRequest(docs=DOCS_DIRECTORY, project=PROJECT_FILE, output=tmp_path)] + assert f"Wrote {tmp_path / 'index.html'}" in capsys.readouterr().out diff --git a/tests/unit/assets/documentation/test_navigation.py b/tests/unit/assets/documentation/test_navigation.py new file mode 100644 index 000000000..1f7ea918e --- /dev/null +++ b/tests/unit/assets/documentation/test_navigation.py @@ -0,0 +1,20 @@ +from pathlib import PurePosixPath + +from assets.documentation.navigation import Navigation, NavigationEntry, NavigationGroup + + +class TestNavigation: + def test_each_section_linking_published_pages_is_a_group(self, navigation: Navigation) -> None: + assert navigation.groups == ( + NavigationGroup( + title="Using it", + entries=( + NavigationEntry(page=PurePosixPath("guide/start.md"), title="Start here"), + NavigationEntry(page=PurePosixPath("guide/next.md"), title="Next steps"), + NavigationEntry(page=PurePosixPath("glossary.md"), title="Glossary"), + ), + ), + ) + + def test_a_folder_leads_to_the_first_page_listed_in_it(self, navigation: Navigation) -> None: + assert navigation.folder_pages == {"guide": PurePosixPath("guide/start.md")} diff --git a/tests/unit/assets/documentation/test_project.py b/tests/unit/assets/documentation/test_project.py new file mode 100644 index 000000000..0e4306aca --- /dev/null +++ b/tests/unit/assets/documentation/test_project.py @@ -0,0 +1,18 @@ +from pathlib import Path + +import pytest + +from assets.documentation.project import ProjectFacts +from tests.unit.assets.documentation.conftest import ADDRESS, VERSION + + +class TestProjectFacts: + def test_the_version_and_the_address_are_read(self, project: Path) -> None: + assert ProjectFacts.read(project) == ProjectFacts(version=VERSION, documentation_address=ADDRESS) + + def test_a_project_naming_no_documentation_is_refused(self, tmp_path: Path) -> None: + path = tmp_path / "pyproject.toml" + path.write_text('[project]\nversion = "1.0"\n', encoding="utf-8") + + with pytest.raises(KeyError): + ProjectFacts.read(path) diff --git a/tests/unit/assets/documentation/test_published.py b/tests/unit/assets/documentation/test_published.py new file mode 100644 index 000000000..dfe742dbc --- /dev/null +++ b/tests/unit/assets/documentation/test_published.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +import pytest + +from assets.documentation.published import is_published, page_path, published_pages +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +class TestWhichDocumentsArePublished(BaseTestSuite): + """The site carries the index, the glossary and the user's sections; the rest stays online.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + relative: str + expected: bool + + test_cases = ( + TestCase(label="the index", relative="index.md", expected=True), + TestCase(label="the glossary", relative="glossary.md", expected=True), + TestCase(label="a guide page", relative="guide/converting.md", expected=True), + TestCase(label="a formats page", relative="formats/nsf.md", expected=True), + TestCase(label="an api page", relative="api/index.md", expected=True), + TestCase(label="a development page", relative="development/guidelines.md", expected=False), + TestCase(label="a test plan", relative="tests/screens.md", expected=False), + TestCase(label="another root page", relative="CHANGELOG.md", expected=False), + TestCase(label="a picture in a published folder", relative="guide/picture.webp", expected=False), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_answer(self, test_case: TestCase) -> None: + assert is_published(PurePosixPath(test_case.relative)) is test_case.expected + + +class TestPublishedPages: + def test_every_published_document_is_listed_in_order(self, docs: Path) -> None: + assert published_pages(docs) == [ + PurePosixPath("glossary.md"), + PurePosixPath("guide/next.md"), + PurePosixPath("guide/start.md"), + PurePosixPath("index.md"), + ] + + def test_a_page_sits_where_its_document_sits(self) -> None: + assert page_path(PurePosixPath("guide/start.md")) == PurePosixPath("guide/start.html") diff --git a/tests/unit/assets/documentation/test_static.py b/tests/unit/assets/documentation/test_static.py new file mode 100644 index 000000000..c2f9da1ca --- /dev/null +++ b/tests/unit/assets/documentation/test_static.py @@ -0,0 +1,49 @@ +import re +from pathlib import Path +from typing import Final, Set + +from assets.documentation.build import DOCUMENTATION_STYLESHEET, TEMPLATE_FILE +from assets.documentation.paths import STATIC_DIRECTORY +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from sampletones_tools.web.design import ApplicationDesign +from sampletones_tools.web.writer import write_layout + +COLOR_LITERAL: Final[re.Pattern[str]] = re.compile(r"#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(") +USED: Final[re.Pattern[str]] = re.compile(r"var\(--([a-z0-9-]+)\)") +DECLARED: Final[re.Pattern[str]] = re.compile(r"--([a-z0-9-]+):") +PLACEHOLDER: Final[re.Pattern[str]] = re.compile(r"\$\{?(\w+)") +TEMPLATE_FIELDS: Final[Set[str]] = { + "title", + "heading", + "stylesheets", + "root", + "layout", + "navigation", + "content", + "version", + "source", +} + + +def stylesheet() -> str: + return (STATIC_DIRECTORY / DOCUMENTATION_STYLESHEET).read_text(encoding="utf-8") + + +class TestDocumentationStylesheet: + def test_no_color_is_written_into_it(self) -> None: + assert COLOR_LITERAL.findall(stylesheet()) == [] + + def test_every_variable_it_reads_is_declared_by_the_layout(self, tmp_path: Path) -> None: + write_layout(tmp_path, ApplicationDesign.load(DEFAULT_PALETTE_NAME)) + declared: Set[str] = set() + for written in tmp_path.glob("*.css"): + declared.update(DECLARED.findall(written.read_text(encoding="utf-8"))) + + assert sorted(set(USED.findall(stylesheet())) - declared) == [] + + +class TestPageTemplate: + def test_it_names_exactly_the_fields_a_page_fills(self) -> None: + template = (STATIC_DIRECTORY / TEMPLATE_FILE).read_text(encoding="utf-8") + + assert set(PLACEHOLDER.findall(template)) == TEMPLATE_FIELDS diff --git a/tests/unit/sampletones_shared/documentation/__init__.py b/tests/unit/sampletones_shared/documentation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_shared/documentation/test_site.py b/tests/unit/sampletones_shared/documentation/test_site.py new file mode 100644 index 000000000..2dd860733 --- /dev/null +++ b/tests/unit/sampletones_shared/documentation/test_site.py @@ -0,0 +1,47 @@ +from pathlib import Path + +import pytest + +from sampletones_shared.documentation.site import DOCUMENTATION_DIRECTORY, INDEX_FILE, STAMP_FILE, DocumentationSite +from sampletones_shared.paths.package import package_directory +from sampletones_shared.paths.resources import ASSETS_PACKAGE + + +@pytest.fixture +def site(tmp_path: Path) -> DocumentationSite: + (tmp_path / "guide").mkdir() + (tmp_path / INDEX_FILE).write_text("

    index

    ", encoding="utf-8") + (tmp_path / "guide" / "page.html").write_text("

    page

    ", encoding="utf-8") + return DocumentationSite(tmp_path) + + +class TestDocumentationSite: + def test_the_shipped_site_lies_in_the_assets_package(self) -> None: + assert DocumentationSite.shipped().directory == package_directory(ASSETS_PACKAGE) / DOCUMENTATION_DIRECTORY + + def test_an_unsealed_site_reads_no_stamp(self, site: DocumentationSite) -> None: + assert site.read_stamp() is None + + def test_the_written_stamp_is_the_one_its_files_earn(self, site: DocumentationSite) -> None: + stamp = site.write_stamp() + + assert site.read_stamp() == stamp == site.content_stamp() + assert (site.directory / STAMP_FILE).is_file() + + def test_a_changed_file_earns_another_stamp(self, site: DocumentationSite) -> None: + stamp = site.write_stamp() + (site.directory / "guide" / "page.html").write_text("

    changed

    ", encoding="utf-8") + + assert site.content_stamp() != stamp + + def test_a_moved_file_earns_another_stamp(self, site: DocumentationSite) -> None: + stamp = site.write_stamp() + (site.directory / "guide" / "page.html").rename(site.directory / "page.html") + + assert site.content_stamp() != stamp + + def test_the_stamp_leaves_itself_out(self, site: DocumentationSite) -> None: + before = site.content_stamp() + site.write_stamp() + + assert site.content_stamp() == before diff --git a/uv.lock b/uv.lock index af1c18c49..343ac492a 100644 --- a/uv.lock +++ b/uv.lock @@ -1857,6 +1857,7 @@ gpu-cuda11 = [ [package.dev-dependencies] assets = [ + { name = "markdown-it-py" }, { name = "pillow" }, ] calibration = [ @@ -1865,6 +1866,7 @@ calibration = [ dev = [ { name = "black" }, { name = "isort" }, + { name = "markdown-it-py" }, { name = "mypy" }, { name = "pillow" }, { name = "pre-commit" }, @@ -1909,11 +1911,15 @@ requires-dist = [ provides-extras = ["build", "gpu", "gpu-cuda11"] [package.metadata.requires-dev] -assets = [{ name = "pillow", specifier = ">=11,<13" }] +assets = [ + { name = "markdown-it-py", specifier = ">=3,<5" }, + { name = "pillow", specifier = ">=11,<13" }, +] calibration = [{ name = "zimtohrli", specifier = ">=0.2.1,<0.3" }] dev = [ { name = "black", specifier = "==26.5.1" }, { name = "isort", specifier = "==8.0.1" }, + { name = "markdown-it-py", specifier = ">=3,<5" }, { name = "mypy", specifier = "==2.1.0" }, { name = "pillow", specifier = ">=11,<13" }, { name = "pre-commit", specifier = "==4.6.0" }, From 2e893dc80c79196fa05e2e4d64d340ed97965886 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 14:19:58 +0200 Subject: [PATCH 3/8] Added: a wheel build hook writing the documentation into every installation --- hatch_build.py | 58 +++++++ pyproject.toml | 14 +- scripts/setup_environment.py | 18 +- src/sampletones/__init__.py | 4 +- src/sampletones/dispatcher.py | 2 +- src/sampletones/run.py | 2 +- src/sampletones_application/application.py | 2 +- src/sampletones_application/config/profile.py | 3 +- .../coordinators/config.py | 2 +- src/sampletones_application/paths.py | 2 - src/sampletones_core/data/metadata.py | 2 +- src/sampletones_shared/application.py | 3 - src/sampletones_shared/distribution.py | 7 + src/sampletones_shared/paths/user.py | 1 + .../documentation/test_build_environment.py | 154 ++++++++++++++++++ .../test_session_files_from_elsewhere.py | 3 +- tests/unit/sampletones/test_dispatcher.py | 2 +- .../config/test_profile.py | 3 +- tests/unit/scripts/test_setup_environment.py | 15 +- tests/unit/test_hatch_build.py | 91 +++++++++++ uv.lock | 27 +++ 21 files changed, 386 insertions(+), 29 deletions(-) create mode 100644 hatch_build.py create mode 100644 src/sampletones_shared/distribution.py create mode 100644 tests/integration/assets/documentation/test_build_environment.py create mode 100644 tests/unit/test_hatch_build.py diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 000000000..12be63614 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,58 @@ +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Any, Dict, Final + +from hatchling.builders.config import BuilderConfig +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + +EDITABLE: Final[str] = "editable" +FORCE_INCLUDE: Final[str] = "force_include" +SOURCE_DIRECTORY: Final[str] = "src" +SITE_DESTINATION: Final[str] = "sampletones_assets/documentation" +SITE_PREFIX: Final[str] = "sampletones-documentation-" + + +class DocumentationBuildHook(BuildHookInterface[BuilderConfig]): + """Writes the user documentation into every wheel, as the pages Help > Documentation opens. + + The maker in ``assets/documentation`` writes the site from the checkout's docs into a + temporary folder, which the wheel carries at the assets package's documentation path and + which is removed once the wheel is written. An editable install reads the checkout itself, + where ``make docs`` writes the same site. + """ + + PLUGIN_NAME = "custom" + + def initialize(self, version: str, build_data: Dict[str, Any]) -> None: + if version == EDITABLE: + return + + root = Path(self.root) + for entry in (root / SOURCE_DIRECTORY, root): + if str(entry) not in sys.path: + sys.path.insert(0, str(entry)) + + from assets.documentation.build import ( # pylint: disable=import-outside-toplevel + SiteRequest, + build_site, + ) + from assets.documentation.paths import ( # pylint: disable=import-outside-toplevel + DOCS_DIRECTORY, + PROJECT_FILE, + ) + + site = Path(tempfile.mkdtemp(prefix=SITE_PREFIX)) + build_site(SiteRequest(docs=DOCS_DIRECTORY, project=PROJECT_FILE, output=site)) + build_data[FORCE_INCLUDE][str(site)] = SITE_DESTINATION + + def finalize( + self, + version: str, + build_data: Dict[str, Any], + artifact_path: str, + ) -> None: + for source, destination in build_data[FORCE_INCLUDE].items(): + if destination == SITE_DESTINATION: + shutil.rmtree(source) diff --git a/pyproject.toml b/pyproject.toml index d09e734a2..8e87d292e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,7 @@ calibration = ["zimtohrli>=0.2.1,<0.3"] dev = [ { include-group = "assets" }, "black==26.5.1", + "hatchling>=1.27", "isort==8.0.1", "mypy==2.1.0", "pre-commit==4.6.0", @@ -96,7 +97,7 @@ dev = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [tool.uv] @@ -114,6 +115,16 @@ packages = [ "src/sampletones_tools", ] +[tool.hatch.build.targets.wheel.hooks.custom] +path = "hatch_build.py" +dependencies = [ + "markdown-it-py>=3,<5", + "numpy>=2.0,<3", + "pydantic>=2.9,<3", + "PyYAML>=6.0,<7", + "rich>=13.0,<16", +] + [tool.black] line-length = 120 target-version = ["py312"] @@ -166,6 +177,7 @@ files = [ "scripts", "automation", "assets", + "hatch_build.py", ] exclude = ["tests"] disallow_subclassing_any = true diff --git a/scripts/setup_environment.py b/scripts/setup_environment.py index f0daba10b..4c9a94889 100644 --- a/scripts/setup_environment.py +++ b/scripts/setup_environment.py @@ -13,10 +13,17 @@ from bootstrap.project import DEVELOPMENT_GROUP, read_project -def setup_commands(extra: Optional[str]) -> List[List[str]]: +def setup_commands( + name: str, + extra: Optional[str], +) -> List[List[str]]: """The commands that create the development environment and install the global command. + The global command is rebuilt from the checkout on every run, so it carries the checkout's + code and documentation as they stand. + Args: + name: The distribution's name, the package the global command is rebuilt from. extra: The GPU extra installed with the package, or ``None`` for the CPU backend. Returns: @@ -30,12 +37,13 @@ def setup_commands(extra: Optional[str]) -> List[List[str]]: return [ synchronize, - ["uv", "tool", "install", "--force", package], + ["uv", "tool", "install", "--force", "--reinstall-package", name, package], ] def set_up_environment( root: Path, + name: str, platform: Platform, extra: Optional[str], *, @@ -47,6 +55,7 @@ def set_up_environment( Args: root: The repository. + name: The distribution's name, the package the global command is rebuilt from. platform: The system the setup runs on, which adds the variables the build needs. extra: The GPU extra installed with the package, or ``None`` for the CPU backend. machine: The processor architecture ``platform.machine()`` reports. @@ -57,7 +66,7 @@ def set_up_environment( SystemExit: If a command fails. """ variables = platform.setup_variables(environment, machine=machine) - for command in setup_commands(extra): + for command in setup_commands(name, extra): expect_success(runner, command, cwd=root, environment=variables) @@ -73,10 +82,11 @@ def main(argv: Sequence[str]) -> int: arguments = parser.parse_args(list(argv)) root = repository_root() - read_project(root) + project = read_project(root) platform = current_platform() set_up_environment( root, + project.name, platform, gpu_extra(arguments.gpu, platform, os.environ), machine=running.machine(), diff --git a/src/sampletones/__init__.py b/src/sampletones/__init__.py index a059dd93b..2d52d0467 100644 --- a/src/sampletones/__init__.py +++ b/src/sampletones/__init__.py @@ -19,7 +19,7 @@ ) from sampletones_core.library import InstructionLibrary from sampletones_core.reconstructions import Reconstruction, Reconstructor - from sampletones_shared.application import SAMPLETONES_VERSION as __version__ + from sampletones_shared.distribution import SAMPLETONES_VERSION as __version__ def __getattr__(name: str) -> Any: @@ -29,7 +29,7 @@ def __getattr__(name: str) -> Any: return Config if name == "__version__": - from sampletones_shared.application import SAMPLETONES_VERSION + from sampletones_shared.distribution import SAMPLETONES_VERSION return SAMPLETONES_VERSION diff --git a/src/sampletones/dispatcher.py b/src/sampletones/dispatcher.py index b0d85872a..885e39fd9 100644 --- a/src/sampletones/dispatcher.py +++ b/src/sampletones/dispatcher.py @@ -1,8 +1,8 @@ from argparse import ArgumentParser from typing import Final, Sequence -from sampletones_shared.application import SAMPLETONES_NAME_VERSION from sampletones_shared.command import Command +from sampletones_shared.distribution import SAMPLETONES_NAME_VERSION PROGRAM: Final[str] = "sampletones" DESCRIPTION: Final[str] = "SampleToNES turns recordings into NES instruments and plays them back." diff --git a/src/sampletones/run.py b/src/sampletones/run.py index a99e233be..0febb4038 100644 --- a/src/sampletones/run.py +++ b/src/sampletones/run.py @@ -3,7 +3,7 @@ from sampletones_application.application import Application from sampletones_application.config.profile import UserProfile -from sampletones_shared.application import SAMPLETONES_NAME_VERSION +from sampletones_shared.distribution import SAMPLETONES_NAME_VERSION from sampletones_shared.logger import logger diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index a37c26ee7..6395c44ef 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -195,8 +195,8 @@ from sampletones_shared.application import ( SAMPLETONES_AUTHOR, SAMPLETONES_GROUP, - SAMPLETONES_NAME_VERSION, ) +from sampletones_shared.distribution import SAMPLETONES_NAME_VERSION from sampletones_shared.exceptions import PlaybackError from sampletones_shared.logger import logger from sampletones_shared.types.application import Sender diff --git a/src/sampletones_application/config/profile.py b/src/sampletones_application/config/profile.py index 239bfeafa..aed02ecea 100644 --- a/src/sampletones_application/config/profile.py +++ b/src/sampletones_application/config/profile.py @@ -3,8 +3,7 @@ from dataclasses import dataclass from pathlib import Path -from sampletones_application.paths import APPLICATION_STATE_PATH -from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH +from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH, APPLICATION_STATE_PATH @dataclass(frozen=True) diff --git a/src/sampletones_application/coordinators/config.py b/src/sampletones_application/coordinators/config.py index 282f1df89..8a8218a3a 100644 --- a/src/sampletones_application/coordinators/config.py +++ b/src/sampletones_application/coordinators/config.py @@ -26,7 +26,7 @@ from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer -from sampletones_shared.application import SAMPLETONES_VERSION +from sampletones_shared.distribution import SAMPLETONES_VERSION from sampletones_shared.logger import logger from sampletones_shared.paths.extensions import EXT_FILE_JSON from sampletones_shared.utils.validation import flatten_location diff --git a/src/sampletones_application/paths.py b/src/sampletones_application/paths.py index 0ae44e42d..362ab73fd 100644 --- a/src/sampletones_application/paths.py +++ b/src/sampletones_application/paths.py @@ -2,7 +2,6 @@ from typing import Final from sampletones_shared.paths.resources import CONFIG_DIRECTORY -from sampletones_shared.paths.user import USER_PATH_CONFIG APPLICATION_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "application" BEHAVIOR_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "behavior" @@ -14,4 +13,3 @@ LANG_EN: Final[Path] = LANG_DIRECTORY / "en.yaml" DEPLOYMENT_CONFIG_PATH: Final[Path] = APPLICATION_DIRECTORY / "deployment.yaml" -APPLICATION_STATE_PATH: Final[Path] = USER_PATH_CONFIG / "state.yaml" diff --git a/src/sampletones_core/data/metadata.py b/src/sampletones_core/data/metadata.py index 9f43e9d45..f869f7fd0 100644 --- a/src/sampletones_core/data/metadata.py +++ b/src/sampletones_core/data/metadata.py @@ -9,9 +9,9 @@ SAMPLETONES_LIBRARY_DATA_VERSION, SAMPLETONES_NAME, SAMPLETONES_RECONSTRUCTION_DATA_VERSION, - SAMPLETONES_VERSION, ) from sampletones_shared.deployment.version import compare_versions +from sampletones_shared.distribution import SAMPLETONES_VERSION from sampletones_shared.exceptions import InvalidMetadataError from sampletones_shared.exceptions.version import IncompatibleVersionError diff --git a/src/sampletones_shared/application.py b/src/sampletones_shared/application.py index 503597228..a2be681c8 100644 --- a/src/sampletones_shared/application.py +++ b/src/sampletones_shared/application.py @@ -1,16 +1,13 @@ -from importlib import metadata from typing import Final SAMPLETONES_NAME: Final[str] = "SampleToNES" SAMPLETONES_PACKAGE_NAME: Final[str] = "sampletones" SAMPLETONES_ENV_PREFIX: Final[str] = "SAMPLETONES_" -SAMPLETONES_VERSION: Final[str] = metadata.version(SAMPLETONES_PACKAGE_NAME) SAMPLETONES_LIBRARY_DATA_VERSION: Final[str] = "2.1" SAMPLETONES_RECONSTRUCTION_DATA_VERSION: Final[str] = "2.2" SAMPLETONES_PROJECT_DATA_VERSION: Final[str] = "1.1" -SAMPLETONES_NAME_VERSION: Final[str] = f"{SAMPLETONES_NAME} v{SAMPLETONES_VERSION}" SAMPLETONES_AUTHOR: Final[str] = "Jakim" SAMPLETONES_GROUP: Final[str] = "Stage Magician" diff --git a/src/sampletones_shared/distribution.py b/src/sampletones_shared/distribution.py new file mode 100644 index 000000000..e94bbedd8 --- /dev/null +++ b/src/sampletones_shared/distribution.py @@ -0,0 +1,7 @@ +from importlib import metadata +from typing import Final + +from sampletones_shared.application import SAMPLETONES_NAME, SAMPLETONES_PACKAGE_NAME + +SAMPLETONES_VERSION: Final[str] = metadata.version(SAMPLETONES_PACKAGE_NAME) +SAMPLETONES_NAME_VERSION: Final[str] = f"{SAMPLETONES_NAME} v{SAMPLETONES_VERSION}" diff --git a/src/sampletones_shared/paths/user.py b/src/sampletones_shared/paths/user.py index 6a34673d3..e55c82e79 100644 --- a/src/sampletones_shared/paths/user.py +++ b/src/sampletones_shared/paths/user.py @@ -17,6 +17,7 @@ PROJECTS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "projects" CONFIG_PATH: Final[Path] = USER_PATH_DOCUMENTS / "config.json" APPLICATION_CONFIG_PATH: Final[Path] = USER_PATH_CONFIG / "config.yaml" +APPLICATION_STATE_PATH: Final[Path] = USER_PATH_CONFIG / "state.yaml" PROJECTS_DIRECTORY.mkdir(parents=True, exist_ok=True) LIBRARY_DIRECTORY.mkdir(parents=True, exist_ok=True) diff --git a/tests/integration/assets/documentation/test_build_environment.py b/tests/integration/assets/documentation/test_build_environment.py new file mode 100644 index 000000000..fac84520c --- /dev/null +++ b/tests/integration/assets/documentation/test_build_environment.py @@ -0,0 +1,154 @@ +import json +import re +import subprocess +import sys +import tomllib +from importlib import metadata +from pathlib import Path +from typing import Any, Dict, Final, List, Set + +import pytest + +from hatch_build import SOURCE_DIRECTORY +from sampletones_shared.application import SAMPLETONES_PACKAGE_NAME +from sampletones_shared.paths.source import REPOSITORY_ROOT + +PROJECT_FILE: Final[Path] = REPOSITORY_ROOT / "pyproject.toml" +REQUIREMENT_NAME: Final[re.Pattern[str]] = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)") +EXTRA_MARKER: Final[str] = "extra ==" +PASSING_PREFIXES: Final[List[str]] = ["sampletones_", "assets", "_sysconfigdata"] +FORBIDDEN_MODULES: Final[List[str]] = ["sampletones_shared.paths.user", "dearpygui", "platformdirs"] + +PROBE: Final[str] = """ +import importlib.abc +import importlib.metadata +import json +import sys +from pathlib import Path + +root, source, output, allowed, passing, package = sys.argv[1:7] +allowed = set(json.loads(allowed)) +passing = tuple(json.loads(passing)) + + +class Fence(importlib.abc.MetaPathFinder): + def find_spec(self, name, path=None, target=None): + top = name.partition(".")[0] + if top in allowed or top in sys.stdlib_module_names or top.startswith(passing): + return None + raise ModuleNotFoundError(f"the build environment holds no {name}") + + +real_version = importlib.metadata.version + + +def version(name): + if name == package: + raise importlib.metadata.PackageNotFoundError(name) + return real_version(name) + + +importlib.metadata.version = version +sys.meta_path.insert(0, Fence()) +sys.path[:0] = [source, root] + +from assets.documentation.build import SiteRequest, build_site +from assets.documentation.paths import DOCS_DIRECTORY, PROJECT_FILE + +site = build_site(SiteRequest(docs=DOCS_DIRECTORY, project=PROJECT_FILE, output=Path(output))) +print(json.dumps({"stamp": site.read_stamp(), "modules": sorted(sys.modules)})) +""" + + +def normalized(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def requirement_name(requirement: str) -> str: + match = REQUIREMENT_NAME.match(requirement) + assert match is not None, requirement + return normalized(match.group(1)) + + +def project_tables() -> Dict[str, Any]: + with PROJECT_FILE.open("rb") as stream: + return tomllib.load(stream) + + +def hook_dependencies() -> List[str]: + dependencies: List[str] = project_tables()["tool"]["hatch"]["build"]["targets"]["wheel"]["hooks"]["custom"][ + "dependencies" + ] + return dependencies + + +def distribution_closure(names: List[str]) -> Set[str]: + """Every distribution the named ones install, theirs included, as their normalized names.""" + pending = [requirement_name(name) for name in names] + closure: Set[str] = set() + while pending: + name = pending.pop() + if name in closure: + continue + + closure.add(name) + for requirement in metadata.requires(name) or []: + if EXTRA_MARKER not in requirement: + pending.append(requirement_name(requirement)) + + return closure + + +def importable_modules(distributions: Set[str]) -> List[str]: + return sorted( + module + for module, owners in metadata.packages_distributions().items() + if any(normalized(owner) in distributions for owner in owners) + ) + + +@pytest.fixture(scope="module") +def probe(tmp_path_factory: pytest.TempPathFactory) -> Dict[str, Any]: + output = tmp_path_factory.mktemp("build-environment") / "site" + allowed = importable_modules(distribution_closure(hook_dependencies())) + completed = subprocess.run( + [ + sys.executable, + "-I", + "-c", + PROBE, + str(REPOSITORY_ROOT), + str(REPOSITORY_ROOT / SOURCE_DIRECTORY), + str(output), + json.dumps(allowed), + json.dumps(PASSING_PREFIXES), + SAMPLETONES_PACKAGE_NAME, + ], + capture_output=True, + text=True, + check=False, + timeout=300, + ) + assert completed.returncode == 0, completed.stderr + result: Dict[str, Any] = json.loads(completed.stdout) + return result + + +class TestTheBuildEnvironment: + def test_the_maker_runs_on_the_hook_dependencies_alone(self, probe: Dict[str, Any]) -> None: + assert probe["stamp"] + + @pytest.mark.parametrize("module", FORBIDDEN_MODULES) + def test_the_maker_leaves_the_user_folders_and_the_interface_alone( + self, + probe: Dict[str, Any], + module: str, + ) -> None: + assert module not in probe["modules"] + + def test_each_hook_dependency_carries_the_project_range(self) -> None: + tables = project_tables() + declared = [*tables["project"]["dependencies"], *tables["dependency-groups"]["assets"]] + ranges = {requirement_name(requirement): requirement for requirement in declared} + + assert [ranges.get(requirement_name(requirement)) for requirement in hook_dependencies()] == hook_dependencies() diff --git a/tests/screens/application/old_files/test_session_files_from_elsewhere.py b/tests/screens/application/old_files/test_session_files_from_elsewhere.py index ce3b887e9..4417fcab6 100644 --- a/tests/screens/application/old_files/test_session_files_from_elsewhere.py +++ b/tests/screens/application/old_files/test_session_files_from_elsewhere.py @@ -9,9 +9,8 @@ from automation.worlds.home import World, screen_filling_state from sampletones_application.categories.hierarchy import Tab from sampletones_application.config.session.state.current import Current -from sampletones_application.paths import APPLICATION_STATE_PATH from sampletones_core.configs import Config -from sampletones_shared.paths.user import CONFIG_PATH +from sampletones_shared.paths.user import APPLICATION_STATE_PATH, CONFIG_PATH from tests.suite.screens.seeds.archives import WrittenBytes from tests.suite.screens.seeds.recordings import Recording diff --git a/tests/unit/sampletones/test_dispatcher.py b/tests/unit/sampletones/test_dispatcher.py index 0a55f6eb3..d608fdc7f 100644 --- a/tests/unit/sampletones/test_dispatcher.py +++ b/tests/unit/sampletones/test_dispatcher.py @@ -4,8 +4,8 @@ import pytest from sampletones.dispatcher import COMMAND_METAVAR, DEFAULT_COMMAND, PROGRAM, build_parser, dispatch -from sampletones_shared.application import SAMPLETONES_NAME_VERSION from sampletones_shared.command import Command +from sampletones_shared.distribution import SAMPLETONES_NAME_VERSION def _command(name: str, calls: List[Tuple[str, bool]]) -> Command: diff --git a/tests/unit/sampletones_application/config/test_profile.py b/tests/unit/sampletones_application/config/test_profile.py index 4a123e290..5d3da3f96 100644 --- a/tests/unit/sampletones_application/config/test_profile.py +++ b/tests/unit/sampletones_application/config/test_profile.py @@ -1,8 +1,7 @@ from pathlib import Path from sampletones_application.config.profile import UserProfile -from sampletones_application.paths import APPLICATION_STATE_PATH -from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH +from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH, APPLICATION_STATE_PATH class TestUserProfile: diff --git a/tests/unit/scripts/test_setup_environment.py b/tests/unit/scripts/test_setup_environment.py index 1be608b9f..20386730b 100644 --- a/tests/unit/scripts/test_setup_environment.py +++ b/tests/unit/scripts/test_setup_environment.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Final import pytest @@ -11,6 +12,8 @@ setup_environment = load_script("setup_environment.py") +NAME: Final[str] = "sampletones" + class TestMain: def test_a_gpu_choice_outside_the_extras_is_refused_before_anything_runs( @@ -27,18 +30,18 @@ def test_a_gpu_choice_outside_the_extras_is_refused_before_anything_runs( class TestSetupCommands: def test_the_cpu_backend_synchronizes_and_installs_the_bare_package(self) -> None: - commands = setup_environment.setup_commands(None) + commands = setup_environment.setup_commands(NAME, None) assert commands == [ ["uv", "sync", "--group", DEVELOPMENT_GROUP], - ["uv", "tool", "install", "--force", "."], + ["uv", "tool", "install", "--force", "--reinstall-package", NAME, "."], ] def test_a_gpu_extra_reaches_both_installs(self) -> None: - commands = setup_environment.setup_commands(GPU_EXTRA) + commands = setup_environment.setup_commands(NAME, GPU_EXTRA) assert commands[0] == ["uv", "sync", "--group", DEVELOPMENT_GROUP, "--extra", GPU_EXTRA] - assert commands[1] == ["uv", "tool", "install", "--force", f".[{GPU_EXTRA}]"] + assert commands[1] == ["uv", "tool", "install", "--force", "--reinstall-package", NAME, f".[{GPU_EXTRA}]"] class TestSetUpEnvironment: @@ -47,6 +50,7 @@ def test_macos_runs_the_commands_on_its_native_architecture(self, tmp_path: Path setup_environment.set_up_environment( tmp_path, + NAME, MacOS(), None, machine="arm64", @@ -54,7 +58,7 @@ def test_macos_runs_the_commands_on_its_native_architecture(self, tmp_path: Path environment={"PATH": "/usr/bin"}, ) - assert runner.lines == [" ".join(command) for command in setup_environment.setup_commands(None)] + assert runner.lines == [" ".join(command) for command in setup_environment.setup_commands(NAME, None)] assert all(recorded.environment[ARCHFLAGS] == "-arch arm64" for recorded in runner.commands) def test_linux_passes_the_variables_through(self, tmp_path: Path) -> None: @@ -62,6 +66,7 @@ def test_linux_passes_the_variables_through(self, tmp_path: Path) -> None: setup_environment.set_up_environment( tmp_path, + NAME, Linux(), GPU_EXTRA, machine="x86_64", diff --git a/tests/unit/test_hatch_build.py b/tests/unit/test_hatch_build.py new file mode 100644 index 000000000..d48d04bcc --- /dev/null +++ b/tests/unit/test_hatch_build.py @@ -0,0 +1,91 @@ +from pathlib import Path +from typing import Any, Dict, Final, List + +import pytest + +from assets.documentation.build import SiteRequest +from assets.documentation.paths import DOCS_DIRECTORY, PROJECT_FILE +from hatch_build import EDITABLE, FORCE_INCLUDE, SITE_DESTINATION, DocumentationBuildHook +from sampletones_shared.documentation.site import INDEX_FILE, DocumentationSite +from sampletones_shared.paths.source import REPOSITORY_ROOT + +BUILDER: Final[str] = "assets.documentation.build.build_site" +STANDARD: Final[str] = "standard" + + +class RecordedBuilds: + def __init__(self) -> None: + self.requests: List[SiteRequest] = [] + + def __call__(self, request: SiteRequest) -> DocumentationSite: + self.requests.append(request) + (request.output / INDEX_FILE).write_text("

    index

    ", encoding="utf-8") + site = DocumentationSite(request.output) + site.write_stamp() + return site + + +@pytest.fixture +def builds(monkeypatch: pytest.MonkeyPatch) -> RecordedBuilds: + recorded = RecordedBuilds() + monkeypatch.setattr(BUILDER, recorded) + return recorded + + +@pytest.fixture +def hook(tmp_path: Path) -> DocumentationBuildHook: + return DocumentationBuildHook( + root=str(REPOSITORY_ROOT), + config={}, + build_config=None, + metadata=None, + directory=str(tmp_path), + target_name="wheel", + ) + + +@pytest.fixture +def build_data() -> Dict[str, Any]: + return {FORCE_INCLUDE: {}} + + +class TestDocumentationBuildHook: + def test_an_editable_build_carries_no_site( + self, + hook: DocumentationBuildHook, + builds: RecordedBuilds, + build_data: Dict[str, Any], + ) -> None: + hook.initialize(EDITABLE, build_data) + + assert builds.requests == [] + assert build_data == {FORCE_INCLUDE: {}} + + def test_a_wheel_carries_the_checkout_docs_at_the_package_path( + self, + hook: DocumentationBuildHook, + builds: RecordedBuilds, + build_data: Dict[str, Any], + ) -> None: + hook.initialize(STANDARD, build_data) + + [(source, destination)] = build_data[FORCE_INCLUDE].items() + assert destination == SITE_DESTINATION + assert builds.requests == [SiteRequest(docs=DOCS_DIRECTORY, project=PROJECT_FILE, output=Path(source))] + assert DocumentationSite(Path(source)).read_stamp() is not None + + hook.finalize(STANDARD, build_data, "sampletones.whl") + + def test_the_site_is_removed_once_the_wheel_is_written( + self, + hook: DocumentationBuildHook, + builds: RecordedBuilds, + build_data: Dict[str, Any], + ) -> None: + hook.initialize(STANDARD, build_data) + [source] = build_data[FORCE_INCLUDE] + + hook.finalize(STANDARD, build_data, "sampletones.whl") + + assert len(builds.requests) == 1 + assert not Path(source).exists() diff --git a/uv.lock b/uv.lock index 343ac492a..67b4b035d 100644 --- a/uv.lock +++ b/uv.lock @@ -652,6 +652,22 @@ wheels = [ { 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]] +name = "hatchling" +version = "1.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "tomlkit" }, + { name = "trove-classifiers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/97/b5312f01a8c6daf729a9d272dd442e0c546dbcc630495788786c4b567ed0/hatchling-1.32.4.tar.gz", hash = "sha256:c4468f73144c054d2aab4ef0f0378c43b9878bf07f8ffd6b79690e970d375f07", size = 58065, upload-time = "2026-09-20T22:48:45.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/80/91f51f439c05d4ec4623c22928ce16a938d6d793bf709477830823497859/hatchling-1.32.4-py3-none-any.whl", hash = "sha256:08ecf7548fb48205e7f213d70c71e67b8271b7242093dc3f1da578b42c734a2c", size = 78748, upload-time = "2026-09-20T22:48:44.19Z" }, +] + [[package]] name = "identify" version = "2.6.19" @@ -1865,6 +1881,7 @@ calibration = [ ] dev = [ { name = "black" }, + { name = "hatchling" }, { name = "isort" }, { name = "markdown-it-py" }, { name = "mypy" }, @@ -1918,6 +1935,7 @@ assets = [ calibration = [{ name = "zimtohrli", specifier = ">=0.2.1,<0.3" }] dev = [ { name = "black", specifier = "==26.5.1" }, + { name = "hatchling", specifier = ">=1.27" }, { name = "isort", specifier = "==8.0.1" }, { name = "markdown-it-py", specifier = ">=3,<5" }, { name = "mypy", specifier = "==2.1.0" }, @@ -2164,6 +2182,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, ] +[[package]] +name = "trove-classifiers" +version = "2026.9.21.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/93/af436dfaa845cab5d96f0adbc1e4f3730532d37fa249e4eb796fb1d7fc82/trove_classifiers-2026.9.21.13.tar.gz", hash = "sha256:0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71", size = 17085, upload-time = "2026-09-21T13:29:07.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/81/0da8afb52a71d0a4f2bd3152357b1a441e393b286374802b9d3addab4ab5/trove_classifiers-2026.9.21.13-py3-none-any.whl", hash = "sha256:8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17", size = 14232, upload-time = "2026-09-21T13:29:06.123Z" }, +] + [[package]] name = "types-python-xlib" version = "0.33.0.20260724" From f57ae7ca017e2762050eef135de4ec2a766e404b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 14:30:14 +0200 Subject: [PATCH 4/8] Added: Help > Documentation, opening the shipped pages in the reader's browser --- automation/paths.py | 1 + automation/plugin/fixtures.py | 4 +- src/sampletones/run.py | 2 + src/sampletones_application/application.py | 18 ++- .../categories/elements/global_.py | 1 + .../categories/elements/settings.py | 1 + src/sampletones_application/config/profile.py | 12 +- .../coordinators/documentation.py | 47 ++++++++ .../logic/documentation/__init__.py | 0 .../logic/documentation/shelf.py | 68 +++++++++++ src/sampletones_application/shell.py | 2 + src/sampletones_application/tags/general.py | 6 + src/sampletones_application/ui/menu.py | 4 + .../utils/gui/shortcuts/ids.py | 1 + .../keybindings/default.yaml | 1 + src/sampletones_config/keybindings/macos.yaml | 1 + src/sampletones_config/lang/en.yaml | 5 + src/sampletones_shared/distribution.py | 20 ++++ src/sampletones_shared/exceptions/__init__.py | 4 + .../exceptions/documentation.py | 5 + src/sampletones_shared/exceptions/opening.py | 5 + src/sampletones_shared/paths/user.py | 1 + .../utils/system/opening/__init__.py | 7 ++ .../utils/system/opening/browser.py | 16 +++ .../utils/system/opening/protocol.py | 17 +++ .../utils/system/opening/selection.py | 27 +++++ .../utils/system/opening/xdg_open.py | 50 ++++++++ .../calibration/board/browser.py | 10 +- tests/suite/headless.py | 8 +- .../config/managers/test_session.py | 1 + .../config/test_profile.py | 9 +- .../coordinators/test_documentation.py | 109 ++++++++++++++++++ .../logic/documentation/__init__.py | 0 .../logic/documentation/test_shelf.py | 107 +++++++++++++++++ .../logic/main/converter/test_logic.py | 8 +- .../test_application_gestures.py | 1 + .../sampletones_application/ui/test_menu.py | 14 +++ .../sampletones_shared/test_distribution.py | 18 +++ .../utils/system/opening/__init__.py | 0 .../utils/system/opening/test_browser.py | 26 +++++ .../utils/system/opening/test_selection.py | 33 ++++++ .../utils/system/opening/test_xdg_open.py | 91 +++++++++++++++ .../calibration/board/test_browser.py | 48 ++++---- 43 files changed, 777 insertions(+), 32 deletions(-) create mode 100644 src/sampletones_application/coordinators/documentation.py create mode 100644 src/sampletones_application/logic/documentation/__init__.py create mode 100644 src/sampletones_application/logic/documentation/shelf.py create mode 100644 src/sampletones_shared/exceptions/documentation.py create mode 100644 src/sampletones_shared/exceptions/opening.py create mode 100644 src/sampletones_shared/utils/system/opening/__init__.py create mode 100644 src/sampletones_shared/utils/system/opening/browser.py create mode 100644 src/sampletones_shared/utils/system/opening/protocol.py create mode 100644 src/sampletones_shared/utils/system/opening/selection.py create mode 100644 src/sampletones_shared/utils/system/opening/xdg_open.py create mode 100644 tests/unit/sampletones_application/coordinators/test_documentation.py create mode 100644 tests/unit/sampletones_application/logic/documentation/__init__.py create mode 100644 tests/unit/sampletones_application/logic/documentation/test_shelf.py create mode 100644 tests/unit/sampletones_shared/test_distribution.py create mode 100644 tests/unit/sampletones_shared/utils/system/opening/__init__.py create mode 100644 tests/unit/sampletones_shared/utils/system/opening/test_browser.py create mode 100644 tests/unit/sampletones_shared/utils/system/opening/test_selection.py create mode 100644 tests/unit/sampletones_shared/utils/system/opening/test_xdg_open.py diff --git a/automation/paths.py b/automation/paths.py index 17ee9a728..50acd520a 100644 --- a/automation/paths.py +++ b/automation/paths.py @@ -13,3 +13,4 @@ REPORTS_FILE: Final[str] = "reports.jsonl" NO_BUS_FILE: Final[str] = "no-bus" FAILURE_SCREENSHOT: Final[str] = "failure.png" +SHIPPED_DOCUMENTATION_FOLDER: Final[str] = "shipped-documentation" diff --git a/automation/plugin/fixtures.py b/automation/plugin/fixtures.py index 49729e3d5..fb23ba5f0 100644 --- a/automation/plugin/fixtures.py +++ b/automation/plugin/fixtures.py @@ -28,13 +28,14 @@ screen_size, ) from automation.holds.base import Holds -from automation.paths import FAILURE_SCREENSHOT +from automation.paths import FAILURE_SCREENSHOT, SHIPPED_DOCUMENTATION_FOLDER from automation.plugin.constants import DISPLAY_KEY from automation.render_thread import QueueRenderThread from automation.screen import Screen from automation.worlds.home import World, lived_in_world from sampletones_application.application import Application from sampletones_application.config.profile import UserProfile +from sampletones_shared.documentation.site import DocumentationSite ANSWER_TIMEOUT_SECONDS: Final[float] = 30.0 STAND_IN_PROGRAMS: Final[str] = "programs" @@ -125,6 +126,7 @@ def screen_application_fixture( world.write(profile) application = Application( profile=profile, + documentation=DocumentationSite(Path.home() / SHIPPED_DOCUMENTATION_FOLDER), reconstruction_path=startup.reconstruction, project_path=startup.project, ) diff --git a/src/sampletones/run.py b/src/sampletones/run.py index 0febb4038..cf1543583 100644 --- a/src/sampletones/run.py +++ b/src/sampletones/run.py @@ -4,6 +4,7 @@ from sampletones_application.application import Application from sampletones_application.config.profile import UserProfile from sampletones_shared.distribution import SAMPLETONES_NAME_VERSION +from sampletones_shared.documentation.site import DocumentationSite from sampletones_shared.logger import logger @@ -17,6 +18,7 @@ def run_application( logger.info(SAMPLETONES_NAME_VERSION) gui = Application( profile=UserProfile.user(), + documentation=DocumentationSite.shipped(), config_path=config_path, library_path=library_path, reconstruction_path=reconstruction_path, diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 6395c44ef..c8dd53152 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -18,6 +18,7 @@ from sampletones_application.constants.playback import FollowMode from sampletones_application.coordinators.config import ConfigCoordinator from sampletones_application.coordinators.display import DisplayCoordinator +from sampletones_application.coordinators.documentation import DocumentationCoordinator from sampletones_application.coordinators.edit.router import EditRouter from sampletones_application.coordinators.export import ( InstrumentExportCoordinator, @@ -47,6 +48,7 @@ from sampletones_application.coordinators.tabs.sequencer.coordinator import SequencerTabCoordinator from sampletones_application.exports import ExportBackends from sampletones_application.layout import LayoutConfig, load_layout_config +from sampletones_application.logic.documentation.shelf import DocumentationShelf from sampletones_application.logic.export import SongExportLogic from sampletones_application.logic.export.instrument.logic import InstrumentExportLogic from sampletones_application.logic.export.nsf.logic import NSFExportLogic @@ -196,11 +198,16 @@ SAMPLETONES_AUTHOR, SAMPLETONES_GROUP, ) -from sampletones_shared.distribution import SAMPLETONES_NAME_VERSION +from sampletones_shared.distribution import ( + SAMPLETONES_DOCUMENTATION_ADDRESS, + SAMPLETONES_NAME_VERSION, +) +from sampletones_shared.documentation.site import DocumentationSite from sampletones_shared.exceptions import PlaybackError from sampletones_shared.logger import logger from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import VoidCallback +from sampletones_shared.utils.system.opening import select_file_opener SEQUENCER_SAMPLE_TITLE_FORMAT: Final[str] = "{ordinal}: {name}" SEQUENCER_SAMPLE_ORDINAL_FORMAT: Final[str] = "02X" @@ -224,6 +231,7 @@ class Application: def __init__( self, profile: UserProfile, + documentation: DocumentationSite, config_path: Optional[Path] = None, library_path: Optional[Path] = None, reconstruction_path: Optional[Path] = None, @@ -498,6 +506,13 @@ def __init__( dialogs=self.dialogs, language_manager=self.language_manager, ) + self._documentation_coordinator = DocumentationCoordinator( + shelf=DocumentationShelf(site=documentation, copies=profile.documentation), + opener=select_file_opener(), + dialogs=self.dialogs, + language_manager=self.language_manager, + address=SAMPLETONES_DOCUMENTATION_ADDRESS, + ) self._instrument_exports = InstrumentExportCoordinator( InstrumentExportLogic( @@ -821,6 +836,7 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: toggle_auto_expand_favorite_reconstructions=self._toggle_auto_expand_favorite_reconstructions, toggle_auto_expand_favorite_directories=self._toggle_auto_expand_favorite_directories, toggle_fullscreen=self._shell.toggle_fullscreen, + documentation=self._documentation_coordinator.open, about=self._open_about_dialog, next_tab=self._next_tab, previous_tab=self._previous_tab, diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index bb2ed7505..27b955018 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -99,6 +99,7 @@ class MenuElements(AbstractElement): ITEM_VIEW_DISPLAY_SETTINGS = "item_view_display_settings" ITEM_VIEW_KEYBOARD_SETTINGS = "item_view_keyboard_settings" GROUP_HELP = "group_help" + ITEM_HELP_DOCUMENTATION = "item_help_documentation" ITEM_HELP_ABOUT = "item_help_about" TAB_MAIN = "tab_main" TAB_INSTRUCTIONS = "tab_instructions" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index c99ff24dd..d05644bc4 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -72,6 +72,7 @@ class KeybindingActionElements(AbstractElement): TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = "toggle_auto_expand_favorite_reconstructions" TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES = "toggle_auto_expand_favorite_directories" TOGGLE_FULLSCREEN = "toggle_fullscreen" + DOCUMENTATION = "documentation" ABOUT_DIALOG = "about_dialog" NEXT_TAB = "next_tab" PREVIOUS_TAB = "previous_tab" diff --git a/src/sampletones_application/config/profile.py b/src/sampletones_application/config/profile.py index aed02ecea..40a943308 100644 --- a/src/sampletones_application/config/profile.py +++ b/src/sampletones_application/config/profile.py @@ -3,12 +3,16 @@ from dataclasses import dataclass from pathlib import Path -from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH, APPLICATION_STATE_PATH +from sampletones_shared.paths.user import ( + APPLICATION_CONFIG_PATH, + APPLICATION_STATE_PATH, + DOCUMENTATION_COPIES_DIRECTORY, +) @dataclass(frozen=True) class UserProfile: - """The two files a run keeps its settings and its session in. + """The places a run keeps its settings, its session and its copy of the documentation in. A profile is chosen at startup and travels to the managers that read and write it, which is what lets a run be pointed at a location of its own. @@ -16,11 +20,13 @@ class UserProfile: config: Path state: Path + documentation: Path @classmethod def user(cls) -> UserProfile: - """The profile in the user's configuration directory, which is where a normal run reads.""" + """The profile in the user's own folders, which is where a normal run reads and writes.""" return cls( config=APPLICATION_CONFIG_PATH, state=APPLICATION_STATE_PATH, + documentation=DOCUMENTATION_COPIES_DIRECTORY, ) diff --git a/src/sampletones_application/coordinators/documentation.py b/src/sampletones_application/coordinators/documentation.py new file mode 100644 index 000000000..379e394f5 --- /dev/null +++ b/src/sampletones_application/coordinators/documentation.py @@ -0,0 +1,47 @@ +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.logic.documentation.shelf import DocumentationShelf +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_DOCUMENTATION +from sampletones_application.utils.gui.dialogs import DialogsRenderer +from sampletones_shared.exceptions import DocumentationUnavailableError +from sampletones_shared.utils.system.opening import FileOpener + + +class DocumentationCoordinator: + """Opens the documentation the installation carries in the reader's browser. + + The shelf places a copy of the pages in the user's data folder, and the opener shows its index + in the program the system gives web pages. An installation that came without its pages tells + the reader where the documentation lives online, and a copy or a browser that fails is + reported as an error. + """ + + def __init__( + self, + *, + shelf: DocumentationShelf, + opener: FileOpener, + dialogs: DialogsRenderer, + language_manager: LanguageManager, + address: str, + ) -> None: + self._shelf = shelf + self._opener = opener + self._dialogs = dialogs + self._language_manager = language_manager + self._address = address + + def open(self) -> None: + """Shows the documentation's index in the reader's browser.""" + try: + self._opener.open(self._shelf.place()) + except DocumentationUnavailableError: + self._dialogs.show_info( + TAG_GLOBAL_DIALOG_DOCUMENTATION, + self._language_manager["global.dialog.template.documentation_unavailable"].format( + address=self._address, + ), + self._language_manager["global.dialog.title.documentation"], + modal=True, + ) + except OSError as error: + self._dialogs.show_error(error, self._language_manager["global.dialog.message.documentation_not_opened"]) diff --git a/src/sampletones_application/logic/documentation/__init__.py b/src/sampletones_application/logic/documentation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/documentation/shelf.py b/src/sampletones_application/logic/documentation/shelf.py new file mode 100644 index 000000000..ab248b4db --- /dev/null +++ b/src/sampletones_application/logic/documentation/shelf.py @@ -0,0 +1,68 @@ +import os +import shutil +from pathlib import Path +from typing import Final + +from sampletones_shared.documentation.site import DocumentationSite +from sampletones_shared.exceptions import DocumentationUnavailableError + +STAGING_PREFIX: Final[str] = "." +STAGING_SUFFIX: Final[str] = ".staging" + + +class DocumentationShelf: + """Keeps a copy of the shipped documentation in the user's data folder, where the browser reads it. + + The shipped pages may live only as long as a run, since a one-file bundle unpacks itself into + a temporary folder; the copy outlives the run, so an open page stays readable after the + application closes. A copy is named by the site's stamp, and a site with the same stamp holds + the same pages, so each version of the documentation is copied once and the copies of other + versions are let go. + """ + + def __init__( + self, + *, + site: DocumentationSite, + copies: Path, + ) -> None: + self._site = site + self._copies = copies + + def place(self) -> Path: + """The index of the copy of the shipped pages, copied first where it is missing. + + A copy is written beside its final place and moved there whole, so a copy under a stamp's + name holds every page. + + Raises: + DocumentationUnavailableError: If the installation carries no sealed site. + OSError: If the copy cannot be written. + """ + stamp = self._site.read_stamp() + if stamp is None or not self._site.index.is_file(): + raise DocumentationUnavailableError(f"No documentation site lies in {self._site.directory}") + + copy = DocumentationSite(self._copies / stamp) + if copy.read_stamp() != stamp: + self._copy(copy.directory) + + self._let_others_go(stamp) + return copy.index + + def _copy(self, target: Path) -> None: + staging = self._copies / f"{STAGING_PREFIX}{target.name}{STAGING_SUFFIX}" + if staging.exists(): + shutil.rmtree(staging) + + shutil.copytree(self._site.directory, staging) + if target.exists(): + shutil.rmtree(target) + + os.replace(staging, target) + + def _let_others_go(self, stamp: str) -> None: + """Removes the copies of other versions, leaving any a running browser still holds open.""" + for entry in self._copies.iterdir(): + if entry.is_dir() and entry.name != stamp and not entry.name.startswith(STAGING_PREFIX): + shutil.rmtree(entry, ignore_errors=True) diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 7abc0fb12..2ba2153bb 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -112,6 +112,7 @@ class ShortcutBindings: toggle_auto_expand_favorite_reconstructions: Callback toggle_auto_expand_favorite_directories: Callback toggle_fullscreen: Callback + documentation: Callback about: Callback next_tab: Callback previous_tab: Callback @@ -281,6 +282,7 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback ), ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES: bindings.toggle_auto_expand_favorite_directories, ShortcutId.TOGGLE_FULLSCREEN: bindings.toggle_fullscreen, + ShortcutId.DOCUMENTATION: bindings.documentation, ShortcutId.ABOUT_DIALOG: bindings.about, ShortcutId.NEXT_TAB: bindings.next_tab, ShortcutId.PREVIOUS_TAB: bindings.previous_tab, diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 8c56083f9..b2b981788 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -518,6 +518,12 @@ Widget.DIALOG, "about", ) +TAG_GLOBAL_DIALOG_DOCUMENTATION = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.DIALOG, + "documentation", +) TAG_GLOBAL_MENU_ITEM_FILE_NEW_PROJECT = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 74f737998..02d22a7ec 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -594,6 +594,10 @@ def _create_auto_expand_favorites_menu(self) -> None: def _create_help_menu(self) -> None: with dpg.menu(label=self._label(MenuElements.GROUP_HELP)): + self._shortcut_manager.add_menu_item( + ShortcutId.DOCUMENTATION, + label=self._label(MenuElements.ITEM_HELP_DOCUMENTATION), + ) self._shortcut_manager.add_menu_item( ShortcutId.ABOUT_DIALOG, label=self._label(MenuElements.ITEM_HELP_ABOUT), diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 52aa7003d..c5831dc7a 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -100,6 +100,7 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: ShortcutCategory.APPLICATION, ) TOGGLE_FULLSCREEN = ("ToggleFullscreen", ShortcutCategory.APPLICATION) + DOCUMENTATION = ("Documentation", ShortcutCategory.APPLICATION) ABOUT_DIALOG = ("AboutDialog", ShortcutCategory.APPLICATION) NEXT_TAB = ("NextTab", ShortcutCategory.APPLICATION) PREVIOUS_TAB = ("PreviousTab", ShortcutCategory.APPLICATION) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 22be58d6f..27b1f67a3 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -64,6 +64,7 @@ bindings: ToggleFullscreen: {combination: "F11"} ToggleAutoExpandFavoriteReconstructions: {combination: ~} ToggleAutoExpandFavoriteDirectories: {combination: ~} + Documentation: {combination: ~} AboutDialog: {combination: ~} NextTab: {combination: "Ctrl+PgDn", field_transparent: true} PreviousTab: {combination: "Ctrl+PgUp", field_transparent: true} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 0adfb26c1..2fdc2e41a 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -64,6 +64,7 @@ bindings: ToggleFullscreen: {combination: "Cmd+Ctrl+F"} ToggleAutoExpandFavoriteReconstructions: {combination: ~} ToggleAutoExpandFavoriteDirectories: {combination: ~} + Documentation: {combination: ~} AboutDialog: {combination: ~} NextTab: {combination: "Cmd+Alt+Right", aliases: ["Cmd+PgDn"], field_transparent: true} PreviousTab: {combination: "Cmd+Alt+Left", aliases: ["Cmd+PgUp"], field_transparent: true} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index b38e20500..27a6f7690 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -48,6 +48,7 @@ global.dialog.title.remove_voice: "Remove voice" global.dialog.title.change_nes_frequency: "Change NES frequency" global.dialog.title.frequency_mismatch: "Different NES frequency" global.dialog.title.about: "About" +global.dialog.title.documentation: "Documentation" # Global — Dialog file filters global.dialog.filter.project: "Project files" @@ -109,6 +110,7 @@ global.dialog.message.audio_playback_error: "Audio playback error" global.dialog.message.unexpected_failure: "Something went wrong, and the last action did not finish." global.dialog.message.no_project_open: "No project is open. Open or create a project to add voices." global.dialog.message.no_audio_output: "Can't play sound: no audio output is available." +global.dialog.message.documentation_not_opened: "Can't open the documentation." global.dialog.message.remove_voice: "The voice \"{name}\" is used by one or more patterns. Removing it will clear every row that references it. Remove it anyway?" global.dialog.message.change_nes_frequency: "Song playback already follows the new rate. Editing a reconstruction in the Reconstructions tab still uses its own rate until you retune it. Retune all samples to match?" global.dialog.message.frequency_mismatch: "This reconstruction was generated at {reconstruction} Hz, but the project runs at {project} Hz, so it won't play back as intended. Add it anyway?" @@ -117,6 +119,7 @@ global.dialog.message.about_description: "An application for approximating audio # Global — Dialog templates global.dialog.template.time_estimation: " (ETA: {eta_string})" +global.dialog.template.documentation_unavailable: "This installation came without its documentation. You can read it online at {address}" global.dialog.template.configuration_recovery_intro: "Your configuration was updated from {source} to version {target}." global.dialog.template.fps: "{fps:.0f} FPS" global.dialog.template.about_author: "by {author} / {group}" @@ -254,6 +257,7 @@ global.menu.label.item_view_auto_expand_favorite_directories: "Directories" global.menu.label.item_view_display_settings: "Display settings..." global.menu.label.item_view_keyboard_settings: "Keyboard shortcuts..." global.menu.label.group_help: "Help" +global.menu.label.item_help_documentation: "Documentation" global.menu.label.item_help_about: "About" global.menu.label.tab_main: "Main" global.menu.label.tab_instructions: "Instructions" @@ -1016,6 +1020,7 @@ settings.keybindings.label.toggle_advanced_settings: "Advanced settings" settings.keybindings.label.toggle_auto_expand_favorite_reconstructions: "Auto-expand favorite reconstructions" settings.keybindings.label.toggle_auto_expand_favorite_directories: "Auto-expand favorite directories" settings.keybindings.label.toggle_fullscreen: "Fullscreen" +settings.keybindings.label.documentation: "Documentation" settings.keybindings.label.about_dialog: "About" settings.keybindings.label.next_tab: "Next tab" settings.keybindings.label.previous_tab: "Previous tab" diff --git a/src/sampletones_shared/distribution.py b/src/sampletones_shared/distribution.py index e94bbedd8..e4ff0e906 100644 --- a/src/sampletones_shared/distribution.py +++ b/src/sampletones_shared/distribution.py @@ -3,5 +3,25 @@ from sampletones_shared.application import SAMPLETONES_NAME, SAMPLETONES_PACKAGE_NAME +PROJECT_URL_FIELD: Final[str] = "Project-URL" +PROJECT_URL_SEPARATOR: Final[str] = "," +DOCUMENTATION_LABEL: Final[str] = "Documentation" + + +def project_address(label: str) -> str: + """The address the installed distribution lists under ``label``, as ``[project.urls]`` names it. + + Raises: + KeyError: If the distribution lists no address under that label. + """ + for entry in metadata.metadata(SAMPLETONES_PACKAGE_NAME).get_all(PROJECT_URL_FIELD) or []: + name, _, address = str(entry).partition(PROJECT_URL_SEPARATOR) + if name.strip() == label: + return address.strip() + + raise KeyError(f"The {SAMPLETONES_PACKAGE_NAME} distribution lists no {label} address") + + SAMPLETONES_VERSION: Final[str] = metadata.version(SAMPLETONES_PACKAGE_NAME) SAMPLETONES_NAME_VERSION: Final[str] = f"{SAMPLETONES_NAME} v{SAMPLETONES_VERSION}" +SAMPLETONES_DOCUMENTATION_ADDRESS: Final[str] = project_address(DOCUMENTATION_LABEL) diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py index 6864d640c..d34d0764b 100644 --- a/src/sampletones_shared/exceptions/__init__.py +++ b/src/sampletones_shared/exceptions/__init__.py @@ -8,6 +8,7 @@ from .callback import CallbackQueueStop from .cuda import CuPyNotInstalledWarning, GPUBackendError from .dialog import FileDialogUnavailableError +from .documentation import DocumentationUnavailableError from .instrument import ( IncompatibleInstrumentVersionError, InstrumentError, @@ -29,6 +30,7 @@ NoLibraryDataError, UnhandledLibraryError, ) +from .opening import FileNotOpenedError from .operation import OperationCanceled from .player import ( DriverBuildError, @@ -69,8 +71,10 @@ "CallbackQueueStop", "CuPyNotInstalledWarning", "DeserializationError", + "DocumentationUnavailableError", "DriverBuildError", "FileDialogUnavailableError", + "FileNotOpenedError", "GPUBackendError", "IncompatibleInstrumentVersionError", "IncompatibleLibraryDataVersionError", diff --git a/src/sampletones_shared/exceptions/documentation.py b/src/sampletones_shared/exceptions/documentation.py new file mode 100644 index 000000000..b62e5130b --- /dev/null +++ b/src/sampletones_shared/exceptions/documentation.py @@ -0,0 +1,5 @@ +from .base import SampleToNESError + + +class DocumentationUnavailableError(SampleToNESError): + """Raised when the installation carries no complete documentation site to open.""" diff --git a/src/sampletones_shared/exceptions/opening.py b/src/sampletones_shared/exceptions/opening.py new file mode 100644 index 000000000..9fc13005b --- /dev/null +++ b/src/sampletones_shared/exceptions/opening.py @@ -0,0 +1,5 @@ +from .base import SampleToNESError + + +class FileNotOpenedError(SampleToNESError, OSError): + """Raised when no program on the system takes a file to show.""" diff --git a/src/sampletones_shared/paths/user.py b/src/sampletones_shared/paths/user.py index e55c82e79..1d5b6164d 100644 --- a/src/sampletones_shared/paths/user.py +++ b/src/sampletones_shared/paths/user.py @@ -18,6 +18,7 @@ CONFIG_PATH: Final[Path] = USER_PATH_DOCUMENTS / "config.json" APPLICATION_CONFIG_PATH: Final[Path] = USER_PATH_CONFIG / "config.yaml" APPLICATION_STATE_PATH: Final[Path] = USER_PATH_CONFIG / "state.yaml" +DOCUMENTATION_COPIES_DIRECTORY: Final[Path] = USER_PATH_DATA / "documentation" PROJECTS_DIRECTORY.mkdir(parents=True, exist_ok=True) LIBRARY_DIRECTORY.mkdir(parents=True, exist_ok=True) diff --git a/src/sampletones_shared/utils/system/opening/__init__.py b/src/sampletones_shared/utils/system/opening/__init__.py new file mode 100644 index 000000000..9fa1e847e --- /dev/null +++ b/src/sampletones_shared/utils/system/opening/__init__.py @@ -0,0 +1,7 @@ +from .protocol import FileOpener +from .selection import select_file_opener + +__all__ = [ + "FileOpener", + "select_file_opener", +] diff --git a/src/sampletones_shared/utils/system/opening/browser.py b/src/sampletones_shared/utils/system/opening/browser.py new file mode 100644 index 000000000..ef9ca8478 --- /dev/null +++ b/src/sampletones_shared/utils/system/opening/browser.py @@ -0,0 +1,16 @@ +import webbrowser +from pathlib import Path + +from sampletones_shared.exceptions import FileNotOpenedError + + +class BrowserFileOpener: + """Shows a file through the standard library's ``webbrowser``, as a ``file://`` link. + + ``webbrowser`` reaches the system's own handler on Windows and macOS and the browsers it finds + elsewhere, so it serves every system ``xdg-open`` is missing from. + """ + + def open(self, path: Path) -> None: + if not webbrowser.open(path.resolve().as_uri()): + raise FileNotOpenedError(f"No browser took {path}") diff --git a/src/sampletones_shared/utils/system/opening/protocol.py b/src/sampletones_shared/utils/system/opening/protocol.py new file mode 100644 index 000000000..dc2f4826b --- /dev/null +++ b/src/sampletones_shared/utils/system/opening/protocol.py @@ -0,0 +1,17 @@ +from pathlib import Path +from typing import Protocol + + +class FileOpener(Protocol): + """The surface that shows a file in the program the system gives its kind. + + Callers hand a file over through this type, a page to the browser among them, and read alike + on every system. + """ + + def open(self, path: Path) -> None: + """Shows ``path`` in the program the system gives its kind. + + Raises: + OSError: If no program takes the file. + """ diff --git a/src/sampletones_shared/utils/system/opening/selection.py b/src/sampletones_shared/utils/system/opening/selection.py new file mode 100644 index 000000000..57cdacd9b --- /dev/null +++ b/src/sampletones_shared/utils/system/opening/selection.py @@ -0,0 +1,27 @@ +import os +import shutil +import sys + +from sampletones_shared.utils.system.system import System + +from .browser import BrowserFileOpener +from .protocol import FileOpener +from .xdg_open import XDG_OPEN, XdgOpenFileOpener + + +def select_file_opener() -> FileOpener: + """The file opener that fits the running system. + + A Linux desktop with ``xdg-open`` opens a file through it, the one way a bundle hands the + program the reader's own library path; every other system opens it through the standard + library's browser support. + """ + if System.current() == System.LINUX and shutil.which(XDG_OPEN) is not None: + return XdgOpenFileOpener(os.environ, frozen=running_frozen()) + + return BrowserFileOpener() + + +def running_frozen() -> bool: + """Whether the application runs from a PyInstaller bundle, which marks ``sys`` as frozen.""" + return bool(getattr(sys, "frozen", False)) diff --git a/src/sampletones_shared/utils/system/opening/xdg_open.py b/src/sampletones_shared/utils/system/opening/xdg_open.py new file mode 100644 index 000000000..3b6945f0e --- /dev/null +++ b/src/sampletones_shared/utils/system/opening/xdg_open.py @@ -0,0 +1,50 @@ +import subprocess +from pathlib import Path +from typing import Dict, Final, Mapping + +XDG_OPEN: Final[str] = "xdg-open" +LIBRARY_PATH: Final[str] = "LD_LIBRARY_PATH" +ORIGINAL_LIBRARY_PATH: Final[str] = "LD_LIBRARY_PATH_ORIG" + + +class XdgOpenFileOpener: + """Shows a file through ``xdg-open``, which hands it to the desktop's program for its kind. + + ``xdg-open`` starts in a session of its own, so the program it starts outlives the + application. A PyInstaller bundle points ``LD_LIBRARY_PATH`` at the libraries it carries and + keeps the reader's own value in ``LD_LIBRARY_PATH_ORIG``; the program receives the reader's + value, or none where the reader set none, so it loads the system's libraries. + """ + + def __init__( + self, + environment: Mapping[str, str], + *, + frozen: bool, + ) -> None: + self._environment = environment + self._frozen = frozen + + def open(self, path: Path) -> None: + subprocess.Popen( # pylint: disable=consider-using-with + [XDG_OPEN, str(path)], + env=self.child_environment(), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + def child_environment(self) -> Dict[str, str]: + """The variables ``xdg-open`` starts with: the application's, with the reader's library path.""" + environment = dict(self._environment) + if not self._frozen: + return environment + + original = environment.pop(ORIGINAL_LIBRARY_PATH, None) + if original is None: + environment.pop(LIBRARY_PATH, None) + else: + environment[LIBRARY_PATH] = original + + return environment diff --git a/src/sampletones_tools/calibration/board/browser.py b/src/sampletones_tools/calibration/board/browser.py index 644b60b25..4139f6419 100644 --- a/src/sampletones_tools/calibration/board/browser.py +++ b/src/sampletones_tools/calibration/board/browser.py @@ -1,7 +1,7 @@ -import webbrowser from pathlib import Path from sampletones_shared.logger import logger +from sampletones_shared.utils.system.opening import select_file_opener def open_page(page: Path) -> bool: @@ -17,8 +17,10 @@ def open_page(page: Path) -> bool: Returns: bool: Whether a browser took the page. """ - opened = webbrowser.open(page.resolve().as_uri()) - if not opened: + try: + select_file_opener().open(page.resolve()) + except OSError: logger.info("No browser answered; open the page from the link above") + return False - return opened + return True diff --git a/tests/suite/headless.py b/tests/suite/headless.py index ddce2f68e..35624005d 100644 --- a/tests/suite/headless.py +++ b/tests/suite/headless.py @@ -11,6 +11,7 @@ from sampletones_application.utils.parallelization.background import stop_background_workers from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_core.configs import Config +from sampletones_shared.documentation.site import DocumentationSite from sampletones_shared.paths.user import CONFIG_PATH, LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY DISPLAY_FUNCTIONS: Final[Tuple[str, ...]] = ( @@ -63,6 +64,7 @@ def profile_in(directory: Path) -> UserProfile: return UserProfile( config=directory / "config.yaml", state=directory / "state.yaml", + documentation=directory / "documentation", ) @@ -87,7 +89,11 @@ def settings_in(directory: Path) -> Path: def headless_application(directory: Path) -> Application: """The whole application on a profile and settings of its own in ``directory``.""" - return Application(profile=profile_in(directory), config_path=settings_in(directory)) + return Application( + profile=profile_in(directory), + documentation=DocumentationSite(directory / "shipped-documentation"), + config_path=settings_in(directory), + ) @contextmanager diff --git a/tests/unit/sampletones_application/config/managers/test_session.py b/tests/unit/sampletones_application/config/managers/test_session.py index cf7833b1c..f46cc5e90 100644 --- a/tests/unit/sampletones_application/config/managers/test_session.py +++ b/tests/unit/sampletones_application/config/managers/test_session.py @@ -15,6 +15,7 @@ def session(tmp_path: Path) -> SessionManager: UserProfile( config=tmp_path / "config.yaml", state=tmp_path / "state.yaml", + documentation=tmp_path / "documentation", ) ) diff --git a/tests/unit/sampletones_application/config/test_profile.py b/tests/unit/sampletones_application/config/test_profile.py index 5d3da3f96..22ac55eec 100644 --- a/tests/unit/sampletones_application/config/test_profile.py +++ b/tests/unit/sampletones_application/config/test_profile.py @@ -1,7 +1,11 @@ from pathlib import Path from sampletones_application.config.profile import UserProfile -from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH, APPLICATION_STATE_PATH +from sampletones_shared.paths.user import ( + APPLICATION_CONFIG_PATH, + APPLICATION_STATE_PATH, + DOCUMENTATION_COPIES_DIRECTORY, +) class TestUserProfile: @@ -12,13 +16,16 @@ def test_the_user_profile_names_the_shipped_locations(self) -> None: assert profile.config == APPLICATION_CONFIG_PATH assert profile.state == APPLICATION_STATE_PATH + assert profile.documentation == DOCUMENTATION_COPIES_DIRECTORY def test_a_profile_keeps_the_locations_it_was_given(self, tmp_path: Path) -> None: """A run pointed elsewhere reads and writes there, which is what isolates one from another.""" profile = UserProfile( config=tmp_path / "config.yaml", state=tmp_path / "state.yaml", + documentation=tmp_path / "documentation", ) assert profile.config == tmp_path / "config.yaml" assert profile.state == tmp_path / "state.yaml" + assert profile.documentation == tmp_path / "documentation" diff --git a/tests/unit/sampletones_application/coordinators/test_documentation.py b/tests/unit/sampletones_application/coordinators/test_documentation.py new file mode 100644 index 000000000..dcaaef54a --- /dev/null +++ b/tests/unit/sampletones_application/coordinators/test_documentation.py @@ -0,0 +1,109 @@ +from pathlib import Path +from typing import Final, List +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.coordinators.documentation import DocumentationCoordinator +from sampletones_application.logic.documentation.shelf import DocumentationShelf +from sampletones_application.paths import LANG_EN +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_DOCUMENTATION +from sampletones_shared.documentation.site import INDEX_FILE, DocumentationSite +from sampletones_shared.exceptions import FileNotOpenedError + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +ADDRESS: Final[str] = "https://example.com/docs" + + +class RecordedOpener: + def __init__(self, *, takes: bool) -> None: + self.opened: List[Path] = [] + self._takes = takes + + def open(self, path: Path) -> None: + self.opened.append(path) + if not self._takes: + raise FileNotOpenedError(f"No browser took {path}") + + +@pytest.fixture +def dialogs() -> MagicMock: + return MagicMock() + + +@pytest.fixture +def shipped(tmp_path: Path) -> DocumentationSite: + site = DocumentationSite(tmp_path / "shipped") + site.directory.mkdir() + site.index.write_text("

    index

    ", encoding="utf-8") + site.write_stamp() + return site + + +def coordinator( + site: DocumentationSite, + copies: Path, + opener: RecordedOpener, + dialogs: MagicMock, +) -> DocumentationCoordinator: + return DocumentationCoordinator( + shelf=DocumentationShelf(site=site, copies=copies), + opener=opener, + dialogs=dialogs, + language_manager=LANGUAGE_MANAGER, + address=ADDRESS, + ) + + +class TestOpenDocumentation: + def test_the_copied_index_is_opened(self, shipped: DocumentationSite, tmp_path: Path, dialogs: MagicMock) -> None: + opener = RecordedOpener(takes=True) + + coordinator(shipped, tmp_path / "copies", opener, dialogs).open() + + assert opener.opened == [tmp_path / "copies" / str(shipped.read_stamp()) / INDEX_FILE] + dialogs.show_info.assert_not_called() + dialogs.show_error.assert_not_called() + + def test_an_installation_without_pages_names_the_online_copy(self, tmp_path: Path, dialogs: MagicMock) -> None: + opener = RecordedOpener(takes=True) + + coordinator(DocumentationSite(tmp_path / "missing"), tmp_path / "copies", opener, dialogs).open() + + assert opener.opened == [] + dialogs.show_info.assert_called_once_with( + TAG_GLOBAL_DIALOG_DOCUMENTATION, + LANGUAGE_MANAGER["global.dialog.template.documentation_unavailable"].format(address=ADDRESS), + LANGUAGE_MANAGER["global.dialog.title.documentation"], + modal=True, + ) + assert ADDRESS in dialogs.show_info.call_args.args[1] + + def test_a_browser_that_takes_nothing_is_reported( + self, + shipped: DocumentationSite, + tmp_path: Path, + dialogs: MagicMock, + ) -> None: + coordinator(shipped, tmp_path / "copies", RecordedOpener(takes=False), dialogs).open() + + [exception, message] = dialogs.show_error.call_args.args + assert isinstance(exception, FileNotOpenedError) + assert message == LANGUAGE_MANAGER["global.dialog.message.documentation_not_opened"] + dialogs.show_info.assert_not_called() + + def test_a_copy_that_cannot_be_written_is_reported( + self, + shipped: DocumentationSite, + tmp_path: Path, + dialogs: MagicMock, + ) -> None: + blocked = tmp_path / "copies" + blocked.write_text("a file where the folder belongs", encoding="utf-8") + opener = RecordedOpener(takes=True) + + coordinator(shipped, blocked, opener, dialogs).open() + + assert opener.opened == [] + assert isinstance(dialogs.show_error.call_args.args[0], OSError) diff --git a/tests/unit/sampletones_application/logic/documentation/__init__.py b/tests/unit/sampletones_application/logic/documentation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/documentation/test_shelf.py b/tests/unit/sampletones_application/logic/documentation/test_shelf.py new file mode 100644 index 000000000..cb175d0fc --- /dev/null +++ b/tests/unit/sampletones_application/logic/documentation/test_shelf.py @@ -0,0 +1,107 @@ +from pathlib import Path + +import pytest + +from sampletones_application.logic.documentation.shelf import STAGING_PREFIX, STAGING_SUFFIX, DocumentationShelf +from sampletones_shared.documentation.site import INDEX_FILE, DocumentationSite +from sampletones_shared.exceptions import DocumentationUnavailableError + + +@pytest.fixture +def site(tmp_path: Path) -> DocumentationSite: + shipped = DocumentationSite(tmp_path / "shipped") + (shipped.directory / "guide").mkdir(parents=True) + shipped.index.write_text("

    index

    ", encoding="utf-8") + (shipped.directory / "guide" / "page.html").write_text("

    page

    ", encoding="utf-8") + shipped.write_stamp() + return shipped + + +@pytest.fixture +def copies(tmp_path: Path) -> Path: + return tmp_path / "copies" + + +@pytest.fixture +def shelf(site: DocumentationSite, copies: Path) -> DocumentationShelf: + return DocumentationShelf(site=site, copies=copies) + + +def stamp_of(site: DocumentationSite) -> str: + stamp = site.read_stamp() + assert stamp is not None + return stamp + + +class TestPlace: + def test_the_pages_are_copied_under_their_stamp( + self, + shelf: DocumentationShelf, + site: DocumentationSite, + copies: Path, + ) -> None: + index = shelf.place() + + assert index == copies / stamp_of(site) / INDEX_FILE + assert (index.parent / "guide" / "page.html").read_text(encoding="utf-8") == "

    page

    " + assert DocumentationSite(index.parent).read_stamp() == stamp_of(site) + + def test_a_copy_in_place_is_read_again(self, shelf: DocumentationShelf) -> None: + index = shelf.place() + marker = index.parent / "opened.txt" + marker.write_text("kept", encoding="utf-8") + + assert shelf.place() == index + assert marker.is_file() + + def test_an_unfinished_copy_is_made_again( + self, shelf: DocumentationShelf, site: DocumentationSite, copies: Path + ) -> None: + unfinished = copies / stamp_of(site) + unfinished.mkdir(parents=True) + (unfinished / "half.html").write_text("half", encoding="utf-8") + + index = shelf.place() + + assert index.is_file() + assert not (unfinished / "half.html").exists() + + def test_a_stale_staging_folder_gives_way( + self, shelf: DocumentationShelf, site: DocumentationSite, copies: Path + ) -> None: + staging = copies / f"{STAGING_PREFIX}{stamp_of(site)}{STAGING_SUFFIX}" + staging.mkdir(parents=True) + (staging / "left.html").write_text("left", encoding="utf-8") + + index = shelf.place() + + assert not staging.exists() + assert not (index.parent / "left.html").exists() + + def test_copies_of_other_versions_are_let_go(self, shelf: DocumentationShelf, copies: Path) -> None: + older = copies / "0123456789abcdef" + older.mkdir(parents=True) + + index = shelf.place() + + assert sorted(path.name for path in copies.iterdir()) == [index.parent.name] + + +class TestPlaceRefuses: + def test_an_installation_without_pages(self, tmp_path: Path, copies: Path) -> None: + shelf = DocumentationShelf(site=DocumentationSite(tmp_path / "missing"), copies=copies) + + with pytest.raises(DocumentationUnavailableError): + shelf.place() + + def test_an_unsealed_site(self, site: DocumentationSite, copies: Path) -> None: + site.stamp_path.unlink() + + with pytest.raises(DocumentationUnavailableError): + DocumentationShelf(site=site, copies=copies).place() + + def test_a_site_without_an_index(self, site: DocumentationSite, copies: Path) -> None: + site.index.unlink() + + with pytest.raises(DocumentationUnavailableError): + DocumentationShelf(site=site, copies=copies).place() diff --git a/tests/unit/sampletones_application/logic/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py index bdc96fc5c..85682a682 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_logic.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -52,7 +52,13 @@ def _config_manager_writing_under(reconstructions_directory: Path) -> MagicMock: @pytest.fixture def session_manager(tmp_path: Path) -> SessionManager: """A session writing under the test's own directory, so the joining settings round-trip.""" - return SessionManager(UserProfile(config=tmp_path / "config.json", state=tmp_path / "state.yaml")) + return SessionManager( + UserProfile( + config=tmp_path / "config.json", + state=tmp_path / "state.yaml", + documentation=tmp_path / "documentation", + ) + ) @pytest.fixture diff --git a/tests/unit/sampletones_application/test_application_gestures.py b/tests/unit/sampletones_application/test_application_gestures.py index c643ccec8..ffbd4f0c2 100644 --- a/tests/unit/sampletones_application/test_application_gestures.py +++ b/tests/unit/sampletones_application/test_application_gestures.py @@ -25,6 +25,7 @@ "_config_coordinator", "_display_coordinator", "_keybindings_coordinator", + "_documentation_coordinator", "_main_tab", "_shell", ) diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index 46032aaec..94afed46b 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -672,3 +672,17 @@ def test_the_chosen_voice_is_asked_for_as_the_group_is_built( assert asked == [1] assert framework.containers == [TAG_GLOBAL_MENU_GROUP_VOICE] + + +class TestHelpMenu: + """The Help menu leads to the documentation first, then to the application's own notice.""" + + def test_the_documentation_comes_before_about( + self, + menu_bar: MenuBar, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_help_menu() + + assert [item["shortcut_id"] for item in shortcuts.items] == [ShortcutId.DOCUMENTATION, ShortcutId.ABOUT_DIALOG] + assert shortcuts.labels == ["Documentation", "About"] diff --git a/tests/unit/sampletones_shared/test_distribution.py b/tests/unit/sampletones_shared/test_distribution.py new file mode 100644 index 000000000..c082007f0 --- /dev/null +++ b/tests/unit/sampletones_shared/test_distribution.py @@ -0,0 +1,18 @@ +import tomllib + +import pytest + +from sampletones_shared.distribution import DOCUMENTATION_LABEL, SAMPLETONES_DOCUMENTATION_ADDRESS, project_address +from sampletones_shared.paths.source import REPOSITORY_ROOT + + +class TestProjectAddress: + def test_the_documentation_address_is_the_one_the_project_lists(self) -> None: + with (REPOSITORY_ROOT / "pyproject.toml").open("rb") as stream: + urls = tomllib.load(stream)["project"]["urls"] + + assert SAMPLETONES_DOCUMENTATION_ADDRESS == urls[DOCUMENTATION_LABEL] + + def test_an_unlisted_label_is_refused(self) -> None: + with pytest.raises(KeyError): + project_address("Nowhere") diff --git a/tests/unit/sampletones_shared/utils/system/opening/__init__.py b/tests/unit/sampletones_shared/utils/system/opening/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_shared/utils/system/opening/test_browser.py b/tests/unit/sampletones_shared/utils/system/opening/test_browser.py new file mode 100644 index 000000000..477d4528a --- /dev/null +++ b/tests/unit/sampletones_shared/utils/system/opening/test_browser.py @@ -0,0 +1,26 @@ +from pathlib import Path +from typing import List + +import pytest + +from sampletones_shared.exceptions import FileNotOpenedError +from sampletones_shared.utils.system.opening.browser import BrowserFileOpener + +WEBBROWSER = "sampletones_shared.utils.system.opening.browser.webbrowser.open" + + +class TestBrowserFileOpener: + def test_the_file_is_handed_over_as_a_file_link(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + asked: List[str] = [] + monkeypatch.setattr(WEBBROWSER, lambda link: asked.append(link) or True) + page = tmp_path / "index.html" + + BrowserFileOpener().open(page) + + assert asked == [page.resolve().as_uri()] + + def test_a_system_with_no_browser_is_reported(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(WEBBROWSER, lambda link: False) + + with pytest.raises(FileNotOpenedError): + BrowserFileOpener().open(tmp_path / "index.html") diff --git a/tests/unit/sampletones_shared/utils/system/opening/test_selection.py b/tests/unit/sampletones_shared/utils/system/opening/test_selection.py new file mode 100644 index 000000000..65c2ea67d --- /dev/null +++ b/tests/unit/sampletones_shared/utils/system/opening/test_selection.py @@ -0,0 +1,33 @@ +from typing import Optional, Type + +import pytest + +from sampletones_shared.utils.system.opening.browser import BrowserFileOpener +from sampletones_shared.utils.system.opening.selection import select_file_opener +from sampletones_shared.utils.system.opening.xdg_open import XdgOpenFileOpener +from sampletones_shared.utils.system.system import System + +CURRENT_SYSTEM = "sampletones_shared.utils.system.opening.selection.System.current" +WHICH = "sampletones_shared.utils.system.opening.selection.shutil.which" + + +@pytest.mark.parametrize( + ("system", "xdg_open", "expected"), + [ + (System.LINUX, "/usr/bin/xdg-open", XdgOpenFileOpener), + (System.LINUX, None, BrowserFileOpener), + (System.MACOS, None, BrowserFileOpener), + (System.WINDOWS, None, BrowserFileOpener), + ], + ids=["linux desktop", "linux without xdg-open", "macos", "windows"], +) +def test_the_opener_fits_the_system( + monkeypatch: pytest.MonkeyPatch, + system: System, + xdg_open: Optional[str], + expected: Type[object], +) -> None: + monkeypatch.setattr(CURRENT_SYSTEM, lambda: system) + monkeypatch.setattr(WHICH, lambda name: xdg_open) + + assert isinstance(select_file_opener(), expected) diff --git a/tests/unit/sampletones_shared/utils/system/opening/test_xdg_open.py b/tests/unit/sampletones_shared/utils/system/opening/test_xdg_open.py new file mode 100644 index 000000000..ce98e8e3e --- /dev/null +++ b/tests/unit/sampletones_shared/utils/system/opening/test_xdg_open.py @@ -0,0 +1,91 @@ +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional + +import pytest + +from sampletones_shared.utils.system.opening.xdg_open import ( + LIBRARY_PATH, + ORIGINAL_LIBRARY_PATH, + XDG_OPEN, + XdgOpenFileOpener, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +POPEN = "sampletones_shared.utils.system.opening.xdg_open.subprocess.Popen" +BUNDLE_LIBRARIES = "/tmp/_MEI1234" +READER_LIBRARIES = "/opt/reader/lib" + + +class RecordedPopen: + def __init__(self) -> None: + self.calls: List[Dict[str, Any]] = [] + + def __call__(self, command: List[str], **options: Any) -> None: + self.calls.append({"command": command, **options}) + + +class TestTheChildEnvironment(BaseTestSuite): + """A bundle hands the program the reader's own library path; a plain run hands it on unchanged.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + environment: Dict[str, str] + frozen: bool + expected: Optional[str] + + test_cases = ( + TestCase( + label="a bundle restores the reader's path", + environment={LIBRARY_PATH: BUNDLE_LIBRARIES, ORIGINAL_LIBRARY_PATH: READER_LIBRARIES}, + frozen=True, + expected=READER_LIBRARIES, + ), + TestCase( + label="a bundle drops a path the reader never set", + environment={LIBRARY_PATH: BUNDLE_LIBRARIES}, + frozen=True, + expected=None, + ), + TestCase( + label="a plain run keeps the reader's path", + environment={LIBRARY_PATH: READER_LIBRARIES}, + frozen=False, + expected=READER_LIBRARIES, + ), + TestCase(label="a plain run without a path sets none", environment={}, frozen=False, expected=None), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_library_path(self, test_case: TestCase) -> None: + opener = XdgOpenFileOpener(test_case.environment, frozen=test_case.frozen) + environment = opener.child_environment() + + assert environment.get(LIBRARY_PATH) == test_case.expected + assert ORIGINAL_LIBRARY_PATH not in environment or not test_case.frozen + + +class TestXdgOpenFileOpener: + def test_the_file_is_handed_to_a_detached_xdg_open(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + popen = RecordedPopen() + monkeypatch.setattr(POPEN, popen) + page = tmp_path / "index.html" + + XdgOpenFileOpener({"HOME": "/home/reader"}, frozen=False).open(page) + + [call] = popen.calls + assert call["command"] == [XDG_OPEN, str(page)] + assert call["start_new_session"] is True + assert call["env"] == {"HOME": "/home/reader"} + assert call["stdout"] is subprocess.DEVNULL + + def test_a_missing_program_reaches_the_caller(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def missing(command: List[str], **options: Any) -> None: + raise FileNotFoundError(command[0]) + + monkeypatch.setattr(POPEN, missing) + + with pytest.raises(OSError): + XdgOpenFileOpener({}, frozen=False).open(tmp_path / "index.html") diff --git a/tests/unit/sampletones_tools/calibration/board/test_browser.py b/tests/unit/sampletones_tools/calibration/board/test_browser.py index ae45fd493..87cd48989 100644 --- a/tests/unit/sampletones_tools/calibration/board/test_browser.py +++ b/tests/unit/sampletones_tools/calibration/board/test_browser.py @@ -3,32 +3,40 @@ import pytest +from sampletones_shared.exceptions import FileNotOpenedError from sampletones_tools.calibration.board.browser import open_page -WEBBROWSER = "sampletones_tools.calibration.board.browser.webbrowser.open" +SELECT_OPENER = "sampletones_tools.calibration.board.browser.select_file_opener" + + +class RecordedOpener: + def __init__(self, *, takes: bool) -> None: + self.opened: List[Path] = [] + self._takes = takes + + def open(self, path: Path) -> None: + self.opened.append(path) + if not self._takes: + raise FileNotOpenedError(f"No browser took {path}") + + +@pytest.fixture +def page(tmp_path: Path) -> Path: + path = tmp_path / "index.html" + path.write_text("", encoding="utf-8") + return path class TestOpenPage: - def test_the_page_is_handed_over_as_a_file_link( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - asked: List[str] = [] - monkeypatch.setattr(WEBBROWSER, lambda link: asked.append(link) or True) - page = tmp_path / "index.html" - page.write_text("", encoding="utf-8") + def test_the_page_is_handed_to_the_system_opener(self, page: Path, monkeypatch: pytest.MonkeyPatch) -> None: + opener = RecordedOpener(takes=True) + monkeypatch.setattr(SELECT_OPENER, lambda: opener) assert open_page(page) - assert asked == [page.resolve().as_uri()] - - def test_a_system_with_no_browser_leaves_the_link_alone( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - monkeypatch.setattr(WEBBROWSER, lambda link: False) - page = tmp_path / "index.html" - page.write_text("", encoding="utf-8") + assert opener.opened == [page.resolve()] + + def test_a_system_with_no_browser_leaves_the_link_alone(self, page: Path, monkeypatch: pytest.MonkeyPatch) -> None: + opener = RecordedOpener(takes=False) + monkeypatch.setattr(SELECT_OPENER, lambda: opener) assert not open_page(page) From 1af5c0ca44f981946246c22a3ef51b45a2e38ba7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 14:33:56 +0200 Subject: [PATCH 5/8] Added: a screen scenario opening the documentation from the Help menu --- automation/boundaries/reveals.py | 43 +++++++--- automation/plugin/fixtures.py | 3 +- automation/screen.py | 7 ++ tests/screens/interface/dialogs/cases.py | 8 +- .../interface/menus/test_documentation.py | 80 +++++++++++++++++++ tests/suite/screens/seeds/documentation.py | 26 ++++++ tests/unit/automation/boundaries/__init__.py | 0 .../automation/boundaries/test_reveals.py | 43 ++++++++++ 8 files changed, 196 insertions(+), 14 deletions(-) create mode 100644 tests/screens/interface/menus/test_documentation.py create mode 100644 tests/suite/screens/seeds/documentation.py create mode 100644 tests/unit/automation/boundaries/__init__.py create mode 100644 tests/unit/automation/boundaries/test_reveals.py diff --git a/automation/boundaries/reveals.py b/automation/boundaries/reveals.py index 76516d47e..ca9a3ca55 100644 --- a/automation/boundaries/reveals.py +++ b/automation/boundaries/reveals.py @@ -12,22 +12,26 @@ class FileManagerStandIn: - """Stands in for the desktop's file manager, noting every path the application asks it to show. + """Stands in for the desktop's file manager and its handler of files, noting every path the + application hands them. The application asks ``xdg-mime`` which file manager the desktop uses and then asks that file - manager, or ``xdg-open``, to show a path. Programs of those names lie in ``folder``, which leads - the search path, so the application's own reveal code runs and reaches them: the first names a - file manager they stand for, and the others write the path they were given to ``log``, one line - each. + manager, or ``xdg-open``, to show a path, and it asks ``xdg-open`` to open a page in the + browser. Programs of those names lie in ``folder``, which leads the search path, so the + application's own code runs and reaches them: the first names a file manager they stand for, + the file manager writes the path it was given to ``shown_log``, and ``xdg-open`` writes a folder + there and a file to ``opened_log``, one line each. """ def __init__( self, folder: Path, - log: Path, + shown_log: Path, + opened_log: Path, ) -> None: self.folder = folder - self._log = log + self._shown_log = shown_log + self._opened_log = opened_log def install(self, monkeypatch: pytest.MonkeyPatch) -> None: """Lays the programs in the folder and puts the folder first on the search path.""" @@ -44,15 +48,30 @@ def install(self, monkeypatch: pytest.MonkeyPatch) -> None: def shown(self) -> Tuple[Path, ...]: """Every path the file manager was asked to show, in order.""" - if not self._log.exists(): - return () + return _noted(self._shown_log) - return tuple(Path(line) for line in self._log.read_text().splitlines()) + def opened(self) -> Tuple[Path, ...]: + """Every file handed to the desktop's program for its kind, in order.""" + return _noted(self._opened_log) def _programs(self) -> Mapping[str, str]: - noting = f'#!/bin/sh\nfor path in "$@"; do printf \'%s\\n\' "$path" >> "{self._log}"; done\n' + noting = f'#!/bin/sh\nfor path in "$@"; do printf \'%s\\n\' "$path" >> "{self._shown_log}"; done\n' + sorting = ( + "#!/bin/sh\n" + 'for path in "$@"; do\n' + f' if [ -d "$path" ]; then log="{self._shown_log}"; else log="{self._opened_log}"; fi\n' + ' printf \'%s\\n\' "$path" >> "$log"\n' + "done\n" + ) return { "xdg-mime": f"#!/bin/sh\necho {FILE_MANAGER_ENTRY}\n", FILE_MANAGER: noting, - "xdg-open": noting, + "xdg-open": sorting, } + + +def _noted(log: Path) -> Tuple[Path, ...]: + if not log.exists(): + return () + + return tuple(Path(line) for line in log.read_text().splitlines()) diff --git a/automation/plugin/fixtures.py b/automation/plugin/fixtures.py index fb23ba5f0..7862d8299 100644 --- a/automation/plugin/fixtures.py +++ b/automation/plugin/fixtures.py @@ -40,6 +40,7 @@ ANSWER_TIMEOUT_SECONDS: Final[float] = 30.0 STAND_IN_PROGRAMS: Final[str] = "programs" REVEALS_FILE: Final[str] = "reveals.log" +OPENS_FILE: Final[str] = "opens.log" DISPLAY_NAME_KEY: Final[pytest.StashKey[str]] = pytest.StashKey() @@ -88,7 +89,7 @@ def screen_boundaries_fixture( errors = ErrorRecords() errors.install() artifacts = Path(os.environ[ARTIFACTS_VARIABLE]) - file_manager = FileManagerStandIn(artifacts / STAND_IN_PROGRAMS, artifacts / REVEALS_FILE) + file_manager = FileManagerStandIn(artifacts / STAND_IN_PROGRAMS, artifacts / REVEALS_FILE, artifacts / OPENS_FILE) file_manager.install(monkeypatch) spawns = SpawnGuard() spawns.install() diff --git a/automation/screen.py b/automation/screen.py index 850b48345..6b0e89e2a 100644 --- a/automation/screen.py +++ b/automation/screen.py @@ -68,6 +68,7 @@ from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( SUF_BUTTON, + TAG_GLOBAL_DIALOG_DOCUMENTATION, TAG_GLOBAL_DIALOG_ERROR, TAG_GLOBAL_DIALOG_FILE_NOT_FOUND, TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT, @@ -144,6 +145,7 @@ def __init__( self.file_not_found_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_FILE_NOT_FOUND) self.no_output_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT) self.no_project_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN) + self.documentation_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_DOCUMENTATION) def sound_settled(self) -> bool: """Whether the open reconstruction's sound stands landed, which a box or an edit sends to the worker.""" @@ -282,6 +284,11 @@ def revealed(self) -> Tuple[Path, ...]: """Every path the application asked the desktop's file manager to show, in order.""" return self._file_manager.shown() + def opened(self) -> Tuple[Path, ...]: + """Every file the application handed to the desktop's program for its kind, such as a page for + the browser, in order.""" + return self._file_manager.opened() + def table_highlights(self) -> Dict[HighlightPlace, Tuple[float, ...]]: """Every highlight standing on a table, with the color the application laid it in.""" return self._highlights.standing() diff --git a/tests/screens/interface/dialogs/cases.py b/tests/screens/interface/dialogs/cases.py index 4870e7ead..31c996419 100644 --- a/tests/screens/interface/dialogs/cases.py +++ b/tests/screens/interface/dialogs/cases.py @@ -2,7 +2,7 @@ from typing import Final, Tuple from sampletones_application.categories.elements.global_ import MenuElements -from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_ABOUT +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_ABOUT, TAG_GLOBAL_DIALOG_DOCUMENTATION from sampletones_application.tags.settings import ( TAG_SETTINGS_AUDIO_WINDOW, TAG_SETTINGS_DISPLAY_WINDOW, @@ -67,6 +67,12 @@ class MenuDialog: MenuElements.ITEM_FILE_EXPORT_NSF, TAG_SETTINGS_NSF_WINDOW, ), + MenuDialog( + "Documentation notice", + MenuElements.GROUP_HELP, + MenuElements.ITEM_HELP_DOCUMENTATION, + TAG_GLOBAL_DIALOG_DOCUMENTATION, + ), MenuDialog( "About", MenuElements.GROUP_HELP, diff --git a/tests/screens/interface/menus/test_documentation.py b/tests/screens/interface/menus/test_documentation.py new file mode 100644 index 000000000..06fafeea1 --- /dev/null +++ b/tests/screens/interface/menus/test_documentation.py @@ -0,0 +1,80 @@ +from typing import Final, Tuple + +import pytest + +from automation.screen import Screen +from automation.worlds.home import World, screen_filling_state +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.config.profile import UserProfile +from sampletones_shared.distribution import SAMPLETONES_DOCUMENTATION_ADDRESS +from sampletones_shared.documentation.site import INDEX_FILE +from tests.suite.screens.seeds.documentation import SHIPPED_INDEX, ShippedDocumentation, shipped_site + +STILL_FRAMES: Final[int] = 20 + + +def choose_documentation(screen: Screen) -> None: + screen.menu.choose(MenuElements.GROUP_HELP, MenuElements.ITEM_HELP_DOCUMENTATION) + + +def one_page(opened: Tuple[object, ...]) -> bool: + return len(opened) == 1 + + +def expect_the_copied_index(screen: Screen) -> None: + """The browser was handed the index of the copy in the reader's data folder, holding the shipped page.""" + [index] = screen.expect(screen.opened, one_page, description="the documentation handed to the browser") + stamp = shipped_site().read_stamp() + assert stamp is not None + assert index == UserProfile.user().documentation / stamp / INDEX_FILE + assert index.read_text(encoding="utf-8") == SHIPPED_INDEX + + +class TestDocumentationOpens: + """Help > Documentation copies the shipped pages into the reader's data folder and hands their index + to the browser.""" + + @pytest.fixture + def world(self) -> World: + return World( + state=screen_filling_state(), + application_config=None, + config=None, + files=(ShippedDocumentation(),), + ) + + def test_the_index_opens_from_the_reader_folder(self, screen: Screen) -> None: + def choosing_hands_the_copy_to_the_browser(screen: Screen) -> None: + choose_documentation(screen) + + expect_the_copied_index(screen) + assert not screen.documentation_notice.is_shown() + + screen.scenario(choosing_hands_the_copy_to_the_browser).run() + + +class TestDocumentationMissing: + """An installation without its pages says where the documentation lives online and opens nothing, + and the same item opens the pages once they are there.""" + + def test_the_notice_names_the_online_copy(self, screen: Screen) -> None: + def a_notice_names_the_address(screen: Screen) -> None: + choose_documentation(screen) + + screen.expect(screen.documentation_notice.is_shown, bool, description="the documentation notice") + assert SAMPLETONES_DOCUMENTATION_ADDRESS in screen.documentation_notice.words() + screen.frames(STILL_FRAMES) + assert screen.opened() == () + + screen.documentation_notice.dismiss() + screen.expect(screen.documentation_notice.is_shown, lambda shown: not shown, description="the notice gone") + + def the_pages_open_once_they_are_there(screen: Screen) -> None: + ShippedDocumentation().write() + + choose_documentation(screen) + + expect_the_copied_index(screen) + assert not screen.documentation_notice.is_shown() + + screen.scenario(a_notice_names_the_address, the_pages_open_once_they_are_there).run() diff --git a/tests/suite/screens/seeds/documentation.py b/tests/suite/screens/seeds/documentation.py new file mode 100644 index 000000000..a7b02a72a --- /dev/null +++ b/tests/suite/screens/seeds/documentation.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +from automation.paths import SHIPPED_DOCUMENTATION_FOLDER +from sampletones_shared.documentation.site import DocumentationSite + +SHIPPED_INDEX: Final[str] = "SampleToNES documentation

    Shipped pages

    \n" + + +def shipped_site() -> DocumentationSite: + """The site a scenario's application ships, in the home of the scenario's process.""" + return DocumentationSite(Path.home() / SHIPPED_DOCUMENTATION_FOLDER) + + +@dataclass(frozen=True) +class ShippedDocumentation: + """The documentation an installation ships: an index sealed by its stamp, where the scenario's + application reads its pages from.""" + + def write(self) -> None: + """Writes the index and seals the site.""" + site = shipped_site() + site.directory.mkdir(parents=True, exist_ok=True) + site.index.write_text(SHIPPED_INDEX, encoding="utf-8") + site.write_stamp() diff --git a/tests/unit/automation/boundaries/__init__.py b/tests/unit/automation/boundaries/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/automation/boundaries/test_reveals.py b/tests/unit/automation/boundaries/test_reveals.py new file mode 100644 index 000000000..f2cc622c1 --- /dev/null +++ b/tests/unit/automation/boundaries/test_reveals.py @@ -0,0 +1,43 @@ +import subprocess +from pathlib import Path + +import pytest + +from automation.boundaries.reveals import FILE_MANAGER, FileManagerStandIn + + +@pytest.fixture +def stand_in(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> FileManagerStandIn: + file_manager = FileManagerStandIn(tmp_path / "programs", tmp_path / "shown.log", tmp_path / "opened.log") + file_manager.install(monkeypatch) + return file_manager + + +@pytest.fixture +def page(tmp_path: Path) -> Path: + path = tmp_path / "index.html" + path.write_text("", encoding="utf-8") + return path + + +class TestFileManagerStandIn: + def test_nothing_is_noted_before_a_program_runs(self, stand_in: FileManagerStandIn) -> None: + assert stand_in.shown() == () + assert stand_in.opened() == () + + def test_xdg_open_on_a_folder_shows_it(self, stand_in: FileManagerStandIn, tmp_path: Path) -> None: + subprocess.run(["xdg-open", str(tmp_path)], check=True) + + assert stand_in.shown() == (tmp_path,) + assert stand_in.opened() == () + + def test_xdg_open_on_a_file_opens_it(self, stand_in: FileManagerStandIn, page: Path) -> None: + subprocess.run(["xdg-open", str(page)], check=True) + + assert stand_in.opened() == (page,) + assert stand_in.shown() == () + + def test_the_file_manager_shows_what_it_is_given(self, stand_in: FileManagerStandIn, page: Path) -> None: + subprocess.run([FILE_MANAGER, str(page)], check=True) + + assert stand_in.shown() == (page,) From 0e22071d1f3ceaedb4db367e4cca31842f17191c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 15:07:46 +0200 Subject: [PATCH 6/8] Held: every wheel and bundle to its documentation, and every program the app starts to the reader's library path --- .github/workflows/ci.yml | 5 ++ .github/workflows/workflow.yml | 4 +- README.md | 2 +- docs/development/application/screens.md | 5 +- docs/development/assets.md | 28 ++++++- docs/development/packages.md | 2 +- docs/development/release/dependencies.md | 6 ++ docs/development/tooling.md | 20 +++-- docs/guide/interface.md | 3 +- scripts/bootstrap/layout.py | 4 +- scripts/bundle.py | 8 +- scripts/verify_bundle.py | 33 +++++++-- src/sampletones/commands/self_check.py | 8 +- src/sampletones/self_check.py | 51 ++++++++++--- .../utils/file_dialogs/backends/command.py | 2 + .../utils/system/opening/selection.py | 15 +--- .../utils/system/opening/xdg_open.py | 36 ++------- src/sampletones_shared/utils/system/paths.py | 12 +-- .../utils/system/programs.py | 46 +++++++++++- .../sampletones/commands/test_self_check.py | 19 ++++- tests/unit/sampletones/test_self_check.py | 73 +++++++++++++++--- .../file_dialogs/backends/test_command.py | 7 +- .../utils/system/opening/test_xdg_open.py | 65 +++------------- .../utils/system/test_paths.py | 24 ++++-- .../utils/system/test_programs.py | 74 ++++++++++++++++++- tests/unit/scripts/test_bundle.py | 11 ++- tests/unit/scripts/test_verify_bundle.py | 62 +++++++++++++++- 27 files changed, 462 insertions(+), 163 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77a01a4fc..523da6044 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,11 @@ jobs: - name: Check the committed icons match the mark run: uv run pre-commit run icons --all-files --hook-stage pre-push --color always + - name: Check a built wheel carries its documentation + run: | + uv build --wheel --out-dir dist + uv run --isolated --no-project --with dist/sampletones-*.whl sampletones self-check --documentation + tests: name: Tests (${{ matrix.os }}, py${{ matrix.python }}) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 5d81fec3e..034b094ec 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -86,9 +86,9 @@ jobs: sampletones --version sampletones --help - - name: Check the wheel carries every resource it needs at startup + - name: Check the wheel carries every resource it needs at startup and its documentation shell: bash - run: sampletones self-check + run: sampletones self-check --documentation - name: Check a developer command refuses to run outside a checkout shell: bash diff --git a/README.md b/README.md index 79391f4fa..14945a005 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ recording without the interface, for example. Run `sampletones --help` for the l ## Documentation -The [user guide](https://github.com/JakimPL/SampleToNES/tree/main/docs/guide) explains how to use the app, step by step. The rest of [`docs/`](https://github.com/JakimPL/SampleToNES/tree/main/docs) covers the reconstruction algorithms, the file formats, the Python API and the developer notes. +The [user guide](https://github.com/JakimPL/SampleToNES/tree/main/docs/guide) explains how to use the app, step by step. The app opens it in your browser from **Help ▸ Documentation**. The rest of [`docs/`](https://github.com/JakimPL/SampleToNES/tree/main/docs) covers the reconstruction algorithms, the file formats, the Python API and the developer notes. ## License diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index f1e09d9fc..2b70e8b9e 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -202,8 +202,9 @@ how a menu popup's width is measured. browser or a dialog tool would open on the desktop around the run. A shared library lookup passes, and so do the stand-ins below. - **The file manager.** Stand-in programs lead the search path: `xdg-mime` names a file manager, and that - file manager and `xdg-open` note each path they are asked to show. The application's own reveal code - runs, and `screen.revealed()` reads what it showed. + file manager and `xdg-open` note each path they are handed. The application's own code runs: + `screen.revealed()` reads the folders and files it asked the file manager to show, and `screen.opened()` + the files `xdg-open` handed to their program, such as the documentation's page for the browser. - **The window manager.** The scenario's X server runs none, so `screen.close_window()` sends the request a title bar's close button sends. - **Table highlights.** DearPyGui holds a table's row, cell and column highlights as table state and diff --git a/docs/development/assets.md b/docs/development/assets.md index 68b4b298e..9d09420bc 100644 --- a/docs/development/assets.md +++ b/docs/development/assets.md @@ -1,7 +1,8 @@ # Assets This document governs `assets/`, the checkout unit that makes the repository's own content: the icon suite -the application ships, the demo tree, and the pictures of the application the guide and the README show. +the application ships, the HTML documentation it opens, the demo tree, and the pictures of the application +the guide and the README show. Consult it before adding something the repository keeps that a person would otherwise draw, record or arrange by hand, and before changing how one of these is made. What a checkout unit is, and what it may import, is in [tooling](tooling.md). @@ -28,6 +29,9 @@ same hook. A maker whose output depends on the machine's arithmetic, such as a c `build/` and is run by hand. A maker whose output may differ by a pixel between machines, as a software renderer's does, is run by hand too, and the pull request shows what changed. +The documentation site is the one output the history leaves out. It repeats `docs/` in another form, so +`docs/` stays the single copy and every wheel build derives the site afresh ([documentation](#documentation)). + ## The makers ### Icons @@ -40,6 +44,28 @@ from, `src/sampletones_assets/icons/`. `make icons` writes them, and `python -m them elsewhere. The whole suite is committed, so every wheel, bundle and test run finds the icons where they lie. Pillow rasterizes the suite; it is a development dependency, so the maker runs in a checkout alone. +### Documentation + +`assets/documentation/` writes the user's documentation as HTML pages in the application's look, the pages +**Help > Documentation** opens. The Markdown under `docs/` is the specification: the index, the glossary, +and the guide, concepts, tools, formats and API sections become pages, each at the place its document holds +in `docs/`, so a picture a page shows keeps its relative link. Each second-level heading becomes one of the +application's cards, and the index's sections lay out the navigation beside every page. The look is the +shared web layout ([tooling](tooling.md#pages-a-tool-writes)), in the application's default palette. + +The development pages, the changelog and anything else under `docs/` stay online: a link to one leads to +the copy at the documentation address `pyproject.toml` names. A link to a missing document, heading or +picture fails the run, so a site that builds holds no broken link. A site is sealed by a stamp written +last, a hash over every file beside it, which names the pages exactly: the application keeps its copy of +the pages under that stamp, and the self-check holds a build's pages to it. + +`make docs` writes the site into `src/sampletones_assets/documentation/`, which the history ignores, so a +checkout opens its own documentation. `python -m assets.documentation -o DIR` writes it elsewhere; the +folder must be empty or hold an earlier site, which the run replaces. The wheel's build hook, +`hatch_build.py`, runs the same maker into a temporary folder for every wheel and carries the site at the +same package path, so every installation and every bundle built from a wheel opens the pages it was built +with. An editable install reads the checkout, where `make docs` writes the site. + ### Demo tree `assets/demo/` writes a documents folder a person can run the application from, with recordings beside diff --git a/docs/development/packages.md b/docs/development/packages.md index 72bd29e9c..85383bf57 100644 --- a/docs/development/packages.md +++ b/docs/development/packages.md @@ -44,7 +44,7 @@ graph TD |---------|---------------| | `sampletones_shared` | Facts and helpers any package holds: constants, exception families, paths, the logger, the array backend, and the command type the entry and the tools share | | `sampletones_config` | The shipped YAML — layout, palettes, themes, keybindings, language, behavior, deployment, and these boundaries themselves — reached as package data and not by import | -| `sampletones_assets` | The application icons and the bundled fonts, reached as package data | +| `sampletones_assets` | The application icons, the bundled fonts and, in a wheel, the HTML documentation, reached as package data | | `sampletones_core` | The reconstruction engine, the project model, playing a song out into instructions, and the tracker export formats | | `sampletones_player` | The NES player: the register model, the re-clocking schedule, the 6502 driver and the NSF file | | `sampletones_application` | The DearPyGui front end | diff --git a/docs/development/release/dependencies.md b/docs/development/release/dependencies.md index 70ea55b2e..fa0c75f8e 100644 --- a/docs/development/release/dependencies.md +++ b/docs/development/release/dependencies.md @@ -42,6 +42,10 @@ The icon suite is generated from a mark declared in `assets/icons/mark/config` a Pillow rasterizes the suite. It is declared in the `assets` dependency group, which the `dev` group includes. It serves the assets unit alone, which the build environment never installs, and the bundle script passes `--exclude-module PIL` as well. `pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is installed, and PyInstaller follows that import into the bundle. The application reads its icons as files, so the exclusion keeps Pillow's extension modules and the imaging libraries that come with them out of every bundle. `scripts/verify_bundle.py` holds the release bundles to it. +## Documentation + +Every wheel build writes the HTML documentation ([assets](../assets.md#documentation)), so the build needs what that maker imports. `markdown-it-py` reads the Markdown, with the tables GitHub draws, and belongs to the `assets` group beside Pillow. The build hook names its own dependencies in `pyproject.toml`: markdown-it-py and the libraries the application's theme and palette code imports, each held to the project's own range, which a test compares. Every wheel is built in an isolated environment that installs them, so the first build on a machine reaches the network. `hatchling` 1.27 is the oldest build backend that reads the project's license fields. + ## Calibration The calibration harness scores renders with referees of its own, built on `numpy` and `scipy`. The `calibration` dependency group adds [Zimtohrli](https://github.com/google/zimtohrli), a psychoacoustic model, as a second opinion. PyPI has its wheels for Windows, Intel macOS and x86-64 Linux, and other systems compile it on install. The group stays out of `dev`. `uv sync --group calibration`, with the extras the environment already uses named beside it, installs it. See [Calibration](../../tools/calibration.md). @@ -84,3 +88,5 @@ Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 PortAudio is required. Tk backs the file dialogs where neither a portal nor a desktop tool answers, and `make release` requires it so the shipped executable stays self-contained. The executable links against the glibc of the machine that builds it and runs on that version or newer. A redistributable artifact therefore belongs on the oldest Debian or Ubuntu release being supported, so it runs on every newer one. + +The bootloader points `LD_LIBRARY_PATH` at the libraries the bundle carries, and a program the application starts would load them in place of the system's. Every system program the application starts, a file manager, a browser or a dialog tool, therefore receives the reader's own library path, which `program_environment()` in `sampletones_shared/utils/system/programs.py` restores. diff --git a/docs/development/tooling.md b/docs/development/tooling.md index 8cad8fd1c..1fd5f3964 100644 --- a/docs/development/tooling.md +++ b/docs/development/tooling.md @@ -73,7 +73,7 @@ The Makefile is the developer's index, one line per target. A target names the s and passes its flag. The `run` and `calibration` targets name the `sampletones` command they start with no options, and the `tracker-playback` target passes the inputs its command takes: the Bitphase source folder its `BITPHASE` variable names, the `FamiTracker.exe` its `FAMITRACKER` variable names, and the project -files in `PROJECT`, if any. The `icons` and `demo` targets run makers of the assets unit. It runs each target it is +files in `PROJECT`, if any. The `icons`, `docs` and `demo` targets run makers of the assets unit. It runs each target it is given. `install.sh` and `install.bat` at the root exist for the double-click path and call the same bundle script. @@ -127,11 +127,19 @@ A test guards principle 4. It imports the registry in a subprocess and asserts t registry and package modules of the tools load and no heavy library does. A startup failure in any tool module would break every invocation, the GUI included. -A tool that writes a page ships that page's files as they are read. `calibration/board/static/` holds the -markup, the stylesheet and the script, copied out byte for byte, and the builder writes the palette, the -faces and the run's own contents beside them. The stylesheet names color tokens and the script names no -measurement, so a page is edited in place and drawn in the application's palettes. A test holds every -shipped file to reaching nothing beyond the page, which lets one open from a file. +### Pages a tool writes + +A page the repository writes wears the application's look, read from the application itself. +`sampletones_tools/web/` resolves the theme set and a palette into *roles*: the ground, a card's header +strip, the text, the accent, each the color or size the application draws that part with. It writes them +as one stylesheet of variables beside the fonts the application ships, and the layout's own stylesheets +name roles alone. A change to a theme or a palette therefore reaches every page the next time it is +written. The fonts travel inside their stylesheet, which lets a page open from a file wherever it lies. + +The calibration board and the documentation site ([assets](assets.md#documentation)) are written this +way. A page's own files are shipped as they are read: `calibration/board/static/` holds the board's markup, +its stylesheet and its script, copied out byte for byte beside the layout and the run's own contents. A test +holds every shipped file to reaching nothing beyond the page. ## The bootstrap scripts diff --git a/docs/guide/interface.md b/docs/guide/interface.md index e80918a3f..9f55046d6 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -17,10 +17,11 @@ The **Instructions** tab is optional. Use it to explore single [instructions](.. ## The menus -Two menu items are easy to miss: +A few menu items are easy to miss: - **View ▸ Show advanced settings** adds the **Advanced settings** card to the **Main** tab. See [Configuration](configuration.md). - **Playback ▸ Audio settings...** sets the device you listen through. Its sample rate is separate from **Sample rate** on the **Main** tab, which sets how audio is converted. +- **Help ▸ Documentation** opens this guide in your web browser, from a copy that comes with the app. Project properties belong to a project and are covered in the [sequencer guide](sequencer.md). diff --git a/scripts/bootstrap/layout.py b/scripts/bootstrap/layout.py index baa2838a0..83afcd1a8 100644 --- a/scripts/bootstrap/layout.py +++ b/scripts/bootstrap/layout.py @@ -9,7 +9,9 @@ DISTRIBUTION: Final[str] = "bin" BUNDLES: Final[str] = "bundles" BUILD_ENVIRONMENT: Final[str] = ".venv-build" -DOCUMENTATION_SITE: Final[str] = "src/sampletones_assets/documentation" +DOCUMENTATION_PACKAGE_PATH: Final[str] = "sampletones_assets/documentation" +DOCUMENTATION_SITE: Final[str] = f"{SOURCE_DIRECTORY}/{DOCUMENTATION_PACKAGE_PATH}" +DOCUMENTATION_FILES: Final[Tuple[str, ...]] = ("index.html", "stamp") RELEASE_HOOK: Final[str] = "scripts/runtime_hooks/release_environment.py" GPU_HOOK: Final[str] = "scripts/runtime_hooks/cuda_bundle.py" PYINSTALLER_HOOKS: Final[str] = "scripts/pyinstaller_hooks" diff --git a/scripts/bundle.py b/scripts/bundle.py index caf0cd86b..ead25f2ca 100644 --- a/scripts/bundle.py +++ b/scripts/bundle.py @@ -30,6 +30,7 @@ SELF_CHECK: Final[str] = "self-check" GPU_FLAG: Final[str] = "--gpu" +DOCUMENTATION_FLAG: Final[str] = "--documentation" @dataclass(frozen=True) @@ -139,11 +140,12 @@ def pyinstaller_command( def self_check_command(launcher: Path, options: BundleOptions) -> Tuple[str, ...]: - """The launcher's self-check, which a GPU bundle runs with the GPU check that proves it computes on the card.""" + """The launcher's self-check, held to the documentation the wheel carries, which a GPU bundle runs + with the GPU check that proves it computes on the card.""" if options.gpu: - return (str(launcher), SELF_CHECK, GPU_FLAG) + return (str(launcher), SELF_CHECK, DOCUMENTATION_FLAG, GPU_FLAG) - return (str(launcher), SELF_CHECK) + return (str(launcher), SELF_CHECK, DOCUMENTATION_FLAG) def remove_previous(distribution: Path, bundling: Bundling, name: str) -> None: diff --git a/scripts/verify_bundle.py b/scripts/verify_bundle.py index c647f43ff..69f577ce1 100644 --- a/scripts/verify_bundle.py +++ b/scripts/verify_bundle.py @@ -4,7 +4,15 @@ from pathlib import Path from typing import Final, List, Mapping, Sequence -from bootstrap.layout import BUILD_TOOLS, DISTRIBUTION, GPU_PACKAGES, NOTICES, repository_root +from bootstrap.layout import ( + BUILD_TOOLS, + DISTRIBUTION, + DOCUMENTATION_FILES, + DOCUMENTATION_PACKAGE_PATH, + GPU_PACKAGES, + NOTICES, + repository_root, +) from bootstrap.platforms.factory import current_platform from bootstrap.platforms.protocol import Platform from bootstrap.processes import Runner, run @@ -19,6 +27,16 @@ def missing_notices(bundle: Path) -> List[str]: return [name for name in NOTICES if not (bundle / name).is_file()] +def missing_documentation(bundle: Path) -> List[str]: + """The files of the documentation site a bundle ships that are absent from its internals. + + The wheel the bundle is built from carries the site at the assets package's path, and the + bundle unpacks its data under the same path, where Help > Documentation reads it. + """ + site = bundle / INTERNAL_DIRECTORY / DOCUMENTATION_PACKAGE_PATH + return [name for name in DOCUMENTATION_FILES if not (site / name).is_file()] + + def carried_packages(bundle: Path, names: Sequence[str]) -> List[str]: """The packages among ``names`` found in a bundle, beside its launcher or among its internals.""" directories = (bundle, bundle / INTERNAL_DIRECTORY) @@ -53,8 +71,8 @@ def bundle_failures( ) -> List[str]: """What keeps the release bundle under ``root`` from shipping, each as a line of its own. - The bundle ships its notices, carries the application and its CPU runtime dependencies alone, - and offers a launcher that starts. + The bundle ships its notices and its documentation, carries the application and its CPU runtime + dependencies alone, and offers a launcher that starts. Args: root: The repository the bundle was built in. @@ -76,6 +94,10 @@ def bundle_failures( if absent: failures.append(f"Bundle {bundle} is missing {', '.join(absent)}") + pages = missing_documentation(bundle) + if pages: + failures.append(f"Bundle {bundle} is missing its documentation: {', '.join(pages)}") + carried = carried_build_tools(bundle) if carried: failures.append(f"Bundle {bundle} carries build-time tooling its notices leave out: {', '.join(carried)}") @@ -96,7 +118,8 @@ def bundle_failures( def main(argv: Sequence[str]) -> int: - """Confirms the release bundle ships its notices, holds to them, and that its launcher starts.""" + """Confirms the release bundle ships its notices and its documentation, holds to the notices, and that its + launcher starts.""" parser = argparse.ArgumentParser(description="Verify the release bundle before it is archived.") parser.parse_args(list(argv)) @@ -107,7 +130,7 @@ def main(argv: Sequence[str]) -> int: if failures: return 1 - print("The bundle ships its notices and its launcher starts") + print("The bundle ships its notices and its documentation, and its launcher starts") return 0 diff --git a/src/sampletones/commands/self_check.py b/src/sampletones/commands/self_check.py index 220b9fa02..bae870cae 100644 --- a/src/sampletones/commands/self_check.py +++ b/src/sampletones/commands/self_check.py @@ -6,6 +6,7 @@ NAME: Final[str] = "self-check" HELP: Final[str] = "verify that this build's imports, bundled resources and configuration files are usable" GPU_FLAG: Final[str] = "--gpu" +DOCUMENTATION_FLAG: Final[str] = "--documentation" def configure(parser: ArgumentParser) -> None: @@ -14,13 +15,18 @@ def configure(parser: ArgumentParser) -> None: action="store_true", help="also verify that this build computes on an NVIDIA graphics card", ) + parser.add_argument( + DOCUMENTATION_FLAG, + action="store_true", + help="also verify that this build carries its documentation, as an installed one does", + ) def run(arguments: Namespace) -> int: """Runs every startup check and answers with the status a packaged build is held to.""" from sampletones.self_check import run_self_check - return run_self_check(gpu=arguments.gpu) + return run_self_check(gpu=arguments.gpu, documentation=arguments.documentation) SELF_CHECK: Final[Command] = Command( diff --git a/src/sampletones/self_check.py b/src/sampletones/self_check.py index 2d612a207..1cbcc937c 100644 --- a/src/sampletones/self_check.py +++ b/src/sampletones/self_check.py @@ -156,6 +156,27 @@ def _check_array_backend() -> str: return describe_array_backend() +def _check_documentation() -> str: + """Reads the documentation the build ships, whose stamp has to match the pages beside it. + + The wheel's build hook writes the pages and seals them, so a build carrying no pages, or pages + other than the ones sealed, names that here. + """ + from sampletones_shared.documentation.site import DocumentationSite + from sampletones_shared.exceptions import DocumentationUnavailableError + + site = DocumentationSite.shipped() + stamp = site.read_stamp() + if stamp is None or not site.index.is_file(): + raise DocumentationUnavailableError(f"No documentation site lies in {site.directory}") + + if stamp != site.content_stamp(): + raise DocumentationUnavailableError(f"The documentation in {site.directory} differs from its stamp {stamp}") + + pages = sum(1 for _ in site.directory.rglob(f"*{site.index.suffix}")) + return f"{pages} pages, stamp {stamp}" + + def _check_gpu_backend() -> str: """Computes on the graphics card, which is what a GPU build is held to.""" from sampletones_shared.array import exercise_gpu @@ -176,29 +197,39 @@ def _check_gpu_backend() -> str: SelfCheck(name="file dialog backend", run=_check_file_dialog_backend), SelfCheck(name="array backend", run=_check_array_backend), ) +DOCUMENTATION_CHECK: Final[SelfCheck] = SelfCheck(name="documentation", run=_check_documentation) GPU_CHECK: Final[SelfCheck] = SelfCheck(name="gpu backend", run=_check_gpu_backend) -def checks_for(*, gpu: bool) -> Tuple[SelfCheck, ...]: - """The checks a build runs: every startup check, and the GPU check where the build carries GPU support.""" - if gpu: - return (*CHECKS, GPU_CHECK) - - return CHECKS +def checks_for( + *, + gpu: bool, + documentation: bool, +) -> Tuple[SelfCheck, ...]: + """The checks a build runs: every startup check, the documentation check where the build was + installed from a wheel, and the GPU check last where the build carries GPU support.""" + held = ((documentation, DOCUMENTATION_CHECK), (gpu, GPU_CHECK)) + return (*CHECKS, *(check for wanted, check in held if wanted)) -def run_self_check(*, gpu: bool) -> int: +def run_self_check( + *, + gpu: bool, + documentation: bool, +) -> int: """Runs every startup check in order and returns the process exit status. Prints one line per check so a passing run doubles as an inventory of what the build carries, and stops at the first failure with the offending check named on the standard - error stream. A packaged build runs this to prove it starts before it is shipped, and a GPU - bundle runs it with ``gpu`` set to prove it computes on the graphics card. + error stream. A packaged build runs this to prove it starts before it is shipped, with + ``documentation`` set to prove it carries its pages, and a GPU bundle runs it with ``gpu`` set + to prove it computes on the graphics card. Args: gpu: Whether the build is held to the GPU backend. + documentation: Whether the build is held to carrying its documentation. """ - checks = checks_for(gpu=gpu) + checks = checks_for(gpu=gpu, documentation=documentation) for check in checks: try: detail = check.run() diff --git a/src/sampletones_application/utils/file_dialogs/backends/command.py b/src/sampletones_application/utils/file_dialogs/backends/command.py index 96cd0da8e..2c04ff3df 100644 --- a/src/sampletones_application/utils/file_dialogs/backends/command.py +++ b/src/sampletones_application/utils/file_dialogs/backends/command.py @@ -3,6 +3,7 @@ from typing import List, Optional from sampletones_shared.utils.system.paths import normalize_path +from sampletones_shared.utils.system.programs import program_environment def run_dialog_command(command: List[str]) -> Optional[Path]: @@ -24,5 +25,6 @@ def run_dialog_command(command: List[str]) -> Optional[Path]: capture_output=True, text=True, check=False, + env=program_environment(), ) return normalize_path(result.stdout.strip()) diff --git a/src/sampletones_shared/utils/system/opening/selection.py b/src/sampletones_shared/utils/system/opening/selection.py index 57cdacd9b..400ece143 100644 --- a/src/sampletones_shared/utils/system/opening/selection.py +++ b/src/sampletones_shared/utils/system/opening/selection.py @@ -1,6 +1,4 @@ -import os import shutil -import sys from sampletones_shared.utils.system.system import System @@ -12,16 +10,11 @@ def select_file_opener() -> FileOpener: """The file opener that fits the running system. - A Linux desktop with ``xdg-open`` opens a file through it, the one way a bundle hands the - program the reader's own library path; every other system opens it through the standard - library's browser support. + A Linux desktop with ``xdg-open`` opens a file through it, which hands the file to the + desktop's own program for its kind; every other system opens it through the standard library's + browser support. """ if System.current() == System.LINUX and shutil.which(XDG_OPEN) is not None: - return XdgOpenFileOpener(os.environ, frozen=running_frozen()) + return XdgOpenFileOpener() return BrowserFileOpener() - - -def running_frozen() -> bool: - """Whether the application runs from a PyInstaller bundle, which marks ``sys`` as frozen.""" - return bool(getattr(sys, "frozen", False)) diff --git a/src/sampletones_shared/utils/system/opening/xdg_open.py b/src/sampletones_shared/utils/system/opening/xdg_open.py index 3b6945f0e..e88e9e19f 100644 --- a/src/sampletones_shared/utils/system/opening/xdg_open.py +++ b/src/sampletones_shared/utils/system/opening/xdg_open.py @@ -1,50 +1,26 @@ import subprocess from pathlib import Path -from typing import Dict, Final, Mapping +from typing import Final + +from sampletones_shared.utils.system.programs import program_environment XDG_OPEN: Final[str] = "xdg-open" -LIBRARY_PATH: Final[str] = "LD_LIBRARY_PATH" -ORIGINAL_LIBRARY_PATH: Final[str] = "LD_LIBRARY_PATH_ORIG" class XdgOpenFileOpener: """Shows a file through ``xdg-open``, which hands it to the desktop's program for its kind. ``xdg-open`` starts in a session of its own, so the program it starts outlives the - application. A PyInstaller bundle points ``LD_LIBRARY_PATH`` at the libraries it carries and - keeps the reader's own value in ``LD_LIBRARY_PATH_ORIG``; the program receives the reader's - value, or none where the reader set none, so it loads the system's libraries. + application, and it starts with the reader's own library path, so a browser started from a + bundle loads the system's libraries. """ - def __init__( - self, - environment: Mapping[str, str], - *, - frozen: bool, - ) -> None: - self._environment = environment - self._frozen = frozen - def open(self, path: Path) -> None: subprocess.Popen( # pylint: disable=consider-using-with [XDG_OPEN, str(path)], - env=self.child_environment(), + env=program_environment(), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) - - def child_environment(self) -> Dict[str, str]: - """The variables ``xdg-open`` starts with: the application's, with the reader's library path.""" - environment = dict(self._environment) - if not self._frozen: - return environment - - original = environment.pop(ORIGINAL_LIBRARY_PATH, None) - if original is None: - environment.pop(LIBRARY_PATH, None) - else: - environment[LIBRARY_PATH] = original - - return environment diff --git a/src/sampletones_shared/utils/system/paths.py b/src/sampletones_shared/utils/system/paths.py index dc4c3b3e1..7dd75b6ea 100644 --- a/src/sampletones_shared/utils/system/paths.py +++ b/src/sampletones_shared/utils/system/paths.py @@ -5,6 +5,7 @@ from sampletones_shared.types.path import GeneralPathlike, Pathlike +from .programs import program_environment from .system import System DEFAULT_MAX_FILENAME_DISPLAY: Final[int] = 48 @@ -292,7 +293,7 @@ def open_directory_in_explorer_linux(path: Path) -> None: path (Path): The directory or file path to open. """ path = path if path.is_dir() else path.parent - subprocess.run(["xdg-open", str(path)], check=False) + subprocess.run(["xdg-open", str(path)], check=False, env=program_environment()) def open_file_in_explorer_linux(path: Path) -> None: @@ -312,6 +313,7 @@ def open_file_in_explorer_linux(path: Path) -> None: capture_output=True, text=True, check=False, + env=program_environment(), ) if xdg_mime_result.returncode != 0: @@ -330,7 +332,7 @@ def open_file_in_explorer_linux(path: Path) -> None: command = file_manager_commands.get(desktop_file) if command: - result = subprocess.run(command, check=False, capture_output=True) + result = subprocess.run(command, check=False, capture_output=True, env=program_environment()) if result.returncode != 0: open_directory_in_explorer_linux(path) else: @@ -363,14 +365,14 @@ def open_path_in_explorer(path: Pathlike) -> None: match system: case System.WINDOWS: select = "/select," if path.is_file() else "" - subprocess.run(["explorer", select, path_string], check=False) + subprocess.run(["explorer", select, path_string], check=False, env=program_environment()) case System.LINUX: if path.is_file(): open_file_in_explorer_linux(path) else: - subprocess.run(["xdg-open", path_string], check=False) + subprocess.run(["xdg-open", path_string], check=False, env=program_environment()) case System.MACOS: select = "-R" if path.is_file() else "" - subprocess.run(["open", select, path_string], check=False) + subprocess.run(["open", select, path_string], check=False, env=program_environment()) case _: raise OSError(f"Unsupported operating system: {system}") diff --git a/src/sampletones_shared/utils/system/programs.py b/src/sampletones_shared/utils/system/programs.py index 52e39136c..7a058dc7c 100644 --- a/src/sampletones_shared/utils/system/programs.py +++ b/src/sampletones_shared/utils/system/programs.py @@ -1,9 +1,14 @@ +import os import shutil +import sys from pathlib import Path -from typing import Mapping, Optional +from typing import Dict, Final, Mapping, Optional from .system import System +LIBRARY_PATH: Final[str] = "LD_LIBRARY_PATH" +ORIGINAL_LIBRARY_PATH: Final[str] = "LD_LIBRARY_PATH_ORIG" + def locate_program(program: str) -> Optional[Path]: """Where a program is installed, found the way a shell finds it. @@ -44,3 +49,42 @@ def missing_program_message( KeyError: If no hint is stated for this system. """ return f"{program} is missing: {purpose} ({hints[System.current()]})" + + +def running_frozen() -> bool: + """Whether the application runs from a PyInstaller bundle, which marks ``sys`` as frozen.""" + return bool(getattr(sys, "frozen", False)) + + +def reader_environment( + environment: Mapping[str, str], + *, + frozen: bool, +) -> Dict[str, str]: + """The variables ``environment`` holds, with the library path the reader's own session set. + + A PyInstaller bundle points ``LD_LIBRARY_PATH`` at the libraries it carries and keeps the + reader's own value in ``LD_LIBRARY_PATH_ORIG``. A frozen run therefore answers the reader's + value, or no library path where the reader set none, and any other run answers ``environment`` + as it stands. + """ + variables = dict(environment) + if not frozen: + return variables + + original = variables.pop(ORIGINAL_LIBRARY_PATH, None) + if original is None: + variables.pop(LIBRARY_PATH, None) + else: + variables[LIBRARY_PATH] = original + + return variables + + +def program_environment() -> Dict[str, str]: + """The variables a program of the system starts with: this process's, with the reader's library path. + + A file manager, a browser or a dialog tool started with them loads the system's libraries, in a + bundle as in an installed copy. + """ + return reader_environment(os.environ, frozen=running_frozen()) diff --git a/tests/unit/sampletones/commands/test_self_check.py b/tests/unit/sampletones/commands/test_self_check.py index 0bae99808..1f8957f7b 100644 --- a/tests/unit/sampletones/commands/test_self_check.py +++ b/tests/unit/sampletones/commands/test_self_check.py @@ -3,7 +3,7 @@ import pytest from sampletones.commands.registry import COMMANDS -from sampletones.commands.self_check import GPU_FLAG +from sampletones.commands.self_check import DOCUMENTATION_FLAG, GPU_FLAG from sampletones.dispatcher import dispatch CHECK = "sampletones.self_check.run_self_check" @@ -11,14 +11,14 @@ class TestSelfCheck: def test_the_status_is_the_checks_own(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(CHECK, lambda *, gpu: 7) + monkeypatch.setattr(CHECK, lambda *, gpu, documentation: 7) assert dispatch(COMMANDS, ["self-check"]) == 7 def test_the_gpu_flag_holds_the_build_to_the_gpu(self, monkeypatch: pytest.MonkeyPatch) -> None: held: List[bool] = [] - def run_self_check(*, gpu: bool) -> int: + def run_self_check(*, gpu: bool, documentation: bool) -> int: held.append(gpu) return 0 @@ -27,3 +27,16 @@ def run_self_check(*, gpu: bool) -> int: assert dispatch(COMMANDS, ["self-check"]) == 0 assert dispatch(COMMANDS, ["self-check", GPU_FLAG]) == 0 assert held == [False, True] + + def test_the_documentation_flag_holds_the_build_to_its_pages(self, monkeypatch: pytest.MonkeyPatch) -> None: + held: List[bool] = [] + + def run_self_check(*, gpu: bool, documentation: bool) -> int: + held.append(documentation) + return 0 + + monkeypatch.setattr(CHECK, run_self_check) + + assert dispatch(COMMANDS, ["self-check"]) == 0 + assert dispatch(COMMANDS, ["self-check", DOCUMENTATION_FLAG]) == 0 + assert held == [False, True] diff --git a/tests/unit/sampletones/test_self_check.py b/tests/unit/sampletones/test_self_check.py index dd3a57f0e..60fd775f2 100644 --- a/tests/unit/sampletones/test_self_check.py +++ b/tests/unit/sampletones/test_self_check.py @@ -1,10 +1,20 @@ +from pathlib import Path from unittest.mock import patch import pytest -from sampletones.self_check import CHECKS, FAILURE_STATUS, GPU_CHECK, SUCCESS_STATUS, checks_for, run_self_check +from sampletones.self_check import ( + CHECKS, + DOCUMENTATION_CHECK, + FAILURE_STATUS, + GPU_CHECK, + SUCCESS_STATUS, + checks_for, + run_self_check, +) from sampletones_shared import array from sampletones_shared.array import CUPY_AVAILABLE +from sampletones_shared.documentation.site import DocumentationSite from sampletones_shared.exceptions import FileDialogUnavailableError RESOURCES_MODULE = "sampletones_application.ui.resources.resources" @@ -14,7 +24,7 @@ class TestRunSelfCheck: def test_source_checkout_passes(self, capsys: pytest.CaptureFixture[str]) -> None: - status = run_self_check(gpu=False) + status = run_self_check(gpu=False, documentation=False) output = capsys.readouterr().out assert status == SUCCESS_STATUS @@ -23,7 +33,7 @@ def test_source_checkout_passes(self, capsys: pytest.CaptureFixture[str]) -> Non def test_missing_resource_fails(self, capsys: pytest.CaptureFixture[str]) -> None: with patch(f"{RESOURCES_MODULE}.get_font_path", side_effect=FileNotFoundError("Resource not found")): - status = run_self_check(gpu=False) + status = run_self_check(gpu=False, documentation=False) captured = capsys.readouterr() @@ -37,7 +47,7 @@ def test_unavailable_dialog_backend_fails(self, capsys: pytest.CaptureFixture[st f"{SELECTION_MODULE}.select_file_dialog_backend", side_effect=FileDialogUnavailableError("No file dialog backend is available."), ): - status = run_self_check(gpu=False) + status = run_self_check(gpu=False, documentation=False) assert status == FAILURE_STATUS assert "file dialog backend" in capsys.readouterr().err @@ -45,15 +55,21 @@ def test_unavailable_dialog_backend_fails(self, capsys: pytest.CaptureFixture[st class TestChecksFor: def test_a_cpu_build_runs_the_startup_checks_alone(self) -> None: - assert checks_for(gpu=False) == CHECKS + assert checks_for(gpu=False, documentation=False) == CHECKS def test_a_gpu_build_adds_the_gpu_check_last(self) -> None: - assert checks_for(gpu=True) == (*CHECKS, GPU_CHECK) + assert checks_for(gpu=True, documentation=False) == (*CHECKS, GPU_CHECK) + + def test_an_installed_build_adds_the_documentation_check(self) -> None: + assert checks_for(gpu=False, documentation=True) == (*CHECKS, DOCUMENTATION_CHECK) + + def test_the_gpu_check_stays_last(self) -> None: + assert checks_for(gpu=True, documentation=True) == (*CHECKS, DOCUMENTATION_CHECK, GPU_CHECK) class TestGpuCheck: def test_the_inventory_names_the_array_backend(self, capsys: pytest.CaptureFixture[str]) -> None: - run_self_check(gpu=False) + run_self_check(gpu=False, documentation=False) assert "array backend" in capsys.readouterr().out @@ -64,7 +80,7 @@ def test_a_gpu_build_computing_on_the_cpu_fails( ) -> None: monkeypatch.setattr(array, "CUPY_AVAILABLE", False) - status = run_self_check(gpu=True) + status = run_self_check(gpu=True, documentation=False) captured = capsys.readouterr() assert status == FAILURE_STATUS @@ -73,9 +89,48 @@ def test_a_gpu_build_computing_on_the_cpu_fails( @pytest.mark.skipif(not CUPY_AVAILABLE, reason=CUPY_REQUIRED_REASON) def test_a_gpu_build_computing_on_the_card_passes(self, capsys: pytest.CaptureFixture[str]) -> None: - status = run_self_check(gpu=True) + status = run_self_check(gpu=True, documentation=False) output = capsys.readouterr().out assert status == SUCCESS_STATUS assert GPU_CHECK.name in output assert f"{len(CHECKS) + 1} checks passed" in output + + +@pytest.fixture(name="shipped") +def shipped_fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> DocumentationSite: + """A sealed site standing where the build's documentation is read from.""" + site = DocumentationSite(tmp_path / "documentation") + site.directory.mkdir() + site.index.write_text("

    index

    ", encoding="utf-8") + site.write_stamp() + monkeypatch.setattr(DocumentationSite, "shipped", lambda: site) + return site + + +class TestDocumentationCheck: + def test_a_sealed_site_passes(self, shipped: DocumentationSite, capsys: pytest.CaptureFixture[str]) -> None: + status = run_self_check(gpu=False, documentation=True) + + assert status == SUCCESS_STATUS + assert f"{DOCUMENTATION_CHECK.name}: 1 pages, stamp {shipped.read_stamp()}" in capsys.readouterr().out + + def test_a_page_changed_after_sealing_fails( + self, + shipped: DocumentationSite, + capsys: pytest.CaptureFixture[str], + ) -> None: + shipped.index.write_text("

    changed

    ", encoding="utf-8") + + status = run_self_check(gpu=False, documentation=True) + + assert status == FAILURE_STATUS + assert "differs from its stamp" in capsys.readouterr().err + + def test_a_build_without_pages_fails(self, shipped: DocumentationSite, capsys: pytest.CaptureFixture[str]) -> None: + shipped.stamp_path.unlink() + + status = run_self_check(gpu=False, documentation=True) + + assert status == FAILURE_STATUS + assert "DocumentationUnavailableError" in capsys.readouterr().err diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/test_command.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_command.py index 794a70a28..e22284223 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/backends/test_command.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_command.py @@ -8,6 +8,7 @@ MODULE = "sampletones_application.utils.file_dialogs.backends.command" COMMAND = ["kdialog", "--getopenfilename"] +PROGRAM_ENVIRONMENT = {"PATH": "/usr/bin"} def _completed(stdout: str) -> MagicMock: @@ -28,13 +29,17 @@ def test_the_tool_answers_on_standard_output(self) -> None: """The path is read from the captured output, and a dismissal is read from it as well, so the exit status stays with the caller of the tool. """ - with patch(f"{MODULE}.subprocess.run", return_value=_completed("/audio/clip.wav")) as run: + with ( + patch(f"{MODULE}.subprocess.run", return_value=_completed("/audio/clip.wav")) as run, + patch(f"{MODULE}.program_environment", return_value=PROGRAM_ENVIRONMENT), + ): run_dialog_command(COMMAND) assert run.call_args.kwargs == { "capture_output": True, "text": True, "check": False, + "env": PROGRAM_ENVIRONMENT, } def test_surrounding_whitespace_leaves_the_path(self) -> None: diff --git a/tests/unit/sampletones_shared/utils/system/opening/test_xdg_open.py b/tests/unit/sampletones_shared/utils/system/opening/test_xdg_open.py index ce98e8e3e..84e065a07 100644 --- a/tests/unit/sampletones_shared/utils/system/opening/test_xdg_open.py +++ b/tests/unit/sampletones_shared/utils/system/opening/test_xdg_open.py @@ -1,22 +1,14 @@ import subprocess -from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Final, List import pytest -from sampletones_shared.utils.system.opening.xdg_open import ( - LIBRARY_PATH, - ORIGINAL_LIBRARY_PATH, - XDG_OPEN, - XdgOpenFileOpener, -) -from tests.suite.base import BaseTestSuite -from tests.suite.case import BaseRegularTestCase +from sampletones_shared.utils.system.opening.xdg_open import XDG_OPEN, XdgOpenFileOpener -POPEN = "sampletones_shared.utils.system.opening.xdg_open.subprocess.Popen" -BUNDLE_LIBRARIES = "/tmp/_MEI1234" -READER_LIBRARIES = "/opt/reader/lib" +POPEN: Final[str] = "sampletones_shared.utils.system.opening.xdg_open.subprocess.Popen" +PROGRAM_ENVIRONMENT: Final[str] = "sampletones_shared.utils.system.opening.xdg_open.program_environment" +ENVIRONMENT: Final[Dict[str, str]] = {"HOME": "/home/reader"} class RecordedPopen: @@ -27,58 +19,19 @@ def __call__(self, command: List[str], **options: Any) -> None: self.calls.append({"command": command, **options}) -class TestTheChildEnvironment(BaseTestSuite): - """A bundle hands the program the reader's own library path; a plain run hands it on unchanged.""" - - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - environment: Dict[str, str] - frozen: bool - expected: Optional[str] - - test_cases = ( - TestCase( - label="a bundle restores the reader's path", - environment={LIBRARY_PATH: BUNDLE_LIBRARIES, ORIGINAL_LIBRARY_PATH: READER_LIBRARIES}, - frozen=True, - expected=READER_LIBRARIES, - ), - TestCase( - label="a bundle drops a path the reader never set", - environment={LIBRARY_PATH: BUNDLE_LIBRARIES}, - frozen=True, - expected=None, - ), - TestCase( - label="a plain run keeps the reader's path", - environment={LIBRARY_PATH: READER_LIBRARIES}, - frozen=False, - expected=READER_LIBRARIES, - ), - TestCase(label="a plain run without a path sets none", environment={}, frozen=False, expected=None), - ) - - @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) - def test_the_library_path(self, test_case: TestCase) -> None: - opener = XdgOpenFileOpener(test_case.environment, frozen=test_case.frozen) - environment = opener.child_environment() - - assert environment.get(LIBRARY_PATH) == test_case.expected - assert ORIGINAL_LIBRARY_PATH not in environment or not test_case.frozen - - class TestXdgOpenFileOpener: def test_the_file_is_handed_to_a_detached_xdg_open(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: popen = RecordedPopen() monkeypatch.setattr(POPEN, popen) + monkeypatch.setattr(PROGRAM_ENVIRONMENT, lambda: ENVIRONMENT) page = tmp_path / "index.html" - XdgOpenFileOpener({"HOME": "/home/reader"}, frozen=False).open(page) + XdgOpenFileOpener().open(page) [call] = popen.calls assert call["command"] == [XDG_OPEN, str(page)] assert call["start_new_session"] is True - assert call["env"] == {"HOME": "/home/reader"} + assert call["env"] == ENVIRONMENT assert call["stdout"] is subprocess.DEVNULL def test_a_missing_program_reaches_the_caller(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -88,4 +41,4 @@ def missing(command: List[str], **options: Any) -> None: monkeypatch.setattr(POPEN, missing) with pytest.raises(OSError): - XdgOpenFileOpener({}, frozen=False).open(tmp_path / "index.html") + XdgOpenFileOpener().open(tmp_path / "index.html") diff --git a/tests/unit/sampletones_shared/utils/system/test_paths.py b/tests/unit/sampletones_shared/utils/system/test_paths.py index f5b82ad8d..fb1bd262a 100644 --- a/tests/unit/sampletones_shared/utils/system/test_paths.py +++ b/tests/unit/sampletones_shared/utils/system/test_paths.py @@ -1,7 +1,7 @@ import tempfile from dataclasses import dataclass from pathlib import Path, PurePosixPath, PureWindowsPath -from typing import Any, List, Type, Union +from typing import Any, Dict, Final, List, Type, Union from unittest.mock import MagicMock, call, patch import pytest @@ -28,6 +28,14 @@ from tests.suite.case import BaseRegularTestCase from tests.suite.errors import expect_error +PROGRAM_ENVIRONMENT: Final[Dict[str, str]] = {"PATH": "/usr/bin"} + + +@pytest.fixture(autouse=True) +def program_environment(monkeypatch: pytest.MonkeyPatch) -> None: + """Every program a reveal starts receives the environment the shared helper answers.""" + monkeypatch.setattr("sampletones_shared.utils.system.paths.program_environment", lambda: PROGRAM_ENVIRONMENT) + class TestFirstMissing: def test_answers_the_first_path_that_names_no_file(self, tmp_path: Path) -> None: @@ -751,7 +759,7 @@ def test_open_with_directory(self, mock_run: MagicMock) -> None: path = Path(tmpdir) open_directory_in_explorer_linux(path) - mock_run.assert_called_once_with(["xdg-open", tmpdir], check=False) + mock_run.assert_called_once_with(["xdg-open", tmpdir], check=False, env=PROGRAM_ENVIRONMENT) @patch("sampletones_shared.utils.system.paths.subprocess.run") def test_open_with_file(self, mock_run: MagicMock) -> None: @@ -761,7 +769,7 @@ def test_open_with_file(self, mock_run: MagicMock) -> None: open_directory_in_explorer_linux(filepath) - mock_run.assert_called_once_with(["xdg-open", tmpdir], check=False) + mock_run.assert_called_once_with(["xdg-open", tmpdir], check=False, env=PROGRAM_ENVIRONMENT) class TestOpenFileInExplorerLinux(BaseTestSuite): @@ -908,7 +916,9 @@ def test_open_file_in_explorer_linux( expected = test_case.expected + [str(path)] assert mock_run.call_count == 2 - assert mock_run.call_args_list[1] == call(expected, check=False, capture_output=True) + assert mock_run.call_args_list[1] == call( + expected, check=False, capture_output=True, env=PROGRAM_ENVIRONMENT + ) class TestOpenPathInExplorer(BaseTestSuite): @@ -969,7 +979,7 @@ def test_cross_platform( open_path_in_explorer(path) expected = test_case.expected + [str(path)] - mock_run.assert_called_once_with(expected, check=False) + mock_run.assert_called_once_with(expected, check=False, env=PROGRAM_ENVIRONMENT) @pytest.mark.parametrize( "is_file", @@ -999,7 +1009,7 @@ def test_linux( path = Path(tmpdir) open_path_in_explorer(path) mock_open_file.assert_not_called() - mock_run.assert_called_once_with(["xdg-open", tmpdir], check=False) + mock_run.assert_called_once_with(["xdg-open", tmpdir], check=False, env=PROGRAM_ENVIRONMENT) @patch("sampletones_shared.utils.system.paths.System.current") def test_unsupported_os(self, mock_system: MagicMock) -> None: @@ -1018,7 +1028,7 @@ def test_with_string_path(self, mock_run: MagicMock, mock_system: MagicMock) -> with tempfile.TemporaryDirectory() as tmpdir: path_str = str(tmpdir) open_path_in_explorer(path_str) - mock_run.assert_called_once_with(["xdg-open", tmpdir], check=False) + mock_run.assert_called_once_with(["xdg-open", tmpdir], check=False, env=PROGRAM_ENVIRONMENT) def test_with_integer_raises_type_error(self) -> None: with pytest.raises( diff --git a/tests/unit/sampletones_shared/utils/system/test_programs.py b/tests/unit/sampletones_shared/utils/system/test_programs.py index f59899de6..9cd29e865 100644 --- a/tests/unit/sampletones_shared/utils/system/test_programs.py +++ b/tests/unit/sampletones_shared/utils/system/test_programs.py @@ -1,15 +1,24 @@ +from dataclasses import dataclass from pathlib import Path -from typing import Dict, Final +from typing import Dict, Final, Optional from unittest.mock import patch import pytest from sampletones_shared.utils.system.programs import ( + LIBRARY_PATH, + ORIGINAL_LIBRARY_PATH, locate_program, missing_program_message, + program_environment, + reader_environment, ) from sampletones_shared.utils.system.system import System +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +BUNDLE_LIBRARIES: Final[str] = "/tmp/_MEI1234" +READER_LIBRARIES: Final[str] = "/opt/reader/lib" HINTS: Final[Dict[System, str]] = { System.LINUX: "sudo apt install tool", System.MACOS: "brew install tool", @@ -49,3 +58,66 @@ def test_a_system_with_no_hint_raises(self) -> None: with patch("platform.system", return_value="Linux"): with pytest.raises(KeyError): missing_program_message("tool", "purpose", {System.WINDOWS: "installer"}) + + +class TestTheReaderEnvironment(BaseTestSuite): + """A bundle hands a program the reader's own library path; any other run hands its variables on unchanged.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + environment: Dict[str, str] + frozen: bool + expected: Optional[str] + + test_cases = ( + TestCase( + label="a bundle restores the reader's path", + environment={LIBRARY_PATH: BUNDLE_LIBRARIES, ORIGINAL_LIBRARY_PATH: READER_LIBRARIES}, + frozen=True, + expected=READER_LIBRARIES, + ), + TestCase( + label="a bundle drops a path the reader never set", + environment={LIBRARY_PATH: BUNDLE_LIBRARIES}, + frozen=True, + expected=None, + ), + TestCase( + label="a plain run keeps the reader's path", + environment={LIBRARY_PATH: READER_LIBRARIES}, + frozen=False, + expected=READER_LIBRARIES, + ), + TestCase(label="a plain run without a path sets none", environment={}, frozen=False, expected=None), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_library_path(self, test_case: TestCase) -> None: + environment = reader_environment(test_case.environment, frozen=test_case.frozen) + + assert environment.get(LIBRARY_PATH) == test_case.expected + + def test_a_frozen_run_leaves_the_saved_path_behind(self) -> None: + environment = reader_environment( + {LIBRARY_PATH: BUNDLE_LIBRARIES, ORIGINAL_LIBRARY_PATH: READER_LIBRARIES}, + frozen=True, + ) + + assert ORIGINAL_LIBRARY_PATH not in environment + + def test_the_other_variables_pass_through(self) -> None: + assert reader_environment({"HOME": "/home/reader"}, frozen=True) == {"HOME": "/home/reader"} + + +class TestProgramEnvironment: + def test_a_run_from_a_checkout_hands_its_own_variables_on(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(LIBRARY_PATH, READER_LIBRARIES) + + assert program_environment()[LIBRARY_PATH] == READER_LIBRARIES + + def test_a_bundle_hands_the_reader_path_on(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(LIBRARY_PATH, BUNDLE_LIBRARIES) + monkeypatch.setenv(ORIGINAL_LIBRARY_PATH, READER_LIBRARIES) + monkeypatch.setattr("sys.frozen", True, raising=False) + + assert program_environment()[LIBRARY_PATH] == READER_LIBRARIES diff --git a/tests/unit/scripts/test_bundle.py b/tests/unit/scripts/test_bundle.py index f6eaa6e21..f7d92ad69 100644 --- a/tests/unit/scripts/test_bundle.py +++ b/tests/unit/scripts/test_bundle.py @@ -114,10 +114,15 @@ class TestSelfCheckCommand: def test_a_gpu_bundle_is_held_to_the_gpu_check(self) -> None: launcher = Path("bin", PROJECT_NAME, PROJECT_NAME) - assert bundle.self_check_command(launcher, CPU_DEVELOPMENT) == (str(launcher), bundle.SELF_CHECK) + assert bundle.self_check_command(launcher, CPU_DEVELOPMENT) == ( + str(launcher), + bundle.SELF_CHECK, + bundle.DOCUMENTATION_FLAG, + ) assert bundle.self_check_command(launcher, GPU_DEVELOPMENT) == ( str(launcher), bundle.SELF_CHECK, + bundle.DOCUMENTATION_FLAG, bundle.GPU_FLAG, ) @@ -163,7 +168,7 @@ def test_a_release_runs_every_step_in_order_and_places_the_notices(self, tmp_pat assert "import pyaudio" in runner.lines[3] assert "import tkinter" in runner.lines[4] assert "PyInstaller" in runner.lines[5] - assert runner.lines[6] == f"{launcher} {bundle.SELF_CHECK}" + assert runner.lines[6] == f"{launcher} {bundle.SELF_CHECK} {bundle.DOCUMENTATION_FLAG}" assert all((launcher.parent / notice).read_text() == notice for notice in NOTICES) def test_a_gpu_bundle_installs_the_extra_probes_cupy_and_lands_in_a_directory(self, tmp_path: Path) -> None: @@ -178,7 +183,7 @@ def test_a_gpu_bundle_installs_the_extra_probes_cupy_and_lands_in_a_directory(se assert "import cupy" in runner.lines[4] assert "import tkinter" in runner.lines[5] assert "PyInstaller" in runner.lines[6] - assert runner.lines[7] == f"{launcher} {bundle.SELF_CHECK} {bundle.GPU_FLAG}" + assert runner.lines[7] == f"{launcher} {bundle.SELF_CHECK} {bundle.DOCUMENTATION_FLAG} {bundle.GPU_FLAG}" assert not any((launcher.parent / notice).exists() for notice in NOTICES) def test_a_bundle_pyinstaller_never_wrote_is_reported(self, tmp_path: Path) -> None: diff --git a/tests/unit/scripts/test_verify_bundle.py b/tests/unit/scripts/test_verify_bundle.py index 75d29bd1d..05b022cff 100644 --- a/tests/unit/scripts/test_verify_bundle.py +++ b/tests/unit/scripts/test_verify_bundle.py @@ -2,9 +2,21 @@ import pytest -from bootstrap.layout import BUILD_TOOLS, DISTRIBUTION, GPU_PACKAGES, NOTICES +from bootstrap.layout import ( + BUILD_TOOLS, + DISTRIBUTION, + DOCUMENTATION_FILES, + DOCUMENTATION_PACKAGE_PATH, + DOCUMENTATION_SITE, + GPU_PACKAGES, + NOTICES, + SOURCE_DIRECTORY, +) from bootstrap.platforms.linux import Linux from bootstrap.platforms.macos import MacOS +from hatch_build import SITE_DESTINATION +from sampletones_shared.documentation.site import DOCUMENTATION_DIRECTORY, INDEX_FILE, STAMP_FILE +from sampletones_shared.paths.resources import ASSETS_PACKAGE from tests.suite.bootstrap import PROJECT_NAME, RecordingRunner, write_project from tests.suite.scripts import load_script @@ -13,13 +25,18 @@ @pytest.fixture(name="root") def root_fixture(tmp_path: Path) -> Path: - """A repository holding a release bundle with its notices and its launcher.""" + """A repository holding a release bundle with its notices, its documentation and its launcher.""" launcher = Linux().bundling().launcher(tmp_path / DISTRIBUTION, name=PROJECT_NAME, directory=True) launcher.parent.mkdir(parents=True) launcher.write_bytes(b"launcher") for name in NOTICES: (launcher.parent / name).write_text(name) + site = _site(launcher.parent) + site.mkdir(parents=True) + for name in DOCUMENTATION_FILES: + (site / name).write_text(name) + return write_project(tmp_path) @@ -27,6 +44,10 @@ def _bundle(root: Path) -> Path: return root / DISTRIBUTION / PROJECT_NAME +def _site(bundle: Path) -> Path: + return bundle / verify_bundle.INTERNAL_DIRECTORY / DOCUMENTATION_PACKAGE_PATH + + class TestMissingNotices: def test_a_complete_bundle_lacks_nothing(self, root: Path) -> None: assert verify_bundle.missing_notices(_bundle(root)) == [] @@ -44,6 +65,36 @@ def test_a_notice_directory_counts_as_absent(self, root: Path) -> None: assert verify_bundle.missing_notices(_bundle(root)) == [NOTICES[0]] +class TestMissingDocumentation: + def test_a_complete_bundle_lacks_nothing(self, root: Path) -> None: + assert verify_bundle.missing_documentation(_bundle(root)) == [] + + def test_an_unsealed_site_is_reported(self, root: Path) -> None: + (_site(_bundle(root)) / DOCUMENTATION_FILES[1]).unlink() + + assert verify_bundle.missing_documentation(_bundle(root)) == [DOCUMENTATION_FILES[1]] + + def test_a_bundle_without_pages_is_reported(self, root: Path) -> None: + for name in DOCUMENTATION_FILES: + (_site(_bundle(root)) / name).unlink() + + assert verify_bundle.missing_documentation(_bundle(root)) == list(DOCUMENTATION_FILES) + + +class TestTheDocumentationContract: + """The scripts run on the standard library alone, so they spell the site's place themselves; it has + to be the place the application reads and the build hook writes.""" + + def test_the_package_path_is_the_one_the_application_reads(self) -> None: + assert DOCUMENTATION_PACKAGE_PATH == f"{ASSETS_PACKAGE}/{DOCUMENTATION_DIRECTORY}" == SITE_DESTINATION + + def test_the_checkout_site_lies_in_the_source_package(self) -> None: + assert DOCUMENTATION_SITE == f"{SOURCE_DIRECTORY}/{DOCUMENTATION_PACKAGE_PATH}" + + def test_the_files_are_the_index_and_the_stamp(self) -> None: + assert DOCUMENTATION_FILES == (INDEX_FILE, STAMP_FILE) + + class TestCarriedBuildTools: def test_an_application_bundle_holds_to_its_notices(self, root: Path) -> None: assert verify_bundle.carried_build_tools(_bundle(root)) == [] @@ -85,6 +136,13 @@ def test_a_missing_notice_is_named(self, root: Path) -> None: assert len(failures) == 1 assert NOTICES[1] in failures[0] + def test_a_missing_documentation_is_named(self, root: Path) -> None: + (_site(_bundle(root)) / DOCUMENTATION_FILES[0]).unlink() + + failures = verify_bundle.bundle_failures(root, Linux(), runner=RecordingRunner({}, None), environment={}) + + assert [DOCUMENTATION_FILES[0] in failure for failure in failures] == [True] + def test_bundled_build_tooling_is_named(self, root: Path) -> None: (_bundle(root) / verify_bundle.INTERNAL_DIRECTORY / BUILD_TOOLS[0]).mkdir(parents=True) From f58d12a623909ef63a9f3ccdb4023ccf09240328 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 16:15:12 +0200 Subject: [PATCH 7/8] Spaced: the pages' cards and prose wider, in multiples of the application's own spacing --- assets/documentation/static/documentation.css | 18 ++++++++----- docs/development/tooling.md | 2 ++ .../calibration/board/static/style.css | 4 +-- src/sampletones_tools/web/static/base.css | 4 +++ src/sampletones_tools/web/static/prose.css | 26 ++++++++++++++----- 5 files changed, 39 insertions(+), 15 deletions(-) diff --git a/assets/documentation/static/documentation.css b/assets/documentation/static/documentation.css index 071d67680..e8ae55681 100644 --- a/assets/documentation/static/documentation.css +++ b/assets/documentation/static/documentation.css @@ -17,14 +17,23 @@ flex: 1; } +.documentation .workspace { + padding-block: var(--card-gap); +} + +.documentation .columns, +.documentation .column { + gap: var(--card-gap); +} + .documentation .column.page { max-width: 980px; } .documentation .column.side { position: sticky; - top: var(--panel-gap); - max-height: calc(100vh - 2 * var(--panel-gap)); + top: var(--card-gap); + max-height: calc(100vh - 2 * var(--card-gap)); overflow-y: auto; } @@ -69,7 +78,7 @@ h2.card-header { } .navigation li { - padding: 2px 0; + padding: 3px 0; } .navigation a { @@ -96,9 +105,6 @@ h2.card-header { color: var(--text-muted); } -.page { - gap: var(--panel-gap); -} .prose div[align="center"] { text-align: center; diff --git a/docs/development/tooling.md b/docs/development/tooling.md index 1fd5f3964..2d7814fd0 100644 --- a/docs/development/tooling.md +++ b/docs/development/tooling.md @@ -135,6 +135,8 @@ strip, the text, the accent, each the color or size the application draws that p as one stylesheet of variables beside the fonts the application ships, and the layout's own stylesheets name roles alone. A change to a theme or a palette therefore reaches every page the next time it is written. The fonts travel inside their stylesheet, which lets a page open from a file wherever it lies. +A page is read rather than worked in, so it spaces its cards and its prose wider than the application +does, in multiples of the application's own spacing, and its proportions follow the application. The calibration board and the documentation site ([assets](assets.md#documentation)) are written this way. A page's own files are shipped as they are read: `calibration/board/static/` holds the board's markup, diff --git a/src/sampletones_tools/calibration/board/static/style.css b/src/sampletones_tools/calibration/board/static/style.css index b94443dfb..d3d016c6f 100644 --- a/src/sampletones_tools/calibration/board/static/style.css +++ b/src/sampletones_tools/calibration/board/static/style.css @@ -5,7 +5,7 @@ .board { display: flex; flex-direction: column; - gap: var(--panel-gap); + gap: var(--card-gap); max-width: 1400px; margin: 0 auto; } @@ -13,7 +13,7 @@ .groups { display: flex; flex-direction: column; - gap: var(--panel-gap); + gap: var(--card-gap); } .status-bar p { diff --git a/src/sampletones_tools/web/static/base.css b/src/sampletones_tools/web/static/base.css index 548f2ff1d..861e5b789 100644 --- a/src/sampletones_tools/web/static/base.css +++ b/src/sampletones_tools/web/static/base.css @@ -10,6 +10,10 @@ :root { --page-gutter: 16px; + --card-gap: calc(3 * var(--panel-gap)); + --prose-inset: calc(2 * var(--card-padding-x)); + --block-gap: 1em; + --section-gap: 2em; } html { diff --git a/src/sampletones_tools/web/static/prose.css b/src/sampletones_tools/web/static/prose.css index 44fded04f..2c4b18c5b 100644 --- a/src/sampletones_tools/web/static/prose.css +++ b/src/sampletones_tools/web/static/prose.css @@ -1,12 +1,12 @@ .prose { min-width: 0; - padding: 0 var(--card-padding-x) var(--card-padding-y); + padding: 0 var(--prose-inset) var(--prose-inset); font-size: var(--size-sans-large); - line-height: 1.55; + line-height: 1.6; } .prose > :first-child { - margin-top: var(--card-padding-y); + margin-top: var(--prose-inset); } .prose > :last-child { @@ -20,7 +20,7 @@ .prose pre, .prose .table-scroll, .prose .figure { - margin: 0.7em 0; + margin: var(--block-gap) 0; } .prose ul, @@ -31,12 +31,12 @@ .prose li + li, .prose li > ul, .prose li > ol { - margin-top: 0.25em; + margin-top: 0.4em; } .prose h3, .prose h4 { - margin: 1.4em 0 0.4em; + margin: var(--section-gap) 0 0.5em; line-height: 1.3; text-wrap: balance; } @@ -94,5 +94,17 @@ .prose .table { font-size: var(--size-sans-medium); - line-height: normal; + line-height: 1.45; +} + +.prose .table th, +.prose .table td { + padding: calc(1.5 * var(--cell-padding-y)) calc(1.5 * var(--cell-padding-x)); +} + +@media (max-width: 600px) { + .prose { + padding-inline: var(--card-padding-x); + font-size: var(--size-sans-medium); + } } From 130048ecc30523db8dd245fb61ef73b72867c2a9 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 12 Oct 2026 16:48:32 +0200 Subject: [PATCH 8/8] Kept: one documentation copy at a fixed address, replaced whole when the shipped pages change --- docs/development/assets.md | 4 +- src/sampletones_application/application.py | 2 +- .../logic/documentation/shelf.py | 49 ++++---- .../interface/menus/test_documentation.py | 8 +- .../coordinators/test_documentation.py | 16 +-- .../logic/documentation/test_shelf.py | 107 +++++++++++------- 6 files changed, 104 insertions(+), 82 deletions(-) diff --git a/docs/development/assets.md b/docs/development/assets.md index 9d09420bc..4374a74fe 100644 --- a/docs/development/assets.md +++ b/docs/development/assets.md @@ -56,8 +56,8 @@ shared web layout ([tooling](tooling.md#pages-a-tool-writes)), in the applicatio The development pages, the changelog and anything else under `docs/` stay online: a link to one leads to the copy at the documentation address `pyproject.toml` names. A link to a missing document, heading or picture fails the run, so a site that builds holds no broken link. A site is sealed by a stamp written -last, a hash over every file beside it, which names the pages exactly: the application keeps its copy of -the pages under that stamp, and the self-check holds a build's pages to it. +last, a hash over every file beside it, which names the pages exactly: the application replaces its copy +of the pages when the stamp differs, and the self-check holds a build's pages to it. `make docs` writes the site into `src/sampletones_assets/documentation/`, which the history ignores, so a checkout opens its own documentation. `python -m assets.documentation -o DIR` writes it elsewhere; the diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index c8dd53152..10f7fb7ac 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -507,7 +507,7 @@ def __init__( language_manager=self.language_manager, ) self._documentation_coordinator = DocumentationCoordinator( - shelf=DocumentationShelf(site=documentation, copies=profile.documentation), + shelf=DocumentationShelf(site=documentation, directory=profile.documentation), opener=select_file_opener(), dialogs=self.dialogs, language_manager=self.language_manager, diff --git a/src/sampletones_application/logic/documentation/shelf.py b/src/sampletones_application/logic/documentation/shelf.py index ab248b4db..ea414de13 100644 --- a/src/sampletones_application/logic/documentation/shelf.py +++ b/src/sampletones_application/logic/documentation/shelf.py @@ -6,8 +6,9 @@ from sampletones_shared.documentation.site import DocumentationSite from sampletones_shared.exceptions import DocumentationUnavailableError -STAGING_PREFIX: Final[str] = "." +HIDDEN_PREFIX: Final[str] = "." STAGING_SUFFIX: Final[str] = ".staging" +RETIRED_SUFFIX: Final[str] = ".retired" class DocumentationShelf: @@ -15,25 +16,25 @@ class DocumentationShelf: The shipped pages may live only as long as a run, since a one-file bundle unpacks itself into a temporary folder; the copy outlives the run, so an open page stays readable after the - application closes. A copy is named by the site's stamp, and a site with the same stamp holds - the same pages, so each version of the documentation is copied once and the copies of other - versions are let go. + application closes. The copy keeps one address across versions, so a page the reader bookmarked + still opens after an update. It carries the stamp of the pages it was copied from, and a + different stamp in the installation replaces it whole. """ def __init__( self, *, site: DocumentationSite, - copies: Path, + directory: Path, ) -> None: self._site = site - self._copies = copies + self._copy = DocumentationSite(directory) def place(self) -> Path: - """The index of the copy of the shipped pages, copied first where it is missing. + """The index of the copy of the shipped pages, replaced first where it holds other pages. - A copy is written beside its final place and moved there whole, so a copy under a stamp's - name holds every page. + The new pages are written beside the copy and swapped in once whole, so the copy is either + the earlier pages or the new ones. Raises: DocumentationUnavailableError: If the installation carries no sealed site. @@ -43,26 +44,26 @@ def place(self) -> Path: if stamp is None or not self._site.index.is_file(): raise DocumentationUnavailableError(f"No documentation site lies in {self._site.directory}") - copy = DocumentationSite(self._copies / stamp) - if copy.read_stamp() != stamp: - self._copy(copy.directory) + if self._copy.read_stamp() != stamp: + self._replace() - self._let_others_go(stamp) - return copy.index + return self._copy.index - def _copy(self, target: Path) -> None: - staging = self._copies / f"{STAGING_PREFIX}{target.name}{STAGING_SUFFIX}" - if staging.exists(): - shutil.rmtree(staging) + def _replace(self) -> None: + target = self._copy.directory + staging = self._beside(STAGING_SUFFIX) + retired = self._beside(RETIRED_SUFFIX) + for leftover in (staging, retired): + if leftover.exists(): + shutil.rmtree(leftover) shutil.copytree(self._site.directory, staging) if target.exists(): - shutil.rmtree(target) + os.replace(target, retired) os.replace(staging, target) + shutil.rmtree(retired, ignore_errors=True) - def _let_others_go(self, stamp: str) -> None: - """Removes the copies of other versions, leaving any a running browser still holds open.""" - for entry in self._copies.iterdir(): - if entry.is_dir() and entry.name != stamp and not entry.name.startswith(STAGING_PREFIX): - shutil.rmtree(entry, ignore_errors=True) + def _beside(self, suffix: str) -> Path: + target = self._copy.directory + return target.with_name(f"{HIDDEN_PREFIX}{target.name}{suffix}") diff --git a/tests/screens/interface/menus/test_documentation.py b/tests/screens/interface/menus/test_documentation.py index 06fafeea1..93abc521c 100644 --- a/tests/screens/interface/menus/test_documentation.py +++ b/tests/screens/interface/menus/test_documentation.py @@ -7,7 +7,7 @@ from sampletones_application.categories.elements.global_ import MenuElements from sampletones_application.config.profile import UserProfile from sampletones_shared.distribution import SAMPLETONES_DOCUMENTATION_ADDRESS -from sampletones_shared.documentation.site import INDEX_FILE +from sampletones_shared.documentation.site import DocumentationSite from tests.suite.screens.seeds.documentation import SHIPPED_INDEX, ShippedDocumentation, shipped_site STILL_FRAMES: Final[int] = 20 @@ -24,10 +24,10 @@ def one_page(opened: Tuple[object, ...]) -> bool: def expect_the_copied_index(screen: Screen) -> None: """The browser was handed the index of the copy in the reader's data folder, holding the shipped page.""" [index] = screen.expect(screen.opened, one_page, description="the documentation handed to the browser") - stamp = shipped_site().read_stamp() - assert stamp is not None - assert index == UserProfile.user().documentation / stamp / INDEX_FILE + copy = DocumentationSite(UserProfile.user().documentation) + assert index == copy.index assert index.read_text(encoding="utf-8") == SHIPPED_INDEX + assert copy.read_stamp() == shipped_site().read_stamp() class TestDocumentationOpens: diff --git a/tests/unit/sampletones_application/coordinators/test_documentation.py b/tests/unit/sampletones_application/coordinators/test_documentation.py index dcaaef54a..2bbe8cb7c 100644 --- a/tests/unit/sampletones_application/coordinators/test_documentation.py +++ b/tests/unit/sampletones_application/coordinators/test_documentation.py @@ -43,12 +43,12 @@ def shipped(tmp_path: Path) -> DocumentationSite: def coordinator( site: DocumentationSite, - copies: Path, + directory: Path, opener: RecordedOpener, dialogs: MagicMock, ) -> DocumentationCoordinator: return DocumentationCoordinator( - shelf=DocumentationShelf(site=site, copies=copies), + shelf=DocumentationShelf(site=site, directory=directory), opener=opener, dialogs=dialogs, language_manager=LANGUAGE_MANAGER, @@ -60,16 +60,16 @@ class TestOpenDocumentation: def test_the_copied_index_is_opened(self, shipped: DocumentationSite, tmp_path: Path, dialogs: MagicMock) -> None: opener = RecordedOpener(takes=True) - coordinator(shipped, tmp_path / "copies", opener, dialogs).open() + coordinator(shipped, tmp_path / "documentation", opener, dialogs).open() - assert opener.opened == [tmp_path / "copies" / str(shipped.read_stamp()) / INDEX_FILE] + assert opener.opened == [tmp_path / "documentation" / INDEX_FILE] dialogs.show_info.assert_not_called() dialogs.show_error.assert_not_called() def test_an_installation_without_pages_names_the_online_copy(self, tmp_path: Path, dialogs: MagicMock) -> None: opener = RecordedOpener(takes=True) - coordinator(DocumentationSite(tmp_path / "missing"), tmp_path / "copies", opener, dialogs).open() + coordinator(DocumentationSite(tmp_path / "missing"), tmp_path / "documentation", opener, dialogs).open() assert opener.opened == [] dialogs.show_info.assert_called_once_with( @@ -86,7 +86,7 @@ def test_a_browser_that_takes_nothing_is_reported( tmp_path: Path, dialogs: MagicMock, ) -> None: - coordinator(shipped, tmp_path / "copies", RecordedOpener(takes=False), dialogs).open() + coordinator(shipped, tmp_path / "documentation", RecordedOpener(takes=False), dialogs).open() [exception, message] = dialogs.show_error.call_args.args assert isinstance(exception, FileNotOpenedError) @@ -99,11 +99,11 @@ def test_a_copy_that_cannot_be_written_is_reported( tmp_path: Path, dialogs: MagicMock, ) -> None: - blocked = tmp_path / "copies" + blocked = tmp_path / "blocked" blocked.write_text("a file where the folder belongs", encoding="utf-8") opener = RecordedOpener(takes=True) - coordinator(shipped, blocked, opener, dialogs).open() + coordinator(shipped, blocked / "documentation", opener, dialogs).open() assert opener.opened == [] assert isinstance(dialogs.show_error.call_args.args[0], OSError) diff --git a/tests/unit/sampletones_application/logic/documentation/test_shelf.py b/tests/unit/sampletones_application/logic/documentation/test_shelf.py index cb175d0fc..b38aa891b 100644 --- a/tests/unit/sampletones_application/logic/documentation/test_shelf.py +++ b/tests/unit/sampletones_application/logic/documentation/test_shelf.py @@ -2,7 +2,12 @@ import pytest -from sampletones_application.logic.documentation.shelf import STAGING_PREFIX, STAGING_SUFFIX, DocumentationShelf +from sampletones_application.logic.documentation.shelf import ( + HIDDEN_PREFIX, + RETIRED_SUFFIX, + STAGING_SUFFIX, + DocumentationShelf, +) from sampletones_shared.documentation.site import INDEX_FILE, DocumentationSite from sampletones_shared.exceptions import DocumentationUnavailableError @@ -18,90 +23,106 @@ def site(tmp_path: Path) -> DocumentationSite: @pytest.fixture -def copies(tmp_path: Path) -> Path: - return tmp_path / "copies" +def directory(tmp_path: Path) -> Path: + return tmp_path / "data" / "documentation" @pytest.fixture -def shelf(site: DocumentationSite, copies: Path) -> DocumentationShelf: - return DocumentationShelf(site=site, copies=copies) +def shelf(site: DocumentationSite, directory: Path) -> DocumentationShelf: + return DocumentationShelf(site=site, directory=directory) -def stamp_of(site: DocumentationSite) -> str: - stamp = site.read_stamp() - assert stamp is not None - return stamp +def beside(directory: Path, suffix: str) -> Path: + return directory.with_name(f"{HIDDEN_PREFIX}{directory.name}{suffix}") class TestPlace: - def test_the_pages_are_copied_under_their_stamp( + def test_the_pages_are_copied_into_the_folder( self, shelf: DocumentationShelf, site: DocumentationSite, - copies: Path, + directory: Path, ) -> None: index = shelf.place() - assert index == copies / stamp_of(site) / INDEX_FILE - assert (index.parent / "guide" / "page.html").read_text(encoding="utf-8") == "

    page

    " - assert DocumentationSite(index.parent).read_stamp() == stamp_of(site) + assert index == directory / INDEX_FILE + assert (directory / "guide" / "page.html").read_text(encoding="utf-8") == "

    page

    " + assert DocumentationSite(directory).read_stamp() == site.read_stamp() - def test_a_copy_in_place_is_read_again(self, shelf: DocumentationShelf) -> None: - index = shelf.place() - marker = index.parent / "opened.txt" + def test_a_copy_of_the_same_pages_is_read_again(self, shelf: DocumentationShelf, directory: Path) -> None: + shelf.place() + marker = directory / "opened.txt" marker.write_text("kept", encoding="utf-8") - assert shelf.place() == index + assert shelf.place() == directory / INDEX_FILE assert marker.is_file() - def test_an_unfinished_copy_is_made_again( - self, shelf: DocumentationShelf, site: DocumentationSite, copies: Path + def test_new_pages_replace_the_copy_whole( + self, + shelf: DocumentationShelf, + site: DocumentationSite, + directory: Path, ) -> None: - unfinished = copies / stamp_of(site) - unfinished.mkdir(parents=True) - (unfinished / "half.html").write_text("half", encoding="utf-8") + shelf.place() + (directory / "opened.txt").write_text("from the earlier pages", encoding="utf-8") + site.index.write_text("

    new index

    ", encoding="utf-8") + site.write_stamp() index = shelf.place() - assert index.is_file() - assert not (unfinished / "half.html").exists() + assert index.read_text(encoding="utf-8") == "

    new index

    " + assert not (directory / "opened.txt").exists() + assert DocumentationSite(directory).read_stamp() == site.read_stamp() - def test_a_stale_staging_folder_gives_way( - self, shelf: DocumentationShelf, site: DocumentationSite, copies: Path - ) -> None: - staging = copies / f"{STAGING_PREFIX}{stamp_of(site)}{STAGING_SUFFIX}" - staging.mkdir(parents=True) - (staging / "left.html").write_text("left", encoding="utf-8") + def test_a_copy_in_an_earlier_layout_is_replaced(self, shelf: DocumentationShelf, directory: Path) -> None: + earlier = directory / "0123456789abcdef" + earlier.mkdir(parents=True) + (earlier / INDEX_FILE).write_text("

    earlier

    ", encoding="utf-8") index = shelf.place() - assert not staging.exists() - assert not (index.parent / "left.html").exists() + assert index.read_text(encoding="utf-8") == "

    index

    " + assert not earlier.exists() - def test_copies_of_other_versions_are_let_go(self, shelf: DocumentationShelf, copies: Path) -> None: - older = copies / "0123456789abcdef" - older.mkdir(parents=True) + @pytest.mark.parametrize("suffix", [STAGING_SUFFIX, RETIRED_SUFFIX], ids=["staging", "retired"]) + def test_a_folder_an_interrupted_replacement_left_gives_way( + self, + shelf: DocumentationShelf, + directory: Path, + suffix: str, + ) -> None: + leftover = beside(directory, suffix) + leftover.mkdir(parents=True) + (leftover / "left.html").write_text("left", encoding="utf-8") index = shelf.place() - assert sorted(path.name for path in copies.iterdir()) == [index.parent.name] + assert sorted(path.name for path in directory.parent.iterdir()) == [directory.name] + assert not (index.parent / "left.html").exists() class TestPlaceRefuses: - def test_an_installation_without_pages(self, tmp_path: Path, copies: Path) -> None: - shelf = DocumentationShelf(site=DocumentationSite(tmp_path / "missing"), copies=copies) + def test_an_installation_without_pages(self, tmp_path: Path, directory: Path) -> None: + shelf = DocumentationShelf(site=DocumentationSite(tmp_path / "missing"), directory=directory) with pytest.raises(DocumentationUnavailableError): shelf.place() - def test_an_unsealed_site(self, site: DocumentationSite, copies: Path) -> None: + def test_an_unsealed_site(self, site: DocumentationSite, directory: Path) -> None: site.stamp_path.unlink() with pytest.raises(DocumentationUnavailableError): - DocumentationShelf(site=site, copies=copies).place() + DocumentationShelf(site=site, directory=directory).place() - def test_a_site_without_an_index(self, site: DocumentationSite, copies: Path) -> None: + def test_a_site_without_an_index(self, site: DocumentationSite, directory: Path) -> None: site.index.unlink() with pytest.raises(DocumentationUnavailableError): - DocumentationShelf(site=site, copies=copies).place() + DocumentationShelf(site=site, directory=directory).place() + + def test_a_folder_that_cannot_be_written(self, site: DocumentationSite, tmp_path: Path) -> None: + blocked = tmp_path / "blocked" + blocked.write_text("a file where the folder belongs", encoding="utf-8") + + with pytest.raises(OSError): + DocumentationShelf(site=site, directory=blocked / "documentation").place()