From 11cd62a3b9d826f1606490a095ec8fe100021bd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 18:53:37 +0000 Subject: [PATCH] Introduce LabeledOption and RangeValues named types Replace all tuple[str, str] annotations with purpose-specific names: - LabeledOption = tuple[str, str] for (value, label) pairs used in FilterChoice, FilterSelect params, _modifier_options, _find_label, and _extract_labeled. - RangeValues(min, max) NamedTuple for _parse_range return values, making the two fields self-documenting at every call site. Export LabeledOption from common.components alongside SearchSelectOption. Document the "name compound types explicitly" convention in CLAUDE.md. https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA --- CLAUDE.md | 1 + common/components/__init__.py | 2 ++ common/components/filters.py | 29 ++++++++++++++++++----------- common/components/search_select.py | 14 ++++++++++---- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 532acc0..298af56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,3 +167,4 @@ Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJAN - **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`. - **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls. - **Platform icons** are SVG snippets in `games/templates/icons/.html`. Add new ones there and reference them by slug in `Platform.icon`. +- **Name compound types explicitly** — if a `tuple`, `dict`, or other compound value is passed between functions or appears in multiple signatures, give it a named type (`TypedDict`, `NamedTuple`, or a `type` alias) rather than repeating the structural annotation. This applies even to small types used in only a few places; the name carries intent that the structure cannot. Examples: `LabeledOption = tuple[str, str]` instead of repeating `tuple[str, str]` for (value, label) pairs; `RangeValues(min, max)` instead of `tuple[str, str]` for range bounds. diff --git a/common/components/__init__.py b/common/components/__init__.py index b98d699..d48b045 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -42,6 +42,7 @@ from common.components.primitives import ( ) from common.components.search_select import ( FilterSelect, + LabeledOption, SearchSelect, SearchSelectOption, searchselect_selected, @@ -87,6 +88,7 @@ __all__ = [ "PopoverTruncated", "SearchField", "FilterSelect", + "LabeledOption", "SearchSelect", "SearchSelectOption", "searchselect_selected", diff --git a/common/components/filters.py b/common/components/filters.py index 099aa25..d652427 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -8,7 +8,7 @@ from django.utils.safestring import SafeText, mark_safe from common.components.core import Component from common.components.primitives import Label, Span -from common.components.search_select import FilterSelect +from common.components.search_select import FilterSelect, LabeledOption class FilterChoice(NamedTuple): @@ -19,11 +19,18 @@ class FilterChoice(NamedTuple): for enum fields the label is resolved from the fixed option list. """ - selected: list[tuple[str, str]] - excluded: list[tuple[str, str]] + selected: list[LabeledOption] + excluded: list[LabeledOption] modifier: str +class RangeValues(NamedTuple): + """A (min, max) string pair parsed from a range filter criterion.""" + + min: str + max: str + + _FILTER_LABEL_CLASS = "text-xs font-medium text-body uppercase tracking-wide" @@ -55,7 +62,7 @@ def _filter_parse(filter_json: str) -> dict: return {} -def _extract_labeled(items: list) -> list[tuple[str, str]]: +def _extract_labeled(items: list) -> list[LabeledOption]: """Convert a list of bare values or ``{id, label}`` dicts to ``(value, label)`` pairs.""" result = [] for item in items: @@ -84,12 +91,12 @@ def _filter_get_choice(existing: dict, field: str) -> FilterChoice: ) -def _parse_range(existing: dict, key: str) -> tuple[str, str]: - """Extract (value, value2) from a filter criterion, defaulting to ("", "").""" +def _parse_range(existing: dict, key: str) -> RangeValues: + """Extract (min, max) from a range filter criterion, defaulting to ("", "").""" field = existing.get(key, {}) if not isinstance(field, dict): - return "", "" - return str(field.get("value", "")), str(field.get("value2", "")) + return RangeValues("", "") + return RangeValues(str(field.get("value", "")), str(field.get("value2", ""))) def _parse_bool(existing: dict, key: str) -> bool: @@ -108,7 +115,7 @@ def _parse_bool(existing: dict, key: str) -> bool: _FILTER_PREFETCH = 20 -def _modifier_options(nullable: bool) -> list[tuple[str, str]]: +def _modifier_options(nullable: bool) -> list[LabeledOption]: """Pinned (Any)/(None) pseudo-options; (None) only when the field is nullable.""" options = [("NOT_NULL", "(Any)")] if nullable: @@ -618,7 +625,7 @@ def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeTe def FilterBar( filter_json: str = "", - status_options: list[tuple[str, str]] | None = None, + status_options: list[LabeledOption] | None = None, preset_list_url: str = "", preset_save_url: str = "", ) -> SafeText: @@ -717,7 +724,7 @@ def FilterBar( return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -def _find_label(options: list[tuple[str, str]], value: str) -> str: +def _find_label(options: list[LabeledOption], value: str) -> str: for v, label in options: if str(v) == str(value): return label diff --git a/common/components/search_select.py b/common/components/search_select.py index 636cf6c..ec7dab5 100644 --- a/common/components/search_select.py +++ b/common/components/search_select.py @@ -33,6 +33,12 @@ class SearchSelectOption(TypedDict): data: dict[str, str] # becomes data-* attrs on the row / pill +# A lightweight (value, label) pair used wherever only those two fields are +# needed — e.g. filter pill lists and modifier pseudo-options. The richer +# SearchSelectOption adds a ``data`` dict for extra row attributes. +LabeledOption = tuple[str, str] + + # The pills and the search box share one flex-wrap row (with padding) so the # widget reads as a single clickable field; the pills wrapper uses `contents` # so its pills/hidden inputs flow as direct participants of that row, inline @@ -394,11 +400,11 @@ def _filter_modifier_row(modifier_value: str, label: str) -> SafeText: def FilterSelect( *, field_name: str, - options: list | None = None, - included: list | None = None, - excluded: list | None = None, + options: list[LabeledOption | SearchSelectOption] | None = None, + included: list[LabeledOption | SearchSelectOption] | None = None, + excluded: list[LabeledOption | SearchSelectOption] | None = None, modifier: str = "", - modifier_options: list[tuple[str, str]] | None = None, + modifier_options: list[LabeledOption] | None = None, search_url: str = "", prefetch: int = 0, items_visible: int = 6,