From 60169c159d14c94caca0cdbc075f55209362a824 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 08:46:59 +0000 Subject: [PATCH 1/3] Modernize library: packaging, typed API, bug fixes, tests, and CI - Add pyproject.toml (hatchling) with metadata, ruff and pytest config - Restructure into snake_case modules (vapix_api, ptz_control, geolocation_api) with deprecated CamelCase shims for backward compat - Fix bugs: request timeout was never applied (requests ignores session.timeout), rename_preset_number sent number/name swapped, unsupported HTTP methods crashed with UnboundLocalError, geolocation XML parsing failed on namespaced responses and printed debug output - Add typed exception hierarchy (VapixError, VapixRequestError, VapixAuthenticationError, VapixResponseError) - Add HTTPS support, basic-auth option, configurable camera channel, context-manager support, and a get_parameters() helper (param.cgi) - Robust key=value and XML response parsing with clear errors - Populate __init__.py with public exports and __version__ - Add mocked pytest suite (35 tests, no camera required) - Add GitHub Actions CI (ruff + pytest on Python 3.9-3.13) - Rewrite README with accurate usage, options, and migration notes Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011Biy4fbT2vTaDATsMcrLfm --- .github/workflows/ci.yml | 25 ++ README.md | 99 +++++++- pyproject.toml | 60 +++++ requirements.txt | 4 +- src/vapix_python/GeolocationAPI.py | 57 +---- src/vapix_python/PTZControl.py | 368 +--------------------------- src/vapix_python/VapixAPI.py | 153 +----------- src/vapix_python/__init__.py | 24 ++ src/vapix_python/exceptions.py | 19 ++ src/vapix_python/geolocation_api.py | 107 ++++++++ src/vapix_python/ptz_control.py | 222 +++++++++++++++++ src/vapix_python/vapix_api.py | 179 ++++++++++++++ tests/conftest.py | 58 +++++ tests/test_geolocation_api.py | 100 ++++++++ tests/test_ptz_control.py | 95 +++++++ tests/test_vapix_api.py | 106 ++++++++ 16 files changed, 1114 insertions(+), 562 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 pyproject.toml create mode 100644 src/vapix_python/exceptions.py create mode 100644 src/vapix_python/geolocation_api.py create mode 100644 src/vapix_python/ptz_control.py create mode 100644 src/vapix_python/vapix_api.py create mode 100644 tests/conftest.py create mode 100644 tests/test_geolocation_api.py create mode 100644 tests/test_ptz_control.py create mode 100644 tests/test_vapix_api.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3f9d675 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install package with dev dependencies + run: pip install -e ".[dev]" + - name: Lint + run: ruff check . + - name: Test + run: pytest diff --git a/README.md b/README.md index fdbb97a..119da97 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,99 @@ -# Vapix API Python Wrapper by Axis Communications +# vapix-python -This Python library provides a seamless wrapper around the Vapix API by Axis Communications, facilitating effortless interactions with all their cameras. +A Python wrapper for the [VAPIX API](https://developer.axis.com/vapix/) by Axis Communications, providing a clean, typed interface for controlling Axis network cameras. ## Features -- Complete Pythonic access to all Vapix API endpoints. -- Simplified methods for interacting with all cameras. -- Built with extensibility and ease-of-use in mind. +- **PTZ control** — absolute, relative, and continuous pan/tilt/zoom moves, focus, iris, brightness, presets, and home position via `com/ptz.cgi`. +- **Geolocation** — read and set the camera's configured position and heading. +- **Device parameters** — list device parameters via `param.cgi`. +- **Solid HTTP layer** — one authenticated session (digest or basic auth), HTTP or HTTPS, real per-request timeouts, and typed exceptions (`VapixAuthenticationError`, `VapixRequestError`, `VapixResponseError`). +- **Typed and tested** — full type hints and a mocked test suite that runs without camera hardware. ## Installation -To install the wrapper, you can use pip: +Not yet published to PyPI. Install from source: -TODO - PyPi hosting coming soon +```bash +pip install git+https://github.com/derens99/vapix-python.git +``` -## Quick Start +Or for local development: + +```bash +git clone https://github.com/derens99/vapix-python.git +cd vapix-python +pip install -e ".[dev]" +``` + +## Quick start + +```python +from vapix_python import VapixAPI + +with VapixAPI("192.168.0.90", "root", "your-password") as api: + # Current pan/tilt/zoom + pan, tilt, zoom = api.ptz.get_current_position() + + # Move the camera + api.ptz.absolute_move(pan=90, tilt=0, zoom=500, speed=50) + api.ptz.go_home(speed=100) + + # Geolocation + position = api.geolocation.get_position() + print(position["lat"], position["lon"]) + + # Device parameters + brand = api.get_parameters(group="Brand") +``` + +### Connection options + +```python +api = VapixAPI( + "cam.example.com", + "root", + "your-password", + timeout=10, # per-request timeout in seconds + secure=True, # use HTTPS + verify_ssl=False, # or a path to a CA bundle + auth_method="digest" # or "basic" +) +``` + +### Error handling + +```python +from vapix_python import VapixAPI, VapixAuthenticationError, VapixRequestError + +try: + with VapixAPI("192.168.0.90", "root", "wrong") as api: + api.ptz.get_current_position() +except VapixAuthenticationError: + print("Bad credentials") +except VapixRequestError as exc: + print(f"Camera unreachable or returned an error: {exc}") +``` + +## Development + +```bash +pip install -e ".[dev]" +ruff check . # lint +pytest # run the test suite (no camera required) +``` + +## Migrating from 0.1.x + +The old CamelCase module paths still work but emit a `DeprecationWarning`: ```python -from vapix_python.VapixAPI import VapixAPI +from vapix_python.VapixAPI import VapixAPI # deprecated +from vapix_python import VapixAPI # preferred +``` -# Initialize the API caller with the base URL -vapix_api = VapixAPI(os.environ.get('host'), os.environ.get('user'), os.environ.get('password')) +Notable fixes in 0.2.0: -print(vapix_api.ptz.get_current_ptz()) -``` \ No newline at end of file +- Request timeouts are now actually applied (previously the `timeout` argument was silently ignored). +- `PTZControl.rename_preset_number(number, name)` now sends the number and name in the correct fields (they were swapped). +- Geolocation responses are parsed robustly (namespaced XML supported, no stray `print()` output) and camera-reported errors raise `VapixResponseError`. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..66331ac --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "vapix-python" +dynamic = ["version"] +description = "Python wrapper for the Axis Communications VAPIX camera API" +readme = "README.md" +requires-python = ">=3.9" +authors = [{ name = "derens99" }] +keywords = ["axis", "vapix", "camera", "ptz", "ip-camera"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Video :: Capture", + "Topic :: Software Development :: Libraries", +] +dependencies = ["requests>=2.28"] + +[project.urls] +Homepage = "https://github.com/derens99/vapix-python" +Issues = "https://github.com/derens99/vapix-python/issues" + +[project.optional-dependencies] +dev = ["pytest>=7", "ruff>=0.4"] + +[tool.hatch.version] +path = "src/vapix_python/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/vapix_python"] + +[tool.ruff] +line-length = 100 +target-version = "py39" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM"] +ignore = [ + "UP007", # keep Optional[X] for Python 3.9 compatibility in runtime positions +] + +[tool.ruff.lint.per-file-ignores] +# Deprecated compat shims intentionally shadow their own class names +"src/vapix_python/VapixAPI.py" = ["E402"] +"src/vapix_python/PTZControl.py" = ["E402"] +"src/vapix_python/GeolocationAPI.py" = ["E402"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/requirements.txt b/requirements.txt index 663bd1f..54b137f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ -requests \ No newline at end of file +# Runtime dependencies are declared in pyproject.toml; this file is kept for +# convenience (pip install -r requirements.txt). +requests>=2.28 diff --git a/src/vapix_python/GeolocationAPI.py b/src/vapix_python/GeolocationAPI.py index 36aed01..59ce418 100644 --- a/src/vapix_python/GeolocationAPI.py +++ b/src/vapix_python/GeolocationAPI.py @@ -1,51 +1,14 @@ -from __future__ import annotations -from typing import TYPE_CHECKING +"""Deprecated module path — use ``from vapix_python import GeolocationAPI`` instead.""" -import xml.etree.ElementTree as ET +import warnings -# Import for type hints only -if TYPE_CHECKING: - from .VapixAPI import VapixAPI +from .geolocation_api import GeolocationAPI +warnings.warn( + "vapix_python.GeolocationAPI is deprecated; import from vapix_python instead " + "(from vapix_python import GeolocationAPI)", + DeprecationWarning, + stacklevel=2, +) -class GeolocationAPI: - def __init__(self, api: VapixAPI) -> None: - self.api = api - self.endpoint = "geolocation" - - def get_position(self): - resp = self.api._send_request_vanilla(self.endpoint + "/get.cgi") - print(resp) - - root = ET.fromstring(resp) - - lat = float(root.find(".//Lat").text) - lon = float(root.find(".//Lng").text) - heading = float(root.find(".//Heading").text) - - valid_position_str = root.find(".//ValidPosition").text.strip() - valid_position = False if valid_position_str.lower() == "false" else True - - valid_heading_str = root.find(".//ValidHeading").text.strip() - valid_heading = False if valid_heading_str.lower() == "false" else True - - return { - "lat": lat, - "lon": lon, - "heading": heading, - "valid_position": valid_position, - "valid_heading": valid_heading, - } - - def set_position(self, lat: float, lon: float, heading: float = 0, text: str = ""): - resp = self.api._send_request_vanilla( - self.endpoint + "/set.cgi", - method="POST", - params={ - "lat": lat, - "lng": lon, - "heading": heading, - "text": text, - }, - ) - print(resp) +__all__ = ["GeolocationAPI"] diff --git a/src/vapix_python/PTZControl.py b/src/vapix_python/PTZControl.py index 75d7b58..0030747 100644 --- a/src/vapix_python/PTZControl.py +++ b/src/vapix_python/PTZControl.py @@ -1,362 +1,14 @@ -from __future__ import annotations -from typing import TYPE_CHECKING +"""Deprecated module path — use ``from vapix_python import PTZControl`` instead.""" -# Import for type hints only -if TYPE_CHECKING: - from .VapixAPI import VapixAPI +import warnings -class PTZControl: +from .ptz_control import PTZControl - def __init__(self, api: VapixAPI) -> None: - self.api = api - - def get_current_position(self) -> tuple(float, float, float): - """ - Gets the current position of the camera. +warnings.warn( + "vapix_python.PTZControl is deprecated; import from vapix_python instead " + "(from vapix_python import PTZControl)", + DeprecationWarning, + stacklevel=2, +) - Returns: - tuple: A tuple containing the current pan, tilt, and zoom values. - """ - resp = self.api._send_request('com/ptz.cgi', params={'query': 'position'}) - pan = float(resp.split()[0].split('=')[1]) - tilt = float(resp.split()[1].split('=')[1]) - zoom = float(resp.split()[2].split('=')[1]) - ptz_tuple = (pan, tilt, zoom) - - return ptz_tuple - - def get_current_ptz(self) -> str: - """ - Gets the current position of the camera. - - Returns: - str: A string containing the current pan, tilt, and zoom values. - """ - resp = self.api._send_request('com/ptz.cgi', params={'query': 'position'}) - return resp - - def absolute_move(self, pan: float, tilt: float, zoom: float, speed: float) -> None: - """ - Moves the camera to the specified position. - - Args: - pan (float): The pan value to move to. - tilt (float): The tilt value to move to. - zoom (float): The zoom value to move to. - speed (float): The speed to move at. - """ - params = {'pan': pan, 'tilt': tilt, 'zoom': zoom, 'speed': speed} - self.api._send_request('com/ptz.cgi', params=params) - - def relative_move(self, pan: float, tilt: float, zoom: float, speed: float) -> None: - """ - Moves the camera by the specified amount. - - Args: - pan (float): The pan value to move by. - tilt (float): The tilt value to move by. - zoom (float): The zoom value to move by. - speed (float): The speed to move at. - """ - params = {'rpan': pan, 'rtilt': tilt, 'rzoom': zoom, 'speed': speed} - self.api._send_request('com/ptz.cgi', params=params) - - def continuous_move(self, pan_speed: int, tilt_speed: int, zoom_speed: int) -> None: - """ - Moves the camera continuously in the specified direction. - - Args: - pan_speed (int): The pan speed to move at. - tilt_speed (int): The tilt speed to move at. - zoom_speed (int): The zoom speed to move at. - """ - pan_tilt = str(pan_speed) + "," + str(tilt_speed) - params = {'continuouspantiltmove': pan_tilt, 'continuouszoommove': zoom_speed} - self.api._send_request('com/ptz.cgi', params=params) - - def continuous_pantilt(self, pan_speed: int, tilt_speed: int) -> None: - """ - Moves the camera continuously in the specified direction. - - Args: - pan_speed (int): The pan speed to move at. - tilt_speed (int): The tilt speed to move at. - """ - pt_speed = str(pan_speed) + "," + str(tilt_speed) - params = {'continuouspantiltmove': pt_speed} - self.api._send_request('com/ptz.cgi', params=params) - - def continuous_zoom(self, zoom_speed: int): - """ - Moves the camera continuously in the specified direction. - - Args: - zoom_speed (int): The zoom speed to move at. - """ - params = {'continuouszoommove': zoom_speed} - self.api._send_request('com/ptz.cgi', params=params) - - def continuous_focus(self, focus_speed: int): - """ - Moves the camera continuously in the specified direction. - - Args: - focus_speed (int): The focus speed to move at. - """ - params = {'continuousfocusmove': focus_speed} - self.api._send_request('com/ptz.cgi', params=params) - - def continuous_iris(self, iris_speed: int): - """ - Moves the camera continuously in the specified direction. - - Args: - iris_speed (int): The iris speed to move at. - """ - params = {'continuousirismove': iris_speed} - self.api._send_request('com/ptz.cgi', params=params) - - def continuous_brightness(self, brightness_speed: int): - """ - Moves the camera continuously in the specified direction. - - Args: - brightness_speed (int): The brightness speed to move at. - """ - params = {'continuousbrightnessmove': brightness_speed} - self.api._send_request('com/ptz.cgi', params=params) - - def stop_move(self): - """ - Stops all movement of the camera. - """ - self.api._send_request('com/ptz.cgi', params={'continuouspantiltmove': '0,0', 'continuouszoommove': '0'}) - - def center_move(self, x_pos: int, y_pos: int, speed: int): - """ - Moves the camera to the specified position. - - Args: - x_pos (int): The x position to move to. - y_pos (int): The y position to move to. - speed (int): The speed to move at. - """ - params = {'center': str(x_pos) + ',' + str(y_pos), 'speed': speed} - self.api._send_request('com/ptz.cgi', params=params) - - def area_zoom(self, x_pos: int, y_pos: int, zoom: int, speed: int): - """ - Moves the camera to the specified position and zoom. - - Args: - x_pos (int): The x position to move to. - y_pos (int): The y position to move to. - zoom (int): The zoom value to move to. - speed (int): The speed to move at. - """ - params = {'areazoom': str(x_pos) + ',' + str(y_pos) + ',' + str(zoom), 'speed': speed} - self.api._send_request('com/ptz.cgi', params=params) - - def move(self, position: str, speed: int): - """ - Moves the camera to the specified position. - - Args: - position (str): The position to move to. - speed (int): The speed to move at. - """ - params = {'move': position, 'speed': speed} - self.api._send_request('com/ptz.cgi', params=params) - - def set_move_speed(self, speed: int): - """ - Sets the speed of the camera. - - Args: - speed (int): The speed to move at. - """ - if speed < 0 or speed > 100: - raise ValueError("Speed must be between 0 and 100.") - params = {'speed': speed} - self.api._send_request('com/ptz.cgi', params=params) - - def go_home(self, speed: int): - """ - Moves the camera to the home position. - - Args: - speed (int): The speed to move at. - """ - params = {'move': 'home', 'speed': speed} - self.api._send_request('com/ptz.cgi', params=params) - - def ptz_enabled(self, channel: int = 1): - """ - Checks if PTZ is enabled on the camera. - - Args: - channel (int, optional): The channel to check. Defaults to 1. - - Returns: - str: List of available commands if enabled, empty string if disabled. - """ - resp = self.api._send_request('com/ptz.cgi', params={'info': channel, 'camera': 1}) - return resp - - def set_iris(self, iris_level: int = 1750) -> bool: - """ - Sets the iris to the specified value. - - Args: - enabled (bool, optional): Whether to enable or disable the iris. Defaults to True. - """ - if iris_level < 0 or iris_level > 9999: - raise ValueError("Iris level must be between 0 and 9999.") - params = {'iris': iris_level} - self.api._send_request('com/ptz.cgi', params=params) - return True - - def set_focus(self, focus_level: int) -> bool: - """ - Sets the focus to the specified value. - - Args: - focus_level (int): The focus level to set. - """ - if focus_level < 0 or focus_level > 9999: - raise ValueError("Focus level must be between 0 and 9999.") - params = {'focus': focus_level} - self.api._send_request('com/ptz.cgi', params=params) - return True - - def set_zoom(self, zoom_level: int) -> bool: - """ - Sets the zoom to the specified value. - - Args: - zoom_level (int): The zoom level to set. - """ - if zoom_level < 0 or zoom_level > 9999: - raise ValueError("Zoom level must be between 0 and 9999.") - params = {'zoom': zoom_level} - self.api._send_request('com/ptz.cgi', params=params) - return True - - def set_brightness(self, brightness_level: int) -> bool: - """ - Sets the brightness to the specified value. - - Args: - brightness_level (int): The brightness level to set. - """ - if brightness_level < 0 or brightness_level > 9999: - raise ValueError("Brightness level must be between 0 and 9999.") - params = {'brightness': brightness_level} - self.api._send_request('com/ptz.cgi', params=params) - return True - - def set_autofocus(self, enabled: bool = True): - """ - Enables or disables autofocus. - - Args: - enabled (bool, optional): Whether to enable or disable autofocus. Defaults to True. - """ - if enabled: - enabled = 'on' - else: - enabled = 'off' - params = {'autofocus': enabled} - self.api._send_request('com/ptz.cgi', params=params) - return True - - def set_autoiris(self, enabled: bool = True): - """ - Enables or disables autoiris. - - Args: - enabled (bool, optional): Whether to enable or disable autoiris. Defaults to True. - """ - if enabled: - enabled = 'on' - else: - enabled = 'off' - params = {'autoiris': enabled} - self.api._send_request('com/ptz.cgi', params=params) - return True - - def set_current_preset_name(self, preset_name): - """ - Associates the current position to as a preset position in the Axis product. - - Args: - preset_name (str): The name of the preset to set. - """ - params = {'setserverpresetname': preset_name} - self.api._send_request('com/ptz.cgi', params=params) - return True - - def set_current_preset_no(self, preset_number): - """ - Saves the current position as a preset position number in the Axis product. - - Args: - preset_number (int): The number of the preset to set. - """ - params = {'setserverpresetno': preset_number} - self.api._send_request('com/ptz.cgi', params=params) - return True - - def rename_preset_number(self, preset_number, preset_name): - """ - Renames a preset position number in the Axis product. - - Args: - preset_number (int): The number of the preset to rename. - preset_name (str): The new name of the preset. - """ - params = {'renameserverpresetno': preset_name, 'newname': preset_number} - self.api._send_request('com/ptz.cgi', params=params) - return True - - def set_home(self): - """ - Sets the current position as the home position. - """ - self.api._send_request('com/ptz.cgi', params={'home': 'yes'}) - return True - - def remove_server_preset_name(self, preset_name): - """ - Removes a preset position name from the Axis product. - - Args: - preset_name (str): The name of the preset to remove. - """ - params = {'removeserverpresetname': preset_name} - self.api._send_request('com/ptz.cgi', params=params) - return True - - def remove_server_preset_no(self, preset_number): - """ - Removes a preset position number from the Axis product. - - Args: - preset_number (int): The number of the preset to remove. - """ - params = {'removeserverpresetno': preset_number} - self.api._send_request('com/ptz.cgi', params=params) - return True - - def set_device_preset(self, preset_number): - """ - Bypasses the presetpos interface and tells the device to save its current position as preset position - directly in the device, where is a device-specific preset position number. - This may also be a device-specific special function. - - Args: - preset_number (int): The number of the preset to move to. - """ - params = {'setdevicepreset': preset_number} - self.api._send_request('com/ptz.cgi', params=params) - return True \ No newline at end of file +__all__ = ["PTZControl"] diff --git a/src/vapix_python/VapixAPI.py b/src/vapix_python/VapixAPI.py index f9163c6..5895780 100644 --- a/src/vapix_python/VapixAPI.py +++ b/src/vapix_python/VapixAPI.py @@ -1,147 +1,14 @@ -import time -import requests +"""Deprecated module path — use ``from vapix_python import VapixAPI`` instead.""" -from .PTZControl import PTZControl -from .GeolocationAPI import GeolocationAPI +import warnings -from requests.auth import HTTPDigestAuth +from .vapix_api import VapixAPI -class VapixAPI: - """ - A class that provides an interface to interact with Axis cameras using the VAPIX API. +warnings.warn( + "vapix_python.VapixAPI is deprecated; import from vapix_python instead " + "(from vapix_python import VapixAPI)", + DeprecationWarning, + stacklevel=2, +) - Attributes: - ----------- - host : str - IP address or domain name of the camera. - user : str - Username for the camera's API authentication. - password : str - Password for the camera's API authentication. - base_url : str - Base URL for accessing the VAPIX API endpoints. - session : requests.Session - Session object for handling HTTP requests with authentication. - ptz : PTZControl - Instance for controlling Pan-Tilt-Zoom features of the camera. - geolocation : GeolocationAPI - Instance for handling camera's geolocation functionalities. - """ - - def __init__(self, host, user, password, timeout=5): - """ - Initializes the VapixAPI with host, user, and password credentials. - - Parameters: - ----------- - host : str - IP address or domain name of the camera. - user : str - Username for the camera's API authentication. - password : str - Password for the camera's API authentication. - timeout : int, optional - Timeout for HTTP requests (default is 5 seconds). - """ - self.host = host - self.user = user - self.password = password - self.base_url = 'http://' + self.host + '/axis-cgi' - self.session = requests.Session() - self.session.auth = HTTPDigestAuth(self.user, self.password) - self.session.timeout = timeout - self.ptz = PTZControl(self) - self.geolocation = GeolocationAPI(self) - - def _send_request(self, endpoint, method="GET", params=None, base_args=True): - """ - Send a request to a specific VAPIX API endpoint with base arguments. - - Parameters: - ----------- - endpoint : str - The endpoint to which the request is sent. - method : str, optional - HTTP request method (default is "GET"). - params : dict, optional - Parameters to be included in the request. - base_args : bool, optional - Flag to decide if base arguments need to be included (default is True). - - Returns: - -------- - str - Response text from the request. - - Raises: - ------- - requests.RequestException - If the request encounters an error. - """ - url = f"{self.base_url}/{endpoint}" - base_args_dict = { - 'camera': '1', - 'html': 'no', - 'timestamp': int(time.time()), - } - - if params: - base_args_dict.update(params) - - try: - if method == "GET": - response = self.session.get(url, params=base_args_dict) - elif method == "POST": - response = self.session.post(url, data=base_args_dict) - response.raise_for_status() - return response.text - except requests.RequestException as e: - raise e - - def _send_request_vanilla(self, endpoint, method="GET", params=None): - """ - Send a request to a specific VAPIX API endpoint without base arguments. - - Parameters: - ----------- - endpoint : str - The endpoint to which the request is sent. - method : str, optional - HTTP request method (default is "GET"). - params : dict, optional - Parameters to be included in the request. - - Returns: - -------- - str - Response text from the request. - - Raises: - ------- - requests.RequestException - If the request encounters an error. - """ - url = f"{self.base_url}/{endpoint}" - - try: - if method == "GET": - response = self.session.get(url, params=params) - elif method == "POST": - response = self.session.post(url, data=params) - response.raise_for_status() - return response.text - except requests.RequestException as e: - raise e - - - -if __name__ == '__main__': - import time - import os - import dotenv - dotenv.load_dotenv() - vapix_api = VapixAPI(os.environ.get('host'), os.environ.get('user'), os.environ.get('password')) - - print(vapix_api.ptz.get_current_position()) - - vapix_api.session.close() \ No newline at end of file +__all__ = ["VapixAPI"] diff --git a/src/vapix_python/__init__.py b/src/vapix_python/__init__.py index e69de29..9a28cee 100644 --- a/src/vapix_python/__init__.py +++ b/src/vapix_python/__init__.py @@ -0,0 +1,24 @@ +"""Python wrapper for the Axis Communications VAPIX camera API.""" + +from .exceptions import ( + VapixAuthenticationError, + VapixError, + VapixRequestError, + VapixResponseError, +) +from .geolocation_api import GeolocationAPI +from .ptz_control import PTZControl +from .vapix_api import VapixAPI + +__version__ = "0.2.0" + +__all__ = [ + "GeolocationAPI", + "PTZControl", + "VapixAPI", + "VapixAuthenticationError", + "VapixError", + "VapixRequestError", + "VapixResponseError", + "__version__", +] diff --git a/src/vapix_python/exceptions.py b/src/vapix_python/exceptions.py new file mode 100644 index 0000000..e90a9af --- /dev/null +++ b/src/vapix_python/exceptions.py @@ -0,0 +1,19 @@ +"""Exception types raised by the vapix-python library.""" + +from __future__ import annotations + + +class VapixError(Exception): + """Base class for all errors raised by vapix-python.""" + + +class VapixRequestError(VapixError): + """The HTTP request to the camera failed (network error or HTTP error status).""" + + +class VapixAuthenticationError(VapixRequestError): + """The camera rejected the supplied credentials (HTTP 401).""" + + +class VapixResponseError(VapixError): + """The camera returned a response that could not be parsed or reported an error.""" diff --git a/src/vapix_python/geolocation_api.py b/src/vapix_python/geolocation_api.py new file mode 100644 index 0000000..74e4d03 --- /dev/null +++ b/src/vapix_python/geolocation_api.py @@ -0,0 +1,107 @@ +"""Geolocation (position/heading) support via the VAPIX geolocation API.""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET +from typing import TYPE_CHECKING, Any + +from .exceptions import VapixResponseError + +if TYPE_CHECKING: # imported for type hints only, avoids a circular import + from .vapix_api import VapixAPI + +_ENDPOINT = "geolocation" + + +def _local_name(tag: str) -> str: + """Strip an XML namespace from a tag name.""" + return tag.rsplit("}", 1)[-1] + + +def _find_text(root: ET.Element, name: str) -> str | None: + """Find the text of the first element with the given local name, namespace-agnostic.""" + for elem in root.iter(): + if _local_name(elem.tag) == name and elem.text is not None: + return elem.text.strip() + return None + + +def _parse_xml(text: str) -> ET.Element: + try: + return ET.fromstring(text) + except ET.ParseError as exc: + raise VapixResponseError(f"Camera returned invalid XML: {text!r}") from exc + + +def _raise_on_error(root: ET.Element) -> None: + for elem in root.iter(): + if _local_name(elem.tag) == "Error": + description = _find_text(elem, "ErrorDescription") or "unknown error" + raise VapixResponseError(f"Geolocation request failed: {description}") + + +class GeolocationAPI: + """Read and write the camera's configured geolocation.""" + + def __init__(self, api: VapixAPI) -> None: + self.api = api + + def get_position(self) -> dict[str, Any]: + """Get the camera's configured location and heading. + + Returns: + Dict with ``lat``, ``lon``, ``heading`` (floats) and + ``valid_position``, ``valid_heading`` (bools). + + Raises: + VapixResponseError: The camera reported an error or the response + could not be parsed. + """ + resp = self.api._send_request_vanilla(f"{_ENDPOINT}/get.cgi") + root = _parse_xml(resp) + _raise_on_error(root) + + lat = _find_text(root, "Lat") + lon = _find_text(root, "Lng") + heading = _find_text(root, "Heading") + if lat is None or lon is None or heading is None: + raise VapixResponseError(f"Geolocation response missing position data: {resp!r}") + + valid_position = (_find_text(root, "ValidPosition") or "").lower() == "true" + valid_heading = (_find_text(root, "ValidHeading") or "").lower() == "true" + + try: + return { + "lat": float(lat), + "lon": float(lon), + "heading": float(heading), + "valid_position": valid_position, + "valid_heading": valid_heading, + } + except ValueError as exc: + raise VapixResponseError( + f"Geolocation response contained non-numeric position data: {resp!r}" + ) from exc + + def set_position(self, lat: float, lon: float, heading: float = 0, text: str = "") -> bool: + """Set the camera's location and heading. + + Args: + lat: Latitude in decimal degrees (positive north). + lon: Longitude in decimal degrees (positive east). + heading: Heading in degrees (0 = north). + text: Optional free-form location description. + + Returns: + True on success. + + Raises: + VapixResponseError: The camera rejected the new position. + """ + resp = self.api._send_request_vanilla( + f"{_ENDPOINT}/set.cgi", + method="POST", + params={"lat": lat, "lng": lon, "heading": heading, "text": text}, + ) + _raise_on_error(_parse_xml(resp)) + return True diff --git a/src/vapix_python/ptz_control.py b/src/vapix_python/ptz_control.py new file mode 100644 index 0000000..d2292e8 --- /dev/null +++ b/src/vapix_python/ptz_control.py @@ -0,0 +1,222 @@ +"""Pan/tilt/zoom control via the VAPIX ``com/ptz.cgi`` endpoint.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +from .exceptions import VapixResponseError + +if TYPE_CHECKING: # imported for type hints only, avoids a circular import + from .vapix_api import VapixAPI + +_PTZ_ENDPOINT = "com/ptz.cgi" + + +def _parse_key_value_response(text: str) -> dict[str, str]: + """Parse a VAPIX ``key=value`` line response into a dict.""" + values: dict[str, str] = {} + for line in text.splitlines(): + if "=" in line: + key, _, value = line.partition("=") + values[key.strip()] = value.strip() + return values + + +class PTZControl: + """Pan-Tilt-Zoom control for an Axis camera.""" + + def __init__(self, api: VapixAPI) -> None: + self.api = api + + def _command(self, params: Mapping[str, Any]) -> str: + return self.api._send_request(_PTZ_ENDPOINT, params=params) + + # ------------------------------------------------------------------ + # Position queries + # ------------------------------------------------------------------ + def get_current_position(self) -> tuple[float, float, float]: + """Get the current camera position. + + Returns: + Tuple of the current ``(pan, tilt, zoom)`` values. + + Raises: + VapixResponseError: The camera response did not contain a position. + """ + resp = self._command({"query": "position"}) + values = _parse_key_value_response(resp) + try: + return float(values["pan"]), float(values["tilt"]), float(values["zoom"]) + except (KeyError, ValueError) as exc: + raise VapixResponseError( + f"Could not parse PTZ position from response: {resp!r}" + ) from exc + + def get_current_ptz(self) -> str: + """Get the raw position query response as returned by the camera.""" + return self._command({"query": "position"}) + + def ptz_enabled(self, channel: int = 1) -> str: + """Check whether PTZ is available. + + Args: + channel: The video channel to check. + + Returns: + The list of available PTZ commands, or an empty string if disabled. + """ + return self._command({"info": channel}) + + # ------------------------------------------------------------------ + # Movement + # ------------------------------------------------------------------ + def absolute_move(self, pan: float, tilt: float, zoom: float, speed: float) -> None: + """Move the camera to an absolute position at the given speed.""" + self._command({"pan": pan, "tilt": tilt, "zoom": zoom, "speed": speed}) + + def relative_move(self, pan: float, tilt: float, zoom: float, speed: float) -> None: + """Move the camera by a relative amount at the given speed.""" + self._command({"rpan": pan, "rtilt": tilt, "rzoom": zoom, "speed": speed}) + + def continuous_move(self, pan_speed: int, tilt_speed: int, zoom_speed: int) -> None: + """Start continuous pan/tilt/zoom movement at the given speeds.""" + self._command( + { + "continuouspantiltmove": f"{pan_speed},{tilt_speed}", + "continuouszoommove": zoom_speed, + } + ) + + def continuous_pantilt(self, pan_speed: int, tilt_speed: int) -> None: + """Start continuous pan/tilt movement at the given speeds.""" + self._command({"continuouspantiltmove": f"{pan_speed},{tilt_speed}"}) + + def continuous_zoom(self, zoom_speed: int) -> None: + """Start continuous zoom movement at the given speed.""" + self._command({"continuouszoommove": zoom_speed}) + + def continuous_focus(self, focus_speed: int) -> None: + """Start continuous focus movement at the given speed.""" + self._command({"continuousfocusmove": focus_speed}) + + def continuous_iris(self, iris_speed: int) -> None: + """Start continuous iris movement at the given speed.""" + self._command({"continuousirismove": iris_speed}) + + def continuous_brightness(self, brightness_speed: int) -> None: + """Start continuous brightness adjustment at the given speed.""" + self._command({"continuousbrightnessmove": brightness_speed}) + + def stop_move(self) -> None: + """Stop all pan/tilt and zoom movement.""" + self._command({"continuouspantiltmove": "0,0", "continuouszoommove": "0"}) + + def center_move(self, x_pos: int, y_pos: int, speed: int) -> None: + """Center the view on the given image coordinates.""" + self._command({"center": f"{x_pos},{y_pos}", "speed": speed}) + + def area_zoom(self, x_pos: int, y_pos: int, zoom: int, speed: int) -> None: + """Center on the given image coordinates and zoom by the given factor.""" + self._command({"areazoom": f"{x_pos},{y_pos},{zoom}", "speed": speed}) + + def move(self, position: str, speed: int) -> None: + """Move the camera to a named position (e.g. ``home``, ``up``, ``left``).""" + self._command({"move": position, "speed": speed}) + + def go_home(self, speed: int) -> None: + """Move the camera to the home position at the given speed.""" + self._command({"move": "home", "speed": speed}) + + def set_move_speed(self, speed: int) -> None: + """Set the head speed used for moves. + + Args: + speed: Speed between 0 and 100. + """ + if not 0 <= speed <= 100: + raise ValueError("Speed must be between 0 and 100.") + self._command({"speed": speed}) + + # ------------------------------------------------------------------ + # Imaging settings + # ------------------------------------------------------------------ + def set_iris(self, iris_level: int = 1750) -> bool: + """Set the iris to the given level (0-9999).""" + if not 0 <= iris_level <= 9999: + raise ValueError("Iris level must be between 0 and 9999.") + self._command({"iris": iris_level}) + return True + + def set_focus(self, focus_level: int) -> bool: + """Set the focus to the given level (0-9999).""" + if not 0 <= focus_level <= 9999: + raise ValueError("Focus level must be between 0 and 9999.") + self._command({"focus": focus_level}) + return True + + def set_zoom(self, zoom_level: int) -> bool: + """Set the zoom to the given level (0-9999).""" + if not 0 <= zoom_level <= 9999: + raise ValueError("Zoom level must be between 0 and 9999.") + self._command({"zoom": zoom_level}) + return True + + def set_brightness(self, brightness_level: int) -> bool: + """Set the brightness to the given level (0-9999).""" + if not 0 <= brightness_level <= 9999: + raise ValueError("Brightness level must be between 0 and 9999.") + self._command({"brightness": brightness_level}) + return True + + def set_autofocus(self, enabled: bool = True) -> bool: + """Enable or disable autofocus.""" + self._command({"autofocus": "on" if enabled else "off"}) + return True + + def set_autoiris(self, enabled: bool = True) -> bool: + """Enable or disable auto iris.""" + self._command({"autoiris": "on" if enabled else "off"}) + return True + + # ------------------------------------------------------------------ + # Presets + # ------------------------------------------------------------------ + def set_current_preset_name(self, preset_name: str) -> bool: + """Save the current position as a named preset.""" + self._command({"setserverpresetname": preset_name}) + return True + + def set_current_preset_no(self, preset_number: int) -> bool: + """Save the current position as a numbered preset.""" + self._command({"setserverpresetno": preset_number}) + return True + + def rename_preset_number(self, preset_number: int, preset_name: str) -> bool: + """Rename the preset stored at ``preset_number`` to ``preset_name``.""" + self._command({"renameserverpresetno": preset_number, "newname": preset_name}) + return True + + def set_home(self) -> bool: + """Set the current position as the home position.""" + self._command({"home": "yes"}) + return True + + def remove_server_preset_name(self, preset_name: str) -> bool: + """Remove the preset with the given name.""" + self._command({"removeserverpresetname": preset_name}) + return True + + def remove_server_preset_no(self, preset_number: int) -> bool: + """Remove the preset with the given number.""" + self._command({"removeserverpresetno": preset_number}) + return True + + def set_device_preset(self, preset_number: int) -> bool: + """Save the current position as a device-specific preset number. + + Bypasses the presetpos interface and stores the preset directly in the + device; on some devices this triggers a device-specific special function. + """ + self._command({"setdevicepreset": preset_number}) + return True diff --git a/src/vapix_python/vapix_api.py b/src/vapix_python/vapix_api.py new file mode 100644 index 0000000..efe8035 --- /dev/null +++ b/src/vapix_python/vapix_api.py @@ -0,0 +1,179 @@ +"""Core client for the Axis VAPIX HTTP API.""" + +from __future__ import annotations + +import time +from collections.abc import Mapping +from types import TracebackType +from typing import Any + +import requests +from requests.auth import HTTPBasicAuth, HTTPDigestAuth + +from .exceptions import VapixAuthenticationError, VapixRequestError +from .geolocation_api import GeolocationAPI +from .ptz_control import PTZControl + +_SUPPORTED_METHODS = frozenset({"GET", "POST"}) + + +class VapixAPI: + """Client for interacting with Axis cameras through the VAPIX API. + + The client owns a single authenticated :class:`requests.Session` and exposes + feature areas as attributes (``ptz``, ``geolocation``). It can be used as a + context manager so the underlying session is always closed:: + + with VapixAPI("192.168.0.90", "root", "secret") as api: + pan, tilt, zoom = api.ptz.get_current_position() + + Args: + host: IP address or hostname of the camera. + user: Username for API authentication. + password: Password for API authentication. + timeout: Timeout in seconds applied to every HTTP request. + secure: Use HTTPS instead of HTTP when talking to the camera. + verify_ssl: Passed through to requests' ``verify`` — ``True``/``False`` + or a path to a CA bundle. Only meaningful with ``secure=True``. + auth_method: ``"digest"`` (default, what most Axis firmware expects) + or ``"basic"``. + camera: Camera/channel number included in requests that take one. + """ + + def __init__( + self, + host: str, + user: str, + password: str, + timeout: float = 5.0, + *, + secure: bool = False, + verify_ssl: bool | str = True, + auth_method: str = "digest", + camera: int = 1, + ) -> None: + self.host = host + self.user = user + self.timeout = timeout + self.camera = camera + + scheme = "https" if secure else "http" + self.base_url = f"{scheme}://{host}/axis-cgi" + + self.session = requests.Session() + if auth_method == "digest": + self.session.auth = HTTPDigestAuth(user, password) + elif auth_method == "basic": + self.session.auth = HTTPBasicAuth(user, password) + else: + raise ValueError(f"auth_method must be 'digest' or 'basic', got {auth_method!r}") + self.session.verify = verify_ssl + + self.ptz = PTZControl(self) + self.geolocation = GeolocationAPI(self) + + # ------------------------------------------------------------------ + # Request plumbing + # ------------------------------------------------------------------ + def _send_request( + self, + endpoint: str, + method: str = "GET", + params: Mapping[str, Any] | None = None, + base_args: bool = True, + ) -> str: + """Send a request to a VAPIX endpoint and return the response body. + + Args: + endpoint: Endpoint path relative to ``/axis-cgi``, e.g. ``com/ptz.cgi``. + method: ``"GET"`` or ``"POST"``. + params: Query parameters (GET) or form data (POST). + base_args: Include the common ``camera``/``html``/``timestamp`` + arguments expected by most CGI endpoints. + + Raises: + VapixAuthenticationError: The camera returned HTTP 401. + VapixRequestError: The request failed at the network level or the + camera returned another HTTP error status. + """ + method = method.upper() + if method not in _SUPPORTED_METHODS: + raise ValueError(f"Unsupported HTTP method: {method!r}") + + url = f"{self.base_url}/{endpoint.lstrip('/')}" + payload: dict[str, Any] = {} + if base_args: + payload.update({"camera": self.camera, "html": "no", "timestamp": int(time.time())}) + if params: + payload.update(params) + + request_kwargs: dict[str, Any] = ( + {"params": payload or None} if method == "GET" else {"data": payload or None} + ) + try: + response = self.session.request(method, url, timeout=self.timeout, **request_kwargs) + except requests.RequestException as exc: + raise VapixRequestError(f"Request to {url} failed: {exc}") from exc + + if response.status_code == 401: + raise VapixAuthenticationError( + f"Authentication failed for {url} (HTTP 401) — check user/password" + ) + try: + response.raise_for_status() + except requests.HTTPError as exc: + raise VapixRequestError(str(exc)) from exc + return response.text + + def _send_request_vanilla( + self, + endpoint: str, + method: str = "GET", + params: Mapping[str, Any] | None = None, + ) -> str: + """Send a request without the common base arguments.""" + return self._send_request(endpoint, method=method, params=params, base_args=False) + + # ------------------------------------------------------------------ + # General device helpers + # ------------------------------------------------------------------ + def get_parameters(self, group: str | None = None) -> dict[str, str]: + """List device parameters via ``param.cgi``. + + Args: + group: Optional parameter group filter, e.g. ``"Properties.PTZ"``. + + Returns: + Mapping of fully-qualified parameter names to their values. + """ + params: dict[str, Any] = {"action": "list"} + if group: + params["group"] = group + resp = self._send_request("param.cgi", params=params, base_args=False) + result: dict[str, str] = {} + for line in resp.splitlines(): + if "=" in line: + key, _, value = line.partition("=") + result[key.strip()] = value.strip() + return result + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + def close(self) -> None: + """Close the underlying HTTP session.""" + self.session.close() + + def __enter__(self) -> VapixAPI: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + + def __repr__(self) -> str: + return f"{type(self).__name__}(host={self.host!r}, user={self.user!r})" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ae3a0bb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,58 @@ +"""Shared test fixtures: a VapixAPI wired to a fake HTTP session.""" + +from __future__ import annotations + +import pytest +import requests + +from vapix_python import VapixAPI + + +class FakeResponse: + def __init__(self, text: str = "", status_code: int = 200): + self.text = text + self.status_code = status_code + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise requests.HTTPError(f"HTTP {self.status_code}") + + +class FakeSession: + """Records requests and replays queued responses.""" + + def __init__(self): + self.requests: list[dict] = [] + self.responses: list[FakeResponse] = [] + self.closed = False + + def queue(self, text: str = "", status_code: int = 200) -> None: + self.responses.append(FakeResponse(text, status_code)) + + def request(self, method, url, timeout=None, params=None, data=None): + self.requests.append( + {"method": method, "url": url, "timeout": timeout, "params": params, "data": data} + ) + if not self.responses: + return FakeResponse() + return self.responses.pop(0) + + def close(self) -> None: + self.closed = True + + @property + def last(self) -> dict: + return self.requests[-1] + + +@pytest.fixture +def fake_session() -> FakeSession: + return FakeSession() + + +@pytest.fixture +def api(fake_session: FakeSession) -> VapixAPI: + client = VapixAPI("192.168.0.90", "root", "secret") + client.session.close() # discard the real session before swapping in the fake + client.session = fake_session + return client diff --git a/tests/test_geolocation_api.py b/tests/test_geolocation_api.py new file mode 100644 index 0000000..96fcd74 --- /dev/null +++ b/tests/test_geolocation_api.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import pytest + +from vapix_python import VapixResponseError + +NAMESPACED_GET_RESPONSE = """ + + + + + 59.3293 + 18.0686 + 90.5 + Stockholm + true + false + + + + +""" + +PLAIN_GET_RESPONSE = """ + 1.5 + -2.5 + 0 + True + true + +""" + +ERROR_RESPONSE = """ + + 10 + Invalid latitude + + +""" + +SET_SUCCESS_RESPONSE = """ + + + + +""" + + +def test_get_position_parses_namespaced_xml(api, fake_session): + fake_session.queue(NAMESPACED_GET_RESPONSE) + position = api.geolocation.get_position() + assert position == { + "lat": 59.3293, + "lon": 18.0686, + "heading": 90.5, + "valid_position": True, + "valid_heading": False, + } + + +def test_get_position_parses_plain_xml(api, fake_session): + fake_session.queue(PLAIN_GET_RESPONSE) + position = api.geolocation.get_position() + assert position["lat"] == 1.5 + assert position["lon"] == -2.5 + assert position["valid_position"] is True + + +def test_get_position_raises_on_error_response(api, fake_session): + fake_session.queue(ERROR_RESPONSE) + with pytest.raises(VapixResponseError, match="Invalid latitude"): + api.geolocation.get_position() + + +def test_get_position_raises_on_invalid_xml(api, fake_session): + fake_session.queue("not xml at all") + with pytest.raises(VapixResponseError): + api.geolocation.get_position() + + +def test_get_position_raises_on_missing_fields(api, fake_session): + fake_session.queue("1") + with pytest.raises(VapixResponseError): + api.geolocation.get_position() + + +def test_set_position_posts_and_returns_true(api, fake_session): + fake_session.queue(SET_SUCCESS_RESPONSE) + assert api.geolocation.set_position(1.0, 2.0, heading=45, text="roof") is True + + sent = fake_session.last + assert sent["method"] == "POST" + assert sent["url"].endswith("geolocation/set.cgi") + assert sent["data"] == {"lat": 1.0, "lng": 2.0, "heading": 45, "text": "roof"} + + +def test_set_position_raises_on_error(api, fake_session): + fake_session.queue(ERROR_RESPONSE) + with pytest.raises(VapixResponseError, match="Invalid latitude"): + api.geolocation.set_position(999, 999) diff --git a/tests/test_ptz_control.py b/tests/test_ptz_control.py new file mode 100644 index 0000000..95656ba --- /dev/null +++ b/tests/test_ptz_control.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import pytest + +from vapix_python import VapixResponseError + + +def test_get_current_position_parses_values(api, fake_session): + fake_session.queue("pan=12.5\r\ntilt=-4.25\r\nzoom=100\r\n") + assert api.ptz.get_current_position() == (12.5, -4.25, 100.0) + + +def test_get_current_position_handles_reordered_response(api, fake_session): + fake_session.queue("zoom=1\ntilt=2\npan=3\n") + assert api.ptz.get_current_position() == (3.0, 2.0, 1.0) + + +def test_get_current_position_raises_on_garbage(api, fake_session): + fake_session.queue("Error: PTZ not available") + with pytest.raises(VapixResponseError): + api.ptz.get_current_position() + + +def test_absolute_move_sends_expected_params(api, fake_session): + fake_session.queue() + api.ptz.absolute_move(10, 20, 30, 40) + params = fake_session.last["params"] + assert params["pan"] == 10 + assert params["tilt"] == 20 + assert params["zoom"] == 30 + assert params["speed"] == 40 + + +def test_relative_move_sends_expected_params(api, fake_session): + fake_session.queue() + api.ptz.relative_move(1, 2, 3, 4) + params = fake_session.last["params"] + assert params["rpan"] == 1 + assert params["rtilt"] == 2 + assert params["rzoom"] == 3 + + +def test_continuous_move_formats_pair(api, fake_session): + fake_session.queue() + api.ptz.continuous_move(-50, 25, 10) + params = fake_session.last["params"] + assert params["continuouspantiltmove"] == "-50,25" + assert params["continuouszoommove"] == 10 + + +def test_stop_move_zeroes_speeds(api, fake_session): + fake_session.queue() + api.ptz.stop_move() + params = fake_session.last["params"] + assert params["continuouspantiltmove"] == "0,0" + assert params["continuouszoommove"] == "0" + + +def test_rename_preset_sends_number_and_name_in_correct_fields(api, fake_session): + fake_session.queue() + api.ptz.rename_preset_number(3, "gate") + params = fake_session.last["params"] + assert params["renameserverpresetno"] == 3 + assert params["newname"] == "gate" + + +def test_area_zoom_formats_triplet(api, fake_session): + fake_session.queue() + api.ptz.area_zoom(100, 200, 300, 50) + assert fake_session.last["params"]["areazoom"] == "100,200,300" + + +@pytest.mark.parametrize( + ("method", "bad_value"), + [ + ("set_iris", -1), + ("set_focus", 10000), + ("set_zoom", -5), + ("set_brightness", 12345), + ], +) +def test_level_setters_validate_range(api, method, bad_value): + with pytest.raises(ValueError): + getattr(api.ptz, method)(bad_value) + + +def test_set_move_speed_validates_range(api): + with pytest.raises(ValueError): + api.ptz.set_move_speed(101) + + +def test_set_autofocus_translates_bool(api, fake_session): + fake_session.queue() + api.ptz.set_autofocus(False) + assert fake_session.last["params"]["autofocus"] == "off" diff --git a/tests/test_vapix_api.py b/tests/test_vapix_api.py new file mode 100644 index 0000000..8b4a635 --- /dev/null +++ b/tests/test_vapix_api.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import pytest +import requests +from requests.auth import HTTPBasicAuth, HTTPDigestAuth + +from vapix_python import ( + VapixAPI, + VapixAuthenticationError, + VapixRequestError, +) + + +def test_base_url_defaults_to_http(): + with VapixAPI("cam.local", "root", "secret") as api: + assert api.base_url == "http://cam.local/axis-cgi" + assert isinstance(api.session.auth, HTTPDigestAuth) + + +def test_secure_and_basic_auth(): + with VapixAPI("cam.local", "root", "secret", secure=True, auth_method="basic") as api: + assert api.base_url == "https://cam.local/axis-cgi" + assert isinstance(api.session.auth, HTTPBasicAuth) + + +def test_invalid_auth_method_rejected(): + with pytest.raises(ValueError): + VapixAPI("cam.local", "root", "secret", auth_method="ntlm") + + +def test_get_request_includes_base_args_and_timeout(api, fake_session): + fake_session.queue("ok") + api._send_request("com/ptz.cgi", params={"query": "position"}) + + sent = fake_session.last + assert sent["method"] == "GET" + assert sent["url"] == "http://192.168.0.90/axis-cgi/com/ptz.cgi" + assert sent["timeout"] == 5.0 + assert sent["params"]["camera"] == 1 + assert sent["params"]["html"] == "no" + assert "timestamp" in sent["params"] + assert sent["params"]["query"] == "position" + + +def test_post_request_sends_form_data(api, fake_session): + fake_session.queue("ok") + api._send_request_vanilla("geolocation/set.cgi", method="POST", params={"lat": 1}) + + sent = fake_session.last + assert sent["method"] == "POST" + assert sent["data"] == {"lat": 1} + assert sent["params"] is None + + +def test_vanilla_request_omits_base_args(api, fake_session): + fake_session.queue("ok") + api._send_request_vanilla("geolocation/get.cgi") + assert fake_session.last["params"] is None + + +def test_unsupported_method_raises(api): + with pytest.raises(ValueError): + api._send_request("com/ptz.cgi", method="DELETE") + + +def test_http_401_raises_auth_error(api, fake_session): + fake_session.queue("", status_code=401) + with pytest.raises(VapixAuthenticationError): + api._send_request("com/ptz.cgi") + + +def test_http_error_raises_request_error(api, fake_session): + fake_session.queue("", status_code=500) + with pytest.raises(VapixRequestError): + api._send_request("com/ptz.cgi") + + +def test_network_error_raises_request_error(api, fake_session): + def boom(*args, **kwargs): + raise requests.ConnectionError("no route to host") + + fake_session.request = boom + with pytest.raises(VapixRequestError): + api._send_request("com/ptz.cgi") + + +def test_get_parameters_parses_key_values(api, fake_session): + fake_session.queue("root.Brand.Brand=AXIS\nroot.Brand.ProdNbr=P5655-E\n") + params = api.get_parameters(group="Brand") + + assert params == {"root.Brand.Brand": "AXIS", "root.Brand.ProdNbr": "P5655-E"} + assert fake_session.last["params"]["action"] == "list" + assert fake_session.last["params"]["group"] == "Brand" + + +def test_context_manager_closes_session(api, fake_session): + with api: + pass + assert fake_session.closed + + +def test_deprecated_module_paths_still_work(): + with pytest.deprecated_call(): + from vapix_python.VapixAPI import VapixAPI as LegacyVapixAPI # noqa: PLC0415 + + assert LegacyVapixAPI is VapixAPI From fc6e7ae5cf466c04d4c81418eecd1ceb34676ba2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 08:52:02 +0000 Subject: [PATCH 2/3] Add MIT license and switch tooling to uv - Add MIT LICENSE (copyright 2023 derens99) and reference it in pyproject.toml via SPDX expression + license-files - Add [dependency-groups] dev group and commit uv.lock for reproducible dev environments with uv sync - CI now installs via astral-sh/setup-uv with caching, runs lint/tests through uv, and builds the distribution - README development docs updated for the uv workflow Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011Biy4fbT2vTaDATsMcrLfm --- .github/workflows/ci.yml | 14 +- LICENSE | 21 ++ README.md | 15 +- pyproject.toml | 5 + uv.lock | 444 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 489 insertions(+), 10 deletions(-) create mode 100644 LICENSE create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f9d675..29aa244 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,12 +14,16 @@ jobs: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@v5 with: python-version: ${{ matrix.python-version }} - - name: Install package with dev dependencies - run: pip install -e ".[dev]" + enable-cache: true + - name: Install dependencies + run: uv sync --group dev - name: Lint - run: ruff check . + run: uv run ruff check . - name: Test - run: pytest + run: uv run pytest + - name: Build distribution + run: uv build diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..47fb7df --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 derens99 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 119da97..54d8aca 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,12 @@ Not yet published to PyPI. Install from source: pip install git+https://github.com/derens99/vapix-python.git ``` -Or for local development: +Or for local development (using [uv](https://docs.astral.sh/uv/)): ```bash git clone https://github.com/derens99/vapix-python.git cd vapix-python -pip install -e ".[dev]" +uv sync ``` ## Quick start @@ -77,12 +77,17 @@ except VapixRequestError as exc: ## Development +The project uses [uv](https://docs.astral.sh/uv/) for dependency management: + ```bash -pip install -e ".[dev]" -ruff check . # lint -pytest # run the test suite (no camera required) +uv sync # create .venv and install the package + dev dependencies +uv run ruff check . # lint +uv run pytest # run the test suite (no camera required) +uv build # build sdist and wheel ``` +`pip install -e ".[dev]"` also works if you prefer plain pip. + ## Migrating from 0.1.x The old CamelCase module paths still work but emit a `DeprecationWarning`: diff --git a/pyproject.toml b/pyproject.toml index 66331ac..8dde46e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,8 @@ name = "vapix-python" dynamic = ["version"] description = "Python wrapper for the Axis Communications VAPIX camera API" readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] requires-python = ">=3.9" authors = [{ name = "derens99" }] keywords = ["axis", "vapix", "camera", "ptz", "ip-camera"] @@ -32,6 +34,9 @@ Issues = "https://github.com/derens99/vapix-python/issues" [project.optional-dependencies] dev = ["pytest>=7", "ruff>=0.4"] +[dependency-groups] +dev = ["pytest>=7", "ruff>=0.4"] + [tool.hatch.version] path = "src/vapix_python/__init__.py" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..97356ec --- /dev/null +++ b/uv.lock @@ -0,0 +1,444 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/ef725f8eb19b5a261b30f78efa9252ef9d017985cb499102f6f49834cd12/charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217", size = 299121, upload-time = "2026-04-02T09:28:14.372Z" }, + { url = "https://files.pythonhosted.org/packages/a3/22/2f12878fbc680fbbb52386cd39a379801f62eaca74fc8b323381325f0f04/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5", size = 200612, upload-time = "2026-04-02T09:28:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b6/10c84e789126ca97d4a7228863a30481e786980a8b8cfcbf4f30658ca63c/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9", size = 221041, upload-time = "2026-04-02T09:28:17.554Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/c414866a138400b2e81973d006da7f694cfeaf895ef07d2cba9a8743841a/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a", size = 216323, upload-time = "2026-04-02T09:28:18.863Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/bdcf94997e06b223d826df3abed45a5ad6e17f609b7df9d25cd23b5bde30/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc", size = 208419, upload-time = "2026-04-02T09:28:20.332Z" }, + { url = "https://files.pythonhosted.org/packages/1a/64/3f9142293c88b1b10e199649ed1330f070c2a68e305335a5819fa7f25fa7/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00", size = 195016, upload-time = "2026-04-02T09:28:21.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d1/d8a6b7dd5c5636b76ce0d080bc57d8e56c7bbd6bc2ac941529a35e41d84a/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776", size = 206115, upload-time = "2026-04-02T09:28:23.259Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8c/60ebe912379627d023eb96995b40bc50308729f210f43d66109ca0a7bbd2/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319", size = 204022, upload-time = "2026-04-02T09:28:24.779Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2a/41816ceda78a551cbfdfbeab6f3891152b0e3f758ce6580c2c18c829f774/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24", size = 195914, upload-time = "2026-04-02T09:28:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9b/7c7f4b7f11525fcbdfba752455314ac60646bae91cdd671d531c1f7a97c6/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42", size = 222159, upload-time = "2026-04-02T09:28:27.504Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/301682e7469bdbfa2ce219a804f0668b2266ab8520570d85d3b3ef483ea3/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4", size = 206154, upload-time = "2026-04-02T09:28:28.848Z" }, + { url = "https://files.pythonhosted.org/packages/20/ec/90339ff5cdc598b265748c1f231c7d7fbd9123a92cee10f757e0b1448de4/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67", size = 217423, upload-time = "2026-04-02T09:28:30.248Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e7/a7a6147f8e3375676309cf584b25c72a3bab784ea4085b0011fa07b23aeb/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274", size = 210604, upload-time = "2026-04-02T09:28:31.736Z" }, + { url = "https://files.pythonhosted.org/packages/1a/62/d9340c7a79c393e57807d7fb6c57e82060687891f81b74d3201958b919c1/charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366", size = 144631, upload-time = "2026-04-02T09:28:33.158Z" }, + { url = "https://files.pythonhosted.org/packages/21/e7/92901117e2ddc8facfe8235a3ecd4eb482185b2ad5d5b6606b37c1afea06/charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444", size = 154710, upload-time = "2026-04-02T09:28:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4f/e1fb138201ad9a32499dd9a98aa4a5a5441fbf7f56b52b619a54b7ee8777/charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c", size = 143716, upload-time = "2026-04-02T09:28:35.908Z" }, + { 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 = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +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 = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +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 = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +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 = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "vapix-python" +source = { editable = "." } +dependencies = [ + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ruff" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7" }, + { name = "requests", specifier = ">=2.28" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, +] +provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=7" }, + { name = "ruff", specifier = ">=0.4" }, +] From b9afc27c390132c234882f2fadd9f84f7982f4c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 08:58:55 +0000 Subject: [PATCH 3/3] Apply code-review fixes: namespace bug, compat, and API correctness Correctness fixes from an 8-angle review of the modernization diff: - Remove the deprecated CamelCase shim modules: importing vapix_python.VapixAPI rebound the package attribute from the class to the shim module (standard submodule-import behavior), so mixing old and new import styles in one process made the class un-callable. A loud ModuleNotFoundError beats silent namespace corruption. - VapixRequestError now also subclasses requests.RequestException so pre-0.2.0 'except requests.RequestException' handlers keep working; HTTP 403 (insufficient privileges) now raises VapixAuthenticationError alongside 401. - ptz_enabled(channel) now sends info=1&camera=; the channel number was previously sent as the info value and never selected a channel. - PTZ commands rejected by the camera with an 'Error: ...' body (HTTP 200 on some firmware) now raise VapixResponseError instead of silently succeeding. - geolocation set_position no longer fails on firmware that answers a successful set with an empty or plain-text body; ValidPosition/ ValidHeading accept non-canonical truthy values ('1', 'yes') again, matching 0.1.x leniency. - get_parameters forwards an explicit empty group instead of dropping it; restore the documented .password attribute. Structure/cleanup from the same review: - Transport is now endpoint-agnostic: ptz.cgi base args (camera/html/ timestamp) moved into PTZControl._command; _send_request_vanilla and the base_args flag are gone. - Shared key=value parser in _parsing.py (was duplicated in vapix_api and ptz_control); level setters collapsed into _set_level; geolocation XML parsed in a single pass; GeoPosition TypedDict return type. - Drop requirements.txt and the duplicated optional-dependencies dev list (dependency-groups is the single source); remove dead ruff ignores; pin hatchling>=1.27 for the PEP 639 license fields. - Tests updated and extended: 48 passing (was 35), covering the new behaviors above. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011Biy4fbT2vTaDATsMcrLfm --- README.md | 14 +++-- pyproject.toml | 15 +----- requirements.txt | 3 -- src/vapix_python/GeolocationAPI.py | 14 ----- src/vapix_python/PTZControl.py | 14 ----- src/vapix_python/VapixAPI.py | 14 ----- src/vapix_python/__init__.py | 3 +- src/vapix_python/_parsing.py | 13 +++++ src/vapix_python/exceptions.py | 12 +++-- src/vapix_python/geolocation_api.py | 83 ++++++++++++++++------------- src/vapix_python/ptz_control.py | 69 +++++++++++++----------- src/vapix_python/vapix_api.py | 48 +++++++---------- tests/test_geolocation_api.py | 26 +++++++++ tests/test_ptz_control.py | 25 ++++++++- tests/test_vapix_api.py | 61 +++++++++++++-------- uv.lock | 14 +---- 16 files changed, 227 insertions(+), 201 deletions(-) delete mode 100644 requirements.txt delete mode 100644 src/vapix_python/GeolocationAPI.py delete mode 100644 src/vapix_python/PTZControl.py delete mode 100644 src/vapix_python/VapixAPI.py create mode 100644 src/vapix_python/_parsing.py diff --git a/README.md b/README.md index 54d8aca..f559b5f 100644 --- a/README.md +++ b/README.md @@ -90,15 +90,21 @@ uv build # build sdist and wheel ## Migrating from 0.1.x -The old CamelCase module paths still work but emit a `DeprecationWarning`: +Import from the package root — the old CamelCase module paths were removed +(keeping them silently corrupted the package namespace when both import +styles were used in one process): ```python -from vapix_python.VapixAPI import VapixAPI # deprecated -from vapix_python import VapixAPI # preferred +from vapix_python.VapixAPI import VapixAPI # 0.1.x — no longer works +from vapix_python import VapixAPI # 0.2.0 ``` Notable fixes in 0.2.0: -- Request timeouts are now actually applied (previously the `timeout` argument was silently ignored). +- Request timeouts are now actually applied (previously the `timeout` argument was silently ignored, so calls could hang forever). - `PTZControl.rename_preset_number(number, name)` now sends the number and name in the correct fields (they were swapped). +- `PTZControl.ptz_enabled(channel)` now actually queries the given channel (`info=1&camera=`; previously the channel was sent as the `info` value). +- PTZ commands the camera rejects with an `Error: ...` body now raise `VapixResponseError` instead of silently returning success. - Geolocation responses are parsed robustly (namespaced XML supported, no stray `print()` output) and camera-reported errors raise `VapixResponseError`. +- Errors are raised as `vapix_python` exception types; `VapixRequestError` still subclasses `requests.RequestException`, so existing `except requests.RequestException:` handlers keep working. +- HTTP 401 **and** 403 raise `VapixAuthenticationError` (bad credentials vs. insufficient privileges). diff --git a/pyproject.toml b/pyproject.toml index 8dde46e..4c90c65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [build-system] -requires = ["hatchling"] +# hatchling >= 1.27 is needed for the PEP 639 license / license-files fields +requires = ["hatchling>=1.27"] build-backend = "hatchling.build" [project] @@ -31,9 +32,6 @@ dependencies = ["requests>=2.28"] Homepage = "https://github.com/derens99/vapix-python" Issues = "https://github.com/derens99/vapix-python/issues" -[project.optional-dependencies] -dev = ["pytest>=7", "ruff>=0.4"] - [dependency-groups] dev = ["pytest>=7", "ruff>=0.4"] @@ -50,15 +48,6 @@ src = ["src", "tests"] [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B", "SIM"] -ignore = [ - "UP007", # keep Optional[X] for Python 3.9 compatibility in runtime positions -] - -[tool.ruff.lint.per-file-ignores] -# Deprecated compat shims intentionally shadow their own class names -"src/vapix_python/VapixAPI.py" = ["E402"] -"src/vapix_python/PTZControl.py" = ["E402"] -"src/vapix_python/GeolocationAPI.py" = ["E402"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 54b137f..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -# Runtime dependencies are declared in pyproject.toml; this file is kept for -# convenience (pip install -r requirements.txt). -requests>=2.28 diff --git a/src/vapix_python/GeolocationAPI.py b/src/vapix_python/GeolocationAPI.py deleted file mode 100644 index 59ce418..0000000 --- a/src/vapix_python/GeolocationAPI.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Deprecated module path — use ``from vapix_python import GeolocationAPI`` instead.""" - -import warnings - -from .geolocation_api import GeolocationAPI - -warnings.warn( - "vapix_python.GeolocationAPI is deprecated; import from vapix_python instead " - "(from vapix_python import GeolocationAPI)", - DeprecationWarning, - stacklevel=2, -) - -__all__ = ["GeolocationAPI"] diff --git a/src/vapix_python/PTZControl.py b/src/vapix_python/PTZControl.py deleted file mode 100644 index 0030747..0000000 --- a/src/vapix_python/PTZControl.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Deprecated module path — use ``from vapix_python import PTZControl`` instead.""" - -import warnings - -from .ptz_control import PTZControl - -warnings.warn( - "vapix_python.PTZControl is deprecated; import from vapix_python instead " - "(from vapix_python import PTZControl)", - DeprecationWarning, - stacklevel=2, -) - -__all__ = ["PTZControl"] diff --git a/src/vapix_python/VapixAPI.py b/src/vapix_python/VapixAPI.py deleted file mode 100644 index 5895780..0000000 --- a/src/vapix_python/VapixAPI.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Deprecated module path — use ``from vapix_python import VapixAPI`` instead.""" - -import warnings - -from .vapix_api import VapixAPI - -warnings.warn( - "vapix_python.VapixAPI is deprecated; import from vapix_python instead " - "(from vapix_python import VapixAPI)", - DeprecationWarning, - stacklevel=2, -) - -__all__ = ["VapixAPI"] diff --git a/src/vapix_python/__init__.py b/src/vapix_python/__init__.py index 9a28cee..7adbdf6 100644 --- a/src/vapix_python/__init__.py +++ b/src/vapix_python/__init__.py @@ -6,13 +6,14 @@ VapixRequestError, VapixResponseError, ) -from .geolocation_api import GeolocationAPI +from .geolocation_api import GeolocationAPI, GeoPosition from .ptz_control import PTZControl from .vapix_api import VapixAPI __version__ = "0.2.0" __all__ = [ + "GeoPosition", "GeolocationAPI", "PTZControl", "VapixAPI", diff --git a/src/vapix_python/_parsing.py b/src/vapix_python/_parsing.py new file mode 100644 index 0000000..fab5544 --- /dev/null +++ b/src/vapix_python/_parsing.py @@ -0,0 +1,13 @@ +"""Internal helpers for parsing VAPIX response bodies.""" + +from __future__ import annotations + + +def parse_key_value(text: str) -> dict[str, str]: + """Parse a VAPIX ``key=value`` line response into a dict.""" + values: dict[str, str] = {} + for line in text.splitlines(): + key, sep, value = line.partition("=") + if sep: + values[key.strip()] = value.strip() + return values diff --git a/src/vapix_python/exceptions.py b/src/vapix_python/exceptions.py index e90a9af..6abd98f 100644 --- a/src/vapix_python/exceptions.py +++ b/src/vapix_python/exceptions.py @@ -1,18 +1,22 @@ """Exception types raised by the vapix-python library.""" -from __future__ import annotations +import requests class VapixError(Exception): """Base class for all errors raised by vapix-python.""" -class VapixRequestError(VapixError): - """The HTTP request to the camera failed (network error or HTTP error status).""" +class VapixRequestError(VapixError, requests.RequestException): + """The HTTP request to the camera failed (network error or HTTP error status). + + Also subclasses :class:`requests.RequestException` so pre-0.2.0 code that + catches ``requests.RequestException`` keeps working. + """ class VapixAuthenticationError(VapixRequestError): - """The camera rejected the supplied credentials (HTTP 401).""" + """The camera rejected the request's credentials or privileges (HTTP 401/403).""" class VapixResponseError(VapixError): diff --git a/src/vapix_python/geolocation_api.py b/src/vapix_python/geolocation_api.py index 74e4d03..c4f40cc 100644 --- a/src/vapix_python/geolocation_api.py +++ b/src/vapix_python/geolocation_api.py @@ -3,7 +3,7 @@ from __future__ import annotations import xml.etree.ElementTree as ET -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, TypedDict from .exceptions import VapixResponseError @@ -12,32 +12,46 @@ _ENDPOINT = "geolocation" +# Values some firmware uses where the spec says "false"; anything else counts +# as valid, matching the lenient parsing of pre-0.2.0 releases. +_FALSY_FLAGS = frozenset({"", "false", "no", "0"}) -def _local_name(tag: str) -> str: - """Strip an XML namespace from a tag name.""" - return tag.rsplit("}", 1)[-1] +class GeoPosition(TypedDict): + """Parsed result of :meth:`GeolocationAPI.get_position`.""" + + lat: float + lon: float + heading: float + valid_position: bool + valid_heading: bool -def _find_text(root: ET.Element, name: str) -> str | None: - """Find the text of the first element with the given local name, namespace-agnostic.""" - for elem in root.iter(): - if _local_name(elem.tag) == name and elem.text is not None: - return elem.text.strip() - return None +def _collect_fields(text: str) -> dict[str, str]: + """Parse XML and map each local tag name to its first non-empty text. -def _parse_xml(text: str) -> ET.Element: + Namespace-agnostic single pass; element *presence* is recorded even when + the element has no text (empty string), so callers can detect tags like + ```` that only carry children. + """ try: - return ET.fromstring(text) + root = ET.fromstring(text) except ET.ParseError as exc: raise VapixResponseError(f"Camera returned invalid XML: {text!r}") from exc - -def _raise_on_error(root: ET.Element) -> None: + fields: dict[str, str] = {} for elem in root.iter(): - if _local_name(elem.tag) == "Error": - description = _find_text(elem, "ErrorDescription") or "unknown error" - raise VapixResponseError(f"Geolocation request failed: {description}") + name = elem.tag.rsplit("}", 1)[-1] + value = (elem.text or "").strip() + if name not in fields or (not fields[name] and value): + fields[name] = value + return fields + + +def _raise_on_error(fields: dict[str, str]) -> None: + if "Error" in fields: + description = fields.get("ErrorDescription") or "unknown error" + raise VapixResponseError(f"Geolocation request failed: {description}") class GeolocationAPI: @@ -46,7 +60,7 @@ class GeolocationAPI: def __init__(self, api: VapixAPI) -> None: self.api = api - def get_position(self) -> dict[str, Any]: + def get_position(self) -> GeoPosition: """Get the camera's configured location and heading. Returns: @@ -57,26 +71,20 @@ def get_position(self) -> dict[str, Any]: VapixResponseError: The camera reported an error or the response could not be parsed. """ - resp = self.api._send_request_vanilla(f"{_ENDPOINT}/get.cgi") - root = _parse_xml(resp) - _raise_on_error(root) - - lat = _find_text(root, "Lat") - lon = _find_text(root, "Lng") - heading = _find_text(root, "Heading") - if lat is None or lon is None or heading is None: - raise VapixResponseError(f"Geolocation response missing position data: {resp!r}") + resp = self.api._send_request(f"{_ENDPOINT}/get.cgi") + fields = _collect_fields(resp) + _raise_on_error(fields) - valid_position = (_find_text(root, "ValidPosition") or "").lower() == "true" - valid_heading = (_find_text(root, "ValidHeading") or "").lower() == "true" + if not all(name in fields for name in ("Lat", "Lng", "Heading")): + raise VapixResponseError(f"Geolocation response missing position data: {resp!r}") try: return { - "lat": float(lat), - "lon": float(lon), - "heading": float(heading), - "valid_position": valid_position, - "valid_heading": valid_heading, + "lat": float(fields["Lat"]), + "lon": float(fields["Lng"]), + "heading": float(fields["Heading"]), + "valid_position": fields.get("ValidPosition", "").lower() not in _FALSY_FLAGS, + "valid_heading": fields.get("ValidHeading", "").lower() not in _FALSY_FLAGS, } except ValueError as exc: raise VapixResponseError( @@ -98,10 +106,13 @@ def set_position(self, lat: float, lon: float, heading: float = 0, text: str = " Raises: VapixResponseError: The camera rejected the new position. """ - resp = self.api._send_request_vanilla( + resp = self.api._send_request( f"{_ENDPOINT}/set.cgi", method="POST", params={"lat": lat, "lng": lon, "heading": heading, "text": text}, ) - _raise_on_error(_parse_xml(resp)) + # Some firmware answers a successful set with an empty or plain-text + # body; only XML bodies can carry a structured to check for. + if resp.lstrip().startswith("<"): + _raise_on_error(_collect_fields(resp)) return True diff --git a/src/vapix_python/ptz_control.py b/src/vapix_python/ptz_control.py index d2292e8..41f1e29 100644 --- a/src/vapix_python/ptz_control.py +++ b/src/vapix_python/ptz_control.py @@ -2,9 +2,11 @@ from __future__ import annotations +import time from collections.abc import Mapping from typing import TYPE_CHECKING, Any +from ._parsing import parse_key_value from .exceptions import VapixResponseError if TYPE_CHECKING: # imported for type hints only, avoids a circular import @@ -13,16 +15,6 @@ _PTZ_ENDPOINT = "com/ptz.cgi" -def _parse_key_value_response(text: str) -> dict[str, str]: - """Parse a VAPIX ``key=value`` line response into a dict.""" - values: dict[str, str] = {} - for line in text.splitlines(): - if "=" in line: - key, _, value = line.partition("=") - values[key.strip()] = value.strip() - return values - - class PTZControl: """Pan-Tilt-Zoom control for an Axis camera.""" @@ -30,7 +22,30 @@ def __init__(self, api: VapixAPI) -> None: self.api = api def _command(self, params: Mapping[str, Any]) -> str: - return self.api._send_request(_PTZ_ENDPOINT, params=params) + """Send a ``ptz.cgi`` command with the standard base arguments. + + ``ptz.cgi`` expects ``camera``/``html``/``timestamp`` on every call; + explicit ``params`` override the defaults. Some firmware reports + command failures as an ``Error: ...`` body with HTTP 200, so that is + surfaced as :class:`VapixResponseError` instead of being swallowed. + """ + payload: dict[str, Any] = { + "camera": self.api.camera, + "html": "no", + "timestamp": int(time.time()), + } + payload.update(params) + resp = self.api._send_request(_PTZ_ENDPOINT, params=payload) + if resp.lstrip().startswith("Error"): + raise VapixResponseError(f"PTZ command rejected by camera: {resp.strip()}") + return resp + + def _set_level(self, param: str, value: int, label: str) -> bool: + """Validate a 0-9999 level and send it as a single-parameter command.""" + if not 0 <= value <= 9999: + raise ValueError(f"{label} must be between 0 and 9999.") + self._command({param: value}) + return True # ------------------------------------------------------------------ # Position queries @@ -45,7 +60,7 @@ def get_current_position(self) -> tuple[float, float, float]: VapixResponseError: The camera response did not contain a position. """ resp = self._command({"query": "position"}) - values = _parse_key_value_response(resp) + values = parse_key_value(resp) try: return float(values["pan"]), float(values["tilt"]), float(values["zoom"]) except (KeyError, ValueError) as exc: @@ -57,16 +72,20 @@ def get_current_ptz(self) -> str: """Get the raw position query response as returned by the camera.""" return self._command({"query": "position"}) - def ptz_enabled(self, channel: int = 1) -> str: + def ptz_enabled(self, channel: int | None = None) -> str: """Check whether PTZ is available. Args: - channel: The video channel to check. + channel: The video channel to check. Defaults to the client's + configured camera number. Returns: The list of available PTZ commands, or an empty string if disabled. """ - return self._command({"info": channel}) + params: dict[str, Any] = {"info": "1"} + if channel is not None: + params["camera"] = channel + return self._command(params) # ------------------------------------------------------------------ # Movement @@ -143,31 +162,19 @@ def set_move_speed(self, speed: int) -> None: # ------------------------------------------------------------------ def set_iris(self, iris_level: int = 1750) -> bool: """Set the iris to the given level (0-9999).""" - if not 0 <= iris_level <= 9999: - raise ValueError("Iris level must be between 0 and 9999.") - self._command({"iris": iris_level}) - return True + return self._set_level("iris", iris_level, "Iris level") def set_focus(self, focus_level: int) -> bool: """Set the focus to the given level (0-9999).""" - if not 0 <= focus_level <= 9999: - raise ValueError("Focus level must be between 0 and 9999.") - self._command({"focus": focus_level}) - return True + return self._set_level("focus", focus_level, "Focus level") def set_zoom(self, zoom_level: int) -> bool: """Set the zoom to the given level (0-9999).""" - if not 0 <= zoom_level <= 9999: - raise ValueError("Zoom level must be between 0 and 9999.") - self._command({"zoom": zoom_level}) - return True + return self._set_level("zoom", zoom_level, "Zoom level") def set_brightness(self, brightness_level: int) -> bool: """Set the brightness to the given level (0-9999).""" - if not 0 <= brightness_level <= 9999: - raise ValueError("Brightness level must be between 0 and 9999.") - self._command({"brightness": brightness_level}) - return True + return self._set_level("brightness", brightness_level, "Brightness level") def set_autofocus(self, enabled: bool = True) -> bool: """Enable or disable autofocus.""" diff --git a/src/vapix_python/vapix_api.py b/src/vapix_python/vapix_api.py index efe8035..b4457b4 100644 --- a/src/vapix_python/vapix_api.py +++ b/src/vapix_python/vapix_api.py @@ -2,7 +2,6 @@ from __future__ import annotations -import time from collections.abc import Mapping from types import TracebackType from typing import Any @@ -10,6 +9,7 @@ import requests from requests.auth import HTTPBasicAuth, HTTPDigestAuth +from ._parsing import parse_key_value from .exceptions import VapixAuthenticationError, VapixRequestError from .geolocation_api import GeolocationAPI from .ptz_control import PTZControl @@ -37,7 +37,7 @@ class VapixAPI: or a path to a CA bundle. Only meaningful with ``secure=True``. auth_method: ``"digest"`` (default, what most Axis firmware expects) or ``"basic"``. - camera: Camera/channel number included in requests that take one. + camera: Camera/channel number used by endpoints that take one. """ def __init__( @@ -54,6 +54,7 @@ def __init__( ) -> None: self.host = host self.user = user + self.password = password self.timeout = timeout self.camera = camera @@ -80,19 +81,22 @@ def _send_request( endpoint: str, method: str = "GET", params: Mapping[str, Any] | None = None, - base_args: bool = True, ) -> str: """Send a request to a VAPIX endpoint and return the response body. + The transport is endpoint-agnostic: it sends exactly the parameters it + is given. Endpoint-specific arguments (like ``ptz.cgi``'s ``camera``/ + ``html``/``timestamp``) belong in the feature layer that knows about + them. + Args: endpoint: Endpoint path relative to ``/axis-cgi``, e.g. ``com/ptz.cgi``. method: ``"GET"`` or ``"POST"``. params: Query parameters (GET) or form data (POST). - base_args: Include the common ``camera``/``html``/``timestamp`` - arguments expected by most CGI endpoints. Raises: - VapixAuthenticationError: The camera returned HTTP 401. + VapixAuthenticationError: The camera returned HTTP 401 (bad + credentials) or 403 (insufficient privileges). VapixRequestError: The request failed at the network level or the camera returned another HTTP error status. """ @@ -101,14 +105,8 @@ def _send_request( raise ValueError(f"Unsupported HTTP method: {method!r}") url = f"{self.base_url}/{endpoint.lstrip('/')}" - payload: dict[str, Any] = {} - if base_args: - payload.update({"camera": self.camera, "html": "no", "timestamp": int(time.time())}) - if params: - payload.update(params) - request_kwargs: dict[str, Any] = ( - {"params": payload or None} if method == "GET" else {"data": payload or None} + {"params": params} if method == "GET" else {"data": params} ) try: response = self.session.request(method, url, timeout=self.timeout, **request_kwargs) @@ -119,21 +117,17 @@ def _send_request( raise VapixAuthenticationError( f"Authentication failed for {url} (HTTP 401) — check user/password" ) + if response.status_code == 403: + raise VapixAuthenticationError( + f"Access denied for {url} (HTTP 403) — the account lacks privileges " + "for this operation" + ) try: response.raise_for_status() except requests.HTTPError as exc: raise VapixRequestError(str(exc)) from exc return response.text - def _send_request_vanilla( - self, - endpoint: str, - method: str = "GET", - params: Mapping[str, Any] | None = None, - ) -> str: - """Send a request without the common base arguments.""" - return self._send_request(endpoint, method=method, params=params, base_args=False) - # ------------------------------------------------------------------ # General device helpers # ------------------------------------------------------------------ @@ -147,15 +141,9 @@ def get_parameters(self, group: str | None = None) -> dict[str, str]: Mapping of fully-qualified parameter names to their values. """ params: dict[str, Any] = {"action": "list"} - if group: + if group is not None: params["group"] = group - resp = self._send_request("param.cgi", params=params, base_args=False) - result: dict[str, str] = {} - for line in resp.splitlines(): - if "=" in line: - key, _, value = line.partition("=") - result[key.strip()] = value.strip() - return result + return parse_key_value(self._send_request("param.cgi", params=params)) # ------------------------------------------------------------------ # Lifecycle diff --git a/tests/test_geolocation_api.py b/tests/test_geolocation_api.py index 96fcd74..b148fc8 100644 --- a/tests/test_geolocation_api.py +++ b/tests/test_geolocation_api.py @@ -98,3 +98,29 @@ def test_set_position_raises_on_error(api, fake_session): fake_session.queue(ERROR_RESPONSE) with pytest.raises(VapixResponseError, match="Invalid latitude"): api.geolocation.set_position(999, 999) + + +@pytest.mark.parametrize("body", ["", " ", "OK"]) +def test_set_position_accepts_non_xml_success_body(api, fake_session, body): + """Some firmware answers a successful set.cgi with an empty or plain body.""" + fake_session.queue(body) + assert api.geolocation.set_position(1.0, 2.0) is True + + +@pytest.mark.parametrize("value", ["1", "yes", "TRUE"]) +def test_valid_flags_accept_nonstandard_truthy_values(api, fake_session, value): + fake_session.queue( + "123" + f"{value}false" + "" + ) + position = api.geolocation.get_position() + assert position["valid_position"] is True + assert position["valid_heading"] is False + + +def test_valid_flags_default_false_when_missing(api, fake_session): + fake_session.queue( + "123" + ) + assert api.geolocation.get_position()["valid_position"] is False diff --git a/tests/test_ptz_control.py b/tests/test_ptz_control.py index 95656ba..8efa6f5 100644 --- a/tests/test_ptz_control.py +++ b/tests/test_ptz_control.py @@ -16,11 +16,26 @@ def test_get_current_position_handles_reordered_response(api, fake_session): def test_get_current_position_raises_on_garbage(api, fake_session): - fake_session.queue("Error: PTZ not available") + fake_session.queue("pan is unavailable") with pytest.raises(VapixResponseError): api.ptz.get_current_position() +def test_camera_error_body_raises(api, fake_session): + fake_session.queue("Error: PTZ not available") + with pytest.raises(VapixResponseError, match="PTZ not available"): + api.ptz.set_home() + + +def test_commands_include_ptz_base_args(api, fake_session): + fake_session.queue() + api.ptz.absolute_move(10, 20, 30, 40) + params = fake_session.last["params"] + assert params["camera"] == 1 + assert params["html"] == "no" + assert "timestamp" in params + + def test_absolute_move_sends_expected_params(api, fake_session): fake_session.queue() api.ptz.absolute_move(10, 20, 30, 40) @@ -31,6 +46,14 @@ def test_absolute_move_sends_expected_params(api, fake_session): assert params["speed"] == 40 +def test_ptz_enabled_queries_info_for_channel(api, fake_session): + fake_session.queue("Available commands:\r\n...") + api.ptz.ptz_enabled(channel=2) + params = fake_session.last["params"] + assert params["info"] == "1" + assert params["camera"] == 2 + + def test_relative_move_sends_expected_params(api, fake_session): fake_session.queue() api.ptz.relative_move(1, 2, 3, 4) diff --git a/tests/test_vapix_api.py b/tests/test_vapix_api.py index 8b4a635..38f0a2c 100644 --- a/tests/test_vapix_api.py +++ b/tests/test_vapix_api.py @@ -7,6 +7,7 @@ from vapix_python import ( VapixAPI, VapixAuthenticationError, + VapixError, VapixRequestError, ) @@ -28,23 +29,26 @@ def test_invalid_auth_method_rejected(): VapixAPI("cam.local", "root", "secret", auth_method="ntlm") -def test_get_request_includes_base_args_and_timeout(api, fake_session): +def test_get_request_sends_params_and_timeout(api, fake_session): fake_session.queue("ok") - api._send_request("com/ptz.cgi", params={"query": "position"}) + api._send_request("some/endpoint.cgi", params={"query": "position"}) sent = fake_session.last assert sent["method"] == "GET" - assert sent["url"] == "http://192.168.0.90/axis-cgi/com/ptz.cgi" + assert sent["url"] == "http://192.168.0.90/axis-cgi/some/endpoint.cgi" assert sent["timeout"] == 5.0 - assert sent["params"]["camera"] == 1 - assert sent["params"]["html"] == "no" - assert "timestamp" in sent["params"] - assert sent["params"]["query"] == "position" + assert sent["params"] == {"query": "position"} + + +def test_transport_does_not_inject_extra_params(api, fake_session): + fake_session.queue("ok") + api._send_request("geolocation/get.cgi") + assert fake_session.last["params"] is None def test_post_request_sends_form_data(api, fake_session): fake_session.queue("ok") - api._send_request_vanilla("geolocation/set.cgi", method="POST", params={"lat": 1}) + api._send_request("geolocation/set.cgi", method="POST", params={"lat": 1}) sent = fake_session.last assert sent["method"] == "POST" @@ -52,12 +56,6 @@ def test_post_request_sends_form_data(api, fake_session): assert sent["params"] is None -def test_vanilla_request_omits_base_args(api, fake_session): - fake_session.queue("ok") - api._send_request_vanilla("geolocation/get.cgi") - assert fake_session.last["params"] is None - - def test_unsupported_method_raises(api): with pytest.raises(ValueError): api._send_request("com/ptz.cgi", method="DELETE") @@ -69,6 +67,12 @@ def test_http_401_raises_auth_error(api, fake_session): api._send_request("com/ptz.cgi") +def test_http_403_raises_auth_error(api, fake_session): + fake_session.queue("", status_code=403) + with pytest.raises(VapixAuthenticationError, match="privileges"): + api._send_request("com/ptz.cgi") + + def test_http_error_raises_request_error(api, fake_session): fake_session.queue("", status_code=500) with pytest.raises(VapixRequestError): @@ -84,23 +88,34 @@ def boom(*args, **kwargs): api._send_request("com/ptz.cgi") +def test_request_errors_catchable_as_requests_exception(): + """Pre-0.2.0 code catching requests.RequestException must keep working.""" + assert issubclass(VapixRequestError, requests.RequestException) + assert issubclass(VapixAuthenticationError, requests.RequestException) + assert issubclass(VapixRequestError, VapixError) + + def test_get_parameters_parses_key_values(api, fake_session): fake_session.queue("root.Brand.Brand=AXIS\nroot.Brand.ProdNbr=P5655-E\n") params = api.get_parameters(group="Brand") assert params == {"root.Brand.Brand": "AXIS", "root.Brand.ProdNbr": "P5655-E"} - assert fake_session.last["params"]["action"] == "list" - assert fake_session.last["params"]["group"] == "Brand" + assert fake_session.last["params"] == {"action": "list", "group": "Brand"} + + +def test_get_parameters_forwards_empty_group(api, fake_session): + fake_session.queue("") + api.get_parameters(group="") + assert fake_session.last["params"] == {"action": "list", "group": ""} + + +def test_credentials_available_on_client(): + with VapixAPI("cam.local", "root", "secret") as api: + assert api.user == "root" + assert api.password == "secret" def test_context_manager_closes_session(api, fake_session): with api: pass assert fake_session.closed - - -def test_deprecated_module_paths_still_work(): - with pytest.deprecated_call(): - from vapix_python.VapixAPI import VapixAPI as LegacyVapixAPI # noqa: PLC0415 - - assert LegacyVapixAPI is VapixAPI diff --git a/uv.lock b/uv.lock index 97356ec..68ce042 100644 --- a/uv.lock +++ b/uv.lock @@ -415,13 +415,6 @@ dependencies = [ { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -[package.optional-dependencies] -dev = [ - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "ruff" }, -] - [package.dev-dependencies] dev = [ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -430,12 +423,7 @@ dev = [ ] [package.metadata] -requires-dist = [ - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7" }, - { name = "requests", specifier = ">=2.28" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, -] -provides-extras = ["dev"] +requires-dist = [{ name = "requests", specifier = ">=2.28" }] [package.metadata.requires-dev] dev = [