From 780d9c12fe0ff7cb7ea094776c6eb3ef30a71e1c Mon Sep 17 00:00:00 2001 From: phg Date: Fri, 4 Sep 2026 14:39:02 +0200 Subject: [PATCH] Adopt mise for development workflows - Lock Python and uv versions with mise - Add unified sync, app, test, lint, format, and typecheck tasks - Update development documentation and strengthen type handling --- AGENTS.md | 22 +- CONTRIBUTING.md | 23 +- README.md | 8 +- mise.lock | 45 ++ mise.toml | 42 ++ pyproject.toml | 5 + src/hosts/core/dns.py | 5 +- src/hosts/core/import_export.py | 3 +- src/hosts/tui/add_entry_modal.py | 14 +- src/hosts/tui/app.py | 67 +-- src/hosts/tui/config_modal.py | 2 +- src/hosts/tui/delete_confirmation_modal.py | 2 +- src/hosts/tui/dns_status_widget.py | 155 ------ src/hosts/tui/filter_modal.py | 612 --------------------- src/hosts/tui/save_confirmation_modal.py | 2 +- tests/test_add_entry_modal.py | 12 +- tests/test_dns.py | 2 + tests/test_main.py | 51 +- uv.lock | 45 +- 19 files changed, 216 insertions(+), 901 deletions(-) create mode 100644 mise.lock create mode 100644 mise.toml delete mode 100644 src/hosts/tui/dns_status_widget.py delete mode 100644 src/hosts/tui/filter_modal.py diff --git a/AGENTS.md b/AGENTS.md index 21221c5..dde8956 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,18 +17,20 @@ is reachable in the current application. ## Commands ```bash -uv sync -uv run hosts -uv run pytest -uv run pytest tests/test_parser.py -uv run ruff check . -uv run ruff format --check . -uv run ruff format . +mise install +mise run sync +mise run app +mise run test +mise run test -- tests/test_parser.py +mise run lint +mise run format-check +mise run typecheck +mise run check +mise run format ``` -Use `uv run pytest ` for the narrowest relevant test during development. -Before handing off a change, run the full test suite, Ruff lint, and Ruff format -check. +Use `mise run test -- ` for the narrowest relevant test during +development. Before handing off a change, run `mise run check`. ## Code boundaries diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef67bbf..b386303 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,17 +9,18 @@ bugs or coordinate consequential changes. ## Development setup -The project requires Python 3.13 or newer and -[uv](https://docs.astral.sh/uv/): +The project uses [mise](https://mise.jdx.dev/) to install its locked Python and +uv versions and to provide its development tasks: ```bash git clone https://git.s1q.dev/phg/hosts.git cd hosts -uv sync -uv run hosts +mise install +mise run sync +mise run app ``` -`uv run hosts` starts the real application and reads `/etc/hosts`. Stay in +`mise run app` starts the real application and reads `/etc/hosts`. Stay in Read-only Mode unless you deliberately intend to exercise privileged behavior on your machine. Automated tests must use mocks and temporary files instead. @@ -80,23 +81,21 @@ Treat any test capable of touching the real system file as a defect. Run the narrowest relevant test while developing. Examples: ```bash -uv run pytest tests/test_parser.py -uv run pytest tests/test_manager.py -uv run pytest tests/test_main.py +mise run test -- tests/test_parser.py +mise run test -- tests/test_manager.py +mise run test -- tests/test_main.py ``` Before handing off any change, run the complete validation suite: ```bash -uv run pytest -uv run ruff check . -uv run ruff format --check . +mise run check ``` Apply formatting with: ```bash -uv run ruff format . +mise run format ``` Add or update tests for changed behavior. Test user-reachable workflows at the diff --git a/README.md b/README.md index a40dcec..920a31b 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,14 @@ Clone the repository for development: ```bash git clone https://git.s1q.dev/phg/hosts.git cd hosts -uv sync -uv run hosts +mise install +mise run sync +mise run app ``` +[mise](https://mise.jdx.dev/) installs the locked Python and uv versions and +provides the project's development tasks. + See [CONTRIBUTING.md](CONTRIBUTING.md) for code boundaries, safety contracts, tests, linting, and documentation expectations. Domain language lives in [CONTEXT.md](CONTEXT.md), and consequential design decisions live in diff --git a/mise.lock b/mise.lock new file mode 100644 index 0000000..4d1491f --- /dev/null +++ b/mise.lock @@ -0,0 +1,45 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +lockfile_version = 1 + +[[tools.python]] +version = "3.13.15" +backend = "core:python" +specifiers = ["3.13"] + +[tools.python."platforms.linux-arm64"] +checksum = "sha256:01ce0ce9189feaead3298abf10d4efe998c55a489b3d5d38ca4f83dda7e7977e" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260901/cpython-3.13.15+20260901-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" + +[tools.python."platforms.linux-x64"] +checksum = "sha256:8a689a077337bea6d1c4bc0b7df1d52fcaa28f5f67e50df8bf417c1e3f9d8874" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260901/cpython-3.13.15+20260901-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" + +[tools.python."platforms.macos-arm64"] +checksum = "sha256:d3904bd6a072246e07aa0bdadee9a14e80521e42a943c0848059feb16a2816dc" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260901/cpython-3.13.15+20260901-aarch64-apple-darwin-install_only_stripped.tar.gz" + +[tools.python."platforms.macos-x64"] +checksum = "sha256:f712a9143c8a5d248438ec7921a0b48d548bca4f1337d33c690d28c2d0504137" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260901/cpython-3.13.15+20260901-x86_64-apple-darwin-install_only_stripped.tar.gz" + +[[tools.uv]] +version = "0.12.9" +backend = "aqua:astral-sh/uv" +specifiers = ["0.12"] + +[tools.uv."platforms.linux-arm64"] +checksum = "sha256:7eb9bf48516448c9db6a9e436d8e747ac9c8a9cac74717160a29918249b080a6" +url = "https://github.com/astral-sh/uv/releases/download/0.12.9/uv-aarch64-unknown-linux-musl.tar.gz" + +[tools.uv."platforms.linux-x64"] +checksum = "sha256:aa4b1f8770910f7c7c543c7acc980e4270e52e70750c996acef813ea1c7c2912" +url = "https://github.com/astral-sh/uv/releases/download/0.12.9/uv-x86_64-unknown-linux-musl.tar.gz" + +[tools.uv."platforms.macos-arm64"] +checksum = "sha256:301f72afaf54060f92da7016cb0115bd077f43a9c8e39c1d8170a0bac80fd398" +url = "https://github.com/astral-sh/uv/releases/download/0.12.9/uv-aarch64-apple-darwin.tar.gz" + +[tools.uv."platforms.macos-x64"] +checksum = "sha256:e1ca175824f1056589ce9908f7631879ebc3c36535b5e63dc06510beb370b4c1" +url = "https://github.com/astral-sh/uv/releases/download/0.12.9/uv-x86_64-apple-darwin.tar.gz" diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..d77eb0a --- /dev/null +++ b/mise.toml @@ -0,0 +1,42 @@ +[tools] +python = "3.13" +uv = "0.12" + +[settings] +lockfile = true + +[tool_config] +locked = true + +[tasks.sync] +description = "Synchronize project dependencies" +run = "uv sync" + +[tasks.app] +description = "Run the hosts application" +run = "uv run hosts" + +[tasks.test] +description = "Run the test suite" +run = "uv run pytest" +raw = true + +[tasks.lint] +description = "Check code with Ruff" +run = "uv run ruff check ." + +[tasks.format] +description = "Format code with Ruff" +run = "uv run ruff format ." + +[tasks.format-check] +description = "Check formatting with Ruff" +run = "uv run ruff format --check ." + +[tasks.typecheck] +description = "Type-check the project with ty" +run = "uv run ty check" + +[tasks.check] +description = "Run the complete validation suite" +depends = ["test", "lint", "format-check", "typecheck"] diff --git a/pyproject.toml b/pyproject.toml index 1ca21b5..d49d9ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,9 +6,14 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "textual>=5.0.1", +] + +[dependency-groups] +dev = [ "pytest>=8.4.1", "pytest-asyncio>=0.21.0", "ruff>=0.12.5", + "ty>=0.0.78", ] [project.scripts] diff --git a/src/hosts/core/dns.py b/src/hosts/core/dns.py index 458bfa2..5e3e9f8 100644 --- a/src/hosts/core/dns.py +++ b/src/hosts/core/dns.py @@ -15,7 +15,6 @@ import logging logger = logging.getLogger(__name__) -@dataclass class DNSResolutionStatus(Enum): """Status of DNS resolution for an entry.""" @@ -70,6 +69,8 @@ async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution if result: # Get first result (usually IPv4) ip_address = result[0][4][0] + if not isinstance(ip_address, str): + raise OSError("DNS resolver returned a non-IP address") return DNSResolution( hostname=hostname, resolved_ip=ip_address, @@ -124,7 +125,7 @@ async def resolve_hostnames_batch( # Convert exceptions to failed resolutions resolutions = [] for i, result in enumerate(results): - if isinstance(result, Exception): + if isinstance(result, BaseException): resolutions.append( DNSResolution( hostname=hostnames[i], diff --git a/src/hosts/core/import_export.py b/src/hosts/core/import_export.py index ff3caf9..4393fa2 100644 --- a/src/hosts/core/import_export.py +++ b/src/hosts/core/import_export.py @@ -423,8 +423,9 @@ class ImportExportService: # Validate required columns required_columns = ["hostnames"] + fieldnames = reader.fieldnames or [] missing_columns = [ - col for col in required_columns if col not in reader.fieldnames + col for col in required_columns if col not in fieldnames ] if missing_columns: return ImportResult( diff --git a/src/hosts/tui/add_entry_modal.py b/src/hosts/tui/add_entry_modal.py index d626a8c..c776757 100644 --- a/src/hosts/tui/add_entry_modal.py +++ b/src/hosts/tui/add_entry_modal.py @@ -14,7 +14,7 @@ from ..core.models import HostEntry from .styles import ADD_ENTRY_MODAL_CSS -class AddEntryModal(ModalScreen): +class AddEntryModal(ModalScreen[HostEntry | None]): """ Modal screen for adding new host entries. @@ -132,7 +132,8 @@ class AddEntryModal(ModalScreen): # Reset checkbox to default (active) for IP entries active_checkbox.value = True - active_section.border_title = "Activate Entry" + if isinstance(active_section, Vertical): + active_section.border_title = "Activate Entry" # Focus IP input ip_input = self.query_one("#ip-address-input", Input) @@ -149,9 +150,10 @@ class AddEntryModal(ModalScreen): # Set checkbox to inactive for DNS entries (will be activated after resolution) active_checkbox.value = False - active_section.border_title = ( - "Activate Entry (DNS entries activate after resolution)" - ) + if isinstance(active_section, Vertical): + active_section.border_title = ( + "Activate Entry (DNS entries activate after resolution)" + ) # Focus DNS input dns_input = self.query_one("#dns-name-input", Input) @@ -172,7 +174,7 @@ class AddEntryModal(ModalScreen): # Determine entry type radio_set = self.query_one("#entry-type-radio", RadioSet) is_dns_entry = ( - radio_set.pressed_button + radio_set.pressed_button is not None and radio_set.pressed_button.id == "dns-entry-radio" ) diff --git a/src/hosts/tui/app.py b/src/hosts/tui/app.py index ed47f84..315c98c 100644 --- a/src/hosts/tui/app.py +++ b/src/hosts/tui/app.py @@ -8,6 +8,7 @@ all the handlers and provides the primary user interface. from dataclasses import dataclass from textual.app import App, ComposeResult, SuspendNotSupported +from textual.binding import Binding from textual.containers import Horizontal, Vertical from textual.widgets import ( Header, @@ -29,7 +30,6 @@ from ..core.filters import EntryFilter, FilterOptions from .config_modal import ConfigModal from .add_entry_modal import AddEntryModal from .delete_confirmation_modal import DeleteConfirmationModal -from .filter_modal import FilterModal from .custom_footer import CustomFooter from .styles import HOSTS_MANAGER_CSS from .keybindings import HOSTS_MANAGER_BINDINGS @@ -289,7 +289,7 @@ class HostsManagerApp(App): # Process keybindings and add to appropriate sections for binding in self.BINDINGS: # Skip tuple-style bindings and only process Binding objects - if not hasattr(binding, "show"): + if not isinstance(binding, Binding): continue # Only show bindings marked with show=True @@ -506,7 +506,7 @@ class HostsManagerApp(App): def action_config(self) -> None: """Show configuration modal.""" - def handle_config_result(config_changed: bool) -> None: + def handle_config_result(config_changed: bool | None) -> None: if config_changed: # Reload the table to apply new filtering self.table_handler.populate_entries_table() @@ -719,7 +719,7 @@ class HostsManagerApp(App): self.update_status("❌ Cannot delete system default entry") return - def handle_delete_confirmation(confirmed: bool) -> None: + def handle_delete_confirmation(confirmed: bool | None) -> None: if not confirmed: self.update_status("Entry deletion cancelled") return @@ -750,7 +750,7 @@ class HostsManagerApp(App): self.push_screen(DeleteConfirmationModal(entry), handle_delete_confirmation) - def action_quit(self) -> None: + async def action_quit(self) -> None: """Quit the application.""" self.navigation_handler.quit_application() @@ -987,63 +987,6 @@ class HostsManagerApp(App): self.run_worker(update_single_dns(), exclusive=False) self.update_status(f"🔄 Resolving DNS for {entry.dns_name}...") - def action_show_filters(self) -> None: - """Show advanced filtering modal.""" - - def handle_filter_result(filter_options: FilterOptions) -> None: - if filter_options is None: - # User cancelled - self.update_status("Filtering cancelled") - return - - # Apply the new filter options - self.current_filter_options = filter_options - - # Update the search term from filter if it has one - if filter_options.search_term: - self.search_term = filter_options.search_term - # Update the search input to reflect the filter search term - try: - search_input = self.query_one("#search-input", Input) - search_input.value = filter_options.search_term - except Exception: - pass # Search input not ready - else: - # Clear search term if no search in filter - self.search_term = "" - try: - search_input = self.query_one("#search-input", Input) - search_input.value = "" - except Exception: - pass - - # Refresh the table with new filtering - self.table_handler.populate_entries_table() - self.details_handler.update_entry_details() - - # Get filter statistics for status message - counts = self.entry_filter.count_filtered_entries( - self.hosts_file.entries, filter_options - ) - preset_info = ( - f" (preset: {filter_options.preset_name})" - if filter_options.preset_name - else "" - ) - self.update_status( - f"✅ Filter applied: showing {counts['filtered']} of {counts['total']} entries{preset_info}" - ) - - # Show the filter modal with current options and entries for preview - self.push_screen( - FilterModal( - initial_options=self.current_filter_options, - entries=self.hosts_file.entries, - entry_filter=self.entry_filter, - ), - handle_filter_result, - ) - def _resolve_new_dns_entry(self, entry) -> None: """Trigger DNS resolution for a newly added DNS entry.""" if not hasattr(entry, "dns_name") or not entry.dns_name: diff --git a/src/hosts/tui/config_modal.py b/src/hosts/tui/config_modal.py index ed30834..d1f00b6 100644 --- a/src/hosts/tui/config_modal.py +++ b/src/hosts/tui/config_modal.py @@ -14,7 +14,7 @@ from ..core.config import Config from .styles import CONFIG_MODAL_CSS -class ConfigModal(ModalScreen): +class ConfigModal(ModalScreen[bool]): """ Modal screen for application configuration. diff --git a/src/hosts/tui/delete_confirmation_modal.py b/src/hosts/tui/delete_confirmation_modal.py index 8efe907..dff896d 100644 --- a/src/hosts/tui/delete_confirmation_modal.py +++ b/src/hosts/tui/delete_confirmation_modal.py @@ -14,7 +14,7 @@ from ..core.models import HostEntry from .styles import DELETE_CONFIRMATION_MODAL_CSS -class DeleteConfirmationModal(ModalScreen): +class DeleteConfirmationModal(ModalScreen[bool]): """ Modal screen for confirming entry deletion. diff --git a/src/hosts/tui/dns_status_widget.py b/src/hosts/tui/dns_status_widget.py deleted file mode 100644 index 342b1c4..0000000 --- a/src/hosts/tui/dns_status_widget.py +++ /dev/null @@ -1,155 +0,0 @@ -""" -DNS status widget for displaying DNS resolution status in the TUI. - -This module provides a visual indicator widget that shows the current -DNS resolution status and allows users to toggle DNS service. -""" - -from textual.widgets import Static -from textual.reactive import reactive -from textual.containers import Horizontal -from ..core.dns import DNSService - - -class DNSStatusWidget(Static): - """ - Widget to display DNS resolution service status. - - Shows visual indicators for DNS service status and resolution progress. - """ - - # Reactive attributes - dns_enabled: reactive[bool] = reactive(False) - resolving_count: reactive[int] = reactive(0) - resolved_count: reactive[int] = reactive(0) - failed_count: reactive[int] = reactive(0) - - def __init__(self, dns_service: DNSService, **kwargs): - super().__init__(**kwargs) - self.dns_service = dns_service - self.dns_enabled = dns_service.enabled - self.update_status() - - def compose(self): - """Create the DNS status display.""" - with Horizontal(classes="dns-status-container"): - yield Static("", id="dns-status-indicator", classes="dns-indicator") - yield Static("", id="dns-status-text", classes="dns-status-text") - - def update_status(self) -> None: - """Update the DNS status display.""" - try: - indicator = self.query_one("#dns-status-indicator", Static) - text_widget = self.query_one("#dns-status-text", Static) - - if not self.dns_enabled: - indicator.update("⭕") - text_widget.update("DNS: Disabled") - indicator.remove_class("dns-active") - indicator.remove_class("dns-resolving") - indicator.add_class("dns-disabled") - elif self.resolving_count > 0: - indicator.update("🔄") - text_widget.update(f"DNS: Resolving ({self.resolving_count} pending)") - indicator.remove_class("dns-disabled") - indicator.remove_class("dns-active") - indicator.add_class("dns-resolving") - else: - indicator.update("✅") - status_parts = [] - if self.resolved_count > 0: - status_parts.append(f"{self.resolved_count} resolved") - if self.failed_count > 0: - status_parts.append(f"{self.failed_count} failed") - - if status_parts: - status_text = f"DNS: Active ({', '.join(status_parts)})" - else: - status_text = "DNS: Active" - - text_widget.update(status_text) - indicator.remove_class("dns-disabled") - indicator.remove_class("dns-resolving") - indicator.add_class("dns-active") - - except Exception: - # Widget not ready yet - pass - - def watch_dns_enabled(self, enabled: bool) -> None: - """React to DNS service enable/disable changes.""" - self.update_status() - - def watch_resolving_count(self, count: int) -> None: - """React to changes in resolving count.""" - self.update_status() - - def watch_resolved_count(self, count: int) -> None: - """React to changes in resolved count.""" - self.update_status() - - def watch_failed_count(self, count: int) -> None: - """React to changes in failed count.""" - self.update_status() - - def update_from_service(self) -> None: - """Update status from the current DNS service state.""" - self.dns_enabled = self.dns_service.enabled - - # Count DNS resolution states from the service - if hasattr(self.dns_service, "_resolution_cache"): - cache = self.dns_service._resolution_cache - resolving = sum(1 for r in cache.values() if r.status == "RESOLVING") - resolved = sum( - 1 for r in cache.values() if r.status in ["RESOLVED", "IP_MATCH"] - ) - failed = sum( - 1 - for r in cache.values() - if r.status in ["RESOLUTION_FAILED", "IP_MISMATCH"] - ) - - self.resolving_count = resolving - self.resolved_count = resolved - self.failed_count = failed - else: - self.resolving_count = 0 - self.resolved_count = 0 - self.failed_count = 0 - - def toggle_service(self) -> None: - """Toggle the DNS service on/off.""" - if self.dns_service.enabled: - self.dns_service.stop() - else: - self.dns_service.start() - - self.dns_enabled = self.dns_service.enabled - self.update_status() - - def get_status_text(self) -> str: - """Get current status as text for display purposes.""" - if not self.dns_enabled: - return "DNS Disabled" - elif self.resolving_count > 0: - return f"DNS Resolving ({self.resolving_count})" - else: - parts = [] - if self.resolved_count > 0: - parts.append(f"{self.resolved_count} resolved") - if self.failed_count > 0: - parts.append(f"{self.failed_count} failed") - - if parts: - return f"DNS Active ({', '.join(parts)})" - else: - return "DNS Active" - - def get_status_symbol(self) -> str: - """Get current status symbol.""" - if not self.dns_enabled: - return "⭕" - elif self.resolving_count > 0: - return "🔄" - else: - return "✅" diff --git a/src/hosts/tui/filter_modal.py b/src/hosts/tui/filter_modal.py deleted file mode 100644 index 22dc350..0000000 --- a/src/hosts/tui/filter_modal.py +++ /dev/null @@ -1,612 +0,0 @@ -""" -Filter modal for advanced entry filtering configuration. - -This module provides a professional modal dialog for configuring comprehensive -filtering options including status, type, resolution status, and search filtering. -""" - -from textual.app import ComposeResult -from textual.containers import Grid, Horizontal, Container -from textual.widgets import ( - Static, - Button, - Checkbox, - Input, - Select, - Label, - RadioSet, - RadioButton, - Collapsible, -) -from textual.screen import ModalScreen -from textual.reactive import reactive -from textual import on -from typing import Optional, Dict, List - -from ..core.filters import FilterOptions, EntryFilter - - -class FilterModal(ModalScreen[Optional[FilterOptions]]): - """Advanced filtering configuration modal.""" - - DEFAULT_CSS = """ - FilterModal { - align: center middle; - } - - #filter-dialog { - grid-size: 1; - grid-gutter: 1 2; - grid-rows: auto 1fr auto; - padding: 0 1; - width: 80; - height: auto; - border: thick $background 80%; - background: $surface; - max-height: 90%; - } - - #filter-header { - dock: top; - width: 1fr; - height: 3; - content-align: center middle; - text-style: bold; - background: $primary; - color: $text; - } - - #filter-content { - layout: vertical; - overflow-y: auto; - height: auto; - max-height: 70vh; - padding: 1; - } - - #filter-actions { - dock: bottom; - layout: horizontal; - width: 1fr; - height: 3; - align: center middle; - padding: 0 1; - background: $panel; - } - - .filter-section { - margin: 1 0; - padding: 1; - border: round $primary 20%; - background: $panel; - } - - .filter-section-title { - text-style: bold; - color: $primary; - margin-bottom: 1; - } - - .filter-checkboxes { - layout: vertical; - margin: 0 2; - } - - .filter-radios { - layout: vertical; - margin: 0 2; - } - - .filter-input-row { - layout: horizontal; - margin: 0 2; - height: 3; - align: center left; - } - - .filter-input-label { - width: 20; - content-align: left middle; - margin-right: 1; - } - - .filter-input { - width: 30; - } - - .preset-row { - layout: horizontal; - margin: 1 2; - height: 3; - align: center left; - } - - .preset-select { - width: 30; - margin-right: 2; - } - - Button { - margin: 0 1; - min-width: 12; - } - - Checkbox { - margin: 0 1; - } - - RadioButton { - margin: 0 1; - } - - .count-display { - text-style: italic; - color: $text-muted; - content-align: center middle; - height: 1; - margin: 1 0; - } - """ - - # Reactive properties for real-time updates - current_options: reactive[FilterOptions] = reactive(FilterOptions()) - entry_counts: reactive[Dict[str, int]] = reactive({}) - - def __init__( - self, - initial_options: Optional[FilterOptions] = None, - entries: Optional[List] = None, - entry_filter: Optional[EntryFilter] = None, - ): - """ - Initialize filter modal. - - Args: - initial_options: Current filter options to display - entries: List of entries for count preview - entry_filter: EntryFilter instance for applying filters - """ - super().__init__() - self.current_options = initial_options or FilterOptions() - self.entries = entries or [] - self.entry_filter = entry_filter or EntryFilter() - self.entry_counts = self._calculate_counts() - - def compose(self) -> ComposeResult: - """Compose the filter modal interface.""" - with Grid(id="filter-dialog"): - yield Static("Advanced Filtering", id="filter-header") - - with Container(id="filter-content"): - # Filter presets section - with Collapsible(title="Filter Presets", collapsed=False): - with Container(classes="filter-section"): - with Horizontal(classes="preset-row"): - yield Label("Preset:", classes="filter-input-label") - yield Select( - [ - (name, name) - for name in self.entry_filter.get_preset_names() - ], - value=self.current_options.preset_name, - id="preset-select", - classes="preset-select", - ) - yield Button("Load", id="load-preset", variant="primary") - yield Button("Save", id="save-preset") - yield Button("Delete", id="delete-preset", variant="error") - - # Status filtering section - with Collapsible(title="Status Filtering", collapsed=False): - with Container(classes="filter-section"): - yield Static("Status Filtering", classes="filter-section-title") - with RadioSet(id="status-filter-type"): - yield RadioButton("Show All", value="all", id="status-all") - yield RadioButton( - "Active Only", value="active", id="status-active" - ) - yield RadioButton( - "Inactive Only", value="inactive", id="status-inactive" - ) - yield RadioButton( - "Custom", value="custom", id="status-custom" - ) - - with Container( - classes="filter-checkboxes", id="status-custom-options" - ): - yield Checkbox( - "Show Active Entries", value=True, id="show-active" - ) - yield Checkbox( - "Show Inactive Entries", value=True, id="show-inactive" - ) - - # DNS type filtering section - with Collapsible(title="Entry Type Filtering", collapsed=False): - with Container(classes="filter-section"): - yield Static( - "Entry Type Filtering", classes="filter-section-title" - ) - with RadioSet(id="type-filter-type"): - yield RadioButton("Show All", value="all", id="type-all") - yield RadioButton( - "DNS Entries Only", value="dns", id="type-dns" - ) - yield RadioButton( - "IP Entries Only", value="ip", id="type-ip" - ) - yield RadioButton( - "Custom", value="custom", id="type-custom" - ) - - with Container( - classes="filter-checkboxes", id="type-custom-options" - ): - yield Checkbox( - "Show DNS Entries", value=True, id="show-dns" - ) - yield Checkbox("Show IP Entries", value=True, id="show-ip") - - # DNS resolution status filtering section - with Collapsible(title="Resolution Status Filtering", collapsed=False): - with Container(classes="filter-section"): - yield Static( - "Resolution Status Filtering", - classes="filter-section-title", - ) - with RadioSet(id="resolution-filter-type"): - yield RadioButton( - "Show All", value="all", id="resolution-all" - ) - yield RadioButton( - "Resolved Only", - value="resolved", - id="resolution-resolved", - ) - yield RadioButton( - "Mismatches Only", - value="mismatch", - id="resolution-mismatch", - ) - yield RadioButton( - "Custom", value="custom", id="resolution-custom" - ) - - with Container( - classes="filter-checkboxes", id="resolution-custom-options" - ): - yield Checkbox( - "Show Resolved", value=True, id="show-resolved" - ) - yield Checkbox( - "Show Unresolved", value=True, id="show-unresolved" - ) - yield Checkbox( - "Show Resolving", value=True, id="show-resolving" - ) - yield Checkbox("Show Failed", value=True, id="show-failed") - yield Checkbox( - "Show Mismatched", value=True, id="show-mismatched" - ) - - # Search filtering section - with Collapsible(title="Search Filtering", collapsed=True): - with Container(classes="filter-section"): - yield Static("Search Filtering", classes="filter-section-title") - - with Horizontal(classes="filter-input-row"): - yield Label("Search term:", classes="filter-input-label") - yield Input( - placeholder="Enter search term...", - value=self.current_options.search_term or "", - id="search-term", - classes="filter-input", - ) - - with Container(classes="filter-checkboxes"): - yield Checkbox( - "Search in hostnames", value=True, id="search-hostnames" - ) - yield Checkbox( - "Search in comments", value=True, id="search-comments" - ) - yield Checkbox( - "Search in IP addresses", value=True, id="search-ips" - ) - yield Checkbox( - "Case sensitive", - value=False, - id="search-case-sensitive", - ) - - # Entry count display - yield Static("", id="count-display", classes="count-display") - - with Horizontal(id="filter-actions"): - yield Button("Apply", id="apply", variant="primary") - yield Button("Reset", id="reset") - yield Button("Cancel", id="cancel") - - def on_mount(self) -> None: - """Initialize the modal with current options.""" - self._update_ui_from_options() - self._update_count_display() - - def _update_ui_from_options(self) -> None: - """Update UI controls to reflect current options.""" - options = self.current_options - - # Status filtering - if options.active_only: - self.query_one("#status-active", RadioButton).value = True - elif options.inactive_only: - self.query_one("#status-inactive", RadioButton).value = True - elif options.show_active and options.show_inactive: - self.query_one("#status-all", RadioButton).value = True - else: - self.query_one("#status-custom", RadioButton).value = True - - self.query_one("#show-active", Checkbox).value = options.show_active - self.query_one("#show-inactive", Checkbox).value = options.show_inactive - - # Type filtering - if options.dns_only: - self.query_one("#type-dns", RadioButton).value = True - elif options.ip_only: - self.query_one("#type-ip", RadioButton).value = True - elif options.show_dns_entries and options.show_ip_entries: - self.query_one("#type-all", RadioButton).value = True - else: - self.query_one("#type-custom", RadioButton).value = True - - self.query_one("#show-dns", Checkbox).value = options.show_dns_entries - self.query_one("#show-ip", Checkbox).value = options.show_ip_entries - - # Resolution status filtering - if options.resolved_only: - self.query_one("#resolution-resolved", RadioButton).value = True - elif options.mismatch_only: - self.query_one("#resolution-mismatch", RadioButton).value = True - elif ( - options.show_resolved - and options.show_unresolved - and options.show_resolving - and options.show_failed - and options.show_mismatched - ): - self.query_one("#resolution-all", RadioButton).value = True - else: - self.query_one("#resolution-custom", RadioButton).value = True - - self.query_one("#show-resolved", Checkbox).value = options.show_resolved - self.query_one("#show-unresolved", Checkbox).value = options.show_unresolved - self.query_one("#show-resolving", Checkbox).value = options.show_resolving - self.query_one("#show-failed", Checkbox).value = options.show_failed - self.query_one("#show-mismatched", Checkbox).value = options.show_mismatched - - # Search filtering - if options.search_term: - self.query_one("#search-term", Input).value = options.search_term - self.query_one( - "#search-hostnames", Checkbox - ).value = options.search_in_hostnames - self.query_one("#search-comments", Checkbox).value = options.search_in_comments - self.query_one("#search-ips", Checkbox).value = options.search_in_ips - self.query_one( - "#search-case-sensitive", Checkbox - ).value = options.case_sensitive - - self._update_custom_options_visibility() - - def _update_custom_options_visibility(self) -> None: - """Show/hide custom option containers based on radio selections.""" - # Status custom options - status_custom = self.query_one("#status-custom", RadioButton).value - status_container = self.query_one("#status-custom-options") - status_container.display = status_custom - - # Type custom options - type_custom = self.query_one("#type-custom", RadioButton).value - type_container = self.query_one("#type-custom-options") - type_container.display = type_custom - - # Resolution custom options - resolution_custom = self.query_one("#resolution-custom", RadioButton).value - resolution_container = self.query_one("#resolution-custom-options") - resolution_container.display = resolution_custom - - def _calculate_counts(self) -> Dict[str, int]: - """Calculate entry counts for current filter options.""" - if not self.entries: - return {} - return self.entry_filter.count_filtered_entries( - self.entries, self.current_options - ) - - def _update_count_display(self) -> None: - """Update the count display with current filter results.""" - counts = self._calculate_counts() - if counts: - count_text = ( - f"Showing {counts['filtered']} of {counts['total']} entries " - f"({counts['active']} active, {counts['inactive']} inactive)" - ) - else: - count_text = "No entries to filter" - - self.query_one("#count-display", Static).update(count_text) - - def _get_current_options_from_ui(self) -> FilterOptions: - """Extract current filter options from UI controls.""" - # Status filtering - status_type = self.query_one("#status-filter-type", RadioSet).pressed_button - if status_type and status_type.id == "status-active": - show_active, show_inactive = True, False - active_only, inactive_only = True, False - elif status_type and status_type.id == "status-inactive": - show_active, show_inactive = False, True - active_only, inactive_only = False, True - elif status_type and status_type.id == "status-all": - show_active, show_inactive = True, True - active_only, inactive_only = False, False - else: # custom - show_active = self.query_one("#show-active", Checkbox).value - show_inactive = self.query_one("#show-inactive", Checkbox).value - active_only, inactive_only = False, False - - # Type filtering - type_type = self.query_one("#type-filter-type", RadioSet).pressed_button - if type_type and type_type.id == "type-dns": - show_dns_entries, show_ip_entries = True, False - dns_only, ip_only = True, False - elif type_type and type_type.id == "type-ip": - show_dns_entries, show_ip_entries = False, True - dns_only, ip_only = False, True - elif type_type and type_type.id == "type-all": - show_dns_entries, show_ip_entries = True, True - dns_only, ip_only = False, False - else: # custom - show_dns_entries = self.query_one("#show-dns", Checkbox).value - show_ip_entries = self.query_one("#show-ip", Checkbox).value - dns_only, ip_only = False, False - - # Resolution status filtering - resolution_type = self.query_one( - "#resolution-filter-type", RadioSet - ).pressed_button - if resolution_type and resolution_type.id == "resolution-resolved": - resolved_only, mismatch_only = True, False - ( - show_resolved, - show_unresolved, - show_resolving, - show_failed, - show_mismatched, - ) = True, False, False, False, False - elif resolution_type and resolution_type.id == "resolution-mismatch": - resolved_only, mismatch_only = False, True - ( - show_resolved, - show_unresolved, - show_resolving, - show_failed, - show_mismatched, - ) = False, False, False, False, True - elif resolution_type and resolution_type.id == "resolution-all": - resolved_only, mismatch_only = False, False - ( - show_resolved, - show_unresolved, - show_resolving, - show_failed, - show_mismatched, - ) = True, True, True, True, True - else: # custom - resolved_only, mismatch_only = False, False - show_resolved = self.query_one("#show-resolved", Checkbox).value - show_unresolved = self.query_one("#show-unresolved", Checkbox).value - show_resolving = self.query_one("#show-resolving", Checkbox).value - show_failed = self.query_one("#show-failed", Checkbox).value - show_mismatched = self.query_one("#show-mismatched", Checkbox).value - - # Search filtering - search_term = self.query_one("#search-term", Input).value or None - search_hostnames = self.query_one("#search-hostnames", Checkbox).value - search_comments = self.query_one("#search-comments", Checkbox).value - search_ips = self.query_one("#search-ips", Checkbox).value - case_sensitive = self.query_one("#search-case-sensitive", Checkbox).value - - return FilterOptions( - show_active=show_active, - show_inactive=show_inactive, - active_only=active_only, - inactive_only=inactive_only, - show_dns_entries=show_dns_entries, - show_ip_entries=show_ip_entries, - dns_only=dns_only, - ip_only=ip_only, - show_resolved=show_resolved, - show_unresolved=show_unresolved, - show_resolving=show_resolving, - show_failed=show_failed, - show_mismatched=show_mismatched, - mismatch_only=mismatch_only, - resolved_only=resolved_only, - search_term=search_term, - search_in_hostnames=search_hostnames, - search_in_comments=search_comments, - search_in_ips=search_ips, - case_sensitive=case_sensitive, - ) - - @on(RadioSet.Changed) - def on_radio_changed(self, event: RadioSet.Changed) -> None: - """Handle radio button changes.""" - self._update_custom_options_visibility() - self.current_options = self._get_current_options_from_ui() - self._update_count_display() - - @on(Checkbox.Changed) - @on(Input.Changed) - def on_input_changed(self) -> None: - """Handle input changes for real-time preview.""" - self.current_options = self._get_current_options_from_ui() - self._update_count_display() - - @on(Button.Pressed, "#apply") - def on_apply_pressed(self) -> None: - """Handle apply button press.""" - self.dismiss(self._get_current_options_from_ui()) - - @on(Button.Pressed, "#cancel") - def on_cancel_pressed(self) -> None: - """Handle cancel button press.""" - self.dismiss(None) - - @on(Button.Pressed, "#reset") - def on_reset_pressed(self) -> None: - """Handle reset button press.""" - self.current_options = FilterOptions() - self._update_ui_from_options() - self._update_count_display() - - @on(Button.Pressed, "#load-preset") - def on_load_preset_pressed(self) -> None: - """Handle load preset button press.""" - preset_select = self.query_one("#preset-select", Select) - if preset_select.value != Select.BLANK: - preset_options = self.entry_filter.load_preset(str(preset_select.value)) - if preset_options: - self.current_options = preset_options - self._update_ui_from_options() - self._update_count_display() - - @on(Button.Pressed, "#save-preset") - def on_save_preset_pressed(self) -> None: - """Handle save preset button press.""" - # TODO: Implement preset name input dialog - # For now, just save with a generic name - current_options = self._get_current_options_from_ui() - preset_name = f"Custom Preset {len(self.entry_filter.presets) + 1}" - self.entry_filter.save_preset(preset_name, current_options) - - # Update preset select with new preset - preset_select = self.query_one("#preset-select", Select) - preset_select.set_options( - [(name, name) for name in self.entry_filter.get_preset_names()] - ) - preset_select.value = preset_name - - @on(Button.Pressed, "#delete-preset") - def on_delete_preset_pressed(self) -> None: - """Handle delete preset button press.""" - preset_select = self.query_one("#preset-select", Select) - if preset_select.value != Select.BLANK: - preset_name = str(preset_select.value) - if self.entry_filter.delete_preset(preset_name): - # Update preset select options - preset_select.set_options( - [(name, name) for name in self.entry_filter.get_preset_names()] - ) - preset_select.value = Select.BLANK diff --git a/src/hosts/tui/save_confirmation_modal.py b/src/hosts/tui/save_confirmation_modal.py index 8b9f1cf..c7c0b14 100644 --- a/src/hosts/tui/save_confirmation_modal.py +++ b/src/hosts/tui/save_confirmation_modal.py @@ -13,7 +13,7 @@ from textual.binding import Binding from .styles import SAVE_CONFIRMATION_MODAL_CSS -class SaveConfirmationModal(ModalScreen): +class SaveConfirmationModal(ModalScreen[str]): """ Modal screen for save confirmation when exiting edit entry mode. diff --git a/tests/test_add_entry_modal.py b/tests/test_add_entry_modal.py index 8cf7063..63c901e 100644 --- a/tests/test_add_entry_modal.py +++ b/tests/test_add_entry_modal.py @@ -6,6 +6,7 @@ DNS name entries, validation, and mutual exclusion logic. """ import pytest +from typing import cast from unittest.mock import Mock from textual.widgets import Input, Checkbox, RadioSet, Static @@ -177,10 +178,7 @@ class TestAddEntryModalDNSSupport: self.modal.query_one = Mock(side_effect=Exception("Widget not found")) # This should not raise an exception - try: - self.modal._show_error("dns-error", "Test error message") - except Exception: - pytest.fail("_show_error should handle missing widgets gracefully") + self.modal._show_error("dns-error", "Test error message") class TestAddEntryModalRadioButtonLogic: @@ -222,7 +220,7 @@ class TestAddEntryModalRadioButtonLogic: event = MockEvent() # Call the event handler - self.modal.on_radio_set_changed(event) + self.modal.on_radio_set_changed(cast(RadioSet.Changed, event)) # Verify IP section is shown and DNS section is hidden mock_ip_section.remove_class.assert_called_with("hidden") @@ -261,7 +259,7 @@ class TestAddEntryModalRadioButtonLogic: event = MockEvent() # Call the event handler - self.modal.on_radio_set_changed(event) + self.modal.on_radio_set_changed(cast(RadioSet.Changed, event)) # Verify DNS section is shown and IP section is hidden mock_ip_section.add_class.assert_called_with("hidden") @@ -324,7 +322,7 @@ class TestAddEntryModalSaveLogic: # Verify validation was called self.modal._validate_input.assert_called_once_with( - "192.168.1.1", "", "example.com, www.example.com", None + "192.168.1.1", "", "example.com, www.example.com", False ) # Verify modal was dismissed with a HostEntry diff --git a/tests/test_dns.py b/tests/test_dns.py index b471ac9..9c1a3d8 100644 --- a/tests/test_dns.py +++ b/tests/test_dns.py @@ -125,6 +125,7 @@ class TestResolveHostname: assert resolution.hostname == "slow.example" assert resolution.resolved_ip is None assert resolution.status == DNSResolutionStatus.RESOLUTION_FAILED + assert resolution.error_message is not None assert "Timeout after 1.0s" in resolution.error_message assert resolution.is_success() is False @@ -256,6 +257,7 @@ class TestResolveHostnamesBatch: assert resolutions[0].is_success() is True assert resolutions[1].hostname == "error.example" assert resolutions[1].is_success() is False + assert resolutions[1].error_message is not None assert "Network error" in resolutions[1].error_message diff --git a/tests/test_main.py b/tests/test_main.py index bda5b49..27e0f54 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,6 +6,7 @@ validating application behavior, navigation, and user interactions. """ from datetime import datetime +from typing import Any, cast from unittest.mock import AsyncMock, Mock, patch import pytest @@ -192,7 +193,7 @@ class TestHostsManagerApp: return mock_active_checkbox return Mock() - app.query_one = mock_query_one + cast(Any, app).query_one = mock_query_one # Add test entry app.hosts_file = HostsFile() @@ -248,7 +249,7 @@ class TestHostsManagerApp: return mock_active_checkbox return Mock() - app.query_one = mock_query_one + cast(Any, app).query_one = mock_query_one app.hosts_file = HostsFile() app.update_entry_details() @@ -327,7 +328,7 @@ class TestHostsManagerApp: return mock_footer return Mock() - app.query_one = mock_query_one + cast(Any, app).query_one = mock_query_one # Add test hosts_file for footer status generation app.hosts_file = HostsFile() @@ -837,7 +838,7 @@ class TestHostsManagerApp: return mock_dns_section return Mock() - app.query_one = mock_query_one + cast(Any, app).query_one = mock_query_one app.edit_handler.update_field_visibility(show_ip=True, show_dns=False) @@ -867,7 +868,7 @@ class TestHostsManagerApp: return mock_dns_section return Mock() - app.query_one = mock_query_one + cast(Any, app).query_one = mock_query_one app.edit_handler.update_field_visibility(show_ip=False, show_dns=True) @@ -908,7 +909,7 @@ class TestHostsManagerApp: return mock_dns_radio return Mock() - app.query_one = mock_query_one + cast(Any, app).query_one = mock_query_one app.edit_handler.handle_entry_type_change = Mock() # Test that the method can be called without errors @@ -962,7 +963,7 @@ class TestHostsManagerApp: return mock_dns_input return Mock() - app.query_one = mock_query_one + cast(Any, app).query_one = mock_query_one app.edit_handler.handle_entry_type_change = Mock() # Mock the set_timer method to avoid event loop issues in tests @@ -1017,7 +1018,7 @@ class TestHostsManagerApp: return mock_active_checkbox return Mock() - app.query_one = mock_query_one + cast(Any, app).query_one = mock_query_one # Add test entry app.hosts_file = HostsFile() @@ -1156,10 +1157,12 @@ class TestHostsManagerApp: assert app.hosts_file.entries[0].is_active is False assert app.manager.can_undo() assert not app.manager.can_redo() - app.table_handler.populate_entries_table.assert_called_once() - app.table_handler.move_cursor_to_entry_index.assert_called_once_with(0) - app.details_handler.update_entry_details.assert_called_once() - app.update_status.assert_called_once_with( + cast(Mock, app.table_handler.populate_entries_table).assert_called_once() + cast( + Mock, app.table_handler.move_cursor_to_entry_index + ).assert_called_once_with(0) + cast(Mock, app.details_handler.update_entry_details).assert_called_once() + cast(Mock, app.update_status).assert_called_once_with( "❌ Undo save failed; previous state restored: Permission denied" ) @@ -1204,10 +1207,12 @@ class TestHostsManagerApp: assert app.hosts_file.entries[0].is_active is True assert not app.manager.can_undo() assert app.manager.can_redo() - app.table_handler.populate_entries_table.assert_called_once() - app.table_handler.move_cursor_to_entry_index.assert_called_once_with(0) - app.details_handler.update_entry_details.assert_called_once() - app.update_status.assert_called_once_with( + cast(Mock, app.table_handler.populate_entries_table).assert_called_once() + cast( + Mock, app.table_handler.move_cursor_to_entry_index + ).assert_called_once_with(0) + cast(Mock, app.details_handler.update_entry_details).assert_called_once() + cast(Mock, app.update_status).assert_called_once_with( "❌ Redo save failed; previous state restored: Permission denied" ) @@ -1234,7 +1239,7 @@ class TestHostsManagerApp: assert app.selected_entry_index == 0 assert not app.manager.can_undo() assert app.manager.can_redo() - app.update_status.assert_called_once_with( + cast(Mock, app.update_status).assert_called_once_with( "❌ Toggle save failed; previous state restored: Permission denied" ) @@ -1261,7 +1266,7 @@ class TestHostsManagerApp: ] assert app.selected_entry_index == 1 assert not app.manager.can_undo() - app.update_status.assert_called_once_with( + cast(Mock, app.update_status).assert_called_once_with( "❌ Move save failed; previous state restored: Permission denied" ) @@ -1287,7 +1292,7 @@ class TestHostsManagerApp: ] assert app.selected_entry_index == 0 assert not app.manager.can_undo() - app.update_status.assert_called_once_with( + cast(Mock, app.update_status).assert_called_once_with( "❌ Add save failed; previous state restored: Permission denied" ) @@ -1378,7 +1383,7 @@ class TestHostsManagerApp: ] assert app.selected_entry_index == 1 assert not app.manager.can_undo() - app.update_status.assert_called_once_with( + cast(Mock, app.update_status).assert_called_once_with( "❌ Delete save failed; previous state restored: Permission denied" ) @@ -1503,7 +1508,7 @@ class TestHostsManagerApp: assert entry.last_resolved == original_time assert entry.dns_resolution_status == "match" assert app.selected_entry_index == 0 - app.update_status.assert_any_call( + cast(Mock, app.update_status).assert_any_call( "❌ DNS refresh save failed; previous state restored: Permission denied" ) @@ -1549,7 +1554,7 @@ class TestHostsManagerApp: assert entry.resolved_ip == "192.0.2.1" assert entry.last_resolved == original_time assert entry.dns_resolution_status == "match" - app.update_status.assert_any_call( + cast(Mock, app.update_status).assert_any_call( "❌ DNS refresh save failed; previous state restored: Permission denied" ) @@ -1638,7 +1643,7 @@ class TestHostsManagerApp: assert restored.dns_resolution_status is None assert restored.is_active is False assert app.manager.can_undo() - app.update_status.assert_any_call( + cast(Mock, app.update_status).assert_any_call( "❌ DNS activation save failed; previous state restored: Permission denied" ) diff --git a/uv.lock b/uv.lock index eaf1f52..c15779e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" [[package]] @@ -16,18 +16,26 @@ name = "hosts" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, { name = "textual" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "ty" }, +] + [package.metadata] -requires-dist = [ +requires-dist = [{ name = "textual", specifier = ">=5.0.1" }] + +[package.metadata.requires-dev] +dev = [ { name = "pytest", specifier = ">=8.4.1" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, { name = "ruff", specifier = ">=0.12.5" }, - { name = "textual", specifier = ">=5.0.1" }, + { name = "ty", specifier = ">=0.0.78" }, ] [[package]] @@ -210,6 +218,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/94/976d89db23efed9f3114403faf3f767ec707bfca469a93d0fb715cd352fa/textual-5.0.1-py3-none-any.whl", hash = "sha256:816eab21d22a702b3858ee23615abccaf157c05d386e82968000084c3c2c26aa", size = 699674, upload-time = "2025-07-25T19:50:57.686Z" }, ] +[[package]] +name = "ty" +version = "0.0.78" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/c7/2ba0861384c5b5097ac354383abb98188112cb330208c21d5197e98a29e5/ty-0.0.78.tar.gz", hash = "sha256:770b45854f85fa11595208f08c0f28df80943164d10a2832d86be6ac29f135b2", size = 7050609, upload-time = "2026-09-02T22:41:33.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/ed/f34cfc06a9ba72979df219e48dae1a0b6a6c74a340763ccd30a547edf913/ty-0.0.78-py3-none-linux_armv6l.whl", hash = "sha256:122700b98f9d45785c1ce91a9418154562e7f64f4bf34679f6f7b38c5b97453f", size = 13304624, upload-time = "2026-09-02T22:40:56.649Z" }, + { url = "https://files.pythonhosted.org/packages/1c/28/5576e2a08b57676d9b2a736d528f077a6c6e32c3d1de2d75dbbe28346966/ty-0.0.78-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cbe7e3709ccf29ef3d9f58f5914cc30ec36fb647008a5a4ff8483abe401bfe8d", size = 12928383, upload-time = "2026-09-02T22:40:59.162Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/027d49c3da5235e634da3d3fc52c4874ec89f50830388c9c259f8fb71e0e/ty-0.0.78-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c3528897b3ab9d3589561bc2b5e61a4d5d686de527a4400bb09a439b922a6c70", size = 12730058, upload-time = "2026-09-02T22:41:01.235Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bc/6bf8ffefd8063730a70ae889cf85def72bc155fd1453135ebf8a09c2f1c2/ty-0.0.78-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8bfba4c44a06484093f527b8ef43a317abcf91395b49396170266629eedf74aa", size = 12813321, upload-time = "2026-09-02T22:41:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c0/b3cdf26f82108908d92fdb7ddb741019c446ceb666cf204d4dbf611bde33/ty-0.0.78-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f903c06fdee17baf8373173039ab28f1bce28e66b1c734929a5f50b37eb54d9", size = 13072219, upload-time = "2026-09-02T22:41:05.225Z" }, + { url = "https://files.pythonhosted.org/packages/4d/36/16bfb0abdd178dae11dbc4b57b9a86c2b62642a18c1103fd2c2930aa5879/ty-0.0.78-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd83e5fe3f07291d1bd4e591f8d2607c204f3eab53bcb1b8060c40e876e1aa61", size = 13903958, upload-time = "2026-09-02T22:41:07.333Z" }, + { url = "https://files.pythonhosted.org/packages/0f/0c/74d3b1f0344b13156c719dd68a7edf7e48c2051718c9f326ba3cbf45ea53/ty-0.0.78-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:325285377319cf7168a2b8b771ae5027f411530e3c7eab1faf4583cdd72fd7c9", size = 14355581, upload-time = "2026-09-02T22:41:09.56Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d7/7ca0359e1e61b15b8b02328c8d120db3c507876b9b7c6bd9f26935353e0e/ty-0.0.78-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b7846a404da6492697b524a769755ee9bee157d67674063ecd9c5f69dc52ff", size = 14044749, upload-time = "2026-09-02T22:41:11.678Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6a/731f16ff42c5fc96e742c2f4e0a6915356bae114ae02ae4af6f33502175f/ty-0.0.78-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:169d3b9d134c0b48fe1af8a844142d374655552054a9d6c15a4b6e51bb0382ea", size = 13391647, upload-time = "2026-09-02T22:41:13.928Z" }, + { url = "https://files.pythonhosted.org/packages/0c/af/250cc29daf310e837188509ea4d78460c495d8a74c4fb84b4152d9ef2d4f/ty-0.0.78-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6108cb3b2d28dac5981d4e25008a0a5547d8c877a67f6da9e8c38e7e946be44f", size = 13945020, upload-time = "2026-09-02T22:41:15.968Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/96aab1dee4535e66554e8dc7657c69f6c61c181d8e70b9c3527908e93ef4/ty-0.0.78-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:589ad03608d9d2975ef4b23c41c4f847e325bcc7686f51d4c66066b8dbc18a2a", size = 12851149, upload-time = "2026-09-02T22:41:18.06Z" }, + { url = "https://files.pythonhosted.org/packages/85/b0/53d8fe9a847534ef5fe2165c7d5292cb853b29dfd7011cbfb1cdb90a8012/ty-0.0.78-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b3fa0786edc1af06030f872d83499c0cea7c261dbbb651e0aee8306bf2c8a869", size = 13091464, upload-time = "2026-09-02T22:41:20.227Z" }, + { url = "https://files.pythonhosted.org/packages/21/65/94a4e5a02de559f6c6c0ee7a14b88660b4eee058ec6fec5c0ff6ecfbf5cc/ty-0.0.78-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92c5639befc577578c8abd4e5a7fccf98d828db98b09e8d4dd81605dc0e54aef", size = 13389389, upload-time = "2026-09-02T22:41:22.27Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/d0c7fe6be64ca5a15100c4b1f0e39c19660d53bc544f609fa117b346e661/ty-0.0.78-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0dce70bc51652b2775debd1d1e422fb0179f5207c73deb4d5d0aca37e5f61695", size = 13691928, upload-time = "2026-09-02T22:41:24.292Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5d/36081205611ba3fa802efc4ad63e4b1e949bda2f7bb37b34ac9bb6c00db0/ty-0.0.78-py3-none-win32.whl", hash = "sha256:1c80976ca9185d7a9d1baab1fb57240331b8dac58d3b11e46200284086a6de8d", size = 12647346, upload-time = "2026-09-02T22:41:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/f1/89/b925fe1ea1bc56fc7f11d2496e29072fdd2ceb7b389884fc7b2d07cf0c41/ty-0.0.78-py3-none-win_amd64.whl", hash = "sha256:32e82b704471eab34f67b51c151660ca8a00815977b28278905d76fba54f7415", size = 13241810, upload-time = "2026-09-02T22:41:28.857Z" }, + { url = "https://files.pythonhosted.org/packages/91/f1/090ef7b52355bcedfbfbff6ce70fa81ba5bd5f6ed3f8d99ae05e6f2fe75b/ty-0.0.78-py3-none-win_arm64.whl", hash = "sha256:3a14d641a3c04fa9a80f2a46be1531d915f60d4fb79d4b894627bbe46bb35d64", size = 13077433, upload-time = "2026-09-02T22:41:31.525Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.1"