From 23799d8b2a3bb11f82beb2a882416e50c1588572 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Fri, 11 Jul 2025 02:54:28 -0700 Subject: [PATCH 1/3] add ruff, pyright, and pytest config --- pyproject.toml | 48 ++++++++++++ watchdog.py | 194 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 watchdog.py diff --git a/pyproject.toml b/pyproject.toml index 47f591d..12dae89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,8 +17,34 @@ build-backend = "hatchling.build" dev = [ "build>=1.2.2.post1", "ruff>=0.12.2", + "pyright>=1.1.403", + "pytest>=8.3.4", ] +[tool.pyright] +typeCheckingMode = "standard" +exclude = ["tests/old/", ".venv/", ".git/", "__pycache__/", "./test_*.py", "./debug_*.py", "private_example/"] +venvPath = "." +venv = ".venv" + +[tool.pytest.ini_options] +timeout = 300 +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +asyncio_default_test_loop_scope = "session" +testpaths = [ + "tests" +] +python_files = ["test_*.py", "*_test.py"] +addopts = "-svx --strict-markers --tb=short --dist=loadscope" +log_cli = true +log_cli_format = "%(levelname)-8s [%(name)s] %(message)s" +filterwarnings = [ + "ignore::pytest.PytestDeprecationWarning", + "ignore::DeprecationWarning", +] +log_level = "DEBUG" + [tool.hatch.build] include = [ "cdp_use/**/*.py" @@ -26,3 +52,25 @@ include = [ exclude = [ "cdp_use/generator/*.py" ] + + +[tool.codespell] +ignore-words-list = "bu,wit,dont,cant,wont,re-use,re-used,re-using,re-usable,thats,doesnt,doubleclick" +skip = "*.json" + +[tool.ruff] +line-length = 140 +fix = true + +[tool.ruff.lint] +select = ["ASYNC", "E", "F", "FAST", "I", "PLE"] +ignore = ["ASYNC109", "E101", "E402", "E501", "F841", "E731", "W291"] # TODO: determine if adding timeouts to all the unbounded async functions is needed / worth-it so we can un-ignore ASYNC109 +unfixable = ["E101", "E402", "E501", "F841", "E731"] + +[tool.ruff.format] +quote-style = "single" +indent-style = "space" +line-ending = "lf" +docstring-code-format = true +docstring-code-line-length = 140 +skip-magic-trailing-comma = false diff --git a/watchdog.py b/watchdog.py new file mode 100644 index 0000000..86cdda8 --- /dev/null +++ b/watchdog.py @@ -0,0 +1,194 @@ +import asyncio +import logging +from datetime import datetime, timedelta +from typing import Dict, Optional, Set + +import httpx + +from cdp_use.client import CDPClient + +# Configure logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +class TargetWatchdog: + def __init__(self, cdp_client: CDPClient): + self.cdp = cdp_client + self.crashed_targets: Dict[str, datetime] = {} # target_id -> crashed_at timestamp + self.attached_sessions: Dict[str, str] = {} # target_id -> session_id + self.closed_targets: Set[str] = set() # targets we should stop checking + self.scan_interval = 10 # seconds + self.reload_threshold = timedelta(seconds=60) + self.close_threshold = timedelta(seconds=90) + + async def attach_to_target(self, target_id: str) -> Optional[str]: + """Attach to a target and return the session ID.""" + try: + result = await self.cdp.send.Target.attachToTarget( + params={"targetId": target_id, "flatten": True} + ) + session_id = result["sessionId"] + self.attached_sessions[target_id] = session_id + + # Enable Runtime domain for JS evaluation + await self.cdp.send.Runtime.enable(session_id=session_id) + + return session_id + except Exception as e: + logger.error(f"Failed to attach to target {target_id}: {e}") + return None + + async def check_target_health(self, target_id: str, session_id: str) -> bool: + """Check if a target is responsive by evaluating 1+1.""" + try: + # Set a short timeout for the evaluation + result = await asyncio.wait_for( + self.cdp.send.Runtime.evaluate( + params={"expression": "1+1", "returnByValue": True}, + session_id=session_id + ), + timeout=5.0 # 5 second timeout + ) + + # Check if the result is 2 + if result.get("result", {}).get("value") == 2: + return True + else: + logger.warning(f"Target {target_id} returned unexpected result: {result}") + return False + + except asyncio.TimeoutError: + logger.warning(f"Target {target_id} timed out during health check") + return False + except Exception as e: + logger.error(f"Health check failed for target {target_id}: {e}") + return False + + async def reload_target(self, target_id: str, session_id: str): + """Reload a crashed target.""" + try: + await self.cdp.send.Page.reload(session_id=session_id) + logger.info(f"Reloaded target {target_id}") + except Exception as e: + logger.error(f"Failed to reload target {target_id}: {e}") + + async def close_and_replace_target(self, target_id: str): + """Close a target and create a new about:blank tab.""" + try: + # Close the target + await self.cdp.send.Target.closeTarget(params={"targetId": target_id}) + logger.info(f"Closed crashed target {target_id}") + + # Create a new about:blank tab + result = await self.cdp.send.Target.createTarget(params={"url": "about:blank"}) + new_target_id = result["targetId"] + logger.info(f"Created replacement target {new_target_id}") + + # Mark the old target as closed + self.closed_targets.add(target_id) + + except Exception as e: + logger.error(f"Failed to close and replace target {target_id}: {e}") + + async def scan_targets(self): + """Scan all targets and check their health.""" + try: + # Get all targets + targets_result = await self.cdp.send.Target.getTargets() + page_targets = [t for t in targets_result["targetInfos"] if t["type"] == "page"] + + current_time = datetime.now() + + for target in page_targets: + target_id = target["targetId"] + + # Skip closed targets + if target_id in self.closed_targets: + continue + + # Check if target was closed by user + if target.get("attached") is False: + logger.info(f"Target {target_id} was closed, stopping monitoring") + self.closed_targets.add(target_id) + if target_id in self.crashed_targets: + del self.crashed_targets[target_id] + if target_id in self.attached_sessions: + del self.attached_sessions[target_id] + continue + + # Attach to target if not already attached + if target_id not in self.attached_sessions: + session_id = await self.attach_to_target(target_id) + if not session_id: + continue + else: + session_id = self.attached_sessions[target_id] + + # Check target health + is_healthy = await self.check_target_health(target_id, session_id) + + if is_healthy: + # Target is responsive + if target_id in self.crashed_targets: + logger.info(f"Target {target_id} is now responsive, resetting crashed status") + del self.crashed_targets[target_id] + else: + # Target is unresponsive + if target_id not in self.crashed_targets: + logger.warning(f"Target {target_id} is unresponsive, marking as crashed") + self.crashed_targets[target_id] = current_time + else: + # Check how long it's been crashed + crashed_duration = current_time - self.crashed_targets[target_id] + + if crashed_duration >= self.close_threshold: + # Close and replace after 90 seconds + logger.error(f"Target {target_id} crashed for {crashed_duration.seconds}s, closing and replacing") + await self.close_and_replace_target(target_id) + del self.crashed_targets[target_id] + if target_id in self.attached_sessions: + del self.attached_sessions[target_id] + elif crashed_duration >= self.reload_threshold: + # Reload after 60 seconds + logger.warning(f"Target {target_id} crashed for {crashed_duration.seconds}s, attempting reload") + await self.reload_target(target_id, session_id) + # Recheck health after reload + await asyncio.sleep(2) # Give it time to reload + is_healthy_after_reload = await self.check_target_health(target_id, session_id) + if is_healthy_after_reload: + logger.info(f"Target {target_id} recovered after reload") + del self.crashed_targets[target_id] + + except Exception as e: + logger.error(f"Error during target scan: {e}") + + async def run(self): + """Run the watchdog service.""" + logger.info("Starting target watchdog service...") + + while True: + await self.scan_targets() + await asyncio.sleep(self.scan_interval) + + +async def main(): + # Get WebSocket URL + async with httpx.AsyncClient() as client: + version_info = await client.get("http://localhost:9222/json/version") + browser_ws_url = version_info.json()["webSocketDebuggerUrl"] + + # Connect to Chrome DevTools + async with CDPClient(browser_ws_url) as cdp: + watchdog = TargetWatchdog(cdp) + + try: + await watchdog.run() + except KeyboardInterrupt: + logger.info("Watchdog service stopped by user") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file From 22727626353c684d6c769576172e94c7299dda14 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 16 Jul 2025 00:12:38 -0700 Subject: [PATCH 2/3] add some more helpers from browser-use --- cdp_use/__init__.py | 31 ++++++- cdp_use/helpers.py | 217 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 cdp_use/helpers.py diff --git a/cdp_use/__init__.py b/cdp_use/__init__.py index 7579129..7555383 100644 --- a/cdp_use/__init__.py +++ b/cdp_use/__init__.py @@ -1,3 +1,32 @@ from cdp_use.client import CDPClient +from cdp_use.helpers import ( + CDPError, + CDPEvaluationError, + CDPResponseError, + extract_value, + extract_object_id, + extract_box_model, + extract_navigation_history, + evaluate_expression, + evaluate_with_object, + get_element_box, + cdp_session, +) -__all__ = ["CDPClient"] +__all__ = [ + "CDPClient", + # Exceptions + "CDPError", + "CDPEvaluationError", + "CDPResponseError", + # Extractors + "extract_value", + "extract_object_id", + "extract_box_model", + "extract_navigation_history", + # Helpers + "evaluate_expression", + "evaluate_with_object", + "get_element_box", + "cdp_session", +] diff --git a/cdp_use/helpers.py b/cdp_use/helpers.py new file mode 100644 index 0000000..9c6721a --- /dev/null +++ b/cdp_use/helpers.py @@ -0,0 +1,217 @@ +""" +Helper utilities for common CDP operations. + +This module provides convenience functions to reduce boilerplate when working +with Chrome DevTools Protocol responses. +""" + +from typing import Any, TypeVar, cast +from cdp_use.cdp.runtime.commands import EvaluateReturns +from cdp_use.cdp.runtime.types import ExceptionDetails +from cdp_use.cdp.dom.commands import GetBoxModelReturns, ResolveNodeReturns +from cdp_use.cdp.page.commands import GetNavigationHistoryReturns + + +T = TypeVar('T') + + +class CDPError(Exception): + """Base exception for CDP-related errors.""" + pass + + +class CDPEvaluationError(CDPError): + """Raised when CDP evaluation fails.""" + def __init__(self, exception_details: ExceptionDetails): + self.exception_details = exception_details + super().__init__(f"CDP evaluation failed: {exception_details}") + + +class CDPResponseError(CDPError): + """Raised when CDP response is missing expected data.""" + pass + + +# Runtime helpers + +def extract_value(evaluate_result: EvaluateReturns, default: T | None = None) -> T | None: + """ + Extract value from Runtime.evaluate result with proper type handling. + + Args: + evaluate_result: The result from Runtime.evaluate + default: Default value to return if no value is found + + Returns: + The extracted value or default + + Raises: + CDPEvaluationError: If the evaluation resulted in an exception + """ + if 'exceptionDetails' in evaluate_result: + raise CDPEvaluationError(evaluate_result['exceptionDetails']) + + if 'result' in evaluate_result: + remote_obj = evaluate_result['result'] + if 'value' in remote_obj: + return cast(T, remote_obj['value']) + + return default + + +def extract_object_id(evaluate_result: EvaluateReturns) -> str | None: + """ + Extract objectId from Runtime.evaluate result. + + Args: + evaluate_result: The result from Runtime.evaluate + + Returns: + The objectId if present, None otherwise + + Raises: + CDPEvaluationError: If the evaluation resulted in an exception + """ + if 'exceptionDetails' in evaluate_result: + raise CDPEvaluationError(evaluate_result['exceptionDetails']) + + if 'result' in evaluate_result and 'objectId' in evaluate_result['result']: + return evaluate_result['result']['objectId'] + return None + + +async def evaluate_expression(cdp_client, expression: str, session_id: str | None = None) -> Any: + """ + Evaluate JavaScript expression and return value directly. + + Args: + cdp_client: The CDP client instance + expression: JavaScript expression to evaluate + session_id: Optional session ID for the evaluation context + + Returns: + The evaluated value + + Raises: + CDPEvaluationError: If the evaluation fails + """ + result: EvaluateReturns = await cdp_client.send.Runtime.evaluate( + params={'expression': expression, 'returnByValue': True}, + session_id=session_id + ) + return extract_value(result) + + +async def evaluate_with_object(cdp_client, expression: str, session_id: str | None = None) -> str: + """ + Evaluate JavaScript expression and return objectId. + + Args: + cdp_client: The CDP client instance + expression: JavaScript expression to evaluate + session_id: Optional session ID for the evaluation context + + Returns: + The objectId of the evaluated expression + + Raises: + CDPEvaluationError: If the evaluation fails + CDPResponseError: If no objectId is returned + """ + result: EvaluateReturns = await cdp_client.send.Runtime.evaluate( + params={'expression': expression}, + session_id=session_id + ) + object_id = extract_object_id(result) + if not object_id: + raise CDPResponseError(f"No objectId returned for expression: {expression}") + return object_id + + +# DOM helpers + +def extract_box_model(box_model_result: GetBoxModelReturns) -> dict[str, Any]: + """ + Extract box model from DOM.getBoxModel result. + + Args: + box_model_result: The result from DOM.getBoxModel + + Returns: + The box model dictionary + + Raises: + CDPResponseError: If no box model is present + """ + if 'model' not in box_model_result: + raise CDPResponseError("No box model returned") + return box_model_result['model'] + + +async def get_element_box(cdp_client, backend_node_id: int, session_id: str | None = None) -> dict[str, Any]: + """ + Get element box model with proper error handling. + + Args: + cdp_client: The CDP client instance + backend_node_id: The backend node ID + session_id: Optional session ID + + Returns: + The box model dictionary + + Raises: + CDPResponseError: If no box model is returned + """ + result: GetBoxModelReturns = await cdp_client.send.DOM.getBoxModel( + params={'backendNodeId': backend_node_id}, + session_id=session_id + ) + return extract_box_model(result) + + +# Page helpers + +def extract_navigation_history(history_result: GetNavigationHistoryReturns) -> tuple[int, list[dict[str, Any]]]: + """ + Extract current index and entries from navigation history. + + Args: + history_result: The result from Page.getNavigationHistory + + Returns: + Tuple of (currentIndex, entries) + """ + current_index = history_result.get('currentIndex', 0) + entries = history_result.get('entries', []) + return current_index, entries + + +# Context managers + +from contextlib import asynccontextmanager + +@asynccontextmanager +async def cdp_session(cdp_client, target_id: str): + """ + Context manager for CDP sessions. + + Usage: + async with cdp_session(cdp_client, target_id) as session_id: + # Use session_id in CDP commands + await cdp_client.send.Runtime.evaluate(..., session_id=session_id) + """ + result = await cdp_client.send.Target.attachToTarget( + params={'targetId': target_id, 'flatten': True} + ) + session_id = result['sessionId'] + try: + yield session_id + finally: + try: + await cdp_client.send.Target.detachFromTarget( + params={'sessionId': session_id} + ) + except Exception: + # Session might already be detached + pass \ No newline at end of file From 0d302affe7d723f3d3669cc88461a4e89e6366a9 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 16 Jul 2025 00:24:42 -0700 Subject: [PATCH 3/3] bump version --- pyproject.toml | 2 +- uv.lock | 119 ++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 95 insertions(+), 26 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 12dae89..5e2d0ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cdp-use" -version = "1.3.1" +version = "1.3.3" description = "Type safe generator/client library for CDP" readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 6d24399..592f633 100644 --- a/uv.lock +++ b/uv.lock @@ -32,7 +32,7 @@ wheels = [ [[package]] name = "cdp-use" -version = "1.3.1" +version = "1.3.3" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -42,6 +42,8 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "build" }, + { name = "pyright" }, + { name = "pytest" }, { name = "ruff" }, ] @@ -54,16 +56,18 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "build", specifier = ">=1.2.2.post1" }, + { name = "pyright", specifier = ">=1.1.403" }, + { name = "pytest", specifier = ">=8.3.4" }, { name = "ruff", specifier = ">=0.12.2" }, ] [[package]] name = "certifi" -version = "2025.6.15" +version = "2025.7.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/76/52c535bcebe74590f296d6c77c86dabf761c41980e1347a2422e4aa2ae41/certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995", size = 163981, upload-time = "2025-07-14T03:29:28.449Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" }, + { url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" }, ] [[package]] @@ -121,6 +125,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -130,6 +152,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[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.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + [[package]] name = "pyproject-hooks" version = "1.2.0" @@ -139,29 +179,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, ] +[[package]] +name = "pyright" +version = "1.1.403" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/f6/35f885264ff08c960b23d1542038d8da86971c5d8c955cfab195a4f672d7/pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104", size = 3913526, upload-time = "2025-07-09T07:15:52.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504, upload-time = "2025-07-09T07:15:50.958Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +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/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + [[package]] name = "ruff" -version = "0.12.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/3d/d9a195676f25d00dbfcf3cf95fdd4c685c497fcfa7e862a44ac5e4e96480/ruff-0.12.2.tar.gz", hash = "sha256:d7b4f55cd6f325cb7621244f19c873c565a08aff5a4ba9c69aa7355f3f7afd3e", size = 4432239, upload-time = "2025-07-03T16:40:19.566Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/b6/2098d0126d2d3318fd5bec3ad40d06c25d377d95749f7a0c5af17129b3b1/ruff-0.12.2-py3-none-linux_armv6l.whl", hash = "sha256:093ea2b221df1d2b8e7ad92fc6ffdca40a2cb10d8564477a987b44fd4008a7be", size = 10369761, upload-time = "2025-07-03T16:39:38.847Z" }, - { url = "https://files.pythonhosted.org/packages/b1/4b/5da0142033dbe155dc598cfb99262d8ee2449d76920ea92c4eeb9547c208/ruff-0.12.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:09e4cf27cc10f96b1708100fa851e0daf21767e9709e1649175355280e0d950e", size = 11155659, upload-time = "2025-07-03T16:39:42.294Z" }, - { url = "https://files.pythonhosted.org/packages/3e/21/967b82550a503d7c5c5c127d11c935344b35e8c521f52915fc858fb3e473/ruff-0.12.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ae64755b22f4ff85e9c52d1f82644abd0b6b6b6deedceb74bd71f35c24044cc", size = 10537769, upload-time = "2025-07-03T16:39:44.75Z" }, - { url = "https://files.pythonhosted.org/packages/33/91/00cff7102e2ec71a4890fb7ba1803f2cdb122d82787c7d7cf8041fe8cbc1/ruff-0.12.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eb3a6b2db4d6e2c77e682f0b988d4d61aff06860158fdb413118ca133d57922", size = 10717602, upload-time = "2025-07-03T16:39:47.652Z" }, - { url = "https://files.pythonhosted.org/packages/9b/eb/928814daec4e1ba9115858adcda44a637fb9010618721937491e4e2283b8/ruff-0.12.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73448de992d05517170fc37169cbca857dfeaeaa8c2b9be494d7bcb0d36c8f4b", size = 10198772, upload-time = "2025-07-03T16:39:49.641Z" }, - { url = "https://files.pythonhosted.org/packages/50/fa/f15089bc20c40f4f72334f9145dde55ab2b680e51afb3b55422effbf2fb6/ruff-0.12.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8b94317cbc2ae4a2771af641739f933934b03555e51515e6e021c64441532d", size = 11845173, upload-time = "2025-07-03T16:39:52.069Z" }, - { url = "https://files.pythonhosted.org/packages/43/9f/1f6f98f39f2b9302acc161a4a2187b1e3a97634fe918a8e731e591841cf4/ruff-0.12.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:45fc42c3bf1d30d2008023a0a9a0cfb06bf9835b147f11fe0679f21ae86d34b1", size = 12553002, upload-time = "2025-07-03T16:39:54.551Z" }, - { url = "https://files.pythonhosted.org/packages/d8/70/08991ac46e38ddd231c8f4fd05ef189b1b94be8883e8c0c146a025c20a19/ruff-0.12.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce48f675c394c37e958bf229fb5c1e843e20945a6d962cf3ea20b7a107dcd9f4", size = 12171330, upload-time = "2025-07-03T16:39:57.55Z" }, - { url = "https://files.pythonhosted.org/packages/88/a9/5a55266fec474acfd0a1c73285f19dd22461d95a538f29bba02edd07a5d9/ruff-0.12.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:793d8859445ea47591272021a81391350205a4af65a9392401f418a95dfb75c9", size = 11774717, upload-time = "2025-07-03T16:39:59.78Z" }, - { url = "https://files.pythonhosted.org/packages/87/e5/0c270e458fc73c46c0d0f7cf970bb14786e5fdb88c87b5e423a4bd65232b/ruff-0.12.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6932323db80484dda89153da3d8e58164d01d6da86857c79f1961934354992da", size = 11646659, upload-time = "2025-07-03T16:40:01.934Z" }, - { url = "https://files.pythonhosted.org/packages/b7/b6/45ab96070c9752af37f0be364d849ed70e9ccede07675b0ec4e3ef76b63b/ruff-0.12.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6aa7e623a3a11538108f61e859ebf016c4f14a7e6e4eba1980190cacb57714ce", size = 10604012, upload-time = "2025-07-03T16:40:04.363Z" }, - { url = "https://files.pythonhosted.org/packages/86/91/26a6e6a424eb147cc7627eebae095cfa0b4b337a7c1c413c447c9ebb72fd/ruff-0.12.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2a4a20aeed74671b2def096bdf2eac610c7d8ffcbf4fb0e627c06947a1d7078d", size = 10176799, upload-time = "2025-07-03T16:40:06.514Z" }, - { url = "https://files.pythonhosted.org/packages/f5/0c/9f344583465a61c8918a7cda604226e77b2c548daf8ef7c2bfccf2b37200/ruff-0.12.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:71a4c550195612f486c9d1f2b045a600aeba851b298c667807ae933478fcef04", size = 11241507, upload-time = "2025-07-03T16:40:08.708Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b7/99c34ded8fb5f86c0280278fa89a0066c3760edc326e935ce0b1550d315d/ruff-0.12.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4987b8f4ceadf597c927beee65a5eaf994c6e2b631df963f86d8ad1bdea99342", size = 11717609, upload-time = "2025-07-03T16:40:10.836Z" }, - { url = "https://files.pythonhosted.org/packages/51/de/8589fa724590faa057e5a6d171e7f2f6cffe3287406ef40e49c682c07d89/ruff-0.12.2-py3-none-win32.whl", hash = "sha256:369ffb69b70cd55b6c3fc453b9492d98aed98062db9fec828cdfd069555f5f1a", size = 10523823, upload-time = "2025-07-03T16:40:13.203Z" }, - { url = "https://files.pythonhosted.org/packages/94/47/8abf129102ae4c90cba0c2199a1a9b0fa896f6f806238d6f8c14448cc748/ruff-0.12.2-py3-none-win_amd64.whl", hash = "sha256:dca8a3b6d6dc9810ed8f328d406516bf4d660c00caeaef36eb831cf4871b0639", size = 11629831, upload-time = "2025-07-03T16:40:15.478Z" }, - { url = "https://files.pythonhosted.org/packages/e2/1f/72d2946e3cc7456bb837e88000eb3437e55f80db339c840c04015a11115d/ruff-0.12.2-py3-none-win_arm64.whl", hash = "sha256:48d6c6bfb4761df68bc05ae630e24f506755e702d4fb08f08460be778c7ccb12", size = 10735334, upload-time = "2025-07-03T16:40:17.677Z" }, +version = "0.12.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/2a/43955b530c49684d3c38fcda18c43caf91e99204c2a065552528e0552d4f/ruff-0.12.3.tar.gz", hash = "sha256:f1b5a4b6668fd7b7ea3697d8d98857390b40c1320a63a178eee6be0899ea2d77", size = 4459341, upload-time = "2025-07-11T13:21:16.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/fd/b44c5115539de0d598d75232a1cc7201430b6891808df111b8b0506aae43/ruff-0.12.3-py3-none-linux_armv6l.whl", hash = "sha256:47552138f7206454eaf0c4fe827e546e9ddac62c2a3d2585ca54d29a890137a2", size = 10430499, upload-time = "2025-07-11T13:20:26.321Z" }, + { url = "https://files.pythonhosted.org/packages/43/c5/9eba4f337970d7f639a37077be067e4ec80a2ad359e4cc6c5b56805cbc66/ruff-0.12.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a9153b000c6fe169bb307f5bd1b691221c4286c133407b8827c406a55282041", size = 11213413, upload-time = "2025-07-11T13:20:30.017Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2c/fac3016236cf1fe0bdc8e5de4f24c76ce53c6dd9b5f350d902549b7719b2/ruff-0.12.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa6b24600cf3b750e48ddb6057e901dd5b9aa426e316addb2a1af185a7509882", size = 10586941, upload-time = "2025-07-11T13:20:33.046Z" }, + { url = "https://files.pythonhosted.org/packages/c5/0f/41fec224e9dfa49a139f0b402ad6f5d53696ba1800e0f77b279d55210ca9/ruff-0.12.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2506961bf6ead54887ba3562604d69cb430f59b42133d36976421bc8bd45901", size = 10783001, upload-time = "2025-07-11T13:20:35.534Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/dd64a9ce56d9ed6cad109606ac014860b1c217c883e93bf61536400ba107/ruff-0.12.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4faaff1f90cea9d3033cbbcdf1acf5d7fb11d8180758feb31337391691f3df0", size = 10269641, upload-time = "2025-07-11T13:20:38.459Z" }, + { url = "https://files.pythonhosted.org/packages/63/5c/2be545034c6bd5ce5bb740ced3e7014d7916f4c445974be11d2a406d5088/ruff-0.12.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40dced4a79d7c264389de1c59467d5d5cefd79e7e06d1dfa2c75497b5269a5a6", size = 11875059, upload-time = "2025-07-11T13:20:41.517Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d4/a74ef1e801ceb5855e9527dae105eaff136afcb9cc4d2056d44feb0e4792/ruff-0.12.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0262d50ba2767ed0fe212aa7e62112a1dcbfd46b858c5bf7bbd11f326998bafc", size = 12658890, upload-time = "2025-07-11T13:20:44.442Z" }, + { url = "https://files.pythonhosted.org/packages/13/c8/1057916416de02e6d7c9bcd550868a49b72df94e3cca0aeb77457dcd9644/ruff-0.12.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12371aec33e1a3758597c5c631bae9a5286f3c963bdfb4d17acdd2d395406687", size = 12232008, upload-time = "2025-07-11T13:20:47.374Z" }, + { url = "https://files.pythonhosted.org/packages/f5/59/4f7c130cc25220392051fadfe15f63ed70001487eca21d1796db46cbcc04/ruff-0.12.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:560f13b6baa49785665276c963edc363f8ad4b4fc910a883e2625bdb14a83a9e", size = 11499096, upload-time = "2025-07-11T13:20:50.348Z" }, + { url = "https://files.pythonhosted.org/packages/d4/01/a0ad24a5d2ed6be03a312e30d32d4e3904bfdbc1cdbe63c47be9d0e82c79/ruff-0.12.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023040a3499f6f974ae9091bcdd0385dd9e9eb4942f231c23c57708147b06311", size = 11688307, upload-time = "2025-07-11T13:20:52.945Z" }, + { url = "https://files.pythonhosted.org/packages/93/72/08f9e826085b1f57c9a0226e48acb27643ff19b61516a34c6cab9d6ff3fa/ruff-0.12.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:883d844967bffff5ab28bba1a4d246c1a1b2933f48cb9840f3fdc5111c603b07", size = 10661020, upload-time = "2025-07-11T13:20:55.799Z" }, + { url = "https://files.pythonhosted.org/packages/80/a0/68da1250d12893466c78e54b4a0ff381370a33d848804bb51279367fc688/ruff-0.12.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2120d3aa855ff385e0e562fdee14d564c9675edbe41625c87eeab744a7830d12", size = 10246300, upload-time = "2025-07-11T13:20:58.222Z" }, + { url = "https://files.pythonhosted.org/packages/6a/22/5f0093d556403e04b6fd0984fc0fb32fbb6f6ce116828fd54306a946f444/ruff-0.12.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6b16647cbb470eaf4750d27dddc6ebf7758b918887b56d39e9c22cce2049082b", size = 11263119, upload-time = "2025-07-11T13:21:01.503Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/f4c0b69bdaffb9968ba40dd5fa7df354ae0c73d01f988601d8fac0c639b1/ruff-0.12.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e1417051edb436230023575b149e8ff843a324557fe0a265863b7602df86722f", size = 11746990, upload-time = "2025-07-11T13:21:04.524Z" }, + { url = "https://files.pythonhosted.org/packages/fe/84/7cc7bd73924ee6be4724be0db5414a4a2ed82d06b30827342315a1be9e9c/ruff-0.12.3-py3-none-win32.whl", hash = "sha256:dfd45e6e926deb6409d0616078a666ebce93e55e07f0fb0228d4b2608b2c248d", size = 10589263, upload-time = "2025-07-11T13:21:07.148Z" }, + { url = "https://files.pythonhosted.org/packages/07/87/c070f5f027bd81f3efee7d14cb4d84067ecf67a3a8efb43aadfc72aa79a6/ruff-0.12.3-py3-none-win_amd64.whl", hash = "sha256:a946cf1e7ba3209bdef039eb97647f1c77f6f540e5845ec9c114d3af8df873e7", size = 11695072, upload-time = "2025-07-11T13:21:11.004Z" }, + { url = "https://files.pythonhosted.org/packages/e0/30/f3eaf6563c637b6e66238ed6535f6775480db973c836336e4122161986fc/ruff-0.12.3-py3-none-win_arm64.whl", hash = "sha256:5f9c7c9c8f84c2d7f27e93674d27136fbf489720251544c4da7fb3d742e011b1", size = 10805855, upload-time = "2025-07-11T13:21:13.547Z" }, ] [[package]]