diff --git a/pyproject.toml b/pyproject.toml index dead6ec..db58116 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,3 +15,9 @@ dependencies = [ [tool.hatch.build.targets.wheel] packages = ["src/gantry"] + +[dependency-groups] +dev = [ + "pytest>=9.0.3", + "pytest-asyncio>=1.3.0", +] diff --git a/src/gantry/screens.py b/src/gantry/screens.py index aa2b1a9..ba9abb4 100644 --- a/src/gantry/screens.py +++ b/src/gantry/screens.py @@ -4,7 +4,7 @@ from typing import Any, Dict, List, Optional from textual.screen import Screen, ModalScreen from textual.containers import Container, Vertical, Horizontal, ScrollableContainer, VerticalScroll -from textual.widgets import Label, Static, Button, OptionList, Input, TextArea, ListView, ListItem +from textual.widgets import Label, Static, Button, OptionList, Input, TextArea from textual.widgets.option_list import Option from textual.widget import Widget from textual.binding import Binding @@ -14,7 +14,7 @@ import json from gantry import k8s, state -from gantry.widgets import ResourceTable, SearchInput, StatusBar, KeybindingsBar +from gantry.widgets import ResourceTable, SearchInput, StatusBar, KeybindingsBar, ResourceSidebar logger = logging.getLogger(__name__) @@ -214,23 +214,6 @@ class ClusterScreen(Screen): width: 100%; } - #resource-type-sidebar { - width: 20; - height: 100%; - border-right: solid $accent; - background: $panel; - padding: 1 0; - } - - #resource-type-sidebar > ListItem { - padding: 0 1; - height: 1; - } - - #resource-type-sidebar > ListItem > Label { - color: $text; - width: 100%; - } #content-area { height: 100%; @@ -285,7 +268,39 @@ class ClusterScreen(Screen): } """ - _RESOURCE_TYPES = ["Pods", "Services", "Deployments", "ConfigMaps"] + _FETCH_FNS = { + "Pods": k8s.list_pods, + "Services": k8s.list_services, + "Deployments": k8s.list_deployments, + "Config Maps": k8s.list_configmaps, + } + + _COLUMN_DEFS = { + "Pods": { + "default": (["Name", "Status", "Ready", "Restarts"], ["name", "status", "ready", "restarts"]), + "all": (["Name", "Namespace", "Status", "Ready", "Restarts"], ["name", "namespace", "status", "ready", "restarts"]), + }, + "Services": { + "default": (["Name", "Type", "Cluster IP"], ["name", "type", "cluster_ip"]), + "all": (["Name", "Namespace", "Type", "Cluster IP"], ["name", "namespace", "type", "cluster_ip"]), + }, + "Deployments": { + "default": (["Name", "Replicas", "Ready", "Available"], ["name", "replicas", "ready_replicas", "available_replicas"]), + "all": (["Name", "Namespace", "Replicas", "Ready", "Available"], ["name", "namespace", "replicas", "ready_replicas", "available_replicas"]), + }, + "Config Maps": { + "default": (["Name", "Keys"], ["name", "key_count"]), + "all": (["Name", "Namespace", "Keys"], ["name", "namespace", "key_count"]), + }, + } + + # Maps sidebar display names to the type string k8s.describe_resource expects + _DESCRIBE_TYPE_MAP = { + "Pods": "Pod", + "Services": "Service", + "Deployments": "Deployment", + "Config Maps": "ConfigMap", + } current_resource_type = reactive("Pods", init=False) current_namespace = reactive("default") @@ -300,26 +315,14 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._selected_row: Optional[str] = None self._resource_data: List[Dict[str, Any]] = [] - self._all_resources: Dict[str, List[Dict[str, Any]]] = { - "Pods": [], - "Services": [], - "Deployments": [], - "ConfigMaps": [], - } + self._all_resources: Dict[str, List[Dict[str, Any]]] = {} self._fetch_id: int = 0 def compose(self): """Compose the cluster screen.""" # Main container with sidebar, content, and detail panel with Horizontal(id="main-container"): - yield ListView( - ListItem(Label("Pods")), - ListItem(Label("Services")), - ListItem(Label("Deployments")), - ListItem(Label("ConfigMaps")), - id="resource-type-sidebar", - initial_index=0, - ) + yield ResourceSidebar(id="resource-type-sidebar") with Vertical(id="content-area"): yield ResourceTable(id="resource-table") yield SearchInput(id="search-input") @@ -341,7 +344,7 @@ def on_mount(self) -> None: self._load_context_info() # Give focus to the sidebar self.call_after_refresh( - lambda: self.query_one("#resource-type-sidebar", ListView).focus() + lambda: self.query_one(ResourceSidebar).focus_first_item() ) # Initialize panel focus to sidebar self.current_panel = "sidebar" @@ -422,19 +425,14 @@ def _fetch_resources_worker(self, fetch_id: int, resource_type: str, namespace: resources = [] status = "Connected" try: - if resource_type == "Pods": - resources = k8s.list_pods(namespace, context=context) - elif resource_type == "Services": - resources = k8s.list_services(namespace, context=context) - elif resource_type == "Deployments": - resources = k8s.list_deployments(namespace, context=context) - elif resource_type == "ConfigMaps": - resources = k8s.list_configmaps(namespace, context=context) + fetch_fn = self._FETCH_FNS.get(resource_type) + if fetch_fn is None: + return + resources = fetch_fn(namespace, context=context) # Filter out error entries resources = [r for r in resources if "error" not in r] - # Store and display self._all_resources[resource_type] = resources logger.debug(f"_fetch_resources_worker completed: {len(resources)} {resource_type} fetched") self.app.call_from_thread(self._display_resources, fetch_id, resource_type, namespace, resources) @@ -453,7 +451,6 @@ def _apply_fetch_status(self, fetch_id: int, status: str) -> None: def _display_resources(self, fetch_id: int, resource_type: str, namespace: str, resources: List[Dict[str, Any]]) -> None: """Display resources in the table.""" - # Ignore stale fetch results - only render if this is the current request if fetch_id != self._fetch_id: return if resource_type != self.current_resource_type or namespace != self.current_namespace: @@ -461,52 +458,28 @@ def _display_resources(self, fetch_id: int, resource_type: str, namespace: str, table: ResourceTable = self.query_one("#resource-table", ResourceTable) - # Check if we're in all-namespace mode - is_all_namespaces = namespace == "all" - - if resource_type == "Pods": - if is_all_namespaces: - columns = ["Name", "Namespace", "Status", "Ready", "Restarts"] - keys = ["name", "namespace", "status", "ready", "restarts"] - else: - columns = ["Name", "Status", "Ready", "Restarts"] - keys = ["name", "status", "ready", "restarts"] - elif resource_type == "Services": - if is_all_namespaces: - columns = ["Name", "Namespace", "Type", "Cluster IP"] - keys = ["name", "namespace", "type", "cluster_ip"] - else: - columns = ["Name", "Type", "Cluster IP"] - keys = ["name", "type", "cluster_ip"] - elif resource_type == "Deployments": - if is_all_namespaces: - columns = ["Name", "Namespace", "Replicas", "Ready", "Available"] - keys = ["name", "namespace", "replicas", "ready_replicas", "available_replicas"] - else: - columns = ["Name", "Replicas", "Ready", "Available"] - keys = ["name", "replicas", "ready_replicas", "available_replicas"] - elif resource_type == "ConfigMaps": - if is_all_namespaces: - columns = ["Name", "Namespace", "Keys"] - keys = ["name", "namespace", "key_count"] - else: - columns = ["Name", "Keys"] - keys = ["name", "key_count"] - else: + col_def = self._COLUMN_DEFS.get(resource_type) + if col_def is None: return + ns_key = "all" if namespace == "all" else "default" + columns, keys = col_def[ns_key] + table.populate_resources(resources, columns, keys) - # Store resource data for actions like describe and logs self._resource_data = resources - def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: - """Handle sidebar up/down navigation - immediately update resource type. - - This replaces on_list_view_selected (Enter-based) to trigger on every - up/down arrow press, providing live preview of resource types. - """ - if event.list_view.id == "resource-type-sidebar" and event.list_view.index is not None: - self.current_resource_type = self._RESOURCE_TYPES[event.list_view.index] + def on_resource_sidebar_resource_selected(self, event: ResourceSidebar.ResourceSelected) -> None: + """Handle resource type selection from the sidebar.""" + if self.current_resource_type == event.resource_type: + return # Already showing this type, skip redundant fetch + self.current_resource_type = event.resource_type + if not event.implemented: + table: ResourceTable = self.query_one("#resource-table", ResourceTable) + table.clear(columns=True) + self.connection_status = "Not implemented" + self._update_status_bar() + return + self._refresh_resources() def action_focus_search(self) -> None: """Show and focus the search input (vim-style).""" @@ -525,7 +498,7 @@ def action_describe_resource(self) -> None: if 0 <= row_index < len(self._resource_data): resource = self._resource_data[row_index] resource_name = resource.get("name", "Unknown") - resource_type = self.current_resource_type.rstrip("s") # Remove trailing 's' + resource_type = self._DESCRIBE_TYPE_MAP.get(self.current_resource_type, self.current_resource_type) # Use row's namespace if in all-namespace mode, otherwise use current namespace namespace = resource.get("namespace", self.current_namespace) if self.current_namespace == "all" else self.current_namespace @@ -717,7 +690,7 @@ def action_focus_next_panel(self) -> None: # Move focus to the target panel widget try: if next_panel == "sidebar": - self.query_one("#resource-type-sidebar", ListView).focus() + self.query_one(ResourceSidebar).focus_first_item() elif next_panel == "table": self.query_one("#resource-table", ResourceTable).focus() elif next_panel == "detail": @@ -751,7 +724,7 @@ def action_focus_previous_panel(self) -> None: # Move focus to the target panel widget try: if prev_panel == "sidebar": - self.query_one("#resource-type-sidebar", ListView).focus() + self.query_one(ResourceSidebar).focus_first_item() elif prev_panel == "table": self.query_one("#resource-table", ResourceTable).focus() elif prev_panel == "detail": diff --git a/src/gantry/widgets.py b/src/gantry/widgets.py index 44514a5..f992c47 100644 --- a/src/gantry/widgets.py +++ b/src/gantry/widgets.py @@ -2,8 +2,9 @@ import logging from typing import Any, Dict, List, Optional, Callable -from textual.widgets import DataTable, Static, Input -from textual.containers import Container, Horizontal, Vertical +from textual.widget import Widget +from textual.widgets import DataTable, Static, Input, ListView, ListItem, Label, Collapsible +from textual.containers import Container, Horizontal, Vertical, VerticalScroll from textual.message import Message from textual.events import Key from textual.css.query import NoMatches @@ -347,3 +348,177 @@ def _build_text(self) -> str: # Fallback (should not reach here) return "" + + +class ResourceSidebar(Widget): + """Grouped, collapsible sidebar for Kubernetes resource type selection.""" + + GROUPS: list[tuple[str, list[tuple[str, bool]]]] = [ + ("Workloads", [ + ("Pods", True), + ("Deployments", True), + ("Daemon Sets", False), + ("Stateful Sets", False), + ("Replica Sets", False), + ("Replication Controllers", False), + ("Jobs", False), + ("Cron Jobs", False), + ]), + ("Service", [ + ("Services", True), + ("Ingresses", False), + ("Ingress Classes", False), + ]), + ("Config & Storage", [ + ("Config Maps", True), + ("Secrets", False), + ("Persistent Volume Claims", False), + ("Storage Classes", False), + ]), + ("Cluster", [ + ("Nodes", False), + ("Namespaces", False), + ("Events", False), + ("Roles", False), + ("Role Bindings", False), + ("Cluster Roles", False), + ("Cluster Role Bindings", False), + ("Service Accounts", False), + ("Network Policies", False), + ("Persistent Volumes", False), + ]), + ("Custom Resource Definitions", [ + ("CRDs", False), + ]), + ] + + CSS = """ + ResourceSidebar { + width: 24; + height: 100%; + border-right: solid $accent; + background: $panel; + overflow-y: auto; + } + + ResourceSidebar Collapsible { + border: none; + padding: 0; + background: $panel; + } + + ResourceSidebar Collapsible > CollapsibleTitle { + color: $accent; + text-style: bold; + padding: 0 1; + background: $panel; + } + + ResourceSidebar ListView { + border: none; + background: $panel; + padding: 0; + height: auto; + } + + ResourceSidebar ListItem { + padding: 0 2; + height: 1; + } + + ResourceSidebar ListItem > Label { + color: $text; + width: 100%; + } + + ResourceSidebar ListItem.stub-item > Label { + color: $text-muted; + opacity: 0.5; + } + """ + + class ResourceSelected(Message): + """Posted when the user highlights a resource type in the sidebar.""" + + def __init__(self, resource_type: str, implemented: bool) -> None: + super().__init__() + self.resource_type = resource_type + self.implemented = implemented + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + # Pre-build the lookup: ListView ID → list of (name, implemented) + self._list_view_items: dict[str, list[tuple[str, bool]]] = { + ( + "sidebar-" + + group_name.lower() + .replace(" ", "-") + .replace("&", "and") + .replace("(", "") + .replace(")", "") + ): items + for group_name, items in self.GROUPS + } + # Guard: suppress auto-highlight events fired during mount + self._ready: bool = False + + def on_mount(self) -> None: + """Allow ResourceSelected events after the initial mount cycle completes.""" + self.call_after_refresh(self._set_ready) + + def _set_ready(self) -> None: + self._ready = True + + def compose(self): + with VerticalScroll(): + for group_name, items in self.GROUPS: + lv_id = ( + "sidebar-" + + group_name.lower() + .replace(" ", "-") + .replace("&", "and") + .replace("(", "") + .replace(")", "") + ) + with Collapsible(title=group_name): + yield ListView( + *[ + ListItem( + Label(name), + classes="stub-item" if not impl else "", + ) + for name, impl in items + ], + id=lv_id, + ) + + def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: + """Post ResourceSelected when the user moves highlight in any group ListView.""" + if not self._ready: + return + lv_id = event.list_view.id + if lv_id not in self._list_view_items: + return + idx = event.list_view.index + if idx is None: + return + items = self._list_view_items[lv_id] + if 0 <= idx < len(items): + name, implemented = items[idx] + self.post_message(self.ResourceSelected(name, implemented)) + + def focus_first_item(self) -> None: + """Focus the first inner ListView (Workloads group).""" + try: + first_lv_id = next(iter(self._list_view_items)) + self.query_one(f"#{first_lv_id}", ListView).focus() + except (StopIteration, Exception): + pass + + async def _on_key(self, event: Key) -> None: + """Forward right-arrow to screen panel navigation.""" + if event.key == "right": + event.stop() + self.screen.action_focus_next_panel() + else: + await super()._on_key(event) diff --git a/tests/test_app.py b/tests/test_app.py index 25740e3..3c43289 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -6,7 +6,7 @@ from gantry.app import GantryApp from gantry.screens import ClusterScreen, HelmScreen -from gantry.widgets import KeybindingsBar +from gantry.widgets import KeybindingsBar, ResourceSidebar def test_app_initializes(): @@ -112,13 +112,16 @@ async def test_tab_switches_back_to_cluster_screen(): assert isinstance(app.screen, ClusterScreen) -def test_cluster_screen_has_sidebar(): - """Test that ClusterScreen has a resource type sidebar.""" +def test_cluster_screen_has_dispatch_tables(): + """Test that ClusterScreen has dispatch tables for resource types.""" screen = ClusterScreen() - # We can't test the full DOM without mounting, but we can check - # that the sidebar list is defined in the class - assert hasattr(screen, "_RESOURCE_TYPES") - assert screen._RESOURCE_TYPES == ["Pods", "Services", "Deployments", "ConfigMaps"] + # After migration to ResourceSidebar, dispatch tables replace _RESOURCE_TYPES + assert hasattr(screen, "_FETCH_FNS") + assert hasattr(screen, "_COLUMN_DEFS") + assert "Pods" in screen._FETCH_FNS + assert "Services" in screen._FETCH_FNS + assert "Deployments" in screen._FETCH_FNS + assert "Config Maps" in screen._FETCH_FNS @pytest.mark.asyncio @@ -138,11 +141,12 @@ async def test_sidebar_selection_changes_resource_type(): async with app.run_test() as pilot: screen = app.screen assert isinstance(screen, ClusterScreen) - # Arrow down to Services, press Enter + # Allow the _ready flag to be set after the initial mount cycle + await pilot.pause() + # Arrow down from Pods → Deployments (second item in Workloads group) await pilot.press("down") - await pilot.press("enter") await pilot.pause() - assert screen.current_resource_type == "Services" + assert screen.current_resource_type == "Deployments" @pytest.mark.asyncio @@ -195,31 +199,34 @@ async def test_panel_navigation_left_arrow(): async def test_sidebar_up_down_updates_resources(): """Test that navigating sidebar with arrows immediately updates resource type. - Previously, Enter was required to apply the selection. Now up/down - navigation immediately triggers a resource type change and fetch. + Arrow navigation within a group fires ResourceSelected on each highlight change. + The Workloads group contains: Pods, Deployments, Daemon Sets, ... """ app = GantryApp() async with app.run_test() as pilot: screen = app.screen assert isinstance(screen, ClusterScreen) + # Allow the _ready flag to be set after the initial mount cycle + await pilot.pause() + # Start on Pods assert screen.current_resource_type == "Pods" - # Down arrow to Services + # Down arrow → Deployments (second item in Workloads group) await pilot.press("down") await pilot.pause() - assert screen.current_resource_type == "Services" + assert screen.current_resource_type == "Deployments" - # Down arrow to Deployments + # Down arrow → Daemon Sets (third item, stub) await pilot.press("down") await pilot.pause() - assert screen.current_resource_type == "Deployments" + assert screen.current_resource_type == "Daemon Sets" - # Up arrow back to Services + # Up arrow back to Deployments await pilot.press("up") await pilot.pause() - assert screen.current_resource_type == "Services" + assert screen.current_resource_type == "Deployments" @pytest.mark.asyncio @@ -334,3 +341,79 @@ def test_keybindings_bar_helm_normal(): # Should NOT show cluster-specific bindings assert "Describe" not in output assert "Logs" not in output + + +def test_resource_sidebar_instantiates(): + sidebar = ResourceSidebar() + assert sidebar is not None + + +def test_resource_sidebar_has_five_groups(): + sidebar = ResourceSidebar() + assert len(sidebar.GROUPS) == 5 + + +def test_resource_sidebar_pods_implemented(): + """Pods must be in Workloads and marked implemented.""" + sidebar = ResourceSidebar() + workloads = next(g for g in sidebar.GROUPS if g[0] == "Workloads") + items = {name: impl for name, impl in workloads[1]} + assert items["Pods"] is True + + +def test_resource_sidebar_daemon_sets_stub(): + """Daemon Sets must be in Workloads and marked as stub.""" + sidebar = ResourceSidebar() + workloads = next(g for g in sidebar.GROUPS if g[0] == "Workloads") + items = {name: impl for name, impl in workloads[1]} + assert items["Daemon Sets"] is False + + +def test_resource_sidebar_resource_selected_message(): + """ResourceSelected carries resource_type and implemented flag.""" + msg = ResourceSidebar.ResourceSelected("Pods", True) + assert msg.resource_type == "Pods" + assert msg.implemented is True + + +def test_resource_sidebar_stub_resource_selected_message(): + msg = ResourceSidebar.ResourceSelected("Nodes", False) + assert msg.resource_type == "Nodes" + assert msg.implemented is False + + +@pytest.mark.asyncio +async def test_cluster_screen_mounts_resource_sidebar(): + """ClusterScreen must contain a ResourceSidebar, not a bare ListView with id resource-type-sidebar.""" + app = GantryApp() + async with app.run_test() as pilot: + screen = app.screen + assert isinstance(screen, ClusterScreen) + sidebar = screen.query_one(ResourceSidebar) + assert sidebar is not None + + +@pytest.mark.asyncio +async def test_cluster_screen_no_legacy_sidebar(): + """The old #resource-type-sidebar ListView must not exist (element is now ResourceSidebar).""" + from textual.css.query import QueryError + app = GantryApp() + async with app.run_test() as pilot: + # Querying for a ListView at this ID must fail because the element + # is now a ResourceSidebar, not a ListView. + with pytest.raises(QueryError): + app.screen.query_one("#resource-type-sidebar", ListView) + + +@pytest.mark.asyncio +async def test_stub_resource_shows_not_implemented(): + """Selecting a stub resource type clears table and sets status to Not implemented.""" + app = GantryApp() + async with app.run_test() as pilot: + screen = app.screen + assert isinstance(screen, ClusterScreen) + sidebar = screen.query_one(ResourceSidebar) + screen.on_resource_sidebar_resource_selected( + ResourceSidebar.ResourceSelected("Nodes", False) + ) + assert screen.connection_status == "Not implemented" diff --git a/uv.lock b/uv.lock index 91ceab5..2c51b48 100644 --- a/uv.lock +++ b/uv.lock @@ -100,6 +100,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload_time = "2026-04-02T09:28:37.794Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "durationpy" version = "0.10" @@ -118,12 +127,24 @@ dependencies = [ { name = "textual" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [package.metadata] requires-dist = [ { name = "kubernetes", specifier = ">=28.0.0" }, { name = "textual", specifier = ">=0.40.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, +] + [[package]] name = "idna" version = "3.11" @@ -133,6 +154,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload_time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload_time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload_time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "kubernetes" version = "35.0.0" @@ -212,6 +242,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload_time = "2025-06-19T22:48:06.508Z" }, ] +[[package]] +name = "packaging" +version = "26.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload_time = "2026-04-14T21:12:49.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload_time = "2026-04-14T21:12:47.56Z" }, +] + [[package]] name = "platformdirs" version = "4.9.6" @@ -221,6 +260,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload_time = "2026-04-09T00:04:09.463Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload_time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload_time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -230,6 +278,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload_time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload_time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload_time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload_time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload_time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"