From 6079d62810192240ef45e7af9d1b7800822f3287 Mon Sep 17 00:00:00 2001 From: Jeffry Babb Date: Sat, 27 Jun 2026 07:38:33 -0500 Subject: [PATCH 1/6] feat: modernize X-Plane Web API client --- .github/workflows/ci.yml | 47 +- .gitignore | 17 +- .pre-commit-config.yaml | 30 + .python-version | 1 + .secrets.baseline | 127 ++++ docs/reference/async-rest.md | 3 + docs/reference/beacon.md | 3 + docs/reference/core-api.md | 3 + docs/reference/exceptions.md | 3 + docs/reference/index.md | 16 +- docs/reference/logging.md | 3 + docs/reference/package.md | 5 + docs/reference/rest.md | 3 + docs/reference/udp.md | 3 + docs/reference/websocket.md | 3 + docs/usage/index.md | 277 +++++-- examples/fdr.py | 16 +- examples/geoutil.py | 10 +- examples/oooi.py | 34 +- examples/posreport.py | 4 +- examples/simple_beacon.py | 2 +- examples/simple_monitor.py | 4 +- examples/simple_upd.py | 2 +- examples/simple_ws.py | 4 +- examples/template.py | 2 +- examples/unitutil.py | 4 +- examples/xgs.py | 50 +- examples/xpwsapp.py | 17 +- mkdocs.yml | 15 +- pyproject.toml | 107 +-- tests/__init__.py | 12 + tests/helpers.py | 105 +++ tests/test_api.py | 347 +++++++++ tests/test_async_rest.py | 346 +++++++++ tests/test_beacon.py | 198 +++++ tests/test_documentation.py | 87 +++ tests/test_exceptions.py | 99 +++ tests/test_logging_config.py | 282 ++++++++ tests/test_quality_tool.py | 99 +++ tests/test_rest.py | 348 +++++++++ tests/test_type_annotations.py | 41 ++ tests/test_udp.py | 248 +++++++ tests/test_ws.py | 446 ++++++++++++ tools/quality.py | 121 ++++ uv.lock | 1236 ++++++++++++++++++++++++++++++++ xpwebapi/__init__.py | 49 +- xpwebapi/api.py | 242 +++++-- xpwebapi/async_rest.py | 406 +++++++++++ xpwebapi/beacon.py | 61 +- xpwebapi/exceptions.py | 27 + xpwebapi/logging_config.py | 208 ++++++ xpwebapi/rest.py | 325 ++++++--- xpwebapi/retry.py | 44 ++ xpwebapi/udp.py | 88 ++- xpwebapi/ws.py | 475 +++++++----- 55 files changed, 6145 insertions(+), 610 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 .python-version create mode 100644 .secrets.baseline create mode 100644 docs/reference/async-rest.md create mode 100644 docs/reference/beacon.md create mode 100644 docs/reference/core-api.md create mode 100644 docs/reference/exceptions.md create mode 100644 docs/reference/logging.md create mode 100644 docs/reference/package.md create mode 100644 docs/reference/rest.md create mode 100644 docs/reference/udp.md create mode 100644 docs/reference/websocket.md create mode 100644 tests/__init__.py create mode 100644 tests/helpers.py create mode 100644 tests/test_api.py create mode 100644 tests/test_async_rest.py create mode 100644 tests/test_beacon.py create mode 100644 tests/test_documentation.py create mode 100644 tests/test_exceptions.py create mode 100644 tests/test_logging_config.py create mode 100644 tests/test_quality_tool.py create mode 100644 tests/test_rest.py create mode 100644 tests/test_type_annotations.py create mode 100644 tests/test_udp.py create mode 100644 tests/test_ws.py create mode 100644 tools/quality.py create mode 100644 uv.lock create mode 100644 xpwebapi/async_rest.py create mode 100644 xpwebapi/exceptions.py create mode 100644 xpwebapi/logging_config.py create mode 100644 xpwebapi/retry.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf9e961..f33255f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,30 +1,43 @@ name: ci + on: push: branches: - - master - main + pull_request: + permissions: - contents: write + contents: read + jobs: - deploy: + quality: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: 3.x - - uses: actions/cache@v4 + python-version: "3.12" + - run: python -m pip install uv + - run: uv sync --frozen + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run ty check + - run: uv run python -m unittest discover -v + - run: uv build + + docs: + needs: quality + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 with: - key: ${{ github.ref }} - path: .cache - # - run: pip install -r requirements.txt - - run: pip install pymdown-extensions - - run: pip install mkdocs - - run: pip install mkdocs-material - - run: pip install mkdocs-charts-plugin - - run: pip install mkdocs-callouts - - run: pip install mkdocs-git-revision-date-localized-plugin - - run: pip install mkdocstrings - - run: pip install mkdocstrings-python - - run: mkdocs gh-deploy --force + python-version: "3.12" + - run: python -m pip install uv + - run: uv sync --frozen + - run: uv run mkdocs gh-deploy --force diff --git a/.gitignore b/.gitignore index 7952354..58e41c5 100644 --- a/.gitignore +++ b/.gitignore @@ -138,16 +138,17 @@ venv.bak/ # mkdocs documentation /site -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json +# ruff +.ruff_cache/ -# Pyre type checker -.pyre/ +# ty type checker +.ty_cache/ -# pytype static type analyzer -.pytype/ +# Local git worktrees +.worktrees/ + +# wily metrics cache +.wily/ # Cython debug symbols cython_debug/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..13678b0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +default_stages: [pre-commit] +fail_fast: true + +repos: + - repo: local + hooks: + - id: quality-check + name: quality check + entry: uv run python tools/quality.py check + language: system + pass_filenames: false + + - id: detect-secrets-baseline + name: detect-secrets baseline + entry: uv run detect-secrets-hook --baseline .secrets.baseline + language: system + + - id: lizard-report + name: lizard report + entry: uv run lizard xpwebapi -i -1 + language: system + pass_filenames: false + types: [python] + + - id: cohesion-report + name: cohesion report + entry: uv run cohesion -d xpwebapi + language: system + pass_filenames: false + types: [python] diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/.secrets.baseline b/.secrets.baseline new file mode 100644 index 0000000..5f8d878 --- /dev/null +++ b/.secrets.baseline @@ -0,0 +1,127 @@ +{ + "version": "1.5.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "GitLabTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "IPPublicDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "OpenAIDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "PypiTokenDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TelegramBotTokenDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + } + ], + "results": {}, + "generated_at": "2026-06-19T19:34:38Z" +} diff --git a/docs/reference/async-rest.md b/docs/reference/async-rest.md new file mode 100644 index 0000000..f50d9d7 --- /dev/null +++ b/docs/reference/async-rest.md @@ -0,0 +1,3 @@ +# Async REST + +::: xpwebapi.async_rest diff --git a/docs/reference/beacon.md b/docs/reference/beacon.md new file mode 100644 index 0000000..fa26163 --- /dev/null +++ b/docs/reference/beacon.md @@ -0,0 +1,3 @@ +# Beacon + +::: xpwebapi.beacon diff --git a/docs/reference/core-api.md b/docs/reference/core-api.md new file mode 100644 index 0000000..0402f82 --- /dev/null +++ b/docs/reference/core-api.md @@ -0,0 +1,3 @@ +# Core API + +::: xpwebapi.api diff --git a/docs/reference/exceptions.md b/docs/reference/exceptions.md new file mode 100644 index 0000000..f031eea --- /dev/null +++ b/docs/reference/exceptions.md @@ -0,0 +1,3 @@ +# Exceptions + +::: xpwebapi.exceptions diff --git a/docs/reference/index.md b/docs/reference/index.md index 9ed84bf..7c0b68e 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -4,6 +4,16 @@ hide: - navigation --- -# ::: xpwebapi - options: - show_submodules: true +# API reference + +The reference section is generated from source docstrings with `mkdocstrings`. + +- [Package](package.md) +- [Core API](core-api.md) +- [REST](rest.md) +- [Async REST](async-rest.md) +- [WebSocket](websocket.md) +- [UDP](udp.md) +- [Beacon](beacon.md) +- [Exceptions](exceptions.md) +- [Logging](logging.md) diff --git a/docs/reference/logging.md b/docs/reference/logging.md new file mode 100644 index 0000000..1966476 --- /dev/null +++ b/docs/reference/logging.md @@ -0,0 +1,3 @@ +# Logging + +::: xpwebapi.logging_config diff --git a/docs/reference/package.md b/docs/reference/package.md new file mode 100644 index 0000000..346d932 --- /dev/null +++ b/docs/reference/package.md @@ -0,0 +1,5 @@ +# Package + +::: xpwebapi + options: + show_submodules: false diff --git a/docs/reference/rest.md b/docs/reference/rest.md new file mode 100644 index 0000000..01c6ee2 --- /dev/null +++ b/docs/reference/rest.md @@ -0,0 +1,3 @@ +# REST + +::: xpwebapi.rest diff --git a/docs/reference/udp.md b/docs/reference/udp.md new file mode 100644 index 0000000..64a7746 --- /dev/null +++ b/docs/reference/udp.md @@ -0,0 +1,3 @@ +# UDP + +::: xpwebapi.udp diff --git a/docs/reference/websocket.md b/docs/reference/websocket.md new file mode 100644 index 0000000..d027838 --- /dev/null +++ b/docs/reference/websocket.md @@ -0,0 +1,3 @@ +# WebSocket + +::: xpwebapi.ws diff --git a/docs/usage/index.md b/docs/usage/index.md index 70fe3eb..1f3790a 100644 --- a/docs/usage/index.md +++ b/docs/usage/index.md @@ -1,99 +1,266 @@ --- -# Python wrapper for X-Plane Web API +title: Usage +--- + +# Usage + +## Connection lifecycle + +Use context managers for REST clients so the underlying HTTP session is closed even when a request fails. + +```python +import xpwebapi + + +with xpwebapi.rest_api(host="127.0.0.1", port=8086, api_version="v2") as api: + print(api.capabilities) + + clock = api.dataref("sim/cockpit2/clock_timer/local_time_seconds") + print(api.dataref_value(clock)) +``` + +Async REST clients follow the same pattern with `async with`. + +```python +import asyncio + +import xpwebapi + + +async def main() -> None: + async with xpwebapi.async_rest_api(api_version="v2") as api: + clock = api.dataref("sim/cockpit2/clock_timer/local_time_seconds") + value = await api.dataref_value(clock) + print(value) + + +asyncio.run(main()) +``` + +When creating many short-lived synchronous REST API objects, opt in to shared HTTP connection pooling. Instances with matching pool configuration reuse the same underlying `httpx.Client`; the shared client closes after the last pooled API instance is closed. + +```python +import xpwebapi + + +first = xpwebapi.rest_api( + api_version="v2", + pool_connections=True, + max_connections=8, + max_keepalive_connections=4, + keepalive_expiry=30.0, + timeout=5.0, +) +second = xpwebapi.rest_api( + api_version="v2", + pool_connections=True, + max_connections=8, + max_keepalive_connections=4, + keepalive_expiry=30.0, + timeout=5.0, +) + +try: + print(first.capabilities) + clock = second.dataref("sim/cockpit2/clock_timer/local_time_seconds") + print(second.dataref_value(clock)) +finally: + first.close() + second.close() +``` + +For WebSocket and UDP clients, explicitly stop monitoring/listener threads before disconnecting when you do not use a context manager. + +```python +import xpwebapi + + +ws = xpwebapi.ws_api(api_version="v2") +try: + ws.connect() + ws.wait_connection() + ws.start(release=True) +finally: + ws.stop() + ws.disconnect() +``` + +## Monitoring datarefs + +WebSocket monitoring is callback-driven. Register the callback, connect, subscribe to the datarefs, then start the listener. +Use `monitor_datarefs()` when subscribing to more than one dataref; it sends one WebSocket `dataref_subscribe_values` request for all newly monitored datarefs instead of one request per dataref. +The matching `unmonitor_datarefs()` helper batches unsubscribe requests for datarefs whose monitor count reaches zero. +Selected array elements that share the same dataref id are grouped into one request with an index list. + +```python +from typing import Any + +import xpwebapi + + +def dataref_monitor(dataref: str, value: Any) -> None: + print(f"{dataref}={value}") + + +ws = xpwebapi.ws_api(api_version="v2") +ws.add_callback( + cbtype=xpwebapi.CALLBACK_TYPE.ON_DATAREF_UPDATE, + callback=dataref_monitor, +) + +ws.connect() +ws.wait_connection() + +datarefs = [ + ws.dataref("sim/cockpit2/clock_timer/local_time_seconds"), + ws.dataref("sim/flightmodel/position/latitude"), + ws.dataref("sim/flightmodel/position/longitude"), +] +ws.monitor_datarefs(datarefs=datarefs, reason="example") +ws.start(release=True) +``` + +UDP monitoring uses the X-Plane beacon to discover the simulator before subscribing. + +```python +from typing import Any + +import xpwebapi + + +def dataref_monitor(dataref: str, value: Any) -> None: + print(f"{dataref}={value}") + + +beacon = xpwebapi.beacon() +beacon.start_monitor() +beacon.wait_for_beacon() + +udp = xpwebapi.udp_api(beacon=beacon) +udp.add_callback(callback=dataref_monitor) +udp.monitor_dataref(udp.dataref("sim/flightmodel/position/indicated_airspeed")) +udp.start(release=True) +``` -## Usage of REST API +## Executing commands + +Create a command object from the API instance that will execute it. REST and UDP calls return immediate success/failure values; WebSocket command execution can return a queued request id. ```python import xpwebapi -# assuming both app and simulator on same host computer -api = xpwebapi.rest_api() -print(api.capabilities) -# {'api': {'versions': ['v1', 'v2', 'v3']}, 'x-plane': {'version': '12.2.1'}} +with xpwebapi.rest_api(api_version="v2") as api: + mapview = api.command("sim/map/show_current") + result = mapview.execute() + print(result) +``` -api.set_api_version(api_version="v2") +Long-running commands can pass an explicit duration when the transport supports it. -dataref = api.dataref("sim/cockpit2/clock_timer/local_time_seconds") -print(dataref) -# sim/cockpit2/clock_timer/local_time_seconds=42 +```python +import xpwebapi -mapview = api.command("sim/map/show_current") -mapview.execute() + +with xpwebapi.ws_api(api_version="v2") as ws: + ws.connect() + ws.wait_connection() + + brakes = ws.command("sim/flight_controls/brakes_toggle_max") + request_id = ws.execute_command(brakes, duration=0.5) + print(request_id) ``` -## Usage of Websocket API +## REST API ```python -from xpwebapi import ws_api, CALLBACK_TYPE +import xpwebapi -ws = ws_api() -# Callback function when dataref value changes -def dataref_monitor(dataref: str, value: Any): +with xpwebapi.rest_api(api_version="v2") as api: + print(api.capabilities) + + dataref = api.dataref("sim/cockpit2/clock_timer/local_time_seconds") + print(dataref) + + mapview = api.command("sim/map/show_current") + mapview.execute() +``` + +## Async REST API + +```python +import asyncio + +import xpwebapi + + +async def main() -> None: + async with xpwebapi.async_rest_api(api_version="v2") as api: + dataref = api.dataref("sim/cockpit2/clock_timer/local_time_seconds") + value = await api.dataref_value(dataref) + print(value) + + mapview = api.command("sim/map/show_current") + await api.execute_command(mapview) + + +asyncio.run(main()) +``` + +## WebSocket API + +```python +from typing import Any + +import xpwebapi + + +def dataref_monitor(dataref: str, value: Any) -> None: print(f"dataref updated: {dataref}={value}") -ws.add_callback(cbtype=CALLBACK_TYPE.DATAREF_UPDATE, callback=dataref_monitor) -# Callback function when command gets executed in simulator -def command_active_monitor(command: str, active: bool): +def command_active_monitor(command: str, active: bool) -> None: print(f"command activated: {command}={active}") -ws.add_callback(cbtype=CALLBACK_TYPE.COMMAND_ACTIVE, callback=command_active_monitor) -# Let's go +ws = xpwebapi.ws_api(api_version="v2") +ws.add_callback(cbtype=xpwebapi.CALLBACK_TYPE.ON_DATAREF_UPDATE, callback=dataref_monitor) +ws.add_callback(cbtype=xpwebapi.CALLBACK_TYPE.ON_COMMAND_ACTIVE, callback=command_active_monitor) + ws.connect() -ws.wait_connection() # blocks until X-Plane is reachable +ws.wait_connection() dataref = ws.dataref("sim/cockpit2/clock_timer/local_time_seconds") ws.monitor_dataref(dataref) -# alternative: -# dataref.monitor() - ws.monitor_command_active(ws.command("sim/map/show_current")) -# alternative: -# command = ws.command("sim/map/show_current") -# command.monitor() ws.start(release=True) - -time.sleep(30) #secs - -print("terminating..") -ws.stop() -print("..disconnecting..") -ws.disconnect() -print("..terminated") ``` -## Usage of UDP API +## UDP API -``` -import time +```python from typing import Any + import xpwebapi -# Callback function when dataref value changes -def dataref_monitor(dataref: str, value: Any): + +def dataref_monitor(dataref: str, value: Any) -> None: print(f"{dataref}={value}") -# UDP API + beacon = xpwebapi.beacon() beacon.start_monitor() beacon.wait_for_beacon() -xp = xpwebapi.udp_api(beacon=beacon) - -# In the case of UDP, there are no different types of callbacks -# just for dataref value changes -xp.add_callback(callback=dataref_monitor) - -mapview = xp.command("sim/map/show_current") -xp.execute_command(mapview) - -xp.monitor_dataref(xp.dataref(path="sim/flightmodel/position/indicated_airspeed")) -xp.monitor_dataref(xp.dataref(path="sim/flightmodel/position/latitude")) +udp = xpwebapi.udp_api(beacon=beacon) +udp.add_callback(callback=dataref_monitor) -xp.start() +mapview = udp.command("sim/map/show_current") +udp.execute_command(mapview) +udp.monitor_dataref(udp.dataref(path="sim/flightmodel/position/indicated_airspeed")) +udp.monitor_dataref(udp.dataref(path="sim/flightmodel/position/latitude")) +udp.start(release=True) ``` diff --git a/examples/fdr.py b/examples/fdr.py index 4a88ad2..94d7973 100644 --- a/examples/fdr.py +++ b/examples/fdr.py @@ -17,7 +17,7 @@ import logging import argparse import datetime -from typing import Dict +from typing import Any, Dict from time import sleep sys.path.append(os.path.join(os.path.dirname(__file__), "..")) @@ -100,7 +100,7 @@ class FDR(XPWSAPIApp): - def __init__(self, api, filename: str = FDR_FILENAME, frequency: float = WRITE_FREQUENCY) -> None: + def __init__(self, api: xpwebapi.XPWebsocketAPI, filename: str = FDR_FILENAME, frequency: float = WRITE_FREQUENCY) -> None: XPWSAPIApp.__init__(self, api=api) self.filename = filename @@ -118,7 +118,7 @@ def header_ok(self) -> bool: def get_dataref_names(self) -> set: return HEADER | set(FDR_DATA) | set(FDR_OPTIONAL) - def dataref_value(self, dataref: str, is_string: bool = False, rounding: int | None = None): + def dataref_value(self, dataref: str, is_string: bool = False, rounding: int | None = None) -> Any: dref = self.datarefs.get(dataref) if dref is None: logger.warning(f"dataref {dataref} not found") @@ -130,7 +130,7 @@ def dataref_value(self, dataref: str, is_string: bool = False, rounding: int | N return round(dref.value, rounding) return dref.value - def print_header(self): + def print_header(self) -> None: with open(self.filename, "w") as fp: # FDR Header print("A\r4\n", file=fp) # note A may not be visible on Apple computers because of simple carriage return after it (no new line) @@ -186,7 +186,7 @@ def print_line(self) -> str: optional = "" if len(self.optional_datarefs) == 0 else ", " + ", ".join([f"{self.dataref_value(d)}" for d in self.optional_datarefs.keys()]) return base + optional + "\n" - def loop(self): + def loop(self) -> None: r = 100000 if REPORT_FREQUENCY > 0: r = int(self.frequency if self.frequency > REPORT_FREQUENCY else REPORT_FREQUENCY / self.frequency) @@ -200,7 +200,7 @@ def loop(self): sleep(self.frequency) logger.info("FDR writer stopped") - def dataref_changed(self, dataref, value): + def dataref_changed(self, dataref: str, value: Any) -> None: super().dataref_changed(dataref=dataref, value=value) if not self.header_ok: @@ -214,7 +214,7 @@ def dataref_changed(self, dataref, value): if dataref == "sim/cockpit2/clock_timer/zulu_time_seconds": self.lines.append(self.print_line()) - def start(self): + def start(self) -> None: if not self.header_ok: return # writing header @@ -228,7 +228,7 @@ def start(self): self.lines = [] super().start() - def stop(self): + def stop(self) -> None: if self.file is not None: self.file.close() self.file = None diff --git a/examples/geoutil.py b/examples/geoutil.py index bb44e07..098b6ea 100644 --- a/examples/geoutil.py +++ b/examples/geoutil.py @@ -204,10 +204,10 @@ class GeoJSONIO: # Simple text/dict manipulation to present geographic geometries in GeoJSON for display on geojson.io. # Later: Add coloring - def __init__(self): + def __init__(self) -> None: self.collection = [] - def add(self, feature: dict): + def add(self, feature: dict) -> None: self.collection.append(feature) def feature_collection(self) -> dict: @@ -217,7 +217,7 @@ def feature_collection(self) -> dict: } @staticmethod - def point(lat: float, lon: float): + def point(lat: float, lon: float) -> dict: # "properties": { # "marker-color": "#e32400", # "marker-size": "medium", @@ -233,7 +233,7 @@ def point(lat: float, lon: float): } @staticmethod - def line(lat1: float, lon1: float, lat2: float, lon2: float): + def line(lat1: float, lon1: float, lat2: float, lon2: float) -> dict: # "properties": { # "stroke": "#ff40ff", # "stroke-width": 2, @@ -249,7 +249,7 @@ def line(lat1: float, lon1: float, lat2: float, lon2: float): } @staticmethod - def polygon(points: List[Tuple[float, float]]): + def polygon(points: List[Tuple[float, float]]) -> dict: # "properties": { # "stroke": "#fffb00", # "stroke-width": 2, diff --git a/examples/oooi.py b/examples/oooi.py index b979485..33bbf8f 100644 --- a/examples/oooi.py +++ b/examples/oooi.py @@ -79,7 +79,7 @@ def now() -> datetime: class OOOIManager(XPWSAPIApp): - def __init__(self, api, departure: str, arrival: str, callsign: str, logon: str, station: str, eta: datetime | None = None) -> None: + def __init__(self, api: xpwebapi.XPWebsocketAPI, departure: str, arrival: str, callsign: str, logon: str, station: str, eta: datetime | None = None) -> None: XPWSAPIApp.__init__(self, api=api) self.departure = departure @@ -104,18 +104,18 @@ def __init__(self, api, departure: str, arrival: str, callsign: str, logon: str, # debug self._onblock = False - def set_api(self, api): + def set_api(self, api: xpwebapi.XPWebsocketAPI) -> None: self.ws = api self.datarefs = {path: self.ws.dataref(path) for path in self.get_dataref_names()} self.ws.add_callback(cbtype=xpwebapi.CALLBACK_TYPE.ON_DATAREF_UPDATE, callback=self.dataref_changed) - def start(self): + def start(self) -> None: pass - def stop(self): + def stop(self) -> None: pass - def loop(self): + def loop(self) -> None: pass @property @@ -123,7 +123,7 @@ def oooi(self) -> OOOI | None: return self.current_oooi @oooi.setter - def oooi(self, report: OOOI | Tuple[OOOI, datetime]): + def oooi(self, report: OOOI | Tuple[OOOI, datetime]) -> None: # Changes OOOI and set timestamp to supplied value if any newoooi = report if type(report) is OOOI else report[0] if self.current_oooi is None or self.current_oooi != newoooi: @@ -131,7 +131,7 @@ def oooi(self, report: OOOI | Tuple[OOOI, datetime]): self.all_oooi[newoooi] = now() if type(report) is OOOI else report[1] self.report() - def no_value(self, oooi: OOOI): + def no_value(self, oooi: OOOI) -> None: self.all_oooi[oooi] = EPOCH def has_value(self, oooi: OOOI) -> bool: @@ -153,7 +153,7 @@ def pushback(self) -> bool: h = h + 360 return abs(h - t) > 40 # we are not moving in the direction of the heading of the aircraft - def set_eta(self, eta: datetime): + def set_eta(self, eta: datetime) -> None: # when we get one... first = self.eta is None self.eta = eta @@ -165,7 +165,7 @@ def set_eta(self, eta: datetime): def get_dataref_names(self) -> set: return [d.value for d in DATAREFS] - def dataref_value(self, dataref: str): + def dataref_value(self, dataref: str) -> Any: dref = self.datarefs.get(dataref) return dref.value if dref is not None else 0 @@ -182,7 +182,7 @@ def report(self, display: bool = True) -> str: str: string with all values """ - def strfdelta(tdelta): + def strfdelta(tdelta: timedelta) -> str: ret = "" if tdelta.days > 0: ret = f"{tdelta.days} d " @@ -194,7 +194,7 @@ def strfdelta(tdelta): TIME_FMT = "%H%M" - def pt(ts: datetime | None): + def pt(ts: datetime | None) -> str: if ts is None: return "----" if ts == EPOCH: @@ -252,7 +252,7 @@ def acars_report(self) -> Dict: # def both_engine_off(self): # return True - def inital_state(self): + def inital_state(self) -> None: if self.inited: return for d in DATAREFS: @@ -316,16 +316,16 @@ def inital_state(self): self.show_values(f"..initialized ({self.current_state})", first=True) - def show_values(self, welcome: str = "", first: bool = False): + def show_values(self, welcome: str = "", first: bool = False) -> None: values = self.first if first else self.last logger.debug(f"{welcome}\n{'\n'.join([f'{d} = {values[d]}' for d in values])}") - def set_last_stop(self, force: bool = False): + def set_last_stop(self, force: bool = False) -> None: if self.last_stop is None or force: logger.debug("setting last stop") self.last_stop = now() - def how_long_waiting(self, mark: bool = False): + def how_long_waiting(self, mark: bool = False) -> int: if self.last_stop is None: if mark: self.last_stop = now() @@ -335,7 +335,7 @@ def how_long_waiting(self, mark: bool = False): self.last_stop = now() return howlong.seconds - def dataref_changed(self, dataref, value): + def dataref_changed(self, dataref: str, value: Any) -> None: super().dataref_changed(dataref=dataref, value=value) if not self.inited: @@ -430,7 +430,7 @@ def dataref_changed(self, dataref, value): self.last[dataref] = value - def terminate(self): + def terminate(self) -> None: ws.unmonitor_datarefs(datarefs=self.datarefs, reason=self.name) self.ws.disconnect() diff --git a/examples/posreport.py b/examples/posreport.py index c209411..ee766ac 100644 --- a/examples/posreport.py +++ b/examples/posreport.py @@ -59,14 +59,14 @@ def now() -> datetime: class PositionReport(XPWSAPIApp): - def __init__(self, api, frequency: int, callsign: str, logon: str, station: str, eta: datetime | None = None) -> None: + def __init__(self, api: xpwebapi.XPWebsocketAPI, frequency: int, callsign: str, logon: str, station: str, eta: datetime | None = None) -> None: XPWSAPIApp.__init__(self, api=api) self.frequency = frequency def get_dataref_names(self) -> set: return DATAREFS - def loop(self): + def loop(self) -> None: while not self.finish.is_set(): try: print(self.report()) diff --git a/examples/simple_beacon.py b/examples/simple_beacon.py index bf0449b..04d00c6 100644 --- a/examples/simple_beacon.py +++ b/examples/simple_beacon.py @@ -10,7 +10,7 @@ beacon = xpwebapi.beacon() -def callback(connected: bool, beacon_data: xpwebapi.BeaconData, same_host: bool): +def callback(connected: bool, beacon_data: xpwebapi.BeaconData, same_host: bool) -> None: print("X-Plane beacon " + ("detected" if connected else "not detected")) if connected: # !!beacon defined before print(beacon_data) diff --git a/examples/simple_monitor.py b/examples/simple_monitor.py index 63a3e67..454d51c 100644 --- a/examples/simple_monitor.py +++ b/examples/simple_monitor.py @@ -20,11 +20,11 @@ COMMANDS = ["sim/map/show_current"] -def dataref_monitor(dataref: str, value: Any): +def dataref_monitor(dataref: str, value: Any) -> None: print(f"{dataref}={value}") -def command_active_monitor(command: str, active: bool): +def command_active_monitor(command: str, active: bool) -> None: print(f"{command}={active}") diff --git a/examples/simple_upd.py b/examples/simple_upd.py index 4f679f0..c398582 100644 --- a/examples/simple_upd.py +++ b/examples/simple_upd.py @@ -12,7 +12,7 @@ logging.basicConfig(level=logging.INFO, format=FORMAT, datefmt="%H:%M:%S") -def dataref_monitor(dataref: str, value: Any): +def dataref_monitor(dataref: str, value: Any) -> None: print(f"{dataref}={value}") diff --git a/examples/simple_ws.py b/examples/simple_ws.py index 5a30173..5bb5006 100644 --- a/examples/simple_ws.py +++ b/examples/simple_ws.py @@ -18,11 +18,11 @@ print(ws.ws_url) -def dataref_monitor(dataref: str, value: Any): +def dataref_monitor(dataref: str, value: Any) -> None: print(f"{dataref}={value}") -def command_active_monitor(command: str, active: bool): +def command_active_monitor(command: str, active: bool) -> None: print(f"{command}={active}") diff --git a/examples/template.py b/examples/template.py index 422e989..8edcd62 100644 --- a/examples/template.py +++ b/examples/template.py @@ -42,7 +42,7 @@ def __init__(self, api: xpwebapi.XPWebsocketAPI | None = None, frequency: float def get_dataref_names(self) -> set: return DATAREFS - def loop(self): + def loop(self) -> None: logger.debug(f"{self.name} starting..") while not self.finish.is_set(): t0 = datetime.now(timezone.utc) diff --git a/examples/unitutil.py b/examples/unitutil.py index 0cb7b04..090f8ef 100755 --- a/examples/unitutil.py +++ b/examples/unitutil.py @@ -14,14 +14,14 @@ NAUTICAL_MILE = 1.852 # Nautical mile in meters 6076.118ft=1nm. Easy. -def sign(x: float | int): +def sign(x: float | int) -> int: # why does this function not exists in python? return -1 if x < 0 else (0 if abs(x) == 0 else 1) # -0 is not 0. class convert: @staticmethod - def dms_to_dd(degrees, minutes, seconds, direction) -> float: + def dms_to_dd(degrees: float, minutes: float, seconds: float, direction: str) -> float: dd = float(degrees) + float(minutes) / 60 + float(seconds) / (60 * 60) return dd if direction in ("N", "E") else dd * -1 diff --git a/examples/xgs.py b/examples/xgs.py index d71e2a8..8a6bfb3 100644 --- a/examples/xgs.py +++ b/examples/xgs.py @@ -90,7 +90,7 @@ def values(self, orient: str) -> Tuple[float, float, float, float, float]: return self.he_latitude_deg, self.he_longitude_deg, self.he_elevation_ft, self.he_heading_degT, self.he_displaced_threshold_ft @property - def bbox(self): + def bbox(self) -> List[Tuple[float, float]]: if len(self.cached_bbox) > 0: return self.cached_bbox @@ -137,10 +137,10 @@ def inside(self, lat: float, lon: float) -> bool: # Cleanup procedure -def min_info(r): +def min_info(r: dict) -> bool: # Minimum info needed for runway # No info, no runway - def empty(c): + def empty(c: Any) -> bool: return c is None or c == "" le = True @@ -157,7 +157,7 @@ def empty(c): NOT_SET = -99999 -def float_all(r): +def float_all(r: dict) -> Runway: # Convert string to float # replace non existent with NOT_SET = null float, could be math.inf for c in [ @@ -255,7 +255,7 @@ class DATAREFS(StrEnum): class LandingMonitor(XPWSAPIApp): - def __init__(self, api) -> None: + def __init__(self, api: xpwebapi.XPWebsocketAPI) -> None: XPWSAPIApp.__init__(self, api=api) # Runways @@ -299,7 +299,7 @@ def state(self) -> ALTITUDE: return self._altitude @state.setter - def state(self, state: ALTITUDE): + def state(self, state: ALTITUDE) -> None: """Change monitoring state and reports it""" if self._altitude != state: self._altitude = state @@ -311,7 +311,7 @@ def last_grounded(self) -> bool: return self._last_grounded @last_grounded.setter - def last_grounded(self, grounded: bool): + def last_grounded(self, grounded: bool) -> None: """Change monitoring state and reports it""" if self._last_grounded != grounded: self._last_grounded = grounded @@ -326,7 +326,7 @@ def since_touchdown(self) -> float: def get_dataref_names(self) -> set: return DATAREFS - def init(self): + def init(self) -> None: if self.has_first_set: return for d in DATAREFS: @@ -386,7 +386,7 @@ def init(self): self.state = ALTITUDE.ALT_LOW logger.info(f".. state is {self.state}") # this is the initial value - def show_values(self, welcome: str = "", first: bool = False): + def show_values(self, welcome: str = "", first: bool = False) -> None: values = self.first logger.debug(f"{welcome}\n{'\n'.join([f'{d} = {values[d]}' for d in values])}") @@ -418,7 +418,7 @@ def ensure_below(self, what: str, threshold: float, value: float, count: int) -> return 1 return -1 - def dataref_changed(self, dataref, value): + def dataref_changed(self, dataref: str, value: Any) -> None: """Record changes and adjust ALTITUDE Based on the value of the dataref that has changed we determine a ALTITUDE. @@ -526,7 +526,7 @@ def dataref_changed(self, dataref, value): if self.monitoring: self.monitor_landing() - def monitor_landing(self): + def monitor_landing(self) -> None: """Based on ALTITUDE and dataref values we monitor the landing parameters""" if not self._air_time: return @@ -549,13 +549,13 @@ def closest_orient(self, rwy: Runway) -> str: dhe = distance(lat, lon, rwy.he_latitude_deg, rwy.he_longitude_deg) return "le" if dle <= dhe else "he" - def record_position(self): + def record_position(self) -> None: lat = self.dataref_value(DATAREFS.LATITUDE) lon = self.dataref_value(DATAREFS.LONGITUDE) alt = self.dataref_value(DATAREFS.Y_AGL) self._positions.append((now(), lat, lon, alt)) - def record_vspeed(self): + def record_vspeed(self) -> None: vs = self.dataref_value(DATAREFS.LOCAL_VY) tt = self.dataref_value(DATAREFS.TRUE_THETA) val = vs * math.cos(tt * 0.0174533) # vs projected vertically @@ -594,7 +594,7 @@ def record_vspeed(self): if 0 < self.since_touchdown < 10: # keep min and max smoothed values after touch down self._display_g = (min(self._display_g[0], g_lp), max(self._display_g[1], g_lp)) - def snapshot(self, event: EVENT): + def snapshot(self, event: EVENT) -> None: if event in self.snapshots: logger.warning(f"snapshot {event} already taken") return @@ -616,14 +616,14 @@ def snapshot(self, event: EVENT): self.snapshots[event] = (now(), distthr, {d: self.dataref_value(d) for d in DATAREFS}, vspeed) logger.debug(f"snapshot {event} taken") - def shortlist_closest_runways(self, max_distance: float = CLOSE_AIRPORT, report: bool = False): + def shortlist_closest_runways(self, max_distance: float = CLOSE_AIRPORT, report: bool = False) -> None: # Preselect all airports in the vicinity of the aircraft (out of 44000 airports) # Short list is updated every ~10 minutes when aircraft is below 6000ft/2km # Finer scans in the short list (~20 airports, ~70 runways) will occur very fast. lat = self.dataref_value(DATAREFS.LATITUDE) lon = self.dataref_value(DATAREFS.LONGITUDE) - def dist(rwy) -> bool: + def dist(rwy: Runway) -> bool: close = False if rwy.le_latitude_deg != NOT_SET: d = distance(lat, lon, rwy.le_latitude_deg, rwy.le_longitude_deg) @@ -638,7 +638,7 @@ def dist(rwy) -> bool: logger.info(f"short-listed {len(self._runways_shortlist)} runways within {max_distance}m") logger.debug([str(r) for r in self._runways_shortlist]) - def set_target_runway(self, runway: Runway, orient: str, distance: float | None = None): + def set_target_runway(self, runway: Runway, orient: str, distance: float | None = None) -> None: self.runway = runway self.runway_orient = orient dist = "" @@ -646,7 +646,7 @@ def set_target_runway(self, runway: Runway, orient: str, distance: float | None dist = f" at {round(distance)}m" logger.info(f"new target runway {runway.name(orient=orient)}{dist}") - def target_runway_ahead(self, adjust: bool = True, ahead: float = 20000.0): + def target_runway_ahead(self, adjust: bool = True, ahead: float = 20000.0) -> None: # To be run perriodically # Make a bounding box of length ahead (meters) and 10% of ahead wide, in direction of tracking. # Threshold should be in bbox. @@ -728,8 +728,8 @@ def on_runway(self) -> List[Runway]: logger.info(f"enter runway {self._currently_on_runway}") return rwys - def report(self): - def snap_dataref_value(s, dref: DATAREFS): + def report(self) -> None: + def snap_dataref_value(s: tuple, dref: DATAREFS) -> Any: return s[2][dref] logger.info("\n") @@ -855,7 +855,7 @@ def snap_dataref_value(s, dref: DATAREFS): self.save() self.reset() - def save(self): + def save(self) -> None: with open("vs.csv", "w") as fp: i = 0 for f in self._vspeeds: @@ -869,7 +869,7 @@ def save(self): logger.info("monitor state saved") - def reset(self): + def reset(self) -> None: # sleep 1 minute? self.first = {} self.snapshots = {} @@ -886,13 +886,13 @@ def reset(self): self._display_g = (1.0, 1.0) logger.info("monitor was reset") - def start(self): + def start(self) -> None: pass - def stop(self): + def stop(self) -> None: pass - def loop(self): + def loop(self) -> None: pass diff --git a/examples/xpwsapp.py b/examples/xpwsapp.py index 81a0323..aa11b6c 100644 --- a/examples/xpwsapp.py +++ b/examples/xpwsapp.py @@ -3,6 +3,7 @@ import threading import time from abc import ABC, abstractmethod +from typing import Any try: import xpwebapi @@ -35,14 +36,14 @@ def __init__(self, api: xpwebapi.XPWebsocketAPI | None = None) -> None: self.thread = threading.Thread(target=self.loop, name=self.name) self.finish = threading.Event() - def set_api(self, api: xpwebapi.XPWebsocketAPI): + def set_api(self, api: xpwebapi.XPWebsocketAPI) -> None: self.ws = api @property def has_first_set(self) -> bool: return len([d for d in self.datarefs if d.value is not None]) == len(self.datarefs) - def wait_for_first_set_of_values(self): + def wait_for_first_set_of_values(self) -> None: while not self.has_first_set: time.sleep(2) @@ -50,12 +51,12 @@ def dataref_value(self, dataref: str) -> xpwebapi.DatarefValueType | None: dref = self.datarefs.get(dataref) return dref.value if dref is not None else None - def dataref_changed(self, dataref, value): + def dataref_changed(self, dataref: str, value: Any) -> None: if dataref not in self.get_dataref_names(): return self.datarefs[dataref].value = value - def run(self): + def run(self) -> None: if self.ws is None: logger.error("no api") return @@ -67,13 +68,13 @@ def run(self): self.ws.start() self.start() - def start(self): + def start(self) -> None: self.thread.start() - def stop(self): + def stop(self) -> None: self.finish.set() - def terminate(self): + def terminate(self) -> None: if self.ws is None: logger.error("no api") return @@ -86,5 +87,5 @@ def get_dataref_names(self) -> set: return set() @abstractmethod - def loop(self): + def loop(self) -> None: pass diff --git a/mkdocs.yml b/mkdocs.yml index d1c5c55..d407144 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,7 +28,17 @@ nav: - Overview: index.md - Changelog: changelog.md - Usage: usage/index.md -- Reference: reference/index.md +- Reference: + - Overview: reference/index.md + - Package: reference/package.md + - Core API: reference/core-api.md + - REST: reference/rest.md + - Async REST: reference/async-rest.md + - WebSocket: reference/websocket.md + - UDP: reference/udp.md + - Beacon: reference/beacon.md + - Exceptions: reference/exceptions.md + - Logging: reference/logging.md markdown_extensions: - pymdownx.snippets @@ -67,4 +77,7 @@ plugins: unwrap_annotated: true - git-revision-date-localized: enable_creation_date: true + enable_git_follow: false + exclude: + - superpowers/** type: timeago diff --git a/pyproject.toml b/pyproject.toml index 6e10564..674b910 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,89 +1,96 @@ -# ########################################### -# -# Project -# [project] name = "xpwebapi" - +version = "3.5.0" authors = [ { name="Pierre M.", email="pierre@devleaks.be" } ] - description = "Python Wrapper for Laminar Research X-Plane Web API" - readme = "README.md" - license = {file = "LICENCE"} - classifiers = [ "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Topic :: Games/Entertainment :: Simulation", ] - -requires-python = ">=3.12" -# (using type) - +requires-python = ">=3.12,<3.13" dependencies = [ "ifaddr~=0.2", "natsort~=8.4", "packaging~=25.0", - "requests~=2.32", - "simple-websocket~=1.1", + "httpx~=0.28", + "websockets>=16,<17", + "pydantic>=2,<3", ] -dynamic = [ - "version" -] +[project.urls] +Homepage = "https://devleaks.github.io/xplane-webapi/" +Documentation = "https://devleaks.github.io/xplane-webapi/" +Issues = "https://github.com/devleaks/xplane-webapi/issues" +Source = "https://github.com/devleaks/xplane-webapi" -[project.optional-dependencies] +[dependency-groups] dev = [ + "bandit>=1.9.4", + "cohesion>=1.2.0", + "coverage>=7.14.1", + "detect-secrets>=1.5.0", + "interrogate>=1.7.0", + "lizard>=1.23.0", "mkdocs", + "mkdocs-git-revision-date-localized-plugin", "mkdocs-material", "mkdocstrings", "mkdocstrings-python", - "types-requests", - "types-Werkzeug" + "pre-commit>=4.5.1", + "ruff>=0.15.17", + "ty>=0.0.49", + "vulture>=2.16", + "wily>=1.12.2", + "xenon>=0.9.3", ] -[project.urls] -Homepage = "https://devleaks.github.io/xplane-webapi/" -Documentation = "https://devleaks.github.io/xplane-webapi/" -Issues = "https://github.com/devleaks/xplane-webapi/issues" -Source = "https://github.com/devleaks/xplane-webapi" +[tool.coverage.run] +source = ["xpwebapi"] +branch = false + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if __name__ == .__main__.:", +] + +[build-system] +requires = ["uv_build>=0.11.22,<0.12"] +build-backend = "uv_build" +[tool.uv.build-backend] +module-root = "" -# ########################################### -# -# Edit -# [tool.ruff] line-length = 160 -docstring-code-format = true +extend-exclude = ["examples"] + +[tool.ruff.lint] select = [ "E", "F", "W", ] -ignore = [] - -[tool.black] -line-length = 160 - -[tool.flake8] -max-line-length = 160 -# ########################################### -# -# Build -# -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.version] -path = "xpwebapi/__init__.py" +[tool.ruff.format] +docstring-code-format = true -[tool.hatch.metadata] -allow-direct-references = true +[tool.ty.src] +exclude = ["examples/**"] + +[tool.ty.rules] +all = "error" +# Current code predates strict annotation-modernization requirements. +# Keep ty blocking on compatibility errors while leaving these broad cleanup +# classes for separate focused work. +missing-override-decorator = "ignore" +missing-type-argument = "ignore" +unresolved-attribute = "ignore" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fcb9ea1 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,12 @@ +import logging + + +def _silence_test_logger(name: str) -> None: + logger = logging.getLogger(name) + logger.handlers.clear() + logger.addHandler(logging.NullHandler()) + logger.propagate = False + + +for _logger_name in ("xpwebapi", "webapi"): + _silence_test_logger(_logger_name) diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..ac0aeba --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,105 @@ +import base64 +import struct +import unittest +from unittest.mock import MagicMock + +from xpwebapi.api import API, APIResult, Command, CommandMeta, Dataref, DatarefMeta, DatarefReadResult + + +def mock_response(status_code: int, payload: dict | None = None) -> MagicMock: + response = MagicMock() + response.status_code = status_code + response.reason_phrase = "OK" if status_code == 200 else "Error" + response.text = "" + response.json.return_value = payload or {} + return response + + +class TestHelpersSmoke(unittest.TestCase): + def test_helpers_are_importable(self): + helpers = ( + mock_response, + make_dataref_meta, + make_command_meta, + encoded_data, + make_rref_packet, + make_beacon_packet, + DummyAPI, + ) + + for helper in helpers: + self.assertIsNotNone(helper) + + +def make_dataref_meta(name: str = "sim/test/value", value_type: str = "int", is_writable: bool = True, ident: int = 10) -> DatarefMeta: + return DatarefMeta(name=name, value_type=value_type, is_writable=is_writable, id=ident) + + +def make_command_meta(name: str = "sim/test/command", description: str = "Test command", ident: int = 20) -> CommandMeta: + return CommandMeta(name=name, description=description, id=ident) + + +def encoded_data(value: bytes = b"abc") -> str: + return base64.b64encode(value).decode("ascii") + + +def make_rref_packet(values: list[tuple[int, float]]) -> bytes: + packet = b"RREF," + for ident, value in values: + packet += struct.pack(" bytes: + header = b"BECN\x00" + data = struct.pack(" bool: + return True + + def get_rest_meta(self, obj: Dataref | Command, force: bool = False) -> DatarefMeta | CommandMeta | None: + return self.meta_by_path.get(obj.path) + + def write_dataref(self, dataref: Dataref) -> APIResult: + self.written.append(dataref) + return True + + def dataref_value(self, dataref: Dataref, raw: bool = False, no_decode: bool = False) -> DatarefReadResult: + return self.value_to_return + + def execute_command(self, command: Command, duration: float = 0.0) -> APIResult: + self.executed.append((command, duration)) + return True + + def monitor_dataref(self, dataref: Dataref) -> bool: + self.monitored_datarefs.append(("monitor", dataref)) + return True + + def unmonitor_dataref(self, dataref: Dataref) -> bool: + self.monitored_datarefs.append(("unmonitor", dataref)) + return True + + def register_command_is_active_event(self, path: str, on: bool = True) -> bool: + self.command_events.append((path, on)) + return True diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..d6afe44 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,347 @@ +import base64 +import inspect +import unittest +from tempfile import TemporaryDirectory +from typing import Protocol +from unittest.mock import MagicMock + +from tests.helpers import DummyAPI, encoded_data, make_command_meta, make_dataref_meta, mock_response +from xpwebapi.api import ( + API, + DATAREF_DATATYPE, + Cache, + CommandCache, + Command, + CommandMeta, + DatarefCache, + Dataref, + DatarefMeta, + ValueCache, +) + + +class TestAPIProtocol(unittest.TestCase): + def test_api_is_structural_protocol_without_abstract_methods(self): + self.assertIn(Protocol, API.__mro__) + self.assertFalse(inspect.isabstract(API)) + self.assertEqual(API.__abstractmethods__, frozenset()) + + +class TestDatarefMeta(unittest.TestCase): + def test_construction(self): + meta = DatarefMeta(name="sim/test/value", value_type="int", is_writable=True, id=42) + self.assertEqual(meta.name, "sim/test/value") + self.assertEqual(meta.value_type, "int") + self.assertTrue(meta.is_writable) + self.assertEqual(meta.ident, 42) + + def test_is_array_for_array_types(self): + int_array = DatarefMeta(name="sim/test/int_array", value_type=DATAREF_DATATYPE.INTARRAY.value, is_writable=False, id=1) + float_array = DatarefMeta(name="sim/test/float_array", value_type=DATAREF_DATATYPE.FLOATARRAY.value, is_writable=False, id=2) + scalar = DatarefMeta(name="sim/test/scalar", value_type=DATAREF_DATATYPE.FLOAT.value, is_writable=False, id=3) + self.assertTrue(int_array.is_array) + self.assertTrue(float_array.is_array) + self.assertFalse(scalar.is_array) + + def test_indices_are_unique_and_historical(self): + meta = DatarefMeta(name="sim/test/array", value_type="int_array", is_writable=False, id=1) + meta.append_index(3) + meta.append_index(1) + meta.append_index(3) + self.assertEqual(meta.indices, [3, 1]) + + meta._indices_requested = True + meta.save_indices() + meta.remove_index(3) + self.assertEqual(meta.indices, [1]) + self.assertEqual(meta.last_indices(), [3, 1]) + + +class TestCommandMeta(unittest.TestCase): + def test_construction(self): + meta = CommandMeta(name="sim/test/command", description="Test command", id=99) + self.assertEqual(meta.name, "sim/test/command") + self.assertEqual(meta.description, "Test command") + self.assertEqual(meta.ident, 99) + + +class TestValueCache(unittest.TestCase): + def test_get_rounding_supports_plain_root_and_wildcard(self): + cache = ValueCache({"sim/test/plain": 2, "sim/test/root": 3, "sim/test/wild[*]": 1}) + self.assertEqual(cache.get_rounding("sim/test/plain"), 2) + self.assertEqual(cache.get_rounding("sim/test/root[4]"), 3) + self.assertEqual(cache.get_rounding("sim/test/wild[7]"), 1) + self.assertIsNone(cache.get_rounding("sim/test/missing")) + + def test_changed_tracks_rounded_numeric_values(self): + cache = ValueCache({"sim/test/value": 2}) + self.assertTrue(cache.changed("sim/test/value", 3.14159)) + self.assertFalse(cache.changed("sim/test/value", 3.14)) + self.assertTrue(cache.changed("sim/test/value", 3.15)) + + def test_changed_returns_true_for_non_numeric_values(self): + cache = ValueCache({"sim/test/value": 2}) + self.assertTrue(cache.changed("sim/test/value", "hello")) + + +class TestDatarefCache(unittest.TestCase): + def test_meta_factory_creates_dataref_meta(self): + meta = DatarefCache.meta(name="sim/test/value", value_type="int", is_writable=True, id=1) + self.assertIsInstance(meta, DatarefMeta) + self.assertEqual(meta.name, "sim/test/value") + self.assertEqual(meta.value_type, "int") + self.assertTrue(meta.is_writable) + + def test_lookup_by_name_and_id(self): + cache = DatarefCache(DummyAPI()) + meta = DatarefMeta(name="sim/test/value", value_type="int", is_writable=True, id=7) + cache._by_name = {meta.name: meta} + cache._by_ids = {meta.ident: meta} + self.assertIs(cache.get("sim/test/value"), meta) + self.assertIs(cache.get_by_name("sim/test/value"), meta) + self.assertIs(cache.get_by_id(7), meta) + self.assertIsNone(cache.get_by_name("sim/test/missing")) + self.assertIsNone(cache.get_by_id(99)) + self.assertEqual(cache.count, 1) + self.assertTrue(cache.has_data) + self.assertEqual(cache.equiv(7), "7(sim/test/value)") + + def test_load_uses_datarefs_endpoint(self): + api = DummyAPI() + api.session = MagicMock() + api.session.get.return_value = mock_response(200, {"data": [{"name": "sim/test/value", "value_type": "int", "is_writable": True, "id": 7}]}) + cache = DatarefCache(api) + cache.load() + api.session.get.assert_called_once_with(f"{api.rest_url}/datarefs") + self.assertIsInstance(cache.get_by_name("sim/test/value"), DatarefMeta) + + def test_failed_load_leaves_cache_empty(self): + api = DummyAPI() + api.session = MagicMock() + api.session.get.return_value = mock_response(500) + cache = DatarefCache(api) + cache.load() + self.assertEqual(cache.count, 0) + self.assertFalse(cache.has_data) + + def test_save_writes_loaded_metadata_json(self): + api = DummyAPI() + cache = DatarefCache(api) + meta = make_dataref_meta(name="sim/test/value", ident=7) + cache._raw = [{"name": meta.name, "value_type": meta.value_type, "is_writable": meta.is_writable, "id": meta.ident}] + cache._by_name = {meta.name: meta} + cache._by_ids = {meta.ident: meta} + + with TemporaryDirectory() as tmpdir: + filename = f"{tmpdir}/datarefs.json" + cache.save(filename) + with open(filename, encoding="utf-8") as handle: + content = handle.read() + + self.assertIn("sim/test/value", content) + self.assertIn('"id": 7', content) + + +class TestCommandCache(unittest.TestCase): + def test_meta_factory_creates_command_meta(self): + meta = CommandCache.meta(name="sim/test/command", description="Test command", id=2) + self.assertIsInstance(meta, CommandMeta) + self.assertEqual(meta.name, "sim/test/command") + self.assertEqual(meta.description, "Test command") + + def test_lookup_by_name_and_id(self): + cache = CommandCache(DummyAPI()) + meta = CommandMeta(name="sim/test/command", description="Test command", id=8) + cache._by_name = {meta.name: meta} + cache._by_ids = {meta.ident: meta} + self.assertIs(cache.get("sim/test/command"), meta) + self.assertIs(cache.get_by_name("sim/test/command"), meta) + self.assertIs(cache.get_by_id(8), meta) + self.assertIsNone(cache.get_by_name("sim/test/missing")) + self.assertIsNone(cache.get_by_id(99)) + self.assertEqual(cache.count, 1) + self.assertTrue(cache.has_data) + self.assertEqual(cache.equiv(8), "8(sim/test/command)") + + def test_load_uses_commands_endpoint(self): + api = DummyAPI() + api.session = MagicMock() + api.session.get.return_value = mock_response(200, {"data": [{"name": "sim/test/command", "description": "Test command", "id": 8}]}) + cache = CommandCache(api) + cache.load() + api.session.get.assert_called_once_with(f"{api.rest_url}/commands") + self.assertIsInstance(cache.get_by_name("sim/test/command"), CommandMeta) + + +class TestCacheCompatibility(unittest.TestCase): + def test_meta_factory_keeps_old_dataref_heuristic(self): + meta = Cache.meta(name="sim/test/value", value_type="int", is_writable=True, id=1) + self.assertIsInstance(meta, DatarefMeta) + + def test_meta_factory_keeps_old_command_heuristic(self): + meta = Cache.meta(name="sim/test/command", description="Test command", id=2) + self.assertIsInstance(meta, CommandMeta) + + def test_load_accepts_old_path_argument(self): + api = DummyAPI() + api.session = MagicMock() + api.session.get.return_value = mock_response(200, {"data": [{"name": "sim/test/value", "value_type": "int", "is_writable": True, "id": 7}]}) + cache = Cache(api) + cache.load("/datarefs") + api.session.get.assert_called_once_with(f"{api.rest_url}/datarefs") + self.assertIsInstance(cache.get_by_name("sim/test/value"), DatarefMeta) + + +class TestDataref(unittest.TestCase): + def test_indexed_path_parses_base_path_and_index(self): + api = DummyAPI() + dataref = Dataref(path="sim/test/array[4]", api=api) + + self.assertEqual(dataref.name, "sim/test/array[4]") + self.assertEqual(dataref.path, "sim/test/array") + self.assertEqual(dataref.index, 4) + + def test_string_representation_includes_index_and_value(self): + api = DummyAPI() + dataref = Dataref(path="sim/test/array[4]", api=api) + dataref.value = 12.5 + + self.assertEqual(str(dataref), "sim/test/array[4]=12.5") + + def test_get_string_value_decodes_data_bytes_and_strips_nulls(self): + api = DummyAPI(value=b"ABC\x00\x00") + api.meta_by_path["sim/test/data"] = make_dataref_meta(name="sim/test/data", value_type=DATAREF_DATATYPE.DATA.value) + dataref = Dataref(path="sim/test/data", api=api) + + self.assertEqual(dataref.get_string_value("ascii"), "ABC") + + def test_set_string_value_encodes_data_value(self): + api = DummyAPI() + api.meta_by_path["sim/test/data"] = make_dataref_meta(name="sim/test/data", value_type=DATAREF_DATATYPE.DATA.value) + dataref = Dataref(path="sim/test/data", api=api) + + dataref.set_string_value("ABC", "ascii") + + self.assertEqual(dataref.value, b"ABC") + self.assertEqual(dataref.b64encoded, encoded_data(b"ABC")) + + def test_value_reads_from_api_when_no_local_value_exists(self): + api = DummyAPI(value=12) + dataref = Dataref(path="sim/test/value", api=api) + self.assertEqual(dataref.value, 12) + + def test_value_supports_array_values_from_api(self): + api = DummyAPI(value=[1.0, 2.0]) + dataref = Dataref(path="sim/test/array", api=api) + self.assertEqual(dataref.value, [1.0, 2.0]) + + def test_setting_value_updates_local_value_and_timestamp(self): + api = DummyAPI() + dataref = Dataref(path="sim/test/value", api=api) + before = dataref.last_updated + dataref.value = 34 + self.assertEqual(dataref.value, 34) + self.assertGreaterEqual(dataref.last_updated, before) + + def test_auto_save_writes_on_value_change(self): + api = DummyAPI() + dataref = Dataref(path="sim/test/value", api=api, auto_save=True) + dataref.value = 56 + self.assertEqual(api.written, [dataref]) + + def test_parse_scalar_value(self): + api = DummyAPI() + api.meta_by_path["sim/test/value"] = DatarefMeta(name="sim/test/value", value_type="int", is_writable=False, id=1) + dataref = Dataref(path="sim/test/value", api=api) + self.assertEqual(dataref.parse_raw_value(42), 42) + + def test_parse_data_value_decodes_base64(self): + api = DummyAPI() + api.meta_by_path["sim/test/data"] = DatarefMeta(name="sim/test/data", value_type=DATAREF_DATATYPE.DATA.value, is_writable=False, id=1) + dataref = Dataref(path="sim/test/data", api=api) + raw = base64.b64encode(b"abc").decode("ascii") + self.assertEqual(dataref.parse_raw_value(raw), b"abc") + + def test_parse_array_element_uses_requested_indices(self): + api = DummyAPI() + meta = DatarefMeta(name="sim/test/array", value_type=DATAREF_DATATYPE.FLOATARRAY.value, is_writable=False, id=1) + meta.append_index(0) + meta.append_index(2) + api.meta_by_path["sim/test/array"] = meta + dataref = Dataref(path="sim/test/array[2]", api=api) + self.assertEqual(dataref.parse_raw_value([10.0, 20.0]), 20.0) + + def test_parse_whole_array_when_no_indices_requested(self): + api = DummyAPI() + api.meta_by_path["sim/test/array"] = DatarefMeta(name="sim/test/array", value_type=DATAREF_DATATYPE.FLOATARRAY.value, is_writable=False, id=1) + dataref = Dataref(path="sim/test/array", api=api) + self.assertEqual(dataref.parse_raw_value([1.0, 2.0]), [1.0, 2.0]) + + def test_write_monitor_and_unmonitor_delegate_to_api(self): + api = DummyAPI() + dataref = Dataref(path="sim/test/value", api=api) + self.assertTrue(dataref.write()) + self.assertTrue(dataref.monitor()) + self.assertTrue(dataref.unmonitor()) + self.assertEqual(api.written, [dataref]) + self.assertEqual(api.monitored_datarefs, [("monitor", dataref), ("unmonitor", dataref)]) + + def test_invalid_meta_properties_record_errors(self): + api = DummyAPI() + dataref = Dataref(path="sim/test/missing", api=api) + + self.assertFalse(dataref.valid) + self.assertIsNone(dataref.ident) + self.assertIsNone(dataref.value_type) + self.assertFalse(dataref.is_writable) + self.assertGreaterEqual(dataref._err, 3) + + def test_monitor_counter_tracks_nested_monitoring(self): + api = DummyAPI() + dataref = Dataref(path="sim/test/value", api=api) + + dataref.inc_monitor() + dataref.inc_monitor() + + self.assertTrue(dataref.is_monitored) + self.assertEqual(dataref.monitored_count, 2) + self.assertTrue(dataref.dec_monitor()) + self.assertFalse(dataref.dec_monitor()) + self.assertEqual(dataref.monitored_count, 0) + + +class TestCommand(unittest.TestCase): + def test_command_metadata_properties_use_cached_meta(self): + api = DummyAPI() + api.meta_by_path["sim/test/command"] = make_command_meta(ident=44) + command = Command(path="sim/test/command", api=api) + + self.assertTrue(command.valid) + self.assertEqual(command.ident, 44) + self.assertEqual(command.description, "Test command") + + def test_command_invalid_metadata_records_errors(self): + api = DummyAPI() + command = Command(path="sim/test/missing", api=api) + + self.assertFalse(command.valid) + self.assertIsNone(command.ident) + self.assertIsNone(command.description) + self.assertGreaterEqual(command._err, 2) + + def test_execute_delegates_to_api(self): + api = DummyAPI() + command = Command(path="sim/test/command", api=api) + self.assertTrue(command.execute(duration=1.5)) + self.assertEqual(api.executed, [(command, 1.5)]) + + def test_monitor_and_unmonitor_delegate_to_api(self): + api = DummyAPI() + command = Command(path="sim/test/command", api=api) + self.assertTrue(command.monitor()) + self.assertTrue(command.unmonitor()) + self.assertEqual(api.command_events, [("sim/test/command", True), ("sim/test/command", False)]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_async_rest.py b/tests/test_async_rest.py new file mode 100644 index 0000000..f25385f --- /dev/null +++ b/tests/test_async_rest.py @@ -0,0 +1,346 @@ +import base64 +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx + +import xpwebapi +from tests.helpers import mock_response +from xpwebapi.api import CONNECTION_STATUS, DATAREF_DATATYPE, Command, CommandMeta, Dataref, DatarefMeta +from xpwebapi.async_rest import AsyncXPRestAPI +from xpwebapi.rest import V1_CAPABILITIES, XPRestAPI + + +class AsyncRestAPITestCase(unittest.IsolatedAsyncioTestCase): + def make_api(self): + api = AsyncXPRestAPI(host="127.0.0.1", port=8086, api="/api", api_version="v1") + api.session = MagicMock() + api.session.get = AsyncMock() + api.session.post = AsyncMock() + api.session.patch = AsyncMock() + api.session.aclose = AsyncMock() + api._show_stats = False + return api + + def make_dataref(self, api, value_type="int", is_writable=True, ident=10): + dataref = Dataref(path="sim/test/value", api=api) + dataref._cached_meta = DatarefMeta(name=dataref.path, value_type=value_type, is_writable=is_writable, id=ident) + return dataref + + def make_command(self, api, ident=20): + command = Command(path="sim/test/command", api=api) + command._cached_meta = CommandMeta(name=command.path, description="Test command", id=ident) + return command + + +class TestAsyncXPRestAPILifecycle(AsyncRestAPITestCase): + async def test_imports_async_rest_api(self): + self.assertIs(AsyncXPRestAPI, xpwebapi.AsyncXPRestAPI) + + async def test_aclose_closes_session(self): + api = self.make_api() + await api.aclose() + api.session.aclose.assert_awaited_once() + + async def test_async_context_manager_closes_session(self): + api = self.make_api() + async with api as entered: + self.assertIs(entered, api) + api.session.aclose.assert_awaited_once() + + +class TestAsyncXPRestAPIConnected(AsyncRestAPITestCase): + async def test_rest_api_reachable_returns_true_for_successful_probe(self): + api = self.make_api() + api.session.get.return_value = mock_response(200, {"data": 1}) + self.assertTrue(await api.rest_api_reachable()) + self.assertTrue(api.connected) + self.assertEqual(api.status, CONNECTION_STATUS.REST_API_REACHABLE) + + async def test_rest_api_reachable_returns_false_for_non_200_response(self): + api = self.make_api() + api.session.get.return_value = mock_response(503) + self.assertFalse(await api.rest_api_reachable()) + self.assertFalse(api.connected) + + async def test_rest_api_reachable_returns_false_for_connect_error(self): + api = self.make_api() + api.session.get.side_effect = httpx.ConnectError("failed") + self.assertFalse(await api.rest_api_reachable()) + self.assertEqual(api.status, CONNECTION_STATUS.REST_API_NOT_REACHABLE) + + async def test_successful_reconnect_resets_unreach_count(self): + api = self.make_api() + api._unreach_count = 2 + api.session.get.return_value = mock_response(200, {"data": 1}) + self.assertTrue(await api.rest_api_reachable()) + self.assertEqual(api._unreach_count, 0) + + async def test_rest_api_reachable_retries_transient_connect_error(self): + api = AsyncXPRestAPI(host="127.0.0.1", port=8086, api="/api", api_version="v1", retry_attempts=3, retry_backoff=0.25) + api.session = MagicMock() + api.session.get = AsyncMock(side_effect=[httpx.ConnectError("failed"), mock_response(200, {"data": 1})]) + api.session.aclose = AsyncMock() + api._show_stats = False + + with patch("xpwebapi.async_rest.async_sleep_before_retry", new_callable=AsyncMock) as sleep: + self.assertTrue(await api.rest_api_reachable()) + + self.assertEqual(api.session.get.await_count, 2) + sleep.assert_awaited_once_with(api.retry_config, 0) + + +class TestAsyncXPRestAPICapabilities(AsyncRestAPITestCase): + async def test_capabilities_v1_fallback_uses_unversioned_api_root(self): + api = AsyncXPRestAPI(host="127.0.0.1", port=8086, api="/api", api_version="v2") + api.session = MagicMock() + api.session.get = AsyncMock( + side_effect=[ + mock_response(200, {"data": 1}), + mock_response(404), + mock_response(200, {"data": 1}), + ] + ) + api.session.aclose = AsyncMock() + api._show_stats = False + + self.assertEqual(await api.capabilities(), V1_CAPABILITIES) + probed_urls = [call.args[0] for call in api.session.get.await_args_list] + self.assertIn("http://127.0.0.1:8086/api/v1/datarefs/count", probed_urls) + self.assertNotIn("http://127.0.0.1:8086/api/v2/v1/datarefs/count", probed_urls) + await api.aclose() + + +class TestAsyncXPRestAPIGetRestMeta(AsyncRestAPITestCase): + async def test_get_rest_meta_returns_cached_meta_without_http(self): + api = self.make_api() + dataref = self.make_dataref(api) + self.assertIs(await api.get_rest_meta(dataref), dataref._cached_meta) + api.session.get.assert_not_awaited() + + async def test_get_rest_meta_fetches_dataref_meta(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + api.session.get.side_effect = [ + mock_response(200, {"data": 1}), + mock_response(200, {"data": [{"name": dataref.path, "value_type": "int", "is_writable": True, "id": 7}]}), + ] + meta = await api.get_rest_meta(dataref) + self.assertIsInstance(meta, DatarefMeta) + self.assertEqual(meta.ident, 7) + self.assertIs(dataref._cached_meta, meta) + + async def test_get_rest_meta_fetches_command_meta(self): + api = self.make_api() + command = Command(path="sim/test/command", api=api) + api.session.get.side_effect = [ + mock_response(200, {"data": 1}), + mock_response(200, {"data": [{"name": command.path, "description": "Test command", "id": 8}]}), + ] + meta = await api.get_rest_meta(command) + self.assertIsInstance(meta, CommandMeta) + self.assertEqual(meta.ident, 8) + self.assertIs(command._cached_meta, meta) + + async def test_get_rest_meta_returns_none_when_disconnected(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + api.session.get.return_value = mock_response(503) + self.assertIsNone(await api.get_rest_meta(dataref)) + + async def test_get_rest_meta_returns_none_for_empty_response(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + api.session.get.side_effect = [mock_response(200, {"data": 1}), mock_response(200, {"data": []})] + self.assertIsNone(await api.get_rest_meta(dataref)) + + +class TestAsyncXPRestAPIDatarefValue(AsyncRestAPITestCase): + async def test_dataref_value_returns_scalar(self): + api = self.make_api() + dataref = self.make_dataref(api) + api.session.get.side_effect = [mock_response(200, {"data": 1}), mock_response(200, {"data": 42})] + self.assertEqual(await api.dataref_value(dataref), 42) + + async def test_shared_mock_response_supports_async_rest(self): + api = self.make_api() + dataref = self.make_dataref(api) + api.session.get.side_effect = [mock_response(200, {"data": 1}), mock_response(200, {"data": 77})] + + self.assertEqual(await api.dataref_value(dataref), 77) + + async def test_dataref_value_decodes_base64_string(self): + api = self.make_api() + dataref = self.make_dataref(api, value_type=DATAREF_DATATYPE.DATA.value) + encoded = base64.b64encode(b"abc").decode("ascii") + api.session.get.side_effect = [mock_response(200, {"data": 1}), mock_response(200, {"data": encoded})] + self.assertEqual(await api.dataref_value(dataref), b"abc") + + async def test_dataref_value_raw_returns_encoded_string(self): + api = self.make_api() + dataref = self.make_dataref(api, value_type=DATAREF_DATATYPE.DATA.value) + encoded = base64.b64encode(b"abc").decode("ascii") + api.session.get.side_effect = [mock_response(200, {"data": 1}), mock_response(200, {"data": encoded})] + self.assertEqual(await api.dataref_value(dataref, raw=True), encoded) + + async def test_dataref_value_no_decode_returns_encoded_string(self): + api = self.make_api() + dataref = self.make_dataref(api, value_type=DATAREF_DATATYPE.DATA.value) + encoded = base64.b64encode(b"abc").decode("ascii") + api.session.get.side_effect = [mock_response(200, {"data": 1}), mock_response(200, {"data": encoded})] + self.assertEqual(await api.dataref_value(dataref, no_decode=True), encoded) + + async def test_dataref_value_returns_none_when_not_connected(self): + api = self.make_api() + dataref = self.make_dataref(api) + api.session.get.return_value = mock_response(503) + self.assertIsNone(await api.dataref_value(dataref)) + + async def test_dataref_value_returns_none_when_meta_missing(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + api.session.get.side_effect = [mock_response(200, {"data": 1}), mock_response(200, {"data": []})] + self.assertIsNone(await api.dataref_value(dataref)) + + async def test_dataref_value_returns_none_for_error_response(self): + api = self.make_api() + dataref = self.make_dataref(api) + api.session.get.side_effect = [mock_response(200, {"data": 1}), mock_response(404)] + self.assertIsNone(await api.dataref_value(dataref)) + + +class TestAsyncXPRestAPIWriteDataref(AsyncRestAPITestCase): + async def test_write_dataref_success(self): + api = self.make_api() + dataref = self.make_dataref(api) + dataref._new_value = 99 + api.session.get.return_value = mock_response(200, {"data": 1}) + api.session.patch.return_value = mock_response(200, {"result": "ok"}) + self.assertTrue(await api.write_dataref(dataref)) + api.session.patch.assert_awaited_once() + + async def test_write_dataref_returns_false_when_not_connected(self): + api = self.make_api() + dataref = self.make_dataref(api) + dataref._new_value = 99 + api.session.get.return_value = mock_response(503) + self.assertFalse(await api.write_dataref(dataref)) + api.session.patch.assert_not_awaited() + + async def test_write_dataref_returns_false_when_meta_missing(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + dataref._new_value = 99 + api.session.get.side_effect = [mock_response(200, {"data": 1}), mock_response(200, {"data": []})] + self.assertFalse(await api.write_dataref(dataref)) + + async def test_write_dataref_rejects_unwritable_dataref(self): + api = self.make_api() + dataref = self.make_dataref(api, is_writable=False) + dataref._new_value = 99 + api.session.get.return_value = mock_response(200, {"data": 1}) + self.assertFalse(await api.write_dataref(dataref)) + api.session.patch.assert_not_awaited() + + async def test_write_dataref_rejects_missing_new_value(self): + api = self.make_api() + dataref = self.make_dataref(api) + api.session.get.return_value = mock_response(200, {"data": 1}) + self.assertFalse(await api.write_dataref(dataref)) + + async def test_write_dataref_base64_encodes_data_values(self): + api = self.make_api() + dataref = self.make_dataref(api, value_type=DATAREF_DATATYPE.DATA.value) + dataref._new_value = b"abc" + api.session.get.return_value = mock_response(200, {"data": 1}) + api.session.patch.return_value = mock_response(200, {"result": "ok"}) + self.assertTrue(await api.write_dataref(dataref)) + payload = api.session.patch.call_args.kwargs["json"] + self.assertEqual(payload["data"], base64.b64encode(b"abc").decode("ascii")) + + async def test_write_dataref_selected_array_element_adds_index_to_url(self): + api = self.make_api() + dataref = Dataref(path="sim/test/array[3]", api=api) + dataref._cached_meta = DatarefMeta(name=dataref.path, value_type=DATAREF_DATATYPE.FLOATARRAY.value, is_writable=True, id=11) + dataref._new_value = 3.14 + api.session.get.return_value = mock_response(200, {"data": 1}) + api.session.patch.return_value = mock_response(200, {"result": "ok"}) + self.assertTrue(await api.write_dataref(dataref)) + url = api.session.patch.call_args.args[0] + self.assertTrue(url.endswith("/datarefs/11/value?index=3")) + + async def test_write_dataref_returns_false_for_error_response(self): + api = self.make_api() + dataref = self.make_dataref(api) + dataref._new_value = 99 + api.session.get.return_value = mock_response(200, {"data": 1}) + api.session.patch.return_value = mock_response(400) + self.assertFalse(await api.write_dataref(dataref)) + + +class TestAsyncXPRestAPIExecuteCommand(AsyncRestAPITestCase): + async def test_execute_command_success(self): + api = self.make_api() + command = self.make_command(api) + api.session.get.return_value = mock_response(200, {"data": 1}) + api.session.post.return_value = mock_response(200, {"result": "ok"}) + self.assertTrue(await api.execute_command(command)) + api.session.post.assert_awaited_once() + + async def test_execute_command_returns_false_when_not_connected(self): + api = self.make_api() + command = self.make_command(api) + api.session.get.return_value = mock_response(503) + self.assertFalse(await api.execute_command(command)) + + async def test_execute_command_returns_false_when_meta_missing(self): + api = self.make_api() + command = Command(path="sim/test/command", api=api) + api.session.get.side_effect = [mock_response(200, {"data": 1}), mock_response(200, {"data": []})] + self.assertFalse(await api.execute_command(command)) + + async def test_execute_command_sends_explicit_duration(self): + api = self.make_api() + command = self.make_command(api) + api.session.get.return_value = mock_response(200, {"data": 1}) + api.session.post.return_value = mock_response(200, {"result": "ok"}) + self.assertTrue(await api.execute_command(command, duration=1.5)) + payload = api.session.post.call_args.kwargs["json"] + self.assertEqual(payload["duration"], 1.5) + + async def test_execute_command_uses_command_default_duration(self): + api = self.make_api() + command = self.make_command(api) + command.duration = 2.5 + api.session.get.return_value = mock_response(200, {"data": 1}) + api.session.post.return_value = mock_response(200, {"result": "ok"}) + self.assertTrue(await api.execute_command(command)) + payload = api.session.post.call_args.kwargs["json"] + self.assertEqual(payload["duration"], 2.5) + + async def test_execute_command_returns_false_for_error_response(self): + api = self.make_api() + command = self.make_command(api) + api.session.get.return_value = mock_response(200, {"data": 1}) + api.session.post.return_value = mock_response(500, {"error": "boom"}) + self.assertFalse(await api.execute_command(command)) + + +class TestAsyncXPRestAPIExports(AsyncRestAPITestCase): + async def test_package_factory_returns_async_rest_api(self): + api = xpwebapi.async_rest_api() + try: + self.assertIsInstance(api, AsyncXPRestAPI) + finally: + await api.aclose() + + async def test_sync_rest_factory_still_returns_sync_api(self): + api = xpwebapi.rest_api() + try: + self.assertIsInstance(api, XPRestAPI) + finally: + api.session.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_beacon.py b/tests/test_beacon.py new file mode 100644 index 0000000..98e520b --- /dev/null +++ b/tests/test_beacon.py @@ -0,0 +1,198 @@ +import socket +import unittest +from unittest.mock import MagicMock, patch + +import xpwebapi +from tests.helpers import make_beacon_packet +from xpwebapi.beacon import BEACON_MONITOR_STATUS, BeaconData, XPBeaconMonitor, XPlaneNoBeacon, XPlaneVersionNotSupported + + +class BeaconMonitorTestCase(unittest.TestCase): + def make_monitor(self): + with patch("xpwebapi.beacon.list_my_ips", return_value=[]): + return XPBeaconMonitor() + + def mock_sockets(self, recvfrom): + monitor_socket = MagicMock() + beacon_socket = MagicMock() + beacon_socket.recvfrom.side_effect = recvfrom if isinstance(recvfrom, BaseException) else None + if not isinstance(recvfrom, BaseException): + beacon_socket.recvfrom.return_value = recvfrom + return patch("xpwebapi.beacon.socket.socket", side_effect=[monitor_socket, beacon_socket]), beacon_socket + + +class TestBeaconData(unittest.TestCase): + def test_construction(self): + data = BeaconData(host="10.0.0.1", port=49000, hostname="xp", xplane_version=121400, role=1) + self.assertEqual(data.host, "10.0.0.1") + self.assertEqual(data.port, 49000) + self.assertEqual(data.hostname, "xp") + self.assertEqual(data.xplane_version, 121400) + self.assertEqual(data.role, 1) + + +class TestBeaconFactory(unittest.TestCase): + def test_package_factory_forwards_retry_options(self): + with patch("xpwebapi.beacon.list_my_ips", return_value=[]): + monitor = xpwebapi.beacon(retry_attempts=2, retry_backoff=0.25) + + self.assertEqual(monitor.retry_config.attempts, 2) + self.assertEqual(monitor.retry_config.backoff, 0.25) + + +class TestXPBeaconMonitorGetBeacon(BeaconMonitorTestCase): + def test_get_beacon_decodes_valid_packet(self): + monitor = self.make_monitor() + packet = make_beacon_packet() + socket_patch, beacon_socket = self.mock_sockets((packet, ("127.0.0.1", 49000))) + + with socket_patch: + with patch("xpwebapi.beacon.platform.system", return_value="Windows"): + data = monitor.get_beacon(timeout=1.0) + + self.assertEqual(data, BeaconData(host="127.0.0.1", port=49000, hostname="testhost", xplane_version=121400, role=1)) + beacon_socket.settimeout.assert_called_once_with(1.0) + beacon_socket.close.assert_called_once() + + def test_get_beacon_returns_none_for_unknown_packet_header(self): + monitor = self.make_monitor() + packet = b"XXXX\x00" + b"\x00" * 32 + socket_patch, _beacon_socket = self.mock_sockets((packet, ("127.0.0.1", 49000))) + + with socket_patch: + with patch("xpwebapi.beacon.platform.system", return_value="Windows"): + with patch("xpwebapi.beacon.logger.warning"): + self.assertIsNone(monitor.get_beacon(timeout=1.0)) + + def test_get_beacon_raises_typed_no_beacon_on_timeout(self): + monitor = self.make_monitor() + socket_patch, _beacon_socket = self.mock_sockets(socket.timeout("timed out")) + + with socket_patch: + with patch("xpwebapi.beacon.platform.system", return_value="Windows"): + with self.assertRaises(XPlaneNoBeacon) as caught: + monitor.get_beacon(timeout=1.5) + + self.assertEqual(caught.exception.context, {"timeout": 1.5}) + + def test_get_beacon_retries_timeout_then_returns_valid_packet(self): + with patch("xpwebapi.beacon.list_my_ips", return_value=[]): + monitor = XPBeaconMonitor(retry_attempts=2, retry_backoff=0.25) + packet = make_beacon_packet() + monitor_socket_1 = MagicMock() + beacon_socket_1 = MagicMock() + beacon_socket_1.recvfrom.side_effect = socket.timeout("timed out") + monitor_socket_2 = MagicMock() + beacon_socket_2 = MagicMock() + beacon_socket_2.recvfrom.return_value = (packet, ("127.0.0.1", 49000)) + + with patch("xpwebapi.beacon.socket.socket", side_effect=[monitor_socket_1, beacon_socket_1, monitor_socket_2, beacon_socket_2]): + with patch("xpwebapi.beacon.platform.system", return_value="Windows"): + with patch("xpwebapi.beacon.sleep_before_retry") as sleep: + data = monitor.get_beacon(timeout=1.0) + + self.assertEqual(data, BeaconData(host="127.0.0.1", port=49000, hostname="testhost", xplane_version=121400, role=1)) + self.assertEqual(beacon_socket_1.recvfrom.call_count, 1) + self.assertEqual(beacon_socket_2.recvfrom.call_count, 1) + sleep.assert_called_once_with(monitor.retry_config, 0) + + def test_get_beacon_raises_version_error_for_unsupported_packet(self): + monitor = self.make_monitor() + packet = make_beacon_packet(major=2) + socket_patch, _beacon_socket = self.mock_sockets((packet, ("127.0.0.1", 49000))) + + with socket_patch: + with patch("xpwebapi.beacon.platform.system", return_value="Windows"): + with patch("xpwebapi.beacon.logger.warning"): + with self.assertRaises(XPlaneVersionNotSupported): + monitor.get_beacon(timeout=1.0) + + +class TestXPBeaconMonitorSameHost(BeaconMonitorTestCase): + def test_same_host_returns_true_when_beacon_host_is_local(self): + monitor = self.make_monitor() + monitor.data = BeaconData(host="127.0.0.1", port=49000, hostname="xp", xplane_version=121400, role=1) + monitor.my_ips = ["127.0.0.1"] + self.assertTrue(monitor.same_host()) + + def test_same_host_returns_false_when_no_data(self): + monitor = self.make_monitor() + monitor.data = None + with patch("xpwebapi.beacon.logger.warning"): + self.assertFalse(monitor.same_host()) + + def test_same_host_returns_false_for_remote_host(self): + monitor = self.make_monitor() + monitor.data = BeaconData(host="192.168.1.50", port=49000, hostname="xp", xplane_version=121400, role=1) + monitor.my_ips = ["127.0.0.1"] + self.assertFalse(monitor.same_host()) + + +class TestXPBeaconMonitorStatus(BeaconMonitorTestCase): + def test_receiving_beacon_returns_true_when_data_exists(self): + monitor = self.make_monitor() + monitor.data = BeaconData(host="127.0.0.1", port=49000, hostname="xp", xplane_version=121400, role=1) + + self.assertTrue(monitor.receiving_beacon) + + def test_receiving_beacon_increments_warning_counter_when_no_data(self): + monitor = self.make_monitor() + + with patch("xpwebapi.beacon.logger.warning"): + self.assertFalse(monitor.receiving_beacon) + + self.assertEqual(monitor._already_warned, 1) + + def test_stop_monitor_marks_status_not_running_when_already_stopped(self): + monitor = self.make_monitor() + + with patch("xpwebapi.beacon.logger.warning"): + monitor.stop_monitor() + + self.assertEqual(monitor.status, BEACON_MONITOR_STATUS.NOT_RUNNING) + + +class TestXPBeaconMonitorCallbacks(BeaconMonitorTestCase): + def test_callback_executes_registered_callbacks(self): + monitor = self.make_monitor() + callback = MagicMock() + data = BeaconData(host="127.0.0.1", port=49000, hostname="xp", xplane_version=121400, role=1) + monitor.set_callback(callback) + + monitor.callback(connected=True, beacon_data=data, same_host=True) + + callback.assert_called_once_with(connected=True, beacon_data=data, same_host=True) + + def test_callback_handles_callback_exception(self): + monitor = self.make_monitor() + callback = MagicMock(side_effect=RuntimeError("boom")) + monitor.set_callback(callback) + + with patch("xpwebapi.beacon.logger.warning"): + monitor.callback(connected=False, beacon_data=None, same_host=False) + + callback.assert_called_once() + + def test_multiple_callbacks_are_executed(self): + monitor = self.make_monitor() + first = MagicMock() + second = MagicMock() + monitor.set_callback(first) + monitor.set_callback(second) + + monitor.callback(connected=False, beacon_data=None, same_host=False) + + first.assert_called_once() + second.assert_called_once() + + def test_set_callback_ignores_none(self): + monitor = self.make_monitor() + + monitor.set_callback(None) + monitor.callback(connected=False, beacon_data=None, same_host=None) + + self.assertEqual(monitor._callback, set()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_documentation.py b/tests/test_documentation.py new file mode 100644 index 0000000..442a7b9 --- /dev/null +++ b/tests/test_documentation.py @@ -0,0 +1,87 @@ +"""Documentation and example contract tests.""" + +from __future__ import annotations + +import ast +from pathlib import Path +import unittest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +EXAMPLES_DIR = REPO_ROOT / "examples" +DOCS_DIR = REPO_ROOT / "docs" + + +class TestExampleAnnotations(unittest.TestCase): + def test_examples_have_function_annotations(self) -> None: + missing: list[str] = [] + + for path in sorted(EXAMPLES_DIR.glob("*.py")): + module = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(module): + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + if node.returns is None: + missing.append(f"{path.relative_to(REPO_ROOT)}:{node.lineno} {node.name} missing return annotation") + + arguments = [ + *node.args.posonlyargs, + *node.args.args, + *node.args.kwonlyargs, + ] + if node.args.vararg is not None: + arguments.append(node.args.vararg) + if node.args.kwarg is not None: + arguments.append(node.args.kwarg) + + for argument in arguments: + if argument.arg in {"self", "cls"}: + continue + if argument.annotation is None: + missing.append(f"{path.relative_to(REPO_ROOT)}:{argument.lineno} {node.name}.{argument.arg} missing parameter annotation") + + self.assertEqual([], missing) + + +class TestDocumentationContent(unittest.TestCase): + def test_usage_docs_include_required_patterns(self) -> None: + usage = (DOCS_DIR / "usage" / "index.md").read_text(encoding="utf-8") + + for heading in ["Connection lifecycle", "Monitoring datarefs", "Executing commands"]: + with self.subTest(heading=heading): + self.assertIn(f"## {heading}", usage) + + def test_reference_docs_use_valid_mkdocstrings_directives(self) -> None: + reference_pages = sorted((DOCS_DIR / "reference").glob("*.md")) + directives: dict[str, list[str]] = {} + + for path in reference_pages: + lines = path.read_text(encoding="utf-8").splitlines() + directives[path.name] = [line.strip() for line in lines if line.strip().startswith(":::")] + + self.assertIn("package.md", directives) + self.assertIn("rest.md", directives) + self.assertIn("websocket.md", directives) + self.assertIn("udp.md", directives) + self.assertNotIn("# :::", (DOCS_DIR / "reference" / "index.md").read_text(encoding="utf-8")) + self.assertTrue(any("::: xpwebapi" == directive for directive in directives["package.md"])) + self.assertTrue(any("::: xpwebapi.rest" == directive for directive in directives["rest.md"])) + self.assertTrue(any("::: xpwebapi.ws" == directive for directive in directives["websocket.md"])) + self.assertTrue(any("::: xpwebapi.udp" == directive for directive in directives["udp.md"])) + + def test_mkdocs_navigation_publishes_reference_pages(self) -> None: + mkdocs = (REPO_ROOT / "mkdocs.yml").read_text(encoding="utf-8") + + for nav_entry in [ + "Package: reference/package.md", + "REST: reference/rest.md", + "Async REST: reference/async-rest.md", + "WebSocket: reference/websocket.md", + "UDP: reference/udp.md", + "Beacon: reference/beacon.md", + ]: + with self.subTest(nav_entry=nav_entry): + self.assertIn(nav_entry, mkdocs) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..d7df78b --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,99 @@ +import unittest + +from xpwebapi.exceptions import ( + XPWebAPIError, + XPConnectionError, + XPBeaconError, + XPPacketError, + XPTimeoutError, + XPVersionError, +) + + +class TestExceptionHierarchy(unittest.TestCase): + def test_base_is_exception(self): + self.assertTrue(issubclass(XPWebAPIError, Exception)) + + def test_connection_error_is_xpwebapi_error(self): + self.assertTrue(issubclass(XPConnectionError, XPWebAPIError)) + + def test_beacon_error_is_connection_error(self): + self.assertTrue(issubclass(XPBeaconError, XPConnectionError)) + + def test_timeout_error_is_xpwebapi_error(self): + self.assertTrue(issubclass(XPTimeoutError, XPWebAPIError)) + + def test_packet_error_is_xpwebapi_error(self): + self.assertTrue(issubclass(XPPacketError, XPWebAPIError)) + + def test_version_error_is_xpwebapi_error(self): + self.assertTrue(issubclass(XPVersionError, XPWebAPIError)) + + def test_context_kwargs(self): + err = XPWebAPIError("boom", host="127.0.0.1", port=8086) + self.assertEqual(str(err), "boom") + self.assertEqual(err.context, {"host": "127.0.0.1", "port": 8086}) + + def test_context_empty_by_default(self): + err = XPWebAPIError("oops") + self.assertEqual(err.context, {}) + + def test_beacon_error_context(self): + err = XPBeaconError("no beacon", timeout=3.0) + self.assertEqual(err.context, {"timeout": 3.0}) + self.assertIsInstance(err, XPConnectionError) + + def test_timeout_error_context(self): + err = XPTimeoutError("timed out", host="10.0.0.1") + self.assertEqual(err.context, {"host": "10.0.0.1"}) + + def test_packet_error_context(self): + err = XPPacketError("invalid DREF packet length", packet_type="DREF", expected=509, actual=12) + self.assertEqual(str(err), "invalid DREF packet length") + self.assertEqual(err.context, {"packet_type": "DREF", "expected": 509, "actual": 12}) + + def test_version_error_context(self): + err = XPVersionError("unsupported", version="10.40") + self.assertEqual(err.context, {"version": "10.40"}) + + def test_catch_base_catches_all(self): + for cls in (XPConnectionError, XPBeaconError, XPPacketError, XPTimeoutError, XPVersionError): + with self.assertRaises(XPWebAPIError): + raise cls("test") + + +class TestBackwardCompat(unittest.TestCase): + def test_xplane_no_beacon_is_beacon_error(self): + from xpwebapi.beacon import XPlaneNoBeacon + from xpwebapi.exceptions import XPBeaconError + + self.assertTrue(issubclass(XPlaneNoBeacon, XPBeaconError)) + + def test_xplane_version_not_supported_is_version_error(self): + from xpwebapi.beacon import XPlaneVersionNotSupported + from xpwebapi.exceptions import XPVersionError + + self.assertTrue(issubclass(XPlaneVersionNotSupported, XPVersionError)) + + def test_xplane_timeout_is_timeout_error(self): + from xpwebapi.udp import XPlaneTimeout + from xpwebapi.exceptions import XPTimeoutError + + self.assertTrue(issubclass(XPlaneTimeout, XPTimeoutError)) + + def test_old_names_importable_from_package(self): + from xpwebapi import XPlaneNoBeacon, XPlaneVersionNotSupported, XPlaneTimeout + + self.assertTrue(issubclass(XPlaneNoBeacon, Exception)) + self.assertTrue(issubclass(XPlaneVersionNotSupported, Exception)) + self.assertTrue(issubclass(XPlaneTimeout, Exception)) + + def test_new_names_importable_from_package(self): + from xpwebapi import XPWebAPIError, XPConnectionError, XPBeaconError, XPPacketError, XPTimeoutError, XPVersionError # noqa: F401 + + self.assertTrue(issubclass(XPWebAPIError, Exception)) + self.assertTrue(issubclass(XPPacketError, Exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_logging_config.py b/tests/test_logging_config.py new file mode 100644 index 0000000..b040fe2 --- /dev/null +++ b/tests/test_logging_config.py @@ -0,0 +1,282 @@ +import json +import logging +import subprocess +import sys +import unittest +from io import StringIO +from pathlib import Path +from tempfile import TemporaryDirectory + +from pydantic import ValidationError + +import xpwebapi.logging_config as logging_config_module +from xpwebapi.logging_config import JsonLogFormatter, LoggingConfig, configure_logging, write_logging_config + + +class TestJsonLogFormatter(unittest.TestCase): + def test_json_formatter_emits_stable_core_fields(self): + record = logging.LogRecord( + name="xpwebapi.rest", + level=logging.INFO, + pathname="rest.py", + lineno=188, + msg="rest api reachable", + args=(), + exc_info=None, + func="rest_api_reachable", + ) + + payload = json.loads(JsonLogFormatter().format(record)) + + self.assertEqual(payload["level"], "INFO") + self.assertEqual(payload["logger"], "xpwebapi.rest") + self.assertEqual(payload["message"], "rest api reachable") + self.assertEqual(payload["module"], "rest") + self.assertEqual(payload["function"], "rest_api_reachable") + self.assertEqual(payload["line"], 188) + self.assertRegex(payload["timestamp"], r"^\d{4}-\d{2}-\d{2}T.*Z$") + self.assertNotIn("exception", payload) + + def test_json_formatter_includes_exception_text(self): + try: + raise RuntimeError("boom") + except RuntimeError: + exc_info = sys.exc_info() + record = logging.getLogger("xpwebapi.rest").makeRecord( + name="xpwebapi.rest", + level=logging.ERROR, + fn="rest.py", + lno=200, + msg="request failed", + args=(), + exc_info=exc_info, + func="dataref_value", + extra=None, + ) + + payload = json.loads(JsonLogFormatter().format(record)) + + self.assertEqual(payload["level"], "ERROR") + self.assertIn("RuntimeError: boom", payload["exception"]) + + +class TestLoggingConfig(unittest.TestCase): + def test_logging_config_accepts_standard_python_levels(self): + config = LoggingConfig( + format="json", + level="debug", + traffic_level="WARNING", + components={"xpwebapi.rest": "INFO", "webapi": "ERROR"}, + ) + + self.assertEqual(config.format, "json") + self.assertEqual(config.level, "DEBUG") + self.assertEqual(config.traffic_level, "WARNING") + self.assertEqual(config.components["xpwebapi.rest"], "INFO") + self.assertEqual(config.components["webapi"], "ERROR") + + def test_logging_config_rejects_unknown_level_names(self): + with self.assertRaises(ValidationError): + LoggingConfig(level="NOTICE") + + def test_logging_config_rejects_unrelated_component_names(self): + with self.assertRaises(ValidationError): + LoggingConfig(components={"urllib3": "DEBUG"}) + + def test_logging_config_rejects_trailing_dot_only_component(self): + with self.assertRaises(ValidationError): + LoggingConfig(components={"xpwebapi.": "DEBUG"}) + + +class IsolatedLoggerState: + def __init__(self, *names: str) -> None: + self.names = names + self.state = {} + self.owned_component_levels = {} + + def __enter__(self) -> "IsolatedLoggerState": + self.owned_component_levels = logging_config_module._OWNED_COMPONENT_LOGGER_LEVELS.copy() + logging_config_module._OWNED_COMPONENT_LOGGER_LEVELS.clear() + for name in self.names: + logger = logging.getLogger(name) + self.state[name] = (logger.handlers[:], logger.level, logger.propagate) + logger.handlers.clear() + logger.setLevel(logging.NOTSET) + logger.propagate = True + return self + + def __exit__(self, _exc_type, _exc, _tb) -> None: + logging_config_module._OWNED_COMPONENT_LOGGER_LEVELS.clear() + logging_config_module._OWNED_COMPONENT_LOGGER_LEVELS.update(self.owned_component_levels) + for name, (handlers, level, propagate) in self.state.items(): + logger = logging.getLogger(name) + logger.handlers.clear() + logger.handlers.extend(handlers) + logger.setLevel(level) + logger.propagate = propagate + + +class TestLoggingConfigFileIO(unittest.TestCase): + def test_write_logging_config_writes_valid_starter_config(self): + with TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "xpwebapi-logging.json" + + written = write_logging_config(path) + + self.assertEqual(written, path) + raw = json.loads(path.read_text(encoding="utf-8")) + config = LoggingConfig.model_validate(raw["logging"]) + self.assertEqual(config.format, "text") + self.assertEqual(config.level, "INFO") + self.assertEqual(config.traffic_level, "WARNING") + + def test_write_logging_config_does_not_overwrite_by_default(self): + with TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "xpwebapi-logging.json" + path.write_text("{}", encoding="utf-8") + + with self.assertRaises(FileExistsError): + write_logging_config(path) + + def test_configure_logging_raises_value_error_for_invalid_json(self): + with TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "xpwebapi-logging.json" + path.write_text("{", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "xpwebapi-logging.json"): + configure_logging(config_file=path) + + def test_configure_logging_raises_file_not_found_for_explicit_missing_file(self): + with TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "missing.json" + + with self.assertRaises(FileNotFoundError): + configure_logging(config_file=path) + + +class TestConfigureLogging(unittest.TestCase): + def test_configure_logging_separates_application_and_traffic_loggers(self): + app_stream = StringIO() + + with IsolatedLoggerState("xpwebapi", "webapi"): + config = configure_logging(format="json", level="DEBUG", traffic_level="ERROR", stream=app_stream) + + app_logger = logging.getLogger("xpwebapi") + traffic_logger = logging.getLogger("webapi") + + self.assertEqual(config.format, "json") + self.assertEqual(app_logger.level, logging.DEBUG) + self.assertEqual(traffic_logger.level, logging.ERROR) + self.assertEqual(len(app_logger.handlers), 1) + self.assertEqual(len(traffic_logger.handlers), 1) + self.assertIsInstance(app_logger.handlers[0].formatter, JsonLogFormatter) + self.assertIsInstance(traffic_logger.handlers[0].formatter, JsonLogFormatter) + self.assertIsNot(app_logger.handlers[0], traffic_logger.handlers[0]) + self.assertFalse(traffic_logger.propagate) + + def test_configure_logging_applies_component_levels(self): + with IsolatedLoggerState("xpwebapi", "webapi", "xpwebapi.rest", "xpwebapi.ws"): + configure_logging(level="WARNING", components={"xpwebapi.rest": "DEBUG", "xpwebapi.ws": "ERROR"}) + + self.assertEqual(logging.getLogger("xpwebapi").level, logging.WARNING) + self.assertEqual(logging.getLogger("xpwebapi.rest").level, logging.DEBUG) + self.assertEqual(logging.getLogger("xpwebapi.ws").level, logging.ERROR) + + def test_repeated_configure_logging_restores_removed_component_overrides(self): + with IsolatedLoggerState("xpwebapi", "webapi", "xpwebapi.rest", "xpwebapi.ws"): + rest_logger = logging.getLogger("xpwebapi.rest") + ws_logger = logging.getLogger("xpwebapi.ws") + rest_logger.setLevel(logging.ERROR) + ws_logger.setLevel(logging.CRITICAL) + + configure_logging(components={"xpwebapi.rest": "DEBUG"}) + configure_logging(components={"xpwebapi.ws": "WARNING"}) + + self.assertEqual(rest_logger.level, logging.ERROR) + self.assertEqual(ws_logger.level, logging.WARNING) + + def test_manual_component_level_change_after_configure_is_preserved_when_override_is_removed(self): + with IsolatedLoggerState("xpwebapi", "webapi", "xpwebapi.rest"): + rest_logger = logging.getLogger("xpwebapi.rest") + + configure_logging(components={"xpwebapi.rest": "DEBUG"}) + rest_logger.setLevel(logging.ERROR) + configure_logging() + + self.assertEqual(rest_logger.level, logging.ERROR) + + def test_explicit_component_reconfigure_reclaims_control_after_manual_change(self): + with IsolatedLoggerState("xpwebapi", "webapi", "xpwebapi.rest"): + rest_logger = logging.getLogger("xpwebapi.rest") + + configure_logging(components={"xpwebapi.rest": "DEBUG"}) + rest_logger.setLevel(logging.ERROR) + configure_logging(components={"xpwebapi.rest": "INFO"}) + + self.assertEqual(rest_logger.level, logging.INFO) + + def test_configure_logging_validates_before_mutating_handlers(self): + foreign_handler = logging.NullHandler() + + with IsolatedLoggerState("xpwebapi", "webapi"): + app_logger = logging.getLogger("xpwebapi") + app_logger.addHandler(foreign_handler) + + with self.assertRaises(ValidationError): + configure_logging(level="NOTICE") + + self.assertEqual(app_logger.handlers, [foreign_handler]) + + def test_repeated_configure_logging_replaces_only_owned_handlers(self): + foreign_handler = logging.NullHandler() + + with IsolatedLoggerState("xpwebapi", "webapi"): + app_logger = logging.getLogger("xpwebapi") + app_logger.addHandler(foreign_handler) + + configure_logging(level="INFO") + configure_logging(level="DEBUG") + + self.assertIn(foreign_handler, app_logger.handlers) + owned_handlers = [handler for handler in app_logger.handlers if getattr(handler, "_xpwebapi_owned_handler", False)] + self.assertEqual(len(owned_handlers), 1) + self.assertEqual(app_logger.level, logging.DEBUG) + + def test_importing_package_does_not_configure_logging_handlers(self): + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import json, logging; " + "before = {name: len(logging.getLogger(name).handlers) for name in ('xpwebapi', 'webapi')}; " + "import xpwebapi; " + "after = {name: len(logging.getLogger(name).handlers) for name in ('xpwebapi', 'webapi')}; " + "print(json.dumps({'before': before, 'after': after}))" + ), + ], + capture_output=True, + check=True, + text=True, + ) + + payload = json.loads(result.stdout) + self.assertEqual(payload["after"], payload["before"]) + + +class TestPackageRootExports(unittest.TestCase): + def test_logging_helpers_are_exported_from_package_root(self): + import xpwebapi + + self.assertIs(xpwebapi.configure_logging, configure_logging) + self.assertIs(xpwebapi.write_logging_config, write_logging_config) + self.assertIs(xpwebapi.LoggingConfig, LoggingConfig) + self.assertIs(xpwebapi.JsonLogFormatter, JsonLogFormatter) + self.assertIn("configure_logging", xpwebapi.__all__) + self.assertIn("write_logging_config", xpwebapi.__all__) + self.assertIn("LoggingConfig", xpwebapi.__all__) + self.assertIn("JsonLogFormatter", xpwebapi.__all__) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_quality_tool.py b/tests/test_quality_tool.py new file mode 100644 index 0000000..3cc433f --- /dev/null +++ b/tests/test_quality_tool.py @@ -0,0 +1,99 @@ +import io +import unittest +from unittest.mock import MagicMock, patch + +from tools import quality + + +class TestQualityTool(unittest.TestCase): + def test_check_runs_expected_blocking_steps_in_order(self): + names = [step.name for step in quality.CHECK_STEPS] + self.assertEqual( + names, + [ + "ruff check", + "ruff format --check", + "ty check", + "unittest", + "coverage run", + "coverage report", + "bandit", + "detect-secrets baseline", + "detect-secrets report", + "interrogate", + "vulture", + "xenon complexity", + ], + ) + + def test_generic_tool_gates_are_registered(self): + for gate in ("security", "docs", "dead-code", "complexity", "metrics", "wily", "pre-commit"): + self.assertIn(gate, quality.COMMANDS) + + def test_run_steps_stops_on_first_failure(self): + runner = MagicMock() + runner.side_effect = [ + MagicMock(returncode=0), + MagicMock(returncode=3), + MagicMock(returncode=0), + ] + + with patch("sys.stdout", new_callable=io.StringIO): + result = quality.run_steps( + [ + quality.Step("one", ("cmd", "one")), + quality.Step("two", ("cmd", "two")), + quality.Step("three", ("cmd", "three")), + ], + runner=runner, + ) + + self.assertEqual(result, 3) + self.assertEqual(runner.call_count, 2) + + def test_single_gate_uses_named_command(self): + runner = MagicMock(return_value=MagicMock(returncode=0)) + + with patch("sys.stdout", new_callable=io.StringIO): + result = quality.run_steps(quality.COMMANDS["typecheck"], runner=runner) + + self.assertEqual(result, 0) + runner.assert_called_once_with(("uv", "run", "ty", "check"), check=False) + + def test_tracked_path_gate_appends_git_tracked_files(self): + runner = MagicMock() + runner.side_effect = [ + MagicMock(returncode=0, stdout="xpwebapi/ws.py\ntests/test_ws.py\n"), + MagicMock(returncode=0), + ] + + with patch("sys.stdout", new_callable=io.StringIO): + result = quality.run_steps( + [ + quality.Step( + "tracked", + ("uv", "run", "detect-secrets-hook", "--baseline", ".secrets.baseline"), + tracked_paths=("xpwebapi", "tests"), + ) + ], + runner=runner, + ) + + self.assertEqual(result, 0) + runner.assert_any_call(("git", "ls-files", "--", "xpwebapi", "tests"), check=False, capture_output=True, text=True) + runner.assert_any_call( + ( + "uv", + "run", + "detect-secrets-hook", + "--baseline", + ".secrets.baseline", + "xpwebapi/ws.py", + "tests/test_ws.py", + ), + check=False, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rest.py b/tests/test_rest.py new file mode 100644 index 0000000..0d9800a --- /dev/null +++ b/tests/test_rest.py @@ -0,0 +1,348 @@ +import base64 +import unittest +from unittest.mock import MagicMock, PropertyMock, patch + +import httpx + +from tests.helpers import mock_response +from xpwebapi.api import DATAREF_DATATYPE, Command, CommandMeta, Dataref, DatarefCache, DatarefMeta +from xpwebapi.rest import V1_CAPABILITIES, XPRestAPI + + +class RestAPITestCase(unittest.TestCase): + def make_api(self): + api = XPRestAPI(host="127.0.0.1", port=8086, api="/api", api_version="v1") + api.session = MagicMock() + api._show_stats = False + return api + + def make_dataref(self, api, value_type="int", is_writable=True, ident=10): + dataref = Dataref(path="sim/test/value", api=api) + dataref._cached_meta = DatarefMeta(name=dataref.path, value_type=value_type, is_writable=is_writable, id=ident) + return dataref + + def make_command(self, api, ident=20): + command = Command(path="sim/test/command", api=api) + command._cached_meta = CommandMeta(name=command.path, description="Test command", id=ident) + return command + + +class TestXPRestAPIConnected(RestAPITestCase): + def test_context_manager_closes_session(self): + api = self.make_api() + + with api as active: + self.assertIs(active, api) + + api.session.close.assert_called_once() + + def test_pooled_clients_reuse_session_until_last_close(self): + with patch("xpwebapi.rest.httpx.Client") as client_cls: + shared_session = MagicMock() + client_cls.return_value = shared_session + + first = XPRestAPI(host="127.0.0.1", port=8086, api="/api", api_version="v1", pool_connections=True) + second = XPRestAPI(host="127.0.0.1", port=8086, api="/api", api_version="v1", pool_connections=True) + + self.assertIs(first.session, second.session) + + first.close() + shared_session.close.assert_not_called() + + second.close() + shared_session.close.assert_called_once() + + def test_unpooled_clients_keep_independent_sessions(self): + with patch("xpwebapi.rest.httpx.Client") as client_cls: + first_session = MagicMock() + second_session = MagicMock() + client_cls.side_effect = [first_session, second_session] + + first = XPRestAPI(host="127.0.0.1", port=8086, api="/api", api_version="v1", pool_connections=False) + second = XPRestAPI(host="127.0.0.1", port=8086, api="/api", api_version="v1", pool_connections=False) + + self.assertIsNot(first.session, second.session) + + first.close() + first_session.close.assert_called_once() + second_session.close.assert_not_called() + + second.close() + second_session.close.assert_called_once() + + def test_pool_configuration_passes_limits_and_timeout_to_httpx_client(self): + with patch("xpwebapi.rest.httpx.Client") as client_cls: + client_cls.return_value = MagicMock() + + api = XPRestAPI( + host="127.0.0.1", + port=8086, + api="/api", + api_version="v1", + pool_connections=True, + max_connections=8, + max_keepalive_connections=4, + keepalive_expiry=12.5, + timeout=3.0, + ) + + kwargs = client_cls.call_args.kwargs + limits = kwargs["limits"] + timeout = kwargs["timeout"] + + self.assertEqual(limits.max_connections, 8) + self.assertEqual(limits.max_keepalive_connections, 4) + self.assertEqual(limits.keepalive_expiry, 12.5) + self.assertEqual(timeout.as_dict(), {"connect": 3.0, "read": 3.0, "write": 3.0, "pool": 3.0}) + + api.close() + + def test_connected_returns_true_for_successful_count_probe(self): + api = self.make_api() + api.session.get.return_value = mock_response(200, {"data": 1}) + self.assertTrue(api.connected) + self.assertEqual(api._unreach_count, 0) + + def test_connected_returns_false_for_non_200_response(self): + api = self.make_api() + api.session.get.return_value = mock_response(503) + self.assertFalse(api.connected) + + def test_connected_returns_false_for_connect_error(self): + api = self.make_api() + api.session.get.side_effect = httpx.ConnectError("failed") + self.assertFalse(api.connected) + + def test_rest_api_reachable_retries_transient_connect_error(self): + api = XPRestAPI(host="127.0.0.1", port=8086, api="/api", api_version="v1", retry_attempts=3, retry_backoff=0.25) + api.session = MagicMock() + api._show_stats = False + api.session.get.side_effect = [httpx.ConnectError("failed"), mock_response(200, {"data": 1})] + + with patch("xpwebapi.rest.sleep_before_retry") as sleep: + self.assertTrue(api.connected) + + self.assertEqual(api.session.get.call_count, 2) + sleep.assert_called_once_with(api.retry_config, 0) + + +class TestXPRestAPICapabilities(RestAPITestCase): + def test_capabilities_are_cached_after_successful_fetch(self): + api = self.make_api() + payload = {"api": {"versions": ["v1", "v2"]}, "x-plane": {"version": "12.2.1"}} + api.session.get.return_value = mock_response(200, payload) + + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertEqual(api.capabilities, payload) + self.assertEqual(api.capabilities, payload) + + api.session.get.assert_called_once() + + def test_capabilities_fall_back_to_v1_probe(self): + api = self.make_api() + api.session.get.side_effect = [mock_response(404), mock_response(200, {"data": 1})] + + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertEqual(api.capabilities, V1_CAPABILITIES) + + def test_set_api_version_selects_latest_available_when_unspecified(self): + api = self.make_api() + api._capabilities = {"api": {"versions": ["v1", "v3", "v2"]}, "x-plane": {"version": "12.2.1"}} + + api.set_api_version() + + self.assertEqual(api.version, "v3") + self.assertEqual(api._api_version, "/v3") + + +class TestXPRestAPIGetRestMeta(RestAPITestCase): + def test_get_rest_meta_returns_cached_meta(self): + api = self.make_api() + dataref = self.make_dataref(api) + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertIs(api.get_rest_meta(dataref), dataref._cached_meta) + api.session.get.assert_not_called() + + def test_get_rest_meta_fetches_dataref_meta(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + api.session.get.return_value = mock_response(200, {"data": [{"name": dataref.path, "value_type": "int", "is_writable": True, "id": 7}]}) + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + meta = api.get_rest_meta(dataref) + self.assertIsInstance(meta, DatarefMeta) + self.assertEqual(meta.ident, 7) + self.assertIs(dataref._cached_meta, meta) + + def test_get_rest_meta_fetches_command_meta(self): + api = self.make_api() + command = Command(path="sim/test/command", api=api) + api.session.get.return_value = mock_response(200, {"data": [{"name": command.path, "description": "Test command", "id": 8}]}) + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + meta = api.get_rest_meta(command) + self.assertIsInstance(meta, CommandMeta) + self.assertEqual(meta.ident, 8) + self.assertIs(command._cached_meta, meta) + + def test_get_rest_meta_returns_none_for_empty_metadata(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + api.session.get.return_value = mock_response(200, {"data": []}) + + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertIsNone(api.get_rest_meta(dataref)) + + +class TestXPRestAPIDatarefValue(RestAPITestCase): + def test_dataref_value_returns_scalar(self): + api = self.make_api() + dataref = self.make_dataref(api) + api.session.get.return_value = mock_response(200, {"data": 42}) + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertEqual(api.dataref_value(dataref), 42) + + def test_dataref_value_decodes_base64_string(self): + api = self.make_api() + dataref = self.make_dataref(api, value_type=DATAREF_DATATYPE.DATA.value) + encoded = base64.b64encode(b"abc").decode("ascii") + api.session.get.return_value = mock_response(200, {"data": encoded}) + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertEqual(api.dataref_value(dataref), b"abc") + + def test_dataref_value_raw_returns_encoded_string(self): + api = self.make_api() + dataref = self.make_dataref(api, value_type=DATAREF_DATATYPE.DATA.value) + encoded = base64.b64encode(b"abc").decode("ascii") + api.session.get.return_value = mock_response(200, {"data": encoded}) + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertEqual(api.dataref_value(dataref, raw=True), encoded) + + def test_dataref_value_no_decode_returns_encoded_string(self): + api = self.make_api() + dataref = self.make_dataref(api, value_type=DATAREF_DATATYPE.DATA.value) + encoded = base64.b64encode(b"abc").decode("ascii") + api.session.get.return_value = mock_response(200, {"data": encoded}) + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertEqual(api.dataref_value(dataref, no_decode=True), encoded) + + def test_dataref_value_returns_none_when_not_connected(self): + api = self.make_api() + dataref = self.make_dataref(api) + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=False): + self.assertIsNone(api.dataref_value(dataref)) + + def test_dataref_value_returns_none_for_error_response(self): + api = self.make_api() + dataref = self.make_dataref(api) + api.session.get.return_value = mock_response(404) + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertIsNone(api.dataref_value(dataref)) + + +class TestXPRestAPIWriteDataref(RestAPITestCase): + def test_write_dataref_success(self): + api = self.make_api() + dataref = self.make_dataref(api) + dataref.value = 99 + api.session.patch.return_value = mock_response(200, {"result": "ok"}) + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertTrue(api.write_dataref(dataref)) + api.session.patch.assert_called_once() + + def test_write_dataref_rejects_unwritable_dataref(self): + api = self.make_api() + dataref = self.make_dataref(api, is_writable=False) + dataref.value = 99 + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertFalse(api.write_dataref(dataref)) + api.session.patch.assert_not_called() + + def test_write_dataref_rejects_missing_new_value(self): + api = self.make_api() + dataref = self.make_dataref(api) + + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertFalse(api.write_dataref(dataref)) + + api.session.patch.assert_not_called() + + def test_write_dataref_selected_array_element_adds_index_to_url(self): + api = self.make_api() + dataref = Dataref(path="sim/test/array[2]", api=api) + dataref._cached_meta = DatarefMeta(name=dataref.path, value_type=DATAREF_DATATYPE.FLOATARRAY.value, is_writable=True, id=31) + dataref.value = 8.5 + api.session.patch.return_value = mock_response(200, {"result": "ok"}) + + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertTrue(api.write_dataref(dataref)) + + url = api.session.patch.call_args.args[0] + self.assertTrue(url.endswith("/datarefs/31/value?index=2")) + + def test_write_dataref_returns_false_when_not_connected(self): + api = self.make_api() + dataref = self.make_dataref(api) + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=False): + self.assertFalse(api.write_dataref(dataref)) + + def test_write_dataref_returns_false_for_error_response(self): + api = self.make_api() + dataref = self.make_dataref(api) + dataref.value = 99 + api.session.patch.return_value = mock_response(400) + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertFalse(api.write_dataref(dataref)) + + +class TestXPRestAPIExecuteCommand(RestAPITestCase): + def test_execute_command_success(self): + api = self.make_api() + command = self.make_command(api) + api.session.post.return_value = mock_response(200, {"result": "ok"}) + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertTrue(api.execute_command(command)) + api.session.post.assert_called_once() + + def test_execute_command_uses_command_duration(self): + api = self.make_api() + command = self.make_command(api) + command.duration = 2.5 + api.session.post.return_value = mock_response(200, {"result": "ok"}) + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertTrue(api.execute_command(command)) + payload = api.session.post.call_args.kwargs["json"] + self.assertEqual(payload["duration"], 2.5) + + def test_execute_command_returns_false_when_not_connected(self): + api = self.make_api() + command = self.make_command(api) + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=False): + self.assertFalse(api.execute_command(command)) + + def test_execute_command_returns_false_for_error_response(self): + api = self.make_api() + command = self.make_command(api) + api.session.post.return_value = mock_response(500, {"error": "boom"}) + with patch.object(XPRestAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertFalse(api.execute_command(command)) + + +class TestXPRestAPICaches(RestAPITestCase): + def test_invalidate_caches_clears_loaded_caches(self): + api = self.make_api() + api.all_datarefs = DatarefCache(api) + api.all_commands = MagicMock() + + api.invalidate_caches() + + self.assertIsNone(api.all_datarefs) + self.assertIsNone(api.all_commands) + + def test_get_dataref_meta_by_id_returns_none_without_cache(self): + api = self.make_api() + + self.assertIsNone(api.get_dataref_meta_by_id(99)) + self.assertIsNone(api.get_dataref_meta_by_name("sim/test/value")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_type_annotations.py b/tests/test_type_annotations.py new file mode 100644 index 0000000..860a51e --- /dev/null +++ b/tests/test_type_annotations.py @@ -0,0 +1,41 @@ +import ast +import unittest +from pathlib import Path + + +SOURCE_ROOT = Path(__file__).resolve().parents[1] / "xpwebapi" +LEGACY_TYPING_ALIASES = {"Dict", "List", "Optional", "Tuple"} + + +class TestTypeAnnotationModernization(unittest.TestCase): + def test_source_does_not_use_legacy_collection_or_optional_aliases(self): + offenders = [] + for filename in SOURCE_ROOT.glob("*.py"): + tree = ast.parse(filename.read_text(encoding="utf-8"), filename=str(filename)) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module == "typing": + for alias in node.names: + if alias.name in LEGACY_TYPING_ALIASES: + offenders.append(f"{filename.name}: imports typing.{alias.name}") + if isinstance(node, ast.Name) and node.id in LEGACY_TYPING_ALIASES: + offenders.append(f"{filename.name}: uses {node.id}") + + self.assertEqual(offenders, []) + + def test_context_manager_enter_methods_return_self(self): + offenders = [] + for filename in SOURCE_ROOT.glob("*.py"): + tree = ast.parse(filename.read_text(encoding="utf-8"), filename=str(filename)) + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + continue + if node.name not in {"__enter__", "__aenter__"}: + continue + if not isinstance(node.returns, ast.Name) or node.returns.id != "Self": + offenders.append(f"{filename.name}: {node.name}") + + self.assertEqual(offenders, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_udp.py b/tests/test_udp.py new file mode 100644 index 0000000..e1f4eaf --- /dev/null +++ b/tests/test_udp.py @@ -0,0 +1,248 @@ +import unittest +from unittest.mock import MagicMock, PropertyMock, patch + +from tests.helpers import make_rref_packet +from xpwebapi.api import Command, Dataref +from xpwebapi.exceptions import XPPacketError +from xpwebapi.udp import XPUDPAPI, XPlaneTimeout + + +class UDPAPITestCase(unittest.TestCase): + def make_api(self): + with patch("xpwebapi.udp.socket.socket"): + api = XPUDPAPI(host="127.0.0.1", port=49000) + api.socket = MagicMock() + self.addCleanup(lambda api=api: api.datarefs.clear()) + return api + + +class TestXPUDPAPIWriteDataref(UDPAPITestCase): + def test_context_manager_stops_monitored_datarefs_and_closes_socket(self): + api = self.make_api() + api.datarefs = {0: "sim/test/value"} + + with patch.object(XPUDPAPI, "connected", new_callable=PropertyMock, return_value=True): + with api as active: + self.assertIs(active, api) + + api.socket.close.assert_called_once() + self.assertEqual(api.datarefs, {}) + + def test_write_dataref_sends_dref_packet(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + dataref.value = 3.5 + + self.assertTrue(api.write_dataref(dataref)) + + message, address = api.socket.sendto.call_args.args + self.assertEqual(address, ("127.0.0.1", 49000)) + self.assertTrue(message.startswith(b"DREF\x00")) + self.assertEqual(len(message), 509) + + def test_write_dataref_sends_packet_without_connection_probe(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + dataref.value = 1.25 + + self.assertTrue(api.write_dataref(dataref)) + api.socket.sendto.assert_called_once() + + def test_write_dataref_raises_packet_error_for_invalid_dref_length(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + dataref.value = 3.5 + + with patch("xpwebapi.udp.struct.pack", return_value=b"bad"): + with self.assertRaises(XPPacketError) as caught: + api.write_dataref(dataref) + + self.assertEqual(str(caught.exception), "invalid DREF packet length") + self.assertEqual(caught.exception.context["packet_type"], "DREF") + self.assertEqual(caught.exception.context["expected"], 509) + self.assertEqual(caught.exception.context["actual"], 3) + + +class TestXPUDPAPIExecuteCommand(UDPAPITestCase): + def test_execute_command_sends_cmnd_packet(self): + api = self.make_api() + command = Command(path="sim/test/command", api=api) + + self.assertTrue(api.execute_command(command)) + + message, address = api.socket.sendto.call_args.args + self.assertEqual(address, ("127.0.0.1", 49000)) + self.assertTrue(message.startswith(b"CMND\x00")) + + def test_execute_command_ignores_duration_for_udp_packet(self): + api = self.make_api() + command = Command(path="sim/test/command", api=api) + + self.assertTrue(api.execute_command(command, duration=2.0)) + + message, _address = api.socket.sendto.call_args.args + self.assertTrue(message.startswith(b"CMND\x00")) + self.assertIn(b"sim/test/command", message) + + +class TestXPUDPAPIReadValues(UDPAPITestCase): + def test_read_monitored_dataref_values_decodes_rref_packet(self): + api = self.make_api() + api.datarefs = {0: "sim/test/altitude", 1: "sim/test/speed"} + api.socket.recvfrom.return_value = (make_rref_packet([(0, 5000.0), (1, 120.5)]), ("127.0.0.1", 49000)) + + values = api.read_monitored_dataref_values() + + self.assertEqual(values["sim/test/altitude"], 5000.0) + self.assertEqual(values["sim/test/speed"], 120.5) + + def test_read_monitored_dataref_values_normalizes_negative_zero(self): + api = self.make_api() + api.datarefs = {0: "sim/test/value"} + api.socket.recvfrom.return_value = (make_rref_packet([(0, -0.0001)]), ("127.0.0.1", 49000)) + + values = api.read_monitored_dataref_values() + + self.assertEqual(values["sim/test/value"], 0.0) + + def test_read_monitored_dataref_values_raises_typed_timeout(self): + api = self.make_api() + api.socket.recvfrom.side_effect = OSError("timeout") + + with self.assertRaises(XPlaneTimeout) as caught: + api.read_monitored_dataref_values() + + self.assertEqual(caught.exception.context["host"], "127.0.0.1") + self.assertEqual(caught.exception.context["port"], 49000) + + def test_dataref_value_reads_latest_monitored_value(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + api.datarefs = {0: dataref.path} + api.socket.recvfrom.return_value = (make_rref_packet([(0, 42.0)]), ("127.0.0.1", 49000)) + + self.assertEqual(api.dataref_value(dataref), 42.0) + self.assertEqual(dataref.value, 42.0) + + +class TestXPUDPAPIRequestDataref(UDPAPITestCase): + def test_request_dataref_sends_rref_packet(self): + api = self.make_api() + with patch.object(XPUDPAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertTrue(api._request_dataref("sim/test/value", freq=2)) + + message, address = api.socket.sendto.call_args.args + self.assertEqual(address, ("127.0.0.1", 49000)) + self.assertTrue(message.startswith(b"RREF\x00")) + self.assertIn("sim/test/value", api.datarefs.values()) + + def test_request_dataref_raises_packet_error_for_invalid_rref_length(self): + api = self.make_api() + with patch.object(XPUDPAPI, "connected", new_callable=PropertyMock, return_value=True): + with patch("xpwebapi.udp.struct.pack", return_value=b"bad"): + with self.assertRaises(XPPacketError) as caught: + api._request_dataref("sim/test/value", freq=2) + + self.assertEqual(str(caught.exception), "invalid RREF packet length") + self.assertEqual(caught.exception.context["packet_type"], "RREF") + self.assertEqual(caught.exception.context["expected"], 413) + self.assertEqual(caught.exception.context["actual"], 3) + + def test_request_dataref_returns_false_when_not_connected(self): + api = self.make_api() + with patch.object(XPUDPAPI, "connected", new_callable=PropertyMock, return_value=False): + self.assertFalse(api._request_dataref("sim/test/value", freq=2)) + + api.socket.sendto.assert_not_called() + + def test_monitor_dataref_increments_dataref_monitor_count(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + + with patch.object(XPUDPAPI, "connected", new_callable=PropertyMock, return_value=True): + self.assertTrue(api.monitor_dataref(dataref)) + + self.assertEqual(dataref.monitored_count, 1) + self.assertTrue(dataref.is_monitored) + + def test_monitor_dataref_treats_zero_request_id_as_success(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + + with patch.object(api, "_request_dataref", return_value=0): + self.assertEqual(api.monitor_dataref(dataref), 0) + + self.assertEqual(dataref.monitored_count, 1) + self.assertTrue(dataref.is_monitored) + + def test_unmonitor_datarefs_sends_zero_frequency_request(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + + with patch.object(XPUDPAPI, "connected", new_callable=PropertyMock, return_value=True): + api._request_dataref(dataref.path, freq=1) + result, effectives = api.unmonitor_datarefs({dataref.path: dataref}) + + self.assertTrue(result) + self.assertEqual(effectives, {}) + self.assertNotIn(dataref.path, api.datarefs.values()) + + def test_unmonitor_datarefs_decrements_dataref_monitor_count(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + + with patch.object(XPUDPAPI, "connected", new_callable=PropertyMock, return_value=True): + api.monitor_dataref(dataref) + result, effectives = api.unmonitor_datarefs({dataref.path: dataref}) + + self.assertTrue(result) + self.assertEqual(effectives, {}) + self.assertEqual(dataref.monitored_count, 0) + + def test_unmonitor_datarefs_treats_zero_request_id_as_success(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + dataref.inc_monitor() + + with patch.object(api, "_request_dataref", return_value=0): + result, effectives = api.unmonitor_datarefs({dataref.path: dataref}) + + self.assertTrue(result) + self.assertEqual(effectives, {}) + self.assertEqual(dataref.monitored_count, 0) + self.assertFalse(dataref.is_monitored) + + def test_unmonitor_datarefs_decrements_nested_monitor_before_unsubscribe(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + + with patch.object(XPUDPAPI, "connected", new_callable=PropertyMock, return_value=True): + api.monitor_dataref(dataref) + api.monitor_dataref(dataref) + + api.socket.sendto.reset_mock() + result, effectives = api.unmonitor_datarefs({dataref.path: dataref}) + + self.assertTrue(result) + self.assertEqual(effectives, {}) + self.assertEqual(dataref.monitored_count, 1) + self.assertTrue(dataref.is_monitored) + self.assertIn(dataref.path, api.datarefs.values()) + api.socket.sendto.assert_not_called() + + result, effectives = api.unmonitor_datarefs({dataref.path: dataref}) + + self.assertTrue(result) + self.assertEqual(effectives, {}) + self.assertEqual(dataref.monitored_count, 0) + self.assertFalse(dataref.is_monitored) + self.assertNotIn(dataref.path, api.datarefs.values()) + api.socket.sendto.assert_called_once() + + message, address = api.socket.sendto.call_args.args + self.assertEqual(address, ("127.0.0.1", 49000)) + self.assertTrue(message.startswith(b"RREF\x00")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ws.py b/tests/test_ws.py new file mode 100644 index 0000000..14a162b --- /dev/null +++ b/tests/test_ws.py @@ -0,0 +1,446 @@ +import json +import unittest +from datetime import datetime +from unittest.mock import MagicMock, PropertyMock, patch + +from websockets.exceptions import ConnectionClosedError + +from xpwebapi.api import DATAREF_DATATYPE, Command, CommandMeta, Dataref, DatarefMeta +from xpwebapi.retry import RetryConfig +from xpwebapi.rest import REST_KW +from xpwebapi.ws import CALLBACK_TYPE, XPWebsocketAPI + + +class WebsocketAPITestCase(unittest.TestCase): + def make_api(self): + api = XPWebsocketAPI.__new__(XPWebsocketAPI) + api.host = "127.0.0.1" + api.port = 8086 + api.version = "v2" + api._api_root_path = "/api" + api._api_version = "/v2" + api._status = 0 + api._stats = {} + api._show_stats = False + api._use_rest = False + api._use_cache = False + api._should_use_cache = False + api._first_try = False + api._warning_count = 0 + api._unreach_count = 0 + api.retry_config = RetryConfig() + api._already_warned = 0 + api.req_number = 0 + api._requests = {} + api._dataref_by_id = {} + api.all_commands = None + api.all_datarefs = None + api.session = MagicMock() + api.session.get.return_value = MagicMock(status_code=503) + api.ws = MagicMock() + api.should_not_connect = MagicMock() + api.should_not_connect.is_set.return_value = True + api.callbacks = {callback_type.value: set() for callback_type in CALLBACK_TYPE} + return api + + +class TestXPWebsocketAPISend(WebsocketAPITestCase): + def test_send_increments_request_ids_and_writes_json(self): + api = self.make_api() + with patch.object(XPWebsocketAPI, "connected", new_callable=PropertyMock, return_value=True): + first = api.send({"type": "first"}) + second = api.send({"type": "second"}) + + self.assertEqual(first, 1) + self.assertEqual(second, 2) + self.assertIn(first, api._requests) + sent_payload = json.loads(api.ws.send.call_args_list[0].args[0]) + self.assertEqual(sent_payload["req_id"], 1) + + def test_send_returns_false_when_not_connected(self): + api = self.make_api() + with patch.object(XPWebsocketAPI, "connected", new_callable=PropertyMock, return_value=False): + with patch("xpwebapi.ws.logger.warning"): + self.assertFalse(api.send({"type": "test"})) + api.ws.send.assert_not_called() + + def test_send_returns_false_for_empty_payload(self): + api = self.make_api() + with patch.object(XPWebsocketAPI, "connected", new_callable=PropertyMock, return_value=True): + with patch("xpwebapi.ws.logger.warning"): + self.assertFalse(api.send({})) + api.ws.send.assert_not_called() + + +class TestXPWebsocketAPIConnect(WebsocketAPITestCase): + @patch("xpwebapi.ws.connect") + def test_connect_websocket_success(self, mock_connect): + api = self.make_api() + api.ws = None + websocket = MagicMock() + mock_connect.return_value = websocket + + with patch.object(XPWebsocketAPI, "rest_api_reachable", new_callable=PropertyMock, return_value=True): + with patch.object(XPWebsocketAPI, "reload_caches"): + api.connect_websocket() + + self.assertIs(api.ws, websocket) + mock_connect.assert_called_once_with("ws://127.0.0.1:8086/api/v2", proxy=None) + + @patch("xpwebapi.ws.connect") + def test_connect_websocket_does_not_connect_when_rest_unreachable(self, mock_connect): + api = self.make_api() + api.ws = None + with patch.object(XPWebsocketAPI, "rest_api_reachable", new_callable=PropertyMock, return_value=False): + with patch("xpwebapi.ws.logger.warning"): + api.connect_websocket() + self.assertIsNone(api.ws) + mock_connect.assert_not_called() + + @patch("xpwebapi.ws.connect") + def test_connect_websocket_retries_transient_connect_error(self, mock_connect): + api = self.make_api() + api.ws = None + api.retry_config = RetryConfig(attempts=3, backoff=0.25) + websocket = MagicMock() + mock_connect.side_effect = [RuntimeError("failed"), websocket] + + with patch.object(XPWebsocketAPI, "rest_api_reachable", new_callable=PropertyMock, return_value=True): + with patch.object(XPWebsocketAPI, "reload_caches"): + with patch("xpwebapi.ws.sleep_before_retry") as sleep: + with patch("xpwebapi.ws.logger.error"): + api.connect_websocket() + + self.assertIs(api.ws, websocket) + self.assertEqual(mock_connect.call_count, 2) + sleep.assert_called_once_with(api.retry_config, 0) + + def test_disconnect_websocket_closes_socket_and_runs_callback(self): + api = self.make_api() + websocket = api.ws + callback = MagicMock() + api.add_callback(CALLBACK_TYPE.ON_CLOSE, callback) + + api.disconnect_websocket() + + websocket.close.assert_called_once() + self.assertIsNone(api.ws) + callback.assert_called_once() + + def test_context_manager_connects_on_enter_and_closes_on_exit(self): + api = self.make_api() + + with patch.object(api, "connect") as connect: + with patch.object(api, "close") as close: + with api as active: + self.assertIs(active, api) + connect.assert_called_once_with() + + close.assert_called_once_with() + + +class TestXPWebsocketAPICallbacks(WebsocketAPITestCase): + def test_add_callback_deduplicates_same_callable(self): + api = self.make_api() + callback = MagicMock() + + api.add_callback(CALLBACK_TYPE.ON_OPEN, callback) + api.add_callback(CALLBACK_TYPE.ON_OPEN, callback) + api.execute_callbacks(CALLBACK_TYPE.ON_OPEN) + + callback.assert_called_once() + + def test_execute_callbacks_runs_registered_callbacks(self): + api = self.make_api() + callback = MagicMock() + api.add_callback(CALLBACK_TYPE.ON_OPEN, callback) + self.assertTrue(api.execute_callbacks(CALLBACK_TYPE.ON_OPEN)) + callback.assert_called_once() + + def test_execute_callbacks_returns_false_when_callback_raises(self): + api = self.make_api() + callback = MagicMock(side_effect=RuntimeError("boom")) + api.add_callback(CALLBACK_TYPE.ON_OPEN, callback) + with patch("xpwebapi.ws.logger.error"): + self.assertFalse(api.execute_callbacks(CALLBACK_TYPE.ON_OPEN)) + + def test_execute_callbacks_returns_true_when_no_callbacks_registered(self): + api = self.make_api() + self.assertTrue(api.execute_callbacks(CALLBACK_TYPE.ON_OPEN)) + + +class TestXPWebsocketAPIMessageHandling(WebsocketAPITestCase): + def test_result_message_updates_request_and_runs_feedback_callback(self): + api = self.make_api() + callback = MagicMock() + api.add_callback(CALLBACK_TYPE.ON_REQUEST_FEEDBACK, callback) + with patch.object(XPWebsocketAPI, "connected", new_callable=PropertyMock, return_value=True): + req_id = api.send({"type": "test"}) + message = json.dumps( + { + REST_KW.TYPE.value: "result", + REST_KW.REQID.value: req_id, + REST_KW.SUCCESS.value: False, + REST_KW.ERROR_MESSAGE.value: "boom", + } + ) + + api._handle_websocket_message(message, datetime.now()) + + self.assertFalse(api._requests[req_id].success) + self.assertEqual(api._requests[req_id].error, "boom") + callback.assert_called_once() + + def test_command_active_message_runs_command_callback(self): + api = self.make_api() + callback = MagicMock() + api.add_callback(CALLBACK_TYPE.ON_COMMAND_ACTIVE, callback) + api.get_command_meta_by_id = MagicMock(return_value=CommandMeta(name="sim/test/command", description="Test", id=21)) + message = json.dumps({REST_KW.TYPE.value: "command_update_is_active", REST_KW.DATA.value: {"21": True}}) + + api._handle_websocket_message(message, datetime.now()) + + callback.assert_called_once_with(command="sim/test/command", active=True) + + def test_scalar_dataref_update_runs_dataref_callback(self): + api = self.make_api() + callback = MagicMock() + api.add_callback(CALLBACK_TYPE.ON_DATAREF_UPDATE, callback) + dataref = Dataref(path="sim/test/value", api=api) + dataref._cached_meta = DatarefMeta(name="sim/test/value", value_type="float", is_writable=True, id=11) + api._dataref_by_id[11] = dataref + api.changed = MagicMock(return_value=True) + message = json.dumps({REST_KW.TYPE.value: "dataref_update_values", REST_KW.DATA.value: {"11": 3.5}}) + + api._handle_websocket_message(message, datetime.now()) + + callback.assert_called_once_with(dataref="sim/test/value", value=3.5) + + def test_array_dataref_update_runs_index_callbacks(self): + api = self.make_api() + callback = MagicMock() + api.add_callback(CALLBACK_TYPE.ON_DATAREF_UPDATE, callback) + meta = DatarefMeta(name="sim/test/array", value_type=DATAREF_DATATYPE.FLOATARRAY.value, is_writable=True, id=12) + meta.indices = [2, 4] + dataref = Dataref(path="sim/test/array[2]", api=api) + dataref._cached_meta = meta + api._dataref_by_id[12] = [dataref] + api.changed = MagicMock(return_value=True) + message = json.dumps({REST_KW.TYPE.value: "dataref_update_values", REST_KW.DATA.value: {"12": [7.5, 8.5]}}) + + api._handle_websocket_message(message, datetime.now()) + + callback.assert_any_call(dataref="sim/test/array[2]", value=7.5) + callback.assert_any_call(dataref="sim/test/array[4]", value=8.5) + self.assertEqual(callback.call_count, 2) + + +class TestXPWebsocketAPIListener(WebsocketAPITestCase): + def test_ws_listener_treats_recv_timeout_as_idle_receive(self): + api = self.make_api() + api.RECEIVE_TIMEOUT = 0.01 + api.ws.recv.side_effect = [TimeoutError, '{"type": "result", "req_id": 1, "success": true}'] + api._requests[1] = MagicMock() + + states = [True, True, False] + with patch.object(XPWebsocketAPI, "websocket_listener_running", new_callable=PropertyMock, side_effect=states): + with patch.object(api, "_log_receive_timeout") as log_timeout: + with patch.object(api, "_close_websocket_listener"): + api.ws_listener() + + api.ws.recv.assert_any_call(timeout=1) + log_timeout.assert_called_once_with(0) + self.assertEqual(api._stats["receive_raw"], 1) + self.assertEqual(api._stats["receive"], 1) + + def test_ws_listener_handles_connection_closed(self): + api = self.make_api() + api.RECEIVE_TIMEOUT = 0.01 + api.ws.recv.side_effect = ConnectionClosedError(None, None) + + states = [True, False] + with patch.object(XPWebsocketAPI, "websocket_listener_running", new_callable=PropertyMock, side_effect=states): + with patch.object(api, "_handle_websocket_closed") as handle_closed: + with patch.object(api, "_close_websocket_listener"): + api.ws_listener() + + handle_closed.assert_called_once_with() + + +class TestXPWebsocketAPIMonitoring(WebsocketAPITestCase): + def test_monitor_datarefs_accepts_iterable_and_sends_one_subscribe_request(self): + api = self.make_api() + first = Dataref(path="sim/test/first", api=api) + first._cached_meta = DatarefMeta(name=first.path, value_type="float", is_writable=True, id=101) + second = Dataref(path="sim/test/second", api=api) + second._cached_meta = DatarefMeta(name=second.path, value_type="float", is_writable=True, id=102) + api.send = MagicMock(return_value=7) + + with patch.object(XPWebsocketAPI, "connected", new_callable=PropertyMock, return_value=True): + result, effectives = api.monitor_datarefs([first, second], reason="batch-test") + + self.assertEqual(result, 7) + self.assertEqual(set(effectives), {first.name, second.name}) + api.send.assert_called_once() + payload = api.send.call_args.args[0] + self.assertEqual(payload[REST_KW.TYPE.value], "dataref_subscribe_values") + self.assertEqual(payload[REST_KW.PARAMS.value][REST_KW.DATAREFS.value], [{"id": 101}, {"id": 102}]) + self.assertEqual(api._dataref_by_id, {101: first, 102: second}) + + def test_unmonitor_datarefs_accepts_iterable_and_sends_one_unsubscribe_request(self): + api = self.make_api() + first = Dataref(path="sim/test/first", api=api) + first._cached_meta = DatarefMeta(name=first.path, value_type="float", is_writable=True, id=101) + first.inc_monitor() + second = Dataref(path="sim/test/second", api=api) + second._cached_meta = DatarefMeta(name=second.path, value_type="float", is_writable=True, id=102) + second.inc_monitor() + api._dataref_by_id = {101: first, 102: second} + api.send = MagicMock(return_value=8) + + with patch.object(XPWebsocketAPI, "connected", new_callable=PropertyMock, return_value=True): + result, effectives = api.unmonitor_datarefs([first, second], reason="batch-test") + + self.assertEqual(result, 8) + self.assertEqual(set(effectives), {first.name, second.name}) + api.send.assert_called_once() + payload = api.send.call_args.args[0] + self.assertEqual(payload[REST_KW.TYPE.value], "dataref_unsubscribe_values") + self.assertEqual(payload[REST_KW.PARAMS.value][REST_KW.DATAREFS.value], [{"id": 101}, {"id": 102}]) + self.assertEqual(api._dataref_by_id, {}) + + def test_monitor_datarefs_groups_selected_array_indices_in_one_request(self): + api = self.make_api() + meta = DatarefMeta(name="sim/test/array", value_type=DATAREF_DATATYPE.FLOATARRAY.value, is_writable=True, id=201) + first = Dataref(path="sim/test/array[2]", api=api) + first._cached_meta = meta + second = Dataref(path="sim/test/array[4]", api=api) + second._cached_meta = meta + api.send = MagicMock(return_value=9) + + with patch.object(XPWebsocketAPI, "connected", new_callable=PropertyMock, return_value=True): + result, effectives = api.monitor_datarefs([first, second], reason="array-test") + + self.assertEqual(result, 9) + self.assertEqual(set(effectives), {first.name, second.name}) + api.send.assert_called_once() + payload = api.send.call_args.args[0] + self.assertEqual(payload[REST_KW.TYPE.value], "dataref_subscribe_values") + self.assertEqual(payload[REST_KW.PARAMS.value][REST_KW.DATAREFS.value], [{"id": 201, "index": [2, 4]}]) + self.assertEqual(api._dataref_by_id, {201: [first, second]}) + self.assertEqual(meta.indices, [2, 4]) + + def test_monitor_datarefs_subscribes_only_unmonitored_datarefs(self): + api = self.make_api() + first = Dataref(path="sim/test/first", api=api) + first._cached_meta = DatarefMeta(name=first.path, value_type="float", is_writable=True, id=101) + second = Dataref(path="sim/test/second", api=api) + second._cached_meta = DatarefMeta(name=second.path, value_type="float", is_writable=True, id=102) + second.inc_monitor() + api.register_bulk_dataref_value_event = MagicMock(return_value=7) + + with patch.object(XPWebsocketAPI, "connected", new_callable=PropertyMock, return_value=True): + result, effectives = api.monitor_datarefs({first.path: first, second.path: second}, reason="test") + + self.assertEqual(result, 7) + self.assertEqual(set(effectives), {first.name, second.name}) + api.register_bulk_dataref_value_event.assert_called_once() + bulk = api.register_bulk_dataref_value_event.call_args.kwargs["datarefs"] + self.assertEqual(list(bulk), [101]) + self.assertEqual(first.monitored_count, 1) + self.assertEqual(second.monitored_count, 2) + + def test_unmonitor_datarefs_skips_datarefs_still_monitored_elsewhere(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + dataref._cached_meta = DatarefMeta(name=dataref.path, value_type="float", is_writable=True, id=103) + dataref.inc_monitor() + dataref.inc_monitor() + api.register_bulk_dataref_value_event = MagicMock(return_value=9) + + with patch.object(XPWebsocketAPI, "connected", new_callable=PropertyMock, return_value=True): + result, effectives = api.unmonitor_datarefs({dataref.path: dataref}, reason="test") + + self.assertEqual(result, 0) + self.assertEqual(effectives, {dataref.name: dataref}) + api.register_bulk_dataref_value_event.assert_not_called() + self.assertEqual(dataref.monitored_count, 1) + + def test_monitor_datarefs_returns_false_when_disconnected(self): + api = self.make_api() + dataref = Dataref(path="sim/test/value", api=api) + + with patch.object(XPWebsocketAPI, "connected", new_callable=PropertyMock, return_value=False): + self.assertEqual(api.monitor_datarefs({dataref.path: dataref}), (False, {})) + + +class TestXPWebsocketAPIPayloads(WebsocketAPITestCase): + def test_set_dataref_value_sends_scalar_payload(self): + api = self.make_api() + meta = DatarefMeta(name="sim/test/value", value_type="float", is_writable=True, id=11) + api.get_dataref_meta_by_name = MagicMock(return_value=meta) + api.send = MagicMock(return_value=1) + + self.assertEqual(api.set_dataref_value("sim/test/value", 3.5), 1) + + payload = api.send.call_args.args[0] + self.assertEqual(payload[REST_KW.TYPE.value], "dataref_set_values") + self.assertEqual(payload[REST_KW.PARAMS.value][REST_KW.DATAREFS.value], [{"id": 11, "value": 3.5}]) + + def test_set_dataref_value_sends_array_index_payload(self): + api = self.make_api() + meta = DatarefMeta(name="sim/test/array", value_type=DATAREF_DATATYPE.FLOATARRAY.value, is_writable=True, id=12) + api.get_dataref_meta_by_name = MagicMock(return_value=meta) + api.send = MagicMock(return_value=1) + + self.assertEqual(api.set_dataref_value("sim/test/array[4]", 7.5), 1) + + payload = api.send.call_args.args[0] + entry = payload[REST_KW.PARAMS.value][REST_KW.DATAREFS.value][0] + self.assertEqual(entry, {"id": 12, "value": 7.5, "index": 4}) + + def test_register_command_is_active_event_sends_subscribe_payload(self): + api = self.make_api() + api.get_command_meta_by_name = MagicMock(return_value=CommandMeta(name="sim/test/command", description="Test", id=21)) + api.send = MagicMock(return_value=1) + + self.assertEqual(api.register_command_is_active_event("sim/test/command"), 1) + + payload = api.send.call_args.args[0] + self.assertEqual(payload[REST_KW.TYPE.value], "command_subscribe_is_active") + self.assertEqual(payload[REST_KW.PARAMS.value][REST_KW.COMMANDS.value], [{"id": 21}]) + + def test_set_command_is_active_with_duration_sends_payload(self): + api = self.make_api() + api.get_command_meta_by_name = MagicMock(return_value=CommandMeta(name="sim/test/command", description="Test", id=22)) + api.send = MagicMock(return_value=1) + + self.assertEqual(api.set_command_is_active_with_duration("sim/test/command", duration=1.25), 1) + + payload = api.send.call_args.args[0] + self.assertEqual(payload[REST_KW.TYPE.value], "command_set_is_active") + self.assertEqual(payload[REST_KW.PARAMS.value][REST_KW.COMMANDS.value], [{"id": 22, "is_active": True, "duration": 1.25}]) + + def test_write_dataref_uses_websocket_payload_when_rest_disabled(self): + api = self.make_api() + meta = DatarefMeta(name="sim/test/value", value_type="float", is_writable=True, id=23) + dataref = Dataref(path="sim/test/value", api=api) + dataref._cached_meta = meta + dataref.value = 5.5 + api.get_rest_meta = MagicMock(return_value=meta) + api.set_dataref_value = MagicMock(return_value=1) + + self.assertEqual(api.write_dataref(dataref), 1) + api.set_dataref_value.assert_called_once_with(path="sim/test/value", value=5.5) + + def test_execute_command_uses_websocket_payload_when_rest_disabled(self): + api = self.make_api() + command = Command(path="sim/test/command", api=api) + api.set_command_is_active_with_duration = MagicMock(return_value=1) + + self.assertEqual(api.execute_command(command, duration=0.5), 1) + api.set_command_is_active_with_duration.assert_called_once_with(path="sim/test/command", duration=0.5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/quality.py b/tools/quality.py new file mode 100644 index 0000000..29996c9 --- /dev/null +++ b/tools/quality.py @@ -0,0 +1,121 @@ +"""Repo-local quality gate runner.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from collections.abc import Callable, Sequence +from dataclasses import dataclass + +SOURCE_PATHS = ("xpwebapi", "tests", "tools") +PYTHON_QUALITY_PATHS = SOURCE_PATHS +SECRET_SCAN_PATHS = (".",) +SECRET_BASELINE = ".secrets.baseline" +COVERAGE_MINIMUM = "40" +XENON_MAX_ABSOLUTE = "C" +XENON_MAX_MODULES = "B" +XENON_MAX_AVERAGE = "A" + + +@dataclass(frozen=True) +class Step: + name: str + command: tuple[str, ...] + tracked_paths: tuple[str, ...] = () + + +def uv(*args: str) -> tuple[str, ...]: + return ("uv", "run", *args) + + +COMMANDS: dict[str, tuple[Step, ...]] = { + "lint": (Step("ruff check", uv("ruff", "check", *SOURCE_PATHS)),), + "format-check": (Step("ruff format --check", uv("ruff", "format", "--check", *SOURCE_PATHS)),), + "format": (Step("ruff format", uv("ruff", "format", *SOURCE_PATHS)),), + "typecheck": (Step("ty check", uv("ty", "check")),), + "test": (Step("unittest", uv("python", "-m", "unittest", "discover", "-v")),), + "coverage": ( + Step("coverage run", uv("coverage", "run", "-m", "unittest", "discover", "-s", "tests", "-t", ".")), + Step("coverage report", uv("coverage", "report", f"--fail-under={COVERAGE_MINIMUM}")), + ), + "security": ( + Step("bandit", uv("bandit", "-q", "-r", "xpwebapi")), + Step("detect-secrets baseline", uv("detect-secrets-hook", "--baseline", SECRET_BASELINE), tracked_paths=SECRET_SCAN_PATHS), + Step("detect-secrets report", uv("detect-secrets", "audit", "--report", SECRET_BASELINE)), + ), + "docs": (Step("interrogate", uv("interrogate", "-v", "-f", "40", "xpwebapi")),), + "dead-code": (Step("vulture", uv("vulture", *PYTHON_QUALITY_PATHS, "--min-confidence", "80")),), + "metrics": ( + Step("lizard report", uv("lizard", "xpwebapi", "-i", "-1")), + Step("cohesion report", uv("cohesion", "-d", "xpwebapi")), + ), + "wily": ( + Step("wily build", uv("wily", "build", "xpwebapi")), + Step("wily report", uv("wily", "report", "xpwebapi")), + ), + "complexity": ( + Step( + "xenon complexity", + uv( + "xenon", + "--max-absolute", + XENON_MAX_ABSOLUTE, + "--max-modules", + XENON_MAX_MODULES, + "--max-average", + XENON_MAX_AVERAGE, + "xpwebapi", + ), + ), + ), + "pre-commit": (Step("pre-commit", uv("pre-commit", "run", "--all-files")),), +} + +CHECK_STEPS = ( + *COMMANDS["lint"], + *COMMANDS["format-check"], + *COMMANDS["typecheck"], + *COMMANDS["test"], + *COMMANDS["coverage"], + *COMMANDS["security"], + *COMMANDS["docs"], + *COMMANDS["dead-code"], + *COMMANDS["complexity"], +) + + +def run_steps(steps: Sequence[Step], runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run) -> int: + for step in steps: + command = step.command + if step.tracked_paths: + tracked = runner(("git", "ls-files", "--", *step.tracked_paths), check=False, capture_output=True, text=True) + if tracked.returncode != 0: + return tracked.returncode + command = (*command, *(line for line in tracked.stdout.splitlines() if line)) + + print(f"==> {step.name}: {' '.join(command)}", flush=True) + result = runner(command, check=False) + if result.returncode != 0: + return result.returncode + return 0 + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run xplane-webapi quality gates.") + parser.add_argument( + "gate", + choices=(*COMMANDS.keys(), "check"), + help="Quality gate to run. Use 'check' for the full blocking suite.", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + steps = CHECK_STEPS if args.gate == "check" else COMMANDS[args.gate] + return run_steps(steps) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b725708 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1236 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, +] + +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + +[[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 = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[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/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/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 = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "cohesion" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/96/7e5d3528227b4743ca2b718ef60ff1f58c2ed143fff3c6a7c8b1c0147858/cohesion-1.2.0-py3-none-any.whl", hash = "sha256:d4a4c06a83022fcc56bf6af220247df9d6de7707a7a16442e98e7ad2ea152f79", size = 20519, upload-time = "2024-12-09T15:14:21.422Z" }, +] + +[[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 = "colorlog" +version = "4.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/32/cdfba08674d72fe7895a8ec7be8f171e8502274999cae9497e4545404873/colorlog-4.8.0.tar.gz", hash = "sha256:59b53160c60902c405cdec28d38356e09d40686659048893e026ecbd589516b1", size = 28770, upload-time = "2021-03-22T11:26:32.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/62/61449c6bb74c2a3953c415b2cdb488e4f0518ac67b35e2b03a6d543035ca/colorlog-4.8.0-py2.py3-none-any.whl", hash = "sha256:3dd15cb27e8119a24c1a7b5c93f9f3b455855e0f73993b1c25921b2f646f1dcd", size = 10023, upload-time = "2021-03-22T11:26:31.281Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +] + +[[package]] +name = "detect-secrets" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/67/382a863fff94eae5a0cf05542179169a1c49a4c8784a9480621e2066ca7d/detect_secrets-1.5.0.tar.gz", hash = "sha256:6bb46dcc553c10df51475641bb30fd69d25645cc12339e46c824c1e0c388898a", size = 97351, upload-time = "2024-05-06T17:46:19.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/5e/4f5fe4b89fde1dc3ed0eb51bd4ce4c0bca406246673d370ea2ad0c58d747/detect_secrets-1.5.0-py3-none-any.whl", hash = "sha256:e24e7b9b5a35048c313e983f76c4bd09dad89f045ff059e354f9943bf45aa060", size = 120341, upload-time = "2024-05-06T17:46:16.628Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, +] + +[[package]] +name = "future" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, +] + +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[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 = "ifaddr" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, +] + +[[package]] +name = "interrogate" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "click" }, + { name = "colorama" }, + { name = "py" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/22/74f7fcc96280eea46cf2bcbfa1354ac31de0e60a4be6f7966f12cef20893/interrogate-1.7.0.tar.gz", hash = "sha256:a320d6ec644dfd887cc58247a345054fc4d9f981100c45184470068f4b3719b0", size = 159636, upload-time = "2024-04-07T22:30:46.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl", hash = "sha256:b13ff4dd8403369670e2efe684066de9fcb868ad9d7f2b4095d8112142dc9d12", size = 46982, upload-time = "2024-04-07T22:30:44.277Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "lizard" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathspec" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/94/4967d0868e7db39a72fa2dbef9a798c4d661178f3836bfec58091606f0f3/lizard-1.23.0.tar.gz", hash = "sha256:ed75cd45f086a2f51d6be64b0149b71bda820f92f95e30898254528bb949f795", size = 92659, upload-time = "2026-06-02T06:17:27.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/d9/25e62cbb9c4077a2dabc73e5f34dc947887f4fe1526f53d2c0489abfc186/lizard-1.23.0-py2.py3-none-any.whl", hash = "sha256:e9111e35c8a5f2e00d55cab318fca3504622991417411ea16cf46874fb752f42", size = 100830, upload-time = "2026-06-02T06:17:26.076Z" }, +] + +[[package]] +name = "mando" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/fe/1b94ddd9c89c8be761c0b5ca01498029f1253f059a59f03f7be369255e75/mando-0.6.4.tar.gz", hash = "sha256:79feb19dc0f097daa64a1243db578e7674909b75f88ac2220f1c065c10a0d960", size = 214068, upload-time = "2017-06-05T07:02:54.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/cc/f6e25247c1493a654785e68cd975e479c311e99dafedd49ed17f8d300e0c/mando-0.6.4-py2.py3-none-any.whl", hash = "sha256:4ce09faec7e5192ffc3c57830e26acba0fd6cd11e1ee81af0d4df0657463bd1c", size = 29276, upload-time = "2017-06-05T07:02:57.728Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-git-revision-date-localized-plugin" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "gitpython" }, + { name = "mkdocs" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/99/8067eb7d1652767ee8e5474010647dd5a8e464e0ca8c783b5cac135a2043/mkdocs_git_revision_date_localized_plugin-1.5.3.tar.gz", hash = "sha256:873444b54cab4d47c69bd6e85da05ef5fbe81fee27e64508114c46a0e4f81e37", size = 451961, upload-time = "2026-06-01T08:32:09.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/d0/cbe85158dc091219fd5134bf6d724d30b1f2005ee1d0dabaaa41416bee78/mkdocs_git_revision_date_localized_plugin-1.5.3-py3-none-any.whl", hash = "sha256:cd96e432de6a7e59b31c7041574b22f84179c8636835419ff458877ecfaaaf05", size = 26156, upload-time = "2026-06-01T08:32:07.765Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/b6/e858701499d57eee8b3fd8e78168083956c6683ddbe727b46758b19e1119/mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594", size = 199578, upload-time = "2026-06-19T10:41:08.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951, upload-time = "2026-06-19T10:41:07.378Z" }, +] + +[[package]] +name = "natsort" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "plotly" +version = "5.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/4f/428f6d959818d7425a94c190a6b26fbc58035cbef40bf249be0b62a9aedd/plotly-5.24.1.tar.gz", hash = "sha256:dbc8ac8339d248a4bcc36e08a5659bacfe1b079390b8953533f4eb22169b4bae", size = 9479398, upload-time = "2024-09-12T15:36:31.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ae/580600f441f6fc05218bd6c9d5794f4aef072a7d9093b291f1c50a9db8bc/plotly-5.24.1-py3-none-any.whl", hash = "sha256:f67073a1e637eb0dc3e46324d9d51e2fe76e9727c892dde64ddf1e1b51f29089", size = 19054220, upload-time = "2024-09-12T15:36:24.08Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "progress" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/26/3b086f0c5d6c1c18c2430d6fac3a99d79553884ca6cdf759cf256dd43b7d/progress-1.6.1.tar.gz", hash = "sha256:c1ba719f862ce885232a759eab47971fe74dfc7bb76ab8a51ef5940bad35086c", size = 7164, upload-time = "2025-07-01T05:50:43.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/59/123aee44a039b212cfb8d90be1adf06496a99b313ee1683aadf90b3d9799/progress-1.6.1-py3-none-any.whl", hash = "sha256:5239f22f305c12fdc8ce6e0e47f70f21622a935e16eafc4535617112e7c7ea0b", size = 9761, upload-time = "2025-07-01T05:50:40.963Z" }, +] + +[[package]] +name = "py" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", size = 207796, upload-time = "2021-11-04T17:17:01.377Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708, upload-time = "2021-11-04T17:17:00.152Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[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 = "pymdown-extensions" +version = "10.21.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "radon" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "future" }, + { name = "mando" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/37/737fb1a1786d9daed92e3ce1b8fe484c10d2a51078febffddd14ffdc0081/radon-5.1.0.tar.gz", hash = "sha256:cb1d8752e5f862fb9e20d82b5f758cbc4fb1237c92c9a66450ea0ea7bf29aeee", size = 1873643, upload-time = "2021-08-08T13:25:23.754Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/0f/25c8256018b6e90265f440651ec60311818c01a008564c0f106b7d915b60/radon-5.1.0-py2.py3-none-any.whl", hash = "sha256:fa74e018197f1fcb54578af0f675d8b8e2342bd8e0b72bef8197bc4c9e645f36", size = 52143, upload-time = "2021-08-08T13:25:20.919Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +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 = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" }, + { url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" }, + { url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" }, + { url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" }, + { url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" }, + { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + +[[package]] +name = "stevedore" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/88/35e4d27d9177d7df76d060e0a18f69c6c5794c96960c94042e20a12c8ba2/stevedore-5.8.0.tar.gz", hash = "sha256:b49867b32ca3016e94100e68dbf26e72aa7b8708d0a3f73c08aeb220370ac715", size = 514710, upload-time = "2026-05-18T09:15:27.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/ac/19f9941c74add59d17694930ec8105d5eddeee4ce56dd8632b765ca16d6c/stevedore-5.8.0-py3-none-any.whl", hash = "sha256:88eede9e66ca80e34085b9174e2327da2c61ac91f24f70e41c3ad76e4bb4872b", size = 54553, upload-time = "2026-05-18T09:15:25.82Z" }, +] + +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "traitlets" +version = "5.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, +] + +[[package]] +name = "ty" +version = "0.0.51" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/ce/352fcdba5c72ea20e5d2e46e28809cdb617575b71209d971eff2ace8e6c4/ty-0.0.51.tar.gz", hash = "sha256:b90172d46365bb9d51a7011cbb5c60cc4f514f42c86635df6c092b717f85e1ac", size = 5953151, upload-time = "2026-06-19T01:48:58.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/8f/8fe7cab79a45320b2cdcd602f16d44c8108d2f418ff7ec316c6212f1f0cc/ty-0.0.51-py3-none-linux_armv6l.whl", hash = "sha256:947986bd82d324b3a5c58ce03f1dad160cdf36443d3e8f64b3484b861ba9bc64", size = 11884805, upload-time = "2026-06-19T01:48:20.184Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/56fdc39a3f44c0564fd157e1e59e1f9c3fc5ba57ae4472ded85c67c63d74/ty-0.0.51-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25a5b31e6f23fd5dc63ad29087ded09932409e4154e2fe07bbaed015035990bb", size = 11633593, upload-time = "2026-06-19T01:48:22.998Z" }, + { url = "https://files.pythonhosted.org/packages/33/57/136e83f24fc04f5afdcabff42f40fa27eae5ac3f0e3f12627d072a55f679/ty-0.0.51-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2faed19a8f1505370de071c008df52a994fc03a204f3267c3a33a32ca26f854f", size = 11063076, upload-time = "2026-06-19T01:48:25.223Z" }, + { url = "https://files.pythonhosted.org/packages/32/f8/5d32f0df5692446440ab781b9b119aa3e0c0dbfa78c583fe9be8417d54fa/ty-0.0.51-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08adbe53fb8bc9e7f00e89bf1d3c875a02cda76d83f109d2e6ab1ff35a7bfa8c", size = 11579542, upload-time = "2026-06-19T01:48:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0c/4f54ef338e9623886809ecd508931b0cd5b3aba1e591586a2f6aeaa8bd11/ty-0.0.51-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dc5e93695ab5dcbf1eef663aee60ec23a413547cc9cb06adcb0d842e9166bd0f", size = 11676189, upload-time = "2026-06-19T01:48:29.518Z" }, + { url = "https://files.pythonhosted.org/packages/56/27/31729066f9b9d3596941edaf267894eefc0b30df4518f003dba5f7276258/ty-0.0.51-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd92913bc90d1705ef9391ff8c6822b61e2e827fa295eb30bf0dfabcf815645", size = 12188154, upload-time = "2026-06-19T01:48:31.68Z" }, + { url = "https://files.pythonhosted.org/packages/2f/38/d4301aa12d2283c7130908baf1417a37dfe3e10f5669cb4ce2853c2540b4/ty-0.0.51-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:429a997394dac73870d71b87cc90efc54da3efaf319e72ca18aeef35a78aef90", size = 12780597, upload-time = "2026-06-19T01:48:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/c1/52/4b2e67e53f126d39abe201bd2299e467e27463a284e965ad195cbc217fa0/ty-0.0.51-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62d94f06e8c317e89b6884f2bde443040e596b88c7c79bd944c84c105b06257a", size = 12491115, upload-time = "2026-06-19T01:48:36.169Z" }, + { url = "https://files.pythonhosted.org/packages/74/50/aabfe55c132ebe72b4d639cbf772d931e11b0990d29c1f691922b6ccabc1/ty-0.0.51-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8f52952cff665bc52a36147e610c10f5699d30007d7a14ab7f345cff93476ff", size = 12230135, upload-time = "2026-06-19T01:48:38.445Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1b/9aa428052dbed91c50919cd080426a313cf20ce14c6bfe2b71345e548671/ty-0.0.51-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c1bd1355aee86af01e4e21b0bc16fc460fb05905761f0d8b8d70841de0feade8", size = 12468123, upload-time = "2026-06-19T01:48:40.47Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5a/f6ce69f2575259386c950c40e02578d0902760cb61f95045e9971182c24e/ty-0.0.51-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:79d1877e93460f936bc10ed1a31525702b7ce51075763ccba993be17f0b9e905", size = 11541672, upload-time = "2026-06-19T01:48:42.635Z" }, + { url = "https://files.pythonhosted.org/packages/35/3a/2af48924a683e959e95e5cc4dc88e5a8595206a0812b869032b95196f2b0/ty-0.0.51-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:cc233a6235fb23e2a44b14731a10043e37ba2f30f2c361cf49ad3633c5b9da9c", size = 11694015, upload-time = "2026-06-19T01:48:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/12/899875d8a60b198c8121cb92ce18e18cc072d23ca2130fcdaa176383ef72/ty-0.0.51-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bc7459348a253247bbfb2669a021e614281b86bbea24c36112b8a6e1a2499a16", size = 11832856, upload-time = "2026-06-19T01:48:47.028Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a2/88f681d826d97cc96ef9f6cadd4935f775758944cee07340aa46113bce28/ty-0.0.51-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:49a21237f6fd1de56beaff0a3e85fe022a09a3401e67e3abec41ce838a5d4d2e", size = 12333449, upload-time = "2026-06-19T01:48:49.091Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/535a4163b4452c6978c31fedfd7b5803cf3a2253e9455cde350f86638d6a/ty-0.0.51-py3-none-win32.whl", hash = "sha256:61b4b6a003c3ebe53a63a1125c9b6542aa01bc1b6c9a235d01ee328d000d61a9", size = 11177338, upload-time = "2026-06-19T01:48:51.433Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4d/2334fbb74291a20129fa7aaa8f789619ec9b6883b27f997b8baa27e4674f/ty-0.0.51-py3-none-win_amd64.whl", hash = "sha256:608d417cd1eaf79bcbd713d9830d5e3db9d57ec225c3af3e4ac9a9ff66b45d70", size = 12325675, upload-time = "2026-06-19T01:48:53.774Z" }, + { url = "https://files.pythonhosted.org/packages/50/b5/d49096cd5f3694becb86a5a6ccd0f229ead695fc7430d6bc4dd0a104c6fe/ty-0.0.51-py3-none-win_arm64.whl", hash = "sha256:62ced5e380284f12b2dc4802a3e4ed3dac39913fc6719afde7978814a4c7f169", size = 11657350, upload-time = "2026-06-19T01:48:55.904Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +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 = "virtualenv" +version = "21.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, +] + +[[package]] +name = "vulture" +version = "2.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/3e/4d08c5903b2c0c70cad583c170cc4a663fc6a61e2ad00b711fcda61358cd/vulture-2.16.tar.gz", hash = "sha256:f8d9f6e2af03011664a3c6c240c9765b3f392917d3135fddca6d6a68d359f717", size = 52680, upload-time = "2026-03-25T14:41:27.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "wily" +version = "1.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorlog" }, + { name = "gitpython" }, + { name = "nbformat" }, + { name = "plotly" }, + { name = "progress" }, + { name = "radon" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/b9/bc2e31a2f416f2e8b04333569325bd7bd20bf0a6db3d53ca83bc5f73b5b3/wily-1.25.0.tar.gz", hash = "sha256:b63e85be5855aa3bfdd17db9b27475624539adc3bc1c8265805437ed5256a581", size = 1855966, upload-time = "2023-10-11T03:49:10.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/80/68448bb3ac5c41414f395a4ebfc0c134ef90be0571261243f5a3a0e4859e/wily-1.25.0-py3-none-any.whl", hash = "sha256:c8f6333c77fad438f646d49225409b4fb5336474834a52beea18097205d09204", size = 69251, upload-time = "2023-10-11T03:48:57.016Z" }, +] + +[[package]] +name = "xenon" +version = "0.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "radon" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/7c/2b341eaeec69d514b635ea18481885a956d196a74322a4b0942ef0c31691/xenon-0.9.3.tar.gz", hash = "sha256:4a7538d8ba08aa5d79055fb3e0b2393c0bd6d7d16a4ab0fcdef02ef1f10a43fa", size = 9883, upload-time = "2024-10-21T10:27:53.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/5d/29ff8665b129cafd147d90b86e92babee32e116e3c84447107da3e77f8fb/xenon-0.9.3-py2.py3-none-any.whl", hash = "sha256:6e2c2c251cc5e9d01fe984e623499b13b2140fcbf74d6c03a613fa43a9347097", size = 8966, upload-time = "2024-10-21T10:27:51.121Z" }, +] + +[[package]] +name = "xpwebapi" +version = "3.5.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "ifaddr" }, + { name = "natsort" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "websockets" }, +] + +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "cohesion" }, + { name = "coverage" }, + { name = "detect-secrets" }, + { name = "interrogate" }, + { name = "lizard" }, + { name = "mkdocs" }, + { name = "mkdocs-git-revision-date-localized-plugin" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings" }, + { name = "mkdocstrings-python" }, + { name = "pre-commit" }, + { name = "ruff" }, + { name = "ty" }, + { name = "vulture" }, + { name = "wily" }, + { name = "xenon" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = "~=0.28" }, + { name = "ifaddr", specifier = "~=0.2" }, + { name = "natsort", specifier = "~=8.4" }, + { name = "packaging", specifier = "~=25.0" }, + { name = "pydantic", specifier = ">=2,<3" }, + { name = "websockets", specifier = ">=16,<17" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.9.4" }, + { name = "cohesion", specifier = ">=1.2.0" }, + { name = "coverage", specifier = ">=7.14.1" }, + { name = "detect-secrets", specifier = ">=1.5.0" }, + { name = "interrogate", specifier = ">=1.7.0" }, + { name = "lizard", specifier = ">=1.23.0" }, + { name = "mkdocs" }, + { name = "mkdocs-git-revision-date-localized-plugin" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings" }, + { name = "mkdocstrings-python" }, + { name = "pre-commit", specifier = ">=4.5.1" }, + { name = "ruff", specifier = ">=0.15.17" }, + { name = "ty", specifier = ">=0.0.49" }, + { name = "vulture", specifier = ">=2.16" }, + { name = "wily", specifier = ">=1.12.2" }, + { name = "xenon", specifier = ">=0.9.3" }, +] diff --git a/xpwebapi/__init__.py b/xpwebapi/__init__.py index 15ada83..a8323ea 100644 --- a/xpwebapi/__init__.py +++ b/xpwebapi/__init__.py @@ -1,19 +1,60 @@ -# Interface -from .api import Dataref, Command, DatarefValueType, DATAREF_DATATYPE +from .api import APIResult, Dataref, Command, DatarefReadResult, DatarefValueType, DATAREF_DATATYPE +from .async_rest import AsyncXPRestAPI from .beacon import XPBeaconMonitor, BeaconData, XPlaneNoBeacon, XPlaneVersionNotSupported +from .exceptions import XPWebAPIError, XPConnectionError, XPBeaconError, XPPacketError, XPTimeoutError, XPVersionError +from .logging_config import JsonLogFormatter, LoggingConfig, configure_logging, write_logging_config from .rest import XPRestAPI from .ws import XPWebsocketAPI, CALLBACK_TYPE from .udp import XPUDPAPI, XPlaneTimeout +__all__ = [ + "Dataref", + "Command", + "APIResult", + "DatarefReadResult", + "DatarefValueType", + "DATAREF_DATATYPE", + "AsyncXPRestAPI", + "XPBeaconMonitor", + "BeaconData", + "XPlaneNoBeacon", + "XPlaneVersionNotSupported", + "XPWebAPIError", + "XPConnectionError", + "XPBeaconError", + "XPPacketError", + "XPTimeoutError", + "XPVersionError", + "JsonLogFormatter", + "LoggingConfig", + "XPRestAPI", + "XPWebsocketAPI", + "CALLBACK_TYPE", + "XPUDPAPI", + "XPlaneTimeout", + "configure_logging", + "write_logging_config", + "beacon", + "rest_api", + "async_rest_api", + "ws_api", + "udp_api", + "version", +] -def beacon(): - return XPBeaconMonitor() + +def beacon(**kwargs): + return XPBeaconMonitor(**kwargs) def rest_api(**kwargs): return XPRestAPI(**kwargs) +def async_rest_api(**kwargs): + return AsyncXPRestAPI(**kwargs) + + def ws_api(**kwargs): return XPWebsocketAPI(**kwargs) diff --git a/xpwebapi/api.py b/xpwebapi/api.py index 3617070..dbea860 100644 --- a/xpwebapi/api.py +++ b/xpwebapi/api.py @@ -6,12 +6,22 @@ import json import base64 import math -from abc import ABC, abstractmethod +from abc import ABC from enum import Enum, IntEnum from datetime import datetime -from typing import List, Dict, Tuple, Any +from typing import Any, Protocol, cast +from typing import TYPE_CHECKING -type DatarefValueType = bool | str | int | float +if TYPE_CHECKING: + import httpx + + from .beacon import BeaconData + +type DatarefScalarType = bool | str | int | float +type DatarefArrayType = list[int] | list[float] +type DatarefValueType = DatarefScalarType | DatarefArrayType +type DatarefReadResult = DatarefValueType | bytes | None +type APIResult = bool | int # local logger @@ -89,8 +99,8 @@ def __init__(self, name: str, value_type: str, is_writable: bool, **kwargs) -> N self.value_type = value_type self.is_writable = is_writable - self.indices: List[int] = [] - self.indices_history: List[List[int]] = [] # past lists of indices, might be useful for requests arriving after new requests + self.indices: list[int] = [] + self.indices_history: list[list[int]] = [] # past lists of indices, might be useful for requests arriving after new requests self._last_req_number = 0 self._indices_requested = False @@ -149,7 +159,7 @@ def __init__(self, name: str, description: str, **kwargs) -> None: class ValueCache: """Utility class to round a dataref value and determine if it has changed.""" - def __init__(self, roundings: Dict[str, int]) -> None: + def __init__(self, roundings: dict[str, int]) -> None: self.roundings = roundings # {dataref: int()} self._last_value = {} # {dataref: Any} @@ -174,7 +184,7 @@ def changed(self, dataref: str, value: Any) -> bool: if type(value) in [int, float]: rnd = self.get_rounding(dataref) if rnd is not None: - new_value = round(value, rnd) + new_value = round(value, int(rnd)) if new_value == self._last_value.get(dataref, math.inf): return False self._last_value[dataref] = new_value @@ -184,8 +194,8 @@ def changed(self, dataref: str, value: Any) -> bool: # ############################################# # API # -class API(ABC): - """API Abstract class with connection information""" +class API(Protocol): + """Protocol with shared API connection helpers.""" def __init__(self, host: str, port: int, api: str, api_version: str) -> None: self.host = None @@ -203,6 +213,11 @@ def __init__(self, host: str, port: int, api: str, api_version: str) -> None: self._show_stats = True self._stats = {} + self.session: httpx.Client + self.use_cache: bool = False + self.all_datarefs: DatarefCache | None = None + self.all_commands: CommandCache | None = None + self.set_network(host=host, port=port, api=api, api_version=api_version) @property @@ -232,10 +247,9 @@ def status(self, status: CONNECTION_STATUS): logger.info(f"API status is now {self.status_str}") @property - @abstractmethod def connected(self) -> bool: """Whether X-Plane API is reachable through this instance""" - return False + ... def set_roundings(self, roundings): """Add rounding to simulator variable value. @@ -255,6 +269,9 @@ def inc(self, name, count: int = 1): if self._show_stats and (self._stats[name] % 500 == 0 or ("/" in name and self._stats[name] % 100 == 0)): logger.info(f"*** web api stats: {name}: {self._stats[name]}") + def get_rest_meta(self, obj: Dataref | Command, force: bool = False) -> DatarefMeta | CommandMeta | None: + return None + def set_network(self, host: str, port: int, api: str, api_version: str) -> bool: """Set network and API parameters for connection @@ -332,32 +349,31 @@ def command(self, path: str) -> Command: """ return Command(path=path, api=self) - @abstractmethod - def write_dataref(self, dataref: Dataref) -> bool | int: + def write_dataref(self, dataref: Dataref) -> APIResult: """Write Dataref value to X-Plane if Dataref is writable Args: dataref (Dataref): Dataref to write Returns: - bool: Whether write operation was successful or not + APIResult: True/False for immediate APIs, or a request id for queued APIs. """ - return False + ... - @abstractmethod - def dataref_value(self, dataref: Dataref, raw: bool = False) -> DatarefValueType: + def dataref_value(self, dataref: Dataref, raw: bool = False, no_decode: bool = False) -> DatarefReadResult: """Returns Dataref value from simulator Args: dataref (Dataref): Dataref to get the value from + raw (bool): Return raw value without decoding (default: `False`) + no_decode (bool): Skip base64 decoding for data types (default: `False`) Returns: - bool | str | int | float: Value of dataref + DatarefReadResult: Value of dataref. """ - return False + ... - @abstractmethod - def execute_command(self, command: Command, duration: float = 0.0) -> bool | int: + def execute_command(self, command: Command, duration: float = 0.0) -> APIResult: """Execute command Args: @@ -365,9 +381,9 @@ def execute_command(self, command: Command, duration: float = 0.0) -> bool | int duration (float): Duration of execution for long commands (default: `0.0`) Returns: - bool: [description] + APIResult: True/False for immediate APIs, or a request id for queued APIs. """ - return False + ... def beacon_callback(self, connected: bool, beacon_data: "BeaconData", same_host: bool): """Minimal beacon callback function. @@ -382,8 +398,8 @@ def beacon_callback(self, connected: bool, beacon_data: "BeaconData", same_host: self.status = CONNECTION_STATUS.RECEIVING_BEACON if connected else CONNECTION_STATUS.NO_BEACON -class Cache: - """Stores dataref or command meta data in cache +class MetaCacheBase(ABC): + """Stores X-Plane Web API metadata in cache. Must be "refreshed" each time a new connection is created. Must be refreshed each time a new aircraft is loaded (for new datarefs, commands, etc.) @@ -392,7 +408,9 @@ class Cache: There is no faster structure than a python dict() for (name,value) pair storage. """ - def __init__(self, api: API) -> None: + path = "" + + def __init__(self, api) -> None: self.api = api self._what = "" self._raw = {} @@ -401,19 +419,19 @@ def __init__(self, api: API) -> None: self._last_updated = 0 @classmethod - def meta(cls, **kwargs) -> DatarefMeta | CommandMeta: - """Create DatarefMeta or CommandMeta from dictionary of meta data returned by X-Plane Web API""" - return DatarefMeta(**kwargs) if "is_writable" in kwargs else CommandMeta(**kwargs) # definitely not a good differentiator + def meta(cls, **kwargs) -> APIObjMeta: + """Create metadata from dictionary returned by X-Plane Web API.""" + raise NotImplementedError("use DatarefCache or CommandCache") - def load(self, path): + def load(self): """Load cache data""" if not self.api.connected: logger.warning("not connected") return None - self._what = path - url = self.api.rest_url + path + self._what = self.path + url = self.api.rest_url + self.path response = self.api.session.get(url) - webapi_logger.info(f"GET {path}: {url} = {response}") + webapi_logger.info(f"GET {self.path}: {url} = {response}") if response.status_code != 200: # We have version 12.1.4 or above logger.error(f"load: response={response.status_code}") return @@ -421,12 +439,12 @@ def load(self, path): data = raw["data"] self._raw = data - metas = [Cache.meta(**c) for c in data] + metas = [self.meta(**c) for c in data] self._by_name = {m.name: m for m in metas} self._by_ids = {m.ident: m for m in metas} self.last_cached = datetime.now().timestamp() - logger.debug(f"{path[1:]} cached ({len(metas)} entries)") + logger.debug(f"{self.path[1:]} cached ({len(metas)} entries)") @property def count(self) -> int: @@ -438,15 +456,15 @@ def has_data(self) -> bool: """Cache contains data""" return self._by_name is not None and len(self._by_name) > 0 - def get(self, name) -> DatarefMeta | CommandMeta | None: + def get(self, name) -> APIObjMeta | None: """Get meta data from cache by name""" return self.get_by_name(name=name) - def get_by_name(self, name) -> DatarefMeta | CommandMeta | None: + def get_by_name(self, name) -> APIObjMeta | None: """Get meta data from cache by name""" return self._by_name.get(name) - def get_by_id(self, ident: int) -> DatarefMeta | CommandMeta | None: + def get_by_id(self, ident: int) -> APIObjMeta | None: """Get meta data from cache by dataref or command identifier""" return self._by_ids.get(ident) @@ -463,6 +481,70 @@ def equiv(self, ident) -> str | None: return f"no equivalence for {ident}" +class DatarefCache(MetaCacheBase): + """Stores dataref metadata in cache.""" + + path = "/datarefs" + + @classmethod + def meta(cls, **kwargs) -> DatarefMeta: + """Create DatarefMeta from dictionary returned by X-Plane Web API.""" + return DatarefMeta(**kwargs) + + def get(self, name) -> DatarefMeta | None: + """Get dataref metadata from cache by name.""" + return self.get_by_name(name=name) + + def get_by_name(self, name) -> DatarefMeta | None: + """Get dataref metadata from cache by name.""" + return cast(DatarefMeta | None, self._by_name.get(name)) + + def get_by_id(self, ident: int) -> DatarefMeta | None: + """Get dataref metadata from cache by identifier.""" + return cast(DatarefMeta | None, self._by_ids.get(ident)) + + +class CommandCache(MetaCacheBase): + """Stores command metadata in cache.""" + + path = "/commands" + + @classmethod + def meta(cls, **kwargs) -> CommandMeta: + """Create CommandMeta from dictionary returned by X-Plane Web API.""" + return CommandMeta(**kwargs) + + def get(self, name) -> CommandMeta | None: + """Get command metadata from cache by name.""" + return self.get_by_name(name=name) + + def get_by_name(self, name) -> CommandMeta | None: + """Get command metadata from cache by name.""" + return cast(CommandMeta | None, self._by_name.get(name)) + + def get_by_id(self, ident: int) -> CommandMeta | None: + """Get command metadata from cache by identifier.""" + return cast(CommandMeta | None, self._by_ids.get(ident)) + + +class Cache(MetaCacheBase): + """Backward-compatible generic metadata cache. + + Prefer DatarefCache or CommandCache for new code. + """ + + @classmethod + def meta(cls, **kwargs) -> DatarefMeta | CommandMeta: + """Create metadata using the legacy dataref/command heuristic.""" + return DatarefMeta(**kwargs) if "is_writable" in kwargs else CommandMeta(**kwargs) + + def load(self, path: str | None = None): + """Load cache data, preserving the old optional path argument.""" + if path is not None: + self.path = path + return super().load() + + # ############################################# # CORE ENTITIES # @@ -505,7 +587,7 @@ def meta(self) -> DatarefMeta | None: logger.error(f"dataref {self.path} has no api meta data in cache") else: logger.error("no cache data") - return self.api.get_rest_meta(self) + return cast(DatarefMeta | None, self.api.get_rest_meta(self)) @property def valid(self) -> bool: @@ -538,7 +620,7 @@ def get_string_value(self, encoding: str) -> str | None: """Decodes current dataref value and replaces it with the decoded string value Args: - encoding| None ([str]): [description] (default: `None`) + encoding (str): Encoding used to decode the bytes value. Returns: [type]: [description] @@ -562,7 +644,7 @@ def get_string_value(self, encoding: str) -> str | None: value = value.replace("\x00", "") # remove trailing 0 (bytes with value 0) self._encoding = encoding return value - except: + except Exception: self.add_error() logger.warning(f"could not decode value {value_bytes} with encoding {encoding}", exc_info=True) return None @@ -587,7 +669,7 @@ def set_string_value(self, value: str, encoding: str): try: self.value = value.encode(encoding=encoding) self._encoding = encoding - except: + except Exception: self.add_error() logger.warning(f"could not encode string '{value}'' with encoding {encoding}", exc_info=True) @@ -596,18 +678,19 @@ def b64encoded(self) -> str | None: if self.value_type == DATAREF_DATATYPE.DATA.value: try: return base64.b64encode(self.value).decode("ascii") - except: + except Exception: logger.warning(f"could not base64 encode value {self.value}", exc_info=True) return None @property def ident(self) -> int | None: """Get dataref identifier meta data""" - if not self.valid: + m = self.meta + if m is None: logger.error(f"dataref {self.path} not valid") self.add_error() return None - return self.meta.ident + return m.ident @property def value_type(self) -> str | None: @@ -620,20 +703,22 @@ def value_type(self) -> str | None: - INTARRAY = "int_array" - FLOATARRAY = "float_array" - DATA = "data" """ - if not self.valid: + m = self.meta + if m is None: logger.error(f"dataref {self.path} not valid") self.add_error() return None - return self.meta.value_type + return m.value_type @property def is_writable(self) -> bool: """Whether dataref can be written back to X-Plane""" - if not self.valid: + m = self.meta + if m is None: logger.error(f"dataref {self.path} not valid") self.add_error() return False - return self.meta.is_writable + return m.is_writable @property def is_array(self) -> bool: @@ -646,13 +731,14 @@ def is_array(self) -> bool: @property def selected_indices(self) -> bool: - if not self.valid: + m = self.meta + if m is None: logger.error(f"dataref {self.path} not valid") self.add_error() return False - return len(self.meta.indices) > 0 + return len(m.indices) > 0 - def write(self) -> bool: + def write(self) -> APIResult: """Write new value to X-Plane through REST API Dataref value is saved locally and written to X-Plane when write() or save() is called. @@ -695,7 +781,8 @@ def dec_monitor(self) -> bool: return self._monitored > 0 def parse_raw_value(self, raw_value): - if not self.valid: + m = self.meta + if m is None: logger.error(f"dataref {self.path} not valid") return None @@ -706,19 +793,22 @@ def parse_raw_value(self, raw_value): logger.warning(f"dataref array {self.name}: value: is not a list ({raw_value}, {type(raw_value)})") return None - if len(self.meta.indices) == 0: + if len(m.indices) == 0: logger.debug(f"dataref array {self.name}: no index, returning whole array") return raw_value # 1.2 Single array element - if len(raw_value) != len(self.meta.indices): - logger.warning(f"dataref array {self.name} size mismatch ({len(raw_value)}/{len(self.meta.indices)})") - logger.warning(f"dataref array {self.name}: value: {raw_value}, indices: {self.meta.indices})") + if len(raw_value) != len(m.indices): + logger.warning(f"dataref array {self.name} size mismatch ({len(raw_value)}/{len(m.indices)})") + logger.warning(f"dataref array {self.name}: value: {raw_value}, indices: {m.indices})") return None - idx = self.meta.indices.index(self.index) + if self.index is not None: + idx = m.indices.index(self.index) + else: + idx = -1 if idx == -1: - logger.warning(f"dataref index {self.index} not found in {self.meta.indices}") + logger.warning(f"dataref index {self.index} not found in {m.indices}") return None logger.debug(f"dataref array {self.name}: returning {self.name}[{idx}]={raw_value[idx]}") @@ -728,10 +818,9 @@ def parse_raw_value(self, raw_value): # 2. Scalar values # 2.1 Bytes if self.value_type == "data" and type(raw_value) is str: - ret = raw_value try: return base64.b64decode(raw_value) - except: + except Exception: logger.warning(f"failed to decode base64 {self.name}, {self.value_type}: {type(raw_value)} {raw_value}, returning raw value") return raw_value # 2.1 Number @@ -742,15 +831,17 @@ def parse_raw_value(self, raw_value): def monitor(self) -> bool: """Monitor dataref value change""" - if hasattr(self.api, "monitor_dataref"): - return self.api.monitor_dataref(dataref=self) + fn = getattr(self.api, "monitor_dataref", None) + if fn is not None: + return fn(dataref=self) logger.error(f"{self.path}: not a websocket api") return False def unmonitor(self) -> bool: """Unmonitor dataref value change""" - if hasattr(self.api, "unmonitor_dataref"): - return self.api.unmonitor_dataref(dataref=self) + fn = getattr(self.api, "unmonitor_dataref", None) + if fn is not None: + return fn(dataref=self) logger.error(f"{self.path}: not a websocket api") return False @@ -759,7 +850,7 @@ class Command: """X-Plane Web API Command""" def __init__(self, api: API, path: str, duration: float = 0.0): - self._cached_meta = None + self._cached_meta: CommandMeta | None = None self.api = api self.path = path # some/command self.name = path # some/command @@ -782,7 +873,7 @@ def meta(self) -> CommandMeta | None: logger.error(f"command {self.path} has no api meta data in cache") else: logger.error("no cache data") - return self.api.get_rest_meta(self) + return cast(CommandMeta | None, self.api.get_rest_meta(self)) @property def valid(self) -> bool: @@ -792,19 +883,21 @@ def valid(self) -> bool: @property def ident(self) -> int | None: """Get command identifier meta data""" - if not self.valid: + m = self.meta + if m is None: logger.error(f"command {self.path} not valid") self.add_error() return None - return self.meta.ident + return m.ident @property def description(self) -> str | None: """Get command description as provided by X-Plane""" - if not self.valid: + m = self.meta + if m is None: self.add_error() return None - return self.meta.description + return m.description def add_error(self, message: str = ""): self._err = self._err + 1 @@ -814,14 +907,15 @@ def add_error(self, message: str = ""): def reset_errors(self): self._err = 0 - def execute(self, duration: float = 0.0) -> bool: + def execute(self, duration: float = 0.0) -> APIResult: """Execute command through API supplied at creation""" return self.api.execute_command(command=self, duration=duration) def monitor(self, on: bool = True) -> bool: """Monitor command activation through Websocket API""" - if hasattr(self.api, "register_command_is_active_event"): - return self.api.register_command_is_active_event(path=self.path, on=on) + fn = getattr(self.api, "register_command_is_active_event", None) + if fn is not None: + return fn(path=self.path, on=on) logger.error(f"{self.path}: not a websocket api") return False diff --git a/xpwebapi/async_rest.py b/xpwebapi/async_rest.py new file mode 100644 index 0000000..53b6d31 --- /dev/null +++ b/xpwebapi/async_rest.py @@ -0,0 +1,406 @@ +"""Asynchronous X-Plane Web API access through REST.""" + +from __future__ import annotations + +import base64 +import logging +from types import TracebackType +from typing import Any, Self, cast + +import httpx + +from .api import ( + API, + CONNECTION_STATUS, + DATAREF_DATATYPE, + Command, + CommandCache, + CommandMeta, + Dataref, + DatarefCache, + DatarefMeta, + DatarefReadResult, + ValueCache, + webapi_logger, +) +from .retry import RetryConfig, async_sleep_before_retry +from .rest import PROXY_TCP_PORT, REST_KW, V1_CAPABILITIES, XP_SUPER_MIN_VERSION + +logger = logging.getLogger(__name__) + + +class AsyncXPRestAPI: + """Opt-in asynchronous REST client for X-Plane Web API.""" + + def __init__( + self, + host: str = "127.0.0.1", + port: int = 8086, + api: str = "/api", + api_version: str = "v1", + use_cache: bool = False, + retry_attempts: int = 1, + retry_backoff: float = 0.0, + retry_backoff_max: float = 5.0, + ) -> None: + self.host = None + self.port = None + self.version = None + self._api_root_path = None + self._api_version = None + self._status = CONNECTION_STATUS.NO_BEACON + self.status = CONNECTION_STATUS.NOT_CONNECTED + + self._use_cache = False + self._should_use_cache = use_cache + self._roundings = None + + self._show_stats = True + self._stats = {} + + self._capabilities = {} + self.retry_config = RetryConfig(attempts=retry_attempts, backoff=retry_backoff, max_backoff=retry_backoff_max) + self._first_try = True + self._warning_count = 0 + self._unreach_count = 0 + + self.all_datarefs: DatarefCache | None = None + self.all_commands: CommandCache | None = None + + self.session = httpx.AsyncClient(headers={"Accept": "application/json", "Content-Type": "application/json"}) + self.set_network(host=host, port=port, api=api, api_version=api_version) + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, _exc_type: type[BaseException] | None, _exc: BaseException | None, _tb: TracebackType | None) -> None: + await self.aclose() + + async def aclose(self) -> None: + """Close the underlying async HTTP session.""" + await self.session.aclose() + + @property + def use_cache(self) -> bool: + """Use cache for object metadata.""" + return self._use_cache + + @use_cache.setter + def use_cache(self, use_cache: bool) -> None: + self._should_use_cache = use_cache + self._use_cache = False + if use_cache: + logger.warning("async cache loading is not implemented; per-object metadata fetches will be used") + + @property + def status(self) -> CONNECTION_STATUS: + """Connection status.""" + return self._status + + @property + def status_str(self) -> str: + """Connection status as a string.""" + return f"{CONNECTION_STATUS(self._status).name}" + + @status.setter + def status(self, status: CONNECTION_STATUS) -> None: + if self._status != status: + self._status = status + logger.info(f"API status is now {self.status_str}") + + @property + def connected(self) -> bool: + """Whether the last async REST probe succeeded.""" + return self.status == CONNECTION_STATUS.REST_API_REACHABLE + + @property + def rest_url(self) -> str: + """URL for the REST API.""" + return self._url("http") + + @property + def has_data(self) -> bool: + datarefs = self.all_datarefs + commands = self.all_commands + return datarefs is not None and datarefs.has_data and commands is not None and commands.has_data + + @property + def xp_version(self) -> str | None: + """Returns reported X-Plane version from simulator.""" + details = self._capabilities.get("x-plane") + if details is None: + return None + version = details.get("version") + return str(version) if version is not None else None + + def set_network(self, host: str, port: int, api: str, api_version: str) -> bool: + """Set network and API parameters for connection.""" + changed = False + if self.host != host: + self.host = host + changed = True + if self.port != port: + self.port = port + changed = True + if not api.startswith("/"): + api = "/" + api + if self._api_root_path != api: + self._api_root_path = api + changed = True + if api_version.startswith("/"): + api_version = api_version[1:] + if self.version != api_version: + self.version = api_version + self._api_version = "/" + api_version + changed = True + return changed + + def _url(self, protocol: str) -> str: + return f"{protocol}://{self.host}:{self.port}{self._api_root_path}{self._api_version}" + + def dataref(self, path: str, auto_save: bool = False) -> Dataref: + """Create a Dataref bound to this async API.""" + return Dataref(path=path, api=cast(API, self), auto_save=auto_save) + + def command(self, path: str) -> Command: + """Create a Command bound to this async API.""" + return Command(path=path, api=cast(API, self)) + + def set_roundings(self, roundings: dict[str, int]) -> None: + """Add rounding to simulator variable value change detection.""" + self._roundings = ValueCache(roundings=roundings) + logger.info(f"API dataref value rounding set ({len(self._roundings.roundings)})") + + def changed(self, dataref: str, value: Any) -> bool: + """If rounding applies, determine if value has changed.""" + return True if self._roundings is None else self._roundings.changed(dataref, value) + + def inc(self, name: str, count: int = 1) -> None: + if name not in self._stats: + self._stats[name] = 0 + self._stats[name] = self._stats[name] + count + if self._show_stats and (self._stats[name] % 500 == 0 or ("/" in name and self._stats[name] % 100 == 0)): + logger.info(f"*** web api stats: {name}: {self._stats[name]}") + + async def rest_api_reachable(self) -> bool: + """Whether the REST API is reachable.""" + check_url = f"http://{self.host}:{self.port}/api/v1/datarefs/count" + if self._first_try: + logger.info(f"trying to connect to {check_url}..") + self._first_try = False + for attempt in range(self.retry_config.attempts): + try: + self.inc("get") + response = await self.session.get(check_url) + webapi_logger.info(f"GET {check_url}: {response}") + if response.status_code == 200: + if self._unreach_count > 0: + logger.info("rest api reachable") + self._unreach_count = 0 + self.status = CONNECTION_STATUS.REST_API_REACHABLE + return True + self.status = CONNECTION_STATUS.REST_API_NOT_REACHABLE + self._unreach_count = self._unreach_count + 1 + except httpx.ConnectError: + if self._warning_count % 20 == 0: + logger.warning("api unreachable, X-Plane may be not running") + self.status = CONNECTION_STATUS.REST_API_NOT_REACHABLE + self._warning_count = self._warning_count + 1 + self._unreach_count = self._unreach_count + 1 + if attempt < self.retry_config.attempts - 1: + await async_sleep_before_retry(self.retry_config, attempt) + return False + + async def capabilities(self) -> dict: + """Fetch and cache API capabilities.""" + if len(self._capabilities) > 0: + return self._capabilities + if await self.rest_api_reachable(): + try: + url = f"http://{self.host}:{self.port}/api/capabilities" + self.inc("get") + response = await self.session.get(url) + webapi_logger.info(f"GET {url}: {response}") + if response.status_code == 200: + self._capabilities = response.json() + return self._capabilities + v1_url = f"http://{self.host}:{self.port}{self._api_root_path}/v1/datarefs/count" + self.inc("get") + response = await self.session.get(v1_url) + webapi_logger.info(f"GET {v1_url}: {response}") + if response.status_code == 200: + self._capabilities = V1_CAPABILITIES + return self._capabilities + except Exception: + logger.error("capabilities", exc_info=True) + return self._capabilities + + async def get_rest_meta(self, obj: Dataref | Command, force: bool = False) -> DatarefMeta | CommandMeta | None: + """Get metadata from X-Plane through REST API for an object.""" + if not force and obj._cached_meta is not None: + return obj._cached_meta + if not await self.rest_api_reachable(): + logger.warning("not connected") + return None + return await self._fetch_rest_meta(obj) + + async def _meta_for(self, obj: Dataref | Command) -> DatarefMeta | CommandMeta | None: + if obj._cached_meta is not None: + return obj._cached_meta + return await self._fetch_rest_meta(obj) + + async def _fetch_rest_meta(self, obj: Dataref | Command) -> DatarefMeta | CommandMeta | None: + obj._cached_meta = None + payload = f"filter[name]={obj.path}" + obj_type = "/datarefs" if isinstance(obj, Dataref) else "/commands" + url = self.rest_url + obj_type + self.inc("get") + response = await self.session.get(url, params=payload) + webapi_logger.info(f"GET {obj.path}: {url} = {response}") + if response.status_code == 200: + metadata = response.json().get(REST_KW.DATA.value, []) + if len(metadata) > 0: + if isinstance(obj, Dataref): + meta = DatarefCache.meta(**metadata[0]) + obj._cached_meta = meta + else: + meta = CommandCache.meta(**metadata[0]) + obj._cached_meta = meta + return meta + logger.error(f"{obj_type} {obj.path} could not get meta data through REST API") + return None + + def get_dataref_meta_by_name(self, path: str) -> DatarefMeta | None: + """Get cached dataref metadata by dataref name.""" + if self.all_datarefs is not None: + return self.all_datarefs.get_by_name(path) + return None + + def get_dataref_meta_by_id(self, ident: int) -> DatarefMeta | None: + """Get cached dataref metadata by dataref identifier.""" + if self.all_datarefs is not None: + return self.all_datarefs.get_by_id(ident) + return None + + def get_command_meta_by_name(self, path: str) -> CommandMeta | None: + """Get cached command metadata by command path.""" + if self.all_commands is not None: + return self.all_commands.get_by_name(path) + return None + + def get_command_meta_by_id(self, ident: int) -> CommandMeta | None: + """Get cached command metadata by command identifier.""" + if self.all_commands is not None: + return self.all_commands.get_by_id(ident) + return None + + async def dataref_value(self, dataref: Dataref, raw: bool = False, no_decode: bool = False) -> DatarefReadResult: + """Get dataref value through REST API.""" + if not await self.rest_api_reachable(): + logger.debug("not connected") + return None + meta = await self._meta_for(dataref) + if not isinstance(meta, DatarefMeta): + logger.error(f"dataref {dataref.path} not valid") + return None + url = f"{self.rest_url}/datarefs/{meta.ident}/value" + self.inc("get") + response = await self.session.get(url) + if response.status_code == 200: + payload = response.json() + webapi_logger.info(f"GET {dataref.path}: {url} = {payload}") + value = payload[REST_KW.DATA.value] + if not raw and not no_decode and meta.value_type == DATAREF_DATATYPE.DATA.value and type(value) in [bytes, str]: + try: + return base64.b64decode(value) + except Exception: + logger.warning(f"cannot decode: {response} {response.reason_phrase} {response.text}", exc_info=True) + return value + webapi_logger.info(f"ERROR {dataref.path}: {response} {response.reason_phrase} {response.text}") + logger.error(f"dataref_value: {response} {response.reason_phrase} {response.text}") + return None + + async def write_dataref(self, dataref: Dataref) -> bool: + """Write single dataref value through REST API.""" + if not await self.rest_api_reachable(): + logger.warning("not connected") + return False + meta = await self._meta_for(dataref) + if not isinstance(meta, DatarefMeta): + logger.error(f"dataref {dataref.path} not valid") + return False + if not meta.is_writable: + logger.warning(f"dataref {dataref.path} is not writable") + return False + value = dataref._new_value + if value is None: + logger.warning(f"dataref {dataref.path} has no new value") + return False + if meta.value_type == DATAREF_DATATYPE.DATA.value or type(value) is bytes: + value = base64.b64encode(value).decode("ascii") + payload = {REST_KW.DATA.value: value} + url = f"{self.rest_url}/datarefs/{meta.ident}/value" + if dataref.index is not None and meta.value_type in [DATAREF_DATATYPE.INTARRAY.value, DATAREF_DATATYPE.FLOATARRAY.value]: + url = url + f"?index={dataref.index}" + webapi_logger.info(f"PATCH {dataref.path}: {url}, {payload}") + self.inc("patch") + response = await self.session.patch(url, json=payload) + if response.status_code == 200: + logger.debug(f"result: {response.json()}") + return True + webapi_logger.info(f"ERROR {dataref.path}: {response} {response.reason_phrase} {response.text}") + logger.error(f"rest_write: {response} {response.reason_phrase} {response.text}") + return False + + async def execute_command(self, command: Command, duration: float = 0.0) -> bool: + """Execute command through REST API.""" + if not await self.rest_api_reachable(): + logger.warning("not connected") + return False + meta = await self._meta_for(command) + if not isinstance(meta, CommandMeta): + logger.error(f"command {command.path} is not valid") + return False + if duration == 0.0 and command.duration != 0.0: + duration = command.duration + payload = {REST_KW.IDENT.value: meta.ident, REST_KW.DURATION.value: duration} + url = f"{self.rest_url}/command/{meta.ident}/activate" + self.inc("post") + response = await self.session.post(url, json=payload) + webapi_logger.info(f"POST {command.path}: {url} {payload} {response}") + data = response.json() + if response.status_code == 200: + logger.debug(f"result: {data}") + return True + webapi_logger.info(f"ERROR {command.path}: {response} {response.reason_phrase} {response.text}") + logger.error(f"rest_execute: {response}, {data}") + return False + + def invalidate_caches(self) -> None: + """Remove cached metadata.""" + self.all_datarefs = None + self.all_commands = None + logger.info("cache invalidated") + + def set_connection_from_beacon_data(self, beacon_data, same_host: bool, remote_tcp_port: int = PROXY_TCP_PORT) -> None: + api_tcp_port = 8086 + xp_min_version = 121400 + + new_host = "127.0.0.1" + new_port = api_tcp_port + if not same_host: + new_host = beacon_data.host + new_port = remote_tcp_port + xp_version = beacon_data.xplane_version + if xp_version is not None: + new_api_version = "/v1" + if xp_version >= xp_min_version: + new_api_version = "/v2" + elif xp_version < XP_SUPER_MIN_VERSION: + new_api_version = "" + logger.warning(f"could not set API version from {xp_version} ({beacon_data})") + if new_api_version != "" and (new_api_version != self._api_version or new_host != self.host or new_port != self.port): + self.set_network(host=new_host, port=new_port, api="/api", api_version=new_api_version) + logger.info(f"XPlane API at {self.rest_url} from UDP beacon data") + else: + logger.warning(f"could not get X-Plane version from beacon data {beacon_data}") diff --git a/xpwebapi/beacon.py b/xpwebapi/beacon.py index 87a7cc5..b348355 100644 --- a/xpwebapi/beacon.py +++ b/xpwebapi/beacon.py @@ -17,31 +17,33 @@ import binascii import platform import time -from typing import Callable, List, Set +from typing import Callable from enum import Enum, IntEnum from datetime import datetime from dataclasses import dataclass import ifaddr +from .exceptions import XPBeaconError, XPVersionError +from .retry import RetryConfig, sleep_before_retry + logger = logging.getLogger(__name__) # logger.setLevel(logging.DEBUG) -# XPBeaconMonitor-specific error classes -class XPlaneNoBeacon(Exception): - args = tuple("No beacon received from any running XPlane instance in network") +class XPlaneNoBeacon(XPBeaconError): + pass -class XPlaneVersionNotSupported(Exception): - args = tuple("XPlane version not supported") +class XPlaneVersionNotSupported(XPVersionError): + pass -def list_my_ips() -> List[str]: +def list_my_ips() -> list[str]: """Utility function that list most if not all IP addresses of this host. Returns: - List[str]: List of IP v4 addresses of this host on most, if not all interfaces (cable, wi-fi, bluetooth...) + list[str]: List of IP v4 addresses of this host on most, if not all interfaces (cable, wi-fi, bluetooth...) """ r = list() adapters = ifaddr.get_adapters() @@ -112,7 +114,7 @@ class XPBeaconMonitor: socket (socket.socket | None): Socket to multicast listener status (BEACON_MONITOR_STATUS): Beacon monitor status data: BeaconData | None - Beacon data as broadcasted by X-Plane in its beacon. None if beacon is not received. - my_ips (List[str]): List of this host IP addresses + my_ips (list[str]): List of this host IP addresses _already_warned (bool): _callback: (Callable | None): @@ -125,9 +127,11 @@ class XPBeaconMonitor: ```python import xpwebapi + def callback(connected: bool, beacon_data: xpwebapi.BeaconData, same_host: bool): print("reachable" if connected else "unreachable") + beacon = xpwebapi.beacon() beacon.set_callback(callback) beacon.start_monitor() @@ -147,10 +151,11 @@ def callback(connected: bool, beacon_data: xpwebapi.BeaconData, same_host: bool) ROLES = ["none", "master", "extern visual", "IOS"] - def __init__(self): + def __init__(self, retry_attempts: int = 1, retry_backoff: float = 0.0, retry_backoff_max: float = 5.0): # Open a UDP Socket to receive on Port 49000 self.socket = None self.data: BeaconData | None = None + self.retry_config = RetryConfig(attempts=retry_attempts, backoff=retry_backoff, max_backoff=retry_backoff_max) self.not_monitoring: threading.Event = threading.Event() self.not_monitoring.set() @@ -158,7 +163,7 @@ def __init__(self): self._connect_thread: threading.Thread | None = None self._already_warned = 0 - self._callback: Set[Callable] = set() + self._callback: set[Callable] = set() self.my_ips = list_my_ips() self._status = BEACON_MONITOR_STATUS.RUNNING # init != first value self.status = BEACON_MONITOR_STATUS.NOT_RUNNING # first value set through api @@ -195,7 +200,7 @@ def status(self, status: BEACON_MONITOR_STATUS): # ################################ # Internal functions # - def callback(self, connected: bool, beacon_data: BeaconData, same_host: bool): + def callback(self, connected: bool, beacon_data: BeaconData | None, same_host: bool | None): """Execute all callback functions Callback function prototype @@ -209,10 +214,21 @@ def callback(self, connected: bool, beacon_data: BeaconData, same_host: bool): for c in self._callback: try: c(connected=connected, beacon_data=beacon_data, same_host=same_host) - except: + except Exception: logger.warning(f"issue calling beacon callback {c}", exc_info=True) def get_beacon(self, timeout: float = BEACON_TIMEOUT) -> BeaconData | None: + """Attempts to capture an X-Plane beacon using configured transient retries.""" + for attempt in range(self.retry_config.attempts): + try: + return self._get_beacon_once(timeout=timeout) + except XPlaneNoBeacon: + if attempt >= self.retry_config.attempts - 1: + raise + sleep_before_retry(self.retry_config, attempt) + return None + + def _get_beacon_once(self, timeout: float = BEACON_TIMEOUT) -> BeaconData | None: """Attemps to capture X-Plane beacon. Returns first occurence of beacon data encountered or None if no beacon was detected before timeout. @@ -227,7 +243,6 @@ class BeaconData: hostname: str xplane_version: int role: int - ``` Args: @@ -246,10 +261,11 @@ class BeaconData: # open socket for multicast group. # this socker is for getting the beacon, it can be closed when beacon is found. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) # SO_REUSEPORT? if platform.system() == "Windows": + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(("", self.MCAST_PORT)) else: + sock.setsockopt(socket.SOL_SOCKET, getattr(socket, "SO_REUSEPORT"), 1) sock.bind((self.MCAST_GRP, self.MCAST_PORT)) mreq = struct.pack("=4sl", socket.inet_aton(self.MCAST_GRP), socket.INADDR_ANY) sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) @@ -309,13 +325,13 @@ class BeaconData: logger.info(f"XPlane Beacon Version: {beacon_major_version}.{beacon_minor_version}.{application_host_id} (role: {self.ROLES[role]})") else: logger.warning(f"XPlane Beacon Version not supported: {beacon_major_version}.{beacon_minor_version}.{application_host_id}") - raise XPlaneVersionNotSupported() + raise XPlaneVersionNotSupported(f"beacon version {beacon_major_version}.{beacon_minor_version}.{application_host_id}") except socket.timeout: self._timeout = self._timeout + 1 self._latest_timeout = self._latest_timeout + 1 logger.debug(f"XPlane beacon not received within timeout ({round(timeout, 1)} secs.).") - raise XPlaneNoBeacon() + raise XPlaneNoBeacon("no beacon received", timeout=timeout) finally: sock.close() @@ -389,9 +405,11 @@ def receiving_beacon(self) -> bool: def same_host(self) -> bool: """Attempt to determine if X-Plane is running on local host (where beacon monitor runs) or remote host""" if self.receiving_beacon: - r = self.data.host in self.my_ips - logger.debug(f"{self.data.host}{'' if r else ' not'} in {self.my_ips}") - return r + data = self.data + if data is not None: + r = data.host in self.my_ips + logger.debug(f"{data.host}{'' if r else ' not'} in {self.my_ips}") + return r return False def set_callback(self, callback: Callable | None = None): @@ -408,7 +426,8 @@ def set_callback(self, callback: Callable | None = None): Connected is True is beacon is detected at regular interval, False otherwise """ - self._callback.add(callback) + if callback is not None: + self._callback.add(callback) def start_monitor(self): """Starts beacon monitor""" diff --git a/xpwebapi/exceptions.py b/xpwebapi/exceptions.py new file mode 100644 index 0000000..b88c01a --- /dev/null +++ b/xpwebapi/exceptions.py @@ -0,0 +1,27 @@ +from typing import Any + + +class XPWebAPIError(Exception): + def __init__(self, message: str = "", **context: Any): + self.context = context + super().__init__(message) + + +class XPConnectionError(XPWebAPIError): + pass + + +class XPBeaconError(XPConnectionError): + pass + + +class XPPacketError(XPWebAPIError): + pass + + +class XPTimeoutError(XPWebAPIError): + pass + + +class XPVersionError(XPWebAPIError): + pass diff --git a/xpwebapi/logging_config.py b/xpwebapi/logging_config.py new file mode 100644 index 0000000..7eae220 --- /dev/null +++ b/xpwebapi/logging_config.py @@ -0,0 +1,208 @@ +"""Logging configuration helpers for xpwebapi.""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Mapping +from datetime import UTC, datetime +from pathlib import Path +from typing import Literal, TextIO + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +LOGGING_FORMAT_TEXT = "%(asctime)s %(levelname)s %(name)s: %(message)s" +PYTHON_LEVELS = { + "CRITICAL": logging.CRITICAL, + "ERROR": logging.ERROR, + "WARNING": logging.WARNING, + "INFO": logging.INFO, + "DEBUG": logging.DEBUG, + "NOTSET": logging.NOTSET, +} +OWNED_HANDLER_ATTR = "_xpwebapi_owned_handler" +_OWNED_COMPONENT_LOGGER_LEVELS: dict[str, tuple[int, int]] = {} + + +def _normalize_level_name(level: str) -> str: + if not isinstance(level, str): + raise TypeError("logging level must be a string") + normalized = level.upper() + if normalized not in PYTHON_LEVELS: + valid = ", ".join(PYTHON_LEVELS) + raise ValueError(f"unknown logging level {level!r}; expected one of: {valid}") + return normalized + + +def _validate_component_name(name: str) -> str: + if name == "webapi" or (name.startswith("xpwebapi.") and len(name) > len("xpwebapi.")): + return name + raise ValueError("component logger must be 'webapi' or start with 'xpwebapi.'") + + +class LoggingConfig(BaseModel): + """Validated logging configuration.""" + + model_config = ConfigDict(extra="forbid") + + format: Literal["text", "json"] = "text" + level: str = "INFO" + traffic_level: str = "WARNING" + components: dict[str, str] = Field(default_factory=dict) + + @field_validator("level", "traffic_level") + @classmethod + def _validate_level(cls, value: str) -> str: + return _normalize_level_name(value) + + @model_validator(mode="after") + def _validate_components(self) -> "LoggingConfig": + self.components = {_validate_component_name(name): _normalize_level_name(level) for name, level in self.components.items()} + return self + + +class LoggingConfigFile(BaseModel): + """Top-level JSON config file schema.""" + + model_config = ConfigDict(extra="forbid") + + logging: LoggingConfig = Field(default_factory=LoggingConfig) + + +class JsonLogFormatter(logging.Formatter): + """Format log records as one JSON object per line.""" + + def format(self, record: logging.LogRecord) -> str: + payload = { + "timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z"), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "module": record.module, + "function": record.funcName, + "line": record.lineno, + } + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + return json.dumps(payload, sort_keys=True) + + +def _level_number(level: str) -> int: + return PYTHON_LEVELS[_normalize_level_name(level)] + + +def _load_config_file(config_file: str | Path) -> LoggingConfig: + path = Path(config_file) + raw_text = path.read_text(encoding="utf-8") + try: + raw = json.loads(raw_text) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid JSON logging config {path}: {exc}") from exc + return LoggingConfigFile.model_validate(raw).logging + + +def _make_handler(config: LoggingConfig, stream: TextIO | None) -> logging.Handler: + handler = logging.StreamHandler(stream) + formatter: logging.Formatter + if config.format == "json": + formatter = JsonLogFormatter() + else: + formatter = logging.Formatter(LOGGING_FORMAT_TEXT) + handler.setFormatter(formatter) + setattr(handler, OWNED_HANDLER_ATTR, True) + return handler + + +def _replace_owned_handlers(logger: logging.Logger, handler: logging.Handler) -> None: + logger.handlers = [existing for existing in logger.handlers if not getattr(existing, OWNED_HANDLER_ATTR, False)] + logger.addHandler(handler) + + +def _apply_component_levels(components: Mapping[str, str]) -> None: + configured_names = set(components) + + for logger_name in tuple(_OWNED_COMPONENT_LOGGER_LEVELS): + if logger_name in configured_names: + continue + previous_level, owned_level = _OWNED_COMPONENT_LOGGER_LEVELS.pop(logger_name) + logger = logging.getLogger(logger_name) + if logger.level == owned_level: + logger.setLevel(previous_level) + + for logger_name, logger_level in components.items(): + logger = logging.getLogger(logger_name) + previous_level, owned_level = _OWNED_COMPONENT_LOGGER_LEVELS.get( + logger_name, + (logger.level, logger.level), + ) + new_level = _level_number(logger_level) + if logger_name in _OWNED_COMPONENT_LOGGER_LEVELS and logger.level != owned_level: + previous_level = logger.level + logger.setLevel(new_level) + _OWNED_COMPONENT_LOGGER_LEVELS[logger_name] = (previous_level, new_level) + + +def _merge_config( + base: LoggingConfig, + *, + format: Literal["text", "json"] | None, + level: str | None, + traffic_level: str | None, + components: Mapping[str, str] | None, +) -> LoggingConfig: + data = base.model_dump() + if format is not None: + data["format"] = format + if level is not None: + data["level"] = level + if traffic_level is not None: + data["traffic_level"] = traffic_level + if components is not None: + data["components"] = {**data["components"], **dict(components)} + return LoggingConfig.model_validate(data) + + +def configure_logging( + config_file: str | Path | None = None, + *, + format: Literal["text", "json"] | None = None, + level: str | None = None, + traffic_level: str | None = None, + components: Mapping[str, str] | None = None, + stream: TextIO | None = None, +) -> LoggingConfig: + """Configure xpwebapi application and traffic logging explicitly.""" + + base_config = _load_config_file(config_file) if config_file is not None else LoggingConfig() + config = _merge_config(base_config, format=format, level=level, traffic_level=traffic_level, components=components) + + app_logger = logging.getLogger("xpwebapi") + traffic_logger = logging.getLogger("webapi") + + app_handler = _make_handler(config, stream) + traffic_handler = _make_handler(config, stream) + + _replace_owned_handlers(app_logger, app_handler) + _replace_owned_handlers(traffic_logger, traffic_handler) + + app_logger.setLevel(_level_number(config.level)) + traffic_logger.setLevel(_level_number(config.traffic_level)) + app_logger.propagate = False + traffic_logger.propagate = False + + _apply_component_levels(config.components) + + return config + + +def write_logging_config(path: str | Path, *, config: LoggingConfig | None = None, overwrite: bool = False) -> Path: + """Write a starter JSON logging config file.""" + + output_path = Path(path) + if output_path.exists() and not overwrite: + raise FileExistsError(output_path) + + config_file = LoggingConfigFile(logging=config or LoggingConfig()) + output_path.write_text(json.dumps(config_file.model_dump(), indent=2, sort_keys=True) + "\n", encoding="utf-8") + return output_path diff --git a/xpwebapi/rest.py b/xpwebapi/rest.py index 3b879a2..38fe7e5 100644 --- a/xpwebapi/rest.py +++ b/xpwebapi/rest.py @@ -1,15 +1,37 @@ """X-Plane Web API access through REST API""" +from __future__ import annotations + import logging import base64 +from dataclasses import dataclass from datetime import timedelta -from typing import List +from threading import Lock +from types import TracebackType +from typing import TYPE_CHECKING, Self from enum import Enum -import requests +import httpx from natsort import natsorted -from .api import CONNECTION_STATUS, DATAREF_DATATYPE, API, Dataref, DatarefMeta, Command, CommandMeta, Cache, webapi_logger, DatarefValueType +from .api import ( + CONNECTION_STATUS, + DATAREF_DATATYPE, + API, + APIResult, + Command, + CommandCache, + CommandMeta, + Dataref, + DatarefCache, + DatarefMeta, + DatarefReadResult, + webapi_logger, +) +from .retry import RetryConfig, sleep_before_retry + +if TYPE_CHECKING: + from .beacon import BeaconData # local logging logger = logging.getLogger(__name__) @@ -26,6 +48,73 @@ # Can be changed when calling set_network_from_beacon_data() PROXY_TCP_PORT = 8080 +XP_SUPER_MIN_VERSION = 121010 + +REST_HEADERS = {"Accept": "application/json", "Content-Type": "application/json"} + + +@dataclass(frozen=True) +class _RestClientPoolKey: + max_connections: int | None + max_keepalive_connections: int | None + keepalive_expiry: float | None + timeout: float | None + + +@dataclass +class _RestClientPoolEntry: + client: httpx.Client + references: int = 0 + + +class _RestClientPool: + _clients: dict[_RestClientPoolKey, _RestClientPoolEntry] = {} + _lock = Lock() + + @classmethod + def acquire(cls, key: _RestClientPoolKey) -> httpx.Client: + with cls._lock: + entry = cls._clients.get(key) + if entry is None: + entry = _RestClientPoolEntry(client=_make_http_client(key)) + cls._clients[key] = entry + entry.references = entry.references + 1 + return entry.client + + @classmethod + def release(cls, key: _RestClientPoolKey) -> None: + client: httpx.Client | None = None + with cls._lock: + entry = cls._clients.get(key) + if entry is None: + return + entry.references = entry.references - 1 + if entry.references <= 0: + client = entry.client + del cls._clients[key] + if client is not None: + client.close() + + +def _make_http_client(key: _RestClientPoolKey) -> httpx.Client: + limits = None + if key.max_connections is not None or key.max_keepalive_connections is not None or key.keepalive_expiry is not None: + limits = httpx.Limits( + max_connections=key.max_connections, + max_keepalive_connections=key.max_keepalive_connections, + keepalive_expiry=key.keepalive_expiry, + ) + timeout = httpx.Timeout(key.timeout) if key.timeout is not None else None + if limits is None and timeout is None: + return httpx.Client(headers=REST_HEADERS) + if key.timeout is not None: + if limits is None: + return httpx.Client(headers=REST_HEADERS, timeout=timeout) + return httpx.Client(headers=REST_HEADERS, limits=limits, timeout=timeout) + if limits is not None: + return httpx.Client(headers=REST_HEADERS, limits=limits) + return httpx.Client(headers=REST_HEADERS) + # REST KEYWORDS class REST_KW(Enum): @@ -67,29 +156,71 @@ class XPRestAPI(API): [X-Plane Web API — REST API](https://developer.x-plane.com/article/x-plane-web-api/#REST_API) """ - def __init__(self, host: str = "127.0.0.1", port: int = 8086, api: str = "/api", api_version: str = "v1", use_cache: bool = False) -> None: + def __init__( + self, + host: str = "127.0.0.1", + port: int = 8086, + api: str = "/api", + api_version: str = "v1", + use_cache: bool = False, + retry_attempts: int = 1, + retry_backoff: float = 0.0, + retry_backoff_max: float = 5.0, + pool_connections: bool = False, + max_connections: int | None = None, + max_keepalive_connections: int | None = None, + keepalive_expiry: float | None = None, + timeout: float | None = None, + ) -> None: API.__init__(self, host=host, port=port, api=api, api_version=api_version) self._capabilities = {} + self.retry_config = RetryConfig(attempts=retry_attempts, backoff=retry_backoff, max_backoff=retry_backoff_max) + self._session_pool_key = ( + _RestClientPoolKey( + max_connections=max_connections, + max_keepalive_connections=max_keepalive_connections, + keepalive_expiry=keepalive_expiry, + timeout=timeout, + ) + if pool_connections + else None + ) + self._session_closed = False self._first_try = True self._running_time = Dataref(path=RUNNING_TIME, api=self) # cheating, side effect, works for rest api only, do not force! # Caches ids for all known datarefs and commands self._should_use_cache = use_cache # desired use of cache, not actual one in _use_cache - self.all_datarefs: Cache | None = None - self.all_commands: Cache | None = None + self.all_datarefs: DatarefCache | None = None + self.all_commands: CommandCache | None = None self._last_updated = 0 self._warning_count = 0 self._unreach_count = 0 self._dataref_by_id = {} # {dataref-id: Dataref} - self.session = requests.Session() - # Install session here: - # examples: - # self.session.auth = ('user', 'password') - self.session.headers["Accept"] = "application/json" - self.session.headers["Content-Type"] = "application/json" + self.session = ( + _RestClientPool.acquire(self._session_pool_key) + if self._session_pool_key is not None + else _make_http_client(_RestClientPoolKey(None, None, None, None)) + ) + + def __enter__(self) -> Self: + return self + + def __exit__(self, _exc_type: type[BaseException] | None, _exc: BaseException | None, _tb: TracebackType | None) -> None: + self.close() + + def close(self) -> None: + """Close the underlying HTTP session.""" + if self._session_closed: + return + self._session_closed = True + if self._session_pool_key is None: + self.session.close() + else: + _RestClientPool.release(self._session_pool_key) @property def use_cache(self) -> bool: @@ -137,34 +268,42 @@ def rest_api_reachable(self) -> bool: if self._first_try: logger.info(f"trying to connect to {CHECK_API_URL}..") self._first_try = False - try: - # Relies on the fact that first version is always provided. - # Later verion offer alternative ot detect API - self.inc("get") - response = self.session.get(CHECK_API_URL) - webapi_logger.info(f"GET {CHECK_API_URL}: {response}") - if response.status_code == 200: - if self._unreach_count > 0: - logger.info("rest api reachable") - self._unreach_count = 0 - self.status = CONNECTION_STATUS.REST_API_REACHABLE - return True - except requests.exceptions.ConnectionError: - if self._warning_count % 20 == 0: - logger.warning("api unreachable, X-Plane may be not running") + for attempt in range(self.retry_config.attempts): + try: + # Relies on the fact that first version is always provided. + # Later version offers alternatives to detect API. + self.inc("get") + response = self.session.get(CHECK_API_URL) + webapi_logger.info(f"GET {CHECK_API_URL}: {response}") + if response.status_code == 200: + if self._unreach_count > 0: + logger.info("rest api reachable") + self._unreach_count = 0 + self.status = CONNECTION_STATUS.REST_API_REACHABLE + return True + self.status = CONNECTION_STATUS.REST_API_NOT_REACHABLE + self._unreach_count = self._unreach_count + 1 + except httpx.ConnectError: + if self._warning_count % 20 == 0: + logger.warning("api unreachable, X-Plane may be not running") self.status = CONNECTION_STATUS.REST_API_NOT_REACHABLE self._warning_count = self._warning_count + 1 + self._unreach_count = self._unreach_count + 1 + if attempt < self.retry_config.attempts - 1: + sleep_before_retry(self.retry_config, attempt) return False @property def has_data(self) -> bool: res = "" - d = self.all_datarefs is not None and self.all_datarefs.has_data - if d: - res = res + f"loaded {self.all_datarefs.count} datarefs metadata" - c = self.all_commands is not None and self.all_commands.has_data - if d: - res = res + f", loaded {self.all_commands.count} commands metadata" + adr = self.all_datarefs + d = adr is not None and adr.has_data + if d and adr is not None: + res = res + f"loaded {adr.count} datarefs metadata" + acm = self.all_commands + c = acm is not None and acm.has_data + if c and acm is not None: + res = res + f", loaded {acm.count} commands metadata" logger.debug(res) return d and c @@ -193,7 +332,7 @@ def capabilities(self) -> dict: logger.debug(f"capabilities: {self._capabilities}") return self._capabilities logger.error(f"capabilities at {self.rest_url + '/datarefs/count'}: response={response.status_code}") - except: + except Exception: logger.error("capabilities", exc_info=True) else: logger.error("no connection") @@ -205,7 +344,8 @@ def xp_version(self) -> str | None: a = self._capabilities.get("x-plane") if a is None: return None - return a.get("version") + v = a.get("version") + return str(v) if v is not None else None def set_api_version(self, api_version: str | None = None): """Set API version @@ -231,8 +371,8 @@ def set_api_version(self, api_version: str | None = None): logger.error("cannot determine api, api not set") return sorted_apis = natsorted(api_versions, reverse=True) - api = sorted_apis[0] # takes the latest one, hoping it is the latest in time... - logger.info(f"selected api {api} ({sorted_apis})") + api_version = sorted_apis[0] # takes the latest one, hoping it is the latest in time... + logger.info(f"selected api {api_version} ({sorted_apis})") if api_version in api_versions: self.version = api_version self._api_version = f"/{api_version}" @@ -266,13 +406,13 @@ def reload_caches(self, force: bool = False, save: bool = False): return else: logger.warning(f"no value for {RUNNING_TIME}") - self.all_datarefs = Cache(self) - self.all_datarefs.load("/datarefs") + self.all_datarefs = DatarefCache(self) + self.all_datarefs.load() if save: self.all_datarefs.save("webapi-datarefs.json") - self.all_commands = Cache(self) + self.all_commands = CommandCache(self) if self.version == "v2": # > - self.all_commands.load("/commands") + self.all_commands.load() if save: self.all_commands.save("webapi-commands.json") currtime = self._running_time.value @@ -285,7 +425,8 @@ def reload_caches(self, force: bool = False, save: bool = False): if self._use_cache: logger.info("using caches") logger.info( - f"dataref cache ({self.all_datarefs.count}) and command cache ({self.all_commands.count}) reloaded, sim uptime {str(timedelta(seconds=int(self.uptime)))}" + f"dataref cache ({self.all_datarefs.count}) and command cache ({self.all_commands.count})" + f" reloaded, sim uptime {str(timedelta(seconds=int(self.uptime)))}" ) def invalidate_caches(self): @@ -297,7 +438,8 @@ def invalidate_caches(self): def rebuild_dataref_ids(self): """Rebuild dataref idenfier index""" if len(self._dataref_by_id) > 0: - if self.all_datarefs.has_data: + adr = self.all_datarefs + if adr is not None and adr.has_data: newdict = dict() for d in self._dataref_by_id.values(): if type(d) is Dataref: @@ -341,28 +483,41 @@ def get_rest_meta(self, obj: Dataref | Command, force: bool = False) -> DatarefM metadata = respjson[REST_KW.DATA.value] if len(metadata) > 0: m0 = metadata[0] - obj._cached_meta = Cache.meta(**m0) - return obj._cached_meta + if isinstance(obj, Dataref): + m = DatarefCache.meta(**m0) + obj._cached_meta = m + else: + m = CommandCache.meta(**m0) + obj._cached_meta = m + return m logger.error(f"{obj_type} {obj.path} could not get meta data through REST API") return None def get_dataref_meta_by_name(self, path: str) -> DatarefMeta | None: """Get dataref meta data by dataref name""" - return self.all_datarefs.get_by_name(path) if self.all_datarefs is not None else None + if self.all_datarefs is not None: + return self.all_datarefs.get_by_name(path) + return None def get_dataref_meta_by_id(self, ident: int) -> DatarefMeta | None: """Get dataref meta data by dataref identifier""" - return self.all_datarefs.get_by_id(ident) if self.all_datarefs is not None else None + if self.all_datarefs is not None: + return self.all_datarefs.get_by_id(ident) + return None def get_command_meta_by_name(self, path: str) -> CommandMeta | None: """Get command meta data by command path""" - return self.all_commands.get_by_name(path) if self.all_commands is not None else None + if self.all_commands is not None: + return self.all_commands.get_by_name(path) + return None def get_command_meta_by_id(self, ident: int) -> CommandMeta | None: """Get command meta data by command identifier""" - return self.all_commands.get_by_id(ident) if self.all_commands is not None else None + if self.all_commands is not None: + return self.all_commands.get_by_id(ident) + return None - def write_dataref(self, dataref: Dataref) -> bool | int: + def write_dataref(self, dataref: Dataref) -> APIResult: """Write single dataref value through REST API Returns: @@ -396,11 +551,11 @@ def write_dataref(self, dataref: Dataref) -> bool | int: data = response.json() logger.debug(f"result: {data}") return True - webapi_logger.info(f"ERROR {dataref.path}: {response} {response.reason} {response.text}") - logger.error(f"rest_write: {response} {response.reason} {response.text}") + webapi_logger.info(f"ERROR {dataref.path}: {response} {response.reason_phrase} {response.text}") + logger.error(f"rest_write: {response} {response.reason_phrase} {response.text}") return False - def execute_command(self, command: Command, duration: float = 0.0) -> bool | int: + def execute_command(self, command: Command, duration: float = 0.0) -> APIResult: """Executes Command through REST API Returns: @@ -424,11 +579,11 @@ def execute_command(self, command: Command, duration: float = 0.0) -> bool | int if response.status_code == 200: logger.debug(f"result: {data}") return True - webapi_logger.info(f"ERROR {command.path}: {response} {response.reason} {response.text}") + webapi_logger.info(f"ERROR {command.path}: {response} {response.reason_phrase} {response.text}") logger.error(f"rest_execute: {response}, {data}") return False - def dataref_value(self, dataref: Dataref, raw: bool = False, no_decode: bool = False) -> DatarefValueType | None: + def dataref_value(self, dataref: Dataref, raw: bool = False, no_decode: bool = False) -> DatarefReadResult: """Get dataref value through REST API Value is not stored or cached. @@ -445,21 +600,27 @@ def dataref_value(self, dataref: Dataref, raw: bool = False, no_decode: bool = F if response.status_code == 200: respjson = response.json() webapi_logger.info(f"GET {dataref.path}: {url} = {respjson}") - if not raw and REST_KW.DATA.value in respjson and type(respjson[REST_KW.DATA.value]) in [bytes, str]: + if ( + not raw + and not no_decode + and dataref.value_type == DATAREF_DATATYPE.DATA.value + and REST_KW.DATA.value in respjson + and type(respjson[REST_KW.DATA.value]) in [bytes, str] + ): try: return base64.b64decode(respjson[REST_KW.DATA.value]) - except: - logger.warning(f"cannot decode: {response} {response.reason} {response.text}", exc_info=True) + except Exception: + logger.warning(f"cannot decode: {response} {response.reason_phrase} {response.text}", exc_info=True) return respjson[REST_KW.DATA.value] return respjson[REST_KW.DATA.value] - webapi_logger.info(f"ERROR {dataref.path}: {response} {response.reason} {response.text}") - logger.error(f"dataref_value: {response} {response.reason} {response.text}") + webapi_logger.info(f"ERROR {dataref.path}: {response} {response.reason_phrase} {response.text}") + logger.error(f"dataref_value: {response} {response.reason_phrase} {response.text}") return None - def dataref_meta(self, dataref, fields: List[str] | str = "all") -> DatarefMeta | None: + def dataref_meta(self, dataref, fields: list[str] | str = "all") -> DatarefMeta | None: """Get dataref meta data through REST API - @todo: dataref_meta(self, dataref, fields:List[str]|str = "all") # fields={id, name, value_type, all} + @todo: dataref_meta(self, dataref, fields:list[str]|str = "all") # fields={id, name, value_type, all} """ url = f"{self.rest_url}/datarefs/filter[name]={dataref.path}" if fields != "all": @@ -471,21 +632,20 @@ def dataref_meta(self, dataref, fields: List[str] | str = "all") -> DatarefMeta webapi_logger.info(f"GET {dataref.path}: {url} = {respjson}") data = respjson[REST_KW.DATA.value] try: - ret = Cache.meta(**data[0]) if type(data) is list and len(data) > 0 else Cache.meta(**data) - return ret - except: + return DatarefCache.meta(**data[0]) if type(data) is list and len(data) > 0 else DatarefCache.meta(**data) + except Exception: logger.warning(f"dataref meta invalid {data}", exc_info=True) return None - webapi_logger.info(f"ERROR {dataref.path}: {response} {response.reason} {response.text}") - logger.error(f"dataref_value: {response} {response.reason} {response.text}") + webapi_logger.info(f"ERROR {dataref.path}: {response} {response.reason_phrase} {response.text}") + logger.error(f"dataref_value: {response} {response.reason_phrase} {response.text}") return None # Meta data collection for one or more datarefs or commands # - def datarefs_meta(self, datarefs: List[Dataref], fields: List[str] | str = "all", start: int | None = None, limit: int | None = None) -> List[DatarefMeta]: + def datarefs_meta(self, datarefs: list[Dataref], fields: list[str] | str = "all", start: int | None = None, limit: int | None = None) -> list[DatarefMeta]: """Get dataref meta data through REST API for all dataref supplied - @todo: datarefs_meta(self, dataref, fields:List[str]|str = "all", start: int|None = None, limit: int|None = None) # fields={id, name, value_type, all} + @todo: datarefs_meta(self, dataref, fields:list[str]|str = "all", start: int|None = None, limit: int|None = None) # fields={id, name, value_type, all} """ payload = "&".join([f"filter[name]={d.path}" for d in datarefs]) if fields != "all": @@ -502,19 +662,18 @@ def datarefs_meta(self, datarefs: List[Dataref], fields: List[str] | str = "all" webapi_logger.info(f"GET {payload}: {url} = {respjson}") data = respjson[REST_KW.DATA.value] try: - ret = [Cache.meta(**m) for m in data] - return ret - except: + return [DatarefCache.meta(**m) for m in data] + except Exception: logger.warning(f"dataref meta invalid {data}", exc_info=True) return [] - webapi_logger.info(f"ERROR {payload}: {response} {response.reason} {response.text}") - logger.error(f"datarefs_meta: {response} {response.reason} {response.text}") + webapi_logger.info(f"ERROR {payload}: {response} {response.reason_phrase} {response.text}") + logger.error(f"datarefs_meta: {response} {response.reason_phrase} {response.text}") return [] - def commands_meta(self, commands: List[Command], fields: List[str] | str = "all", start: int | None = None, limit: int | None = None) -> List[CommandMeta]: + def commands_meta(self, commands: list[Command], fields: list[str] | str = "all", start: int | None = None, limit: int | None = None) -> list[CommandMeta]: """Get dataref meta data through REST API for all dataref supplied - @todo: commands_meta(self, dataref, fields:List[str]|str = "all", start: int|None = None, limit: int|None = None) # fields={id, name, description, all} + @todo: commands_meta(self, dataref, fields:list[str]|str = "all", start: int|None = None, limit: int|None = None) # fields={id, name, description, all} """ payload = "&".join([f"filter[name]={c.path}" for c in commands]) if fields != "all": @@ -531,22 +690,18 @@ def commands_meta(self, commands: List[Command], fields: List[str] | str = "all" webapi_logger.info(f"GET {payload}: {url} = {respjson}") data = respjson[REST_KW.DATA.value] try: - ret = [Cache.meta(**m) for m in data] - return ret - except: + return [CommandCache.meta(**m) for m in data] + except Exception: logger.warning(f"command meta invalid {data}", exc_info=True) return [] - webapi_logger.info(f"ERROR {payload}: {response} {response.reason} {response.text}") - logger.error(f"commands_meta: {response} {response.reason} {response.text}") + webapi_logger.info(f"ERROR {payload}: {response} {response.reason_phrase} {response.text}") + logger.error(f"commands_meta: {response} {response.reason_phrase} {response.text}") return [] - def set_connection_from_beacon_data(self, beacon_data: "BeaconData", same_host: bool, remote_tcp_port: int = PROXY_TCP_PORT): + def set_connection_from_beacon_data(self, beacon_data: BeaconData, same_host: bool, remote_tcp_port: int = PROXY_TCP_PORT): API_TCP_PORT = 8086 XP_MIN_VERSION = 121400 - XP_MIN_VERSION_STR = "12.1.4" - XP_MAX_VERSION = 121499 - XP_MAX_VERSION_STR = "12.1.4" self.use_rest = self.use_rest and not same_host new_host = "127.0.0.1" diff --git a/xpwebapi/retry.py b/xpwebapi/retry.py new file mode 100644 index 0000000..3bc32d2 --- /dev/null +++ b/xpwebapi/retry.py @@ -0,0 +1,44 @@ +"""Retry helpers for transient X-Plane connection probes.""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass +from typing import Callable + + +@dataclass(frozen=True) +class RetryConfig: + """Configuration for exponential backoff retries.""" + + attempts: int = 1 + backoff: float = 0.0 + max_backoff: float = 5.0 + multiplier: float = 2.0 + + def __post_init__(self) -> None: + object.__setattr__(self, "attempts", max(1, int(self.attempts))) + object.__setattr__(self, "backoff", max(0.0, float(self.backoff))) + object.__setattr__(self, "max_backoff", max(0.0, float(self.max_backoff))) + object.__setattr__(self, "multiplier", max(1.0, float(self.multiplier))) + + def delay(self, failure_index: int) -> float: + """Return the delay after a failed attempt.""" + if self.backoff == 0.0: + return 0.0 + return min(self.backoff * (self.multiplier**failure_index), self.max_backoff) + + +def sleep_before_retry(config: RetryConfig, failure_index: int, sleeper: Callable[[float], None] = time.sleep) -> None: + """Sleep for the configured backoff delay if one is configured.""" + delay = config.delay(failure_index) + if delay > 0.0: + sleeper(delay) + + +async def async_sleep_before_retry(config: RetryConfig, failure_index: int) -> None: + """Async equivalent of sleep_before_retry.""" + delay = config.delay(failure_index) + if delay > 0.0: + await asyncio.sleep(delay) diff --git a/xpwebapi/udp.py b/xpwebapi/udp.py index d558167..03365db 100644 --- a/xpwebapi/udp.py +++ b/xpwebapi/udp.py @@ -14,20 +14,21 @@ import threading import platform +from types import TracebackType from time import sleep -from typing import Tuple, Dict, Callable +from typing import Callable, Self -from .api import API, CONNECTION_STATUS, DatarefValueType, Dataref, Command +from .api import API, CONNECTION_STATUS, DatarefReadResult, Dataref, Command from .beacon import BeaconData, BEACON_TIMEOUT -from xpwebapi import beacon +from .exceptions import XPPacketError, XPTimeoutError # local logging logger = logging.getLogger(__name__) # logger.setLevel(logging.DEBUG) -class XPlaneTimeout(Exception): - args = tuple("X-Plane timeout") +class XPlaneTimeout(XPTimeoutError): + pass class XPUDPAPI(API): @@ -42,6 +43,7 @@ def __init__(self, **kwargs): self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.socket.settimeout(10.0) + self._closed = False # self.callbacks = set() @@ -67,9 +69,25 @@ def __init__(self, **kwargs): self.beacon.add_callback(self.beacon_callback) # can only add after API.__init__() call since it creates class attributes def __del__(self): - for i in range(len(self.datarefs)): - self._request_dataref(next(iter(self.datarefs.values())), freq=0) + try: + self.close() + except Exception: + logger.debug("UDP socket cleanup failed", exc_info=True) + + def __enter__(self) -> Self: + return self + + def __exit__(self, _exc_type: type[BaseException] | None, _exc: BaseException | None, _tb: TracebackType | None) -> None: + self.close() + + def close(self) -> None: + """Stop monitored datarefs and close the UDP socket.""" + if self._closed: + return + for dataref in list(self.datarefs.values()): + self._request_dataref(dataref, freq=0) self.socket.close() + self._closed = True @property def connected(self) -> bool: @@ -92,11 +110,8 @@ def simple_connection_probe(self) -> bool: MCAST_PORT = 49707 # XPBeaconMonitor.MCAST_PORT logger.warning("no beacon monitor, cannot test connection") - socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - # open socket for multicast group. - # this socker is for getting the beacon, it can be closed when beacon is found. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) # SO_REUSEPORT? + sock.setsockopt(socket.SOL_SOCKET, getattr(socket, "SO_REUSEPORT"), 1) # SO_REUSEPORT? if platform.system() == "Windows": sock.bind(("", MCAST_PORT)) else: @@ -157,7 +172,7 @@ def execute_callbacks(self, **kwargs) -> bool: for callback in cbs: try: callback(**kwargs) - except: + except Exception: logger.error(f"callback {callback}", exc_info=True) ret = False return ret @@ -185,18 +200,19 @@ def write_dataref(self, dataref: Dataref) -> bool: elif vtype == "bool": message = struct.pack("<5sI500s", cmd, int(dataref.value), string) - assert len(message) == 509 + if len(message) != 509: + raise XPPacketError("invalid DREF packet length", packet_type="DREF", expected=509, actual=len(message)) self.socket.sendto(message, (self.host, self.port)) return True - def dataref_value(self, dataref: Dataref) -> DatarefValueType | None: + def dataref_value(self, dataref: Dataref, raw: bool = False, no_decode: bool = False) -> DatarefReadResult: """Returns Dataref value from simulator Args: dataref (Dataref): Dataref to get the value from Returns: - bool | str | int | float: Value of dataref + DatarefReadResult: Value of dataref. """ all_values = self.read_monitored_dataref_values() if dataref.path in all_values: @@ -204,7 +220,7 @@ def dataref_value(self, dataref: Dataref) -> DatarefValueType | None: return dataref.value return None - def execute_command(self, command: Command, duration: float = 0.0) -> bool | int: + def execute_command(self, command: Command, duration: float = 0.0) -> bool: """Execute command Args: @@ -226,8 +242,8 @@ def _execute_command(self, command: str) -> bool: Returns: bool: [description] """ - message = struct.pack("<4sx500s", b"CMND", command.path.encode("utf-8")) - self.socket.sendto(message, (self.beacon_data["IP"], self.UDP_PORT)) + message = struct.pack("<4sx500s", b"CMND", command.encode("utf-8")) + self.socket.sendto(message, (self.host, self.port)) return True def monitor_dataref(self, dataref: Dataref) -> bool | int: @@ -242,9 +258,12 @@ def monitor_dataref(self, dataref: Dataref) -> bool | int: bool if fails request id if succeeded """ - return self._request_dataref(dataref=dataref.path, freq=1) + ret = self._request_dataref(dataref=dataref.path, freq=1) + if ret is not False: + dataref.inc_monitor() + return ret - def unmonitor_datarefs(self, datarefs: dict, reason: str | None = None) -> Tuple[int | bool, Dict]: + def unmonitor_datarefs(self, datarefs: dict, reason: str | None = None) -> tuple[int | bool, dict]: """Stops monitoring supplied datarefs. [description] @@ -254,9 +273,19 @@ def unmonitor_datarefs(self, datarefs: dict, reason: str | None = None) -> Tuple reason (str | None): Documentation only string to identify call to function. Returns: - Tuple[int | bool, Dict]: [description] + tuple[int | bool, dict]: [description] """ - return self._request_dataref(dataref=dataref.path, freq=0) + ret = True + for dataref in datarefs.values(): + if dataref.monitored_count > 1: + dataref.dec_monitor() + continue + r = self._request_dataref(dataref=dataref.path, freq=0) + if r is not False and dataref.is_monitored: + dataref.dec_monitor() + if r is False: + ret = False + return ret, {} def _request_dataref(self, dataref: str, freq: int | None = None) -> bool | int: """Request X-Plane to send the dataref with a certain frequency. @@ -283,13 +312,14 @@ def _request_dataref(self, dataref: str, freq: int | None = None) -> bool | int: cmd = b"RREF\x00" string = dataref.encode() message = struct.pack("<5sii400s", cmd, freq, idx, string) - assert len(message) == 413 + if len(message) != 413: + raise XPPacketError("invalid RREF packet length", packet_type="RREF", expected=413, actual=len(message)) self.socket.sendto(message, (self.host, self.port)) if self.datarefidx % 100 == 0: sleep(0.2) return True - def read_monitored_dataref_values(self): + def read_monitored_dataref_values(self) -> dict: """Do a single read and populate dataref with values. This function should be called at regular intervals to collect all requested datarefs. @@ -330,10 +360,10 @@ def read_monitored_dataref_values(self): retvalues[self.datarefs[idx]] = value self.execute_callbacks(dataref=self.datarefs[idx], value=value) self.xplaneValues.update(retvalues) - except: + except Exception: if self.status != CONNECTION_STATUS.LISTENING_FOR_DATA: self.status = CONNECTION_STATUS.LISTENING_FOR_DATA - raise XPlaneTimeout + raise XPlaneTimeout("UDP read timeout", host=self.host, port=self.port) return self.xplaneValues @property @@ -346,8 +376,8 @@ def udp_listener(self): self.status = CONNECTION_STATUS.UDP_LISTENER_RUNNING while self.udp_listener_running: try: - data = self.read_monitored_dataref_values() - except: + self.read_monitored_dataref_values() + except Exception: logger.warning("error", exc_info=True) logger.info("..udp listener stopped") @@ -377,7 +407,7 @@ def stop(self): self.udp_lsnr_not_running.set() if self.udp_thread is not None and self.udp_thread.is_alive(): logger.debug("stopping udp listener..") - wait = self.RECEIVE_TIMEOUT + wait = BEACON_TIMEOUT logger.debug(f"..asked to stop udp listener (this may last {wait} secs. for timeout)..") self.udp_thread.join(wait) if self.udp_thread.is_alive(): diff --git a/xpwebapi/ws.py b/xpwebapi/ws.py index 11c7d9c..3eca136 100644 --- a/xpwebapi/ws.py +++ b/xpwebapi/ws.py @@ -8,19 +8,22 @@ import json import time +from collections.abc import Iterable, Mapping from dataclasses import dataclass from datetime import datetime -from typing import Tuple, Dict, Optional, Callable +from typing import Callable, Self, cast from enum import Enum # Packaging is used in Cockpit to check driver versions from packaging.version import Version -from simple_websocket import Client, ConnectionClosed +from websockets.exceptions import ConnectionClosed +from websockets.sync.client import ClientConnection, connect -from .api import CONNECTION_STATUS, DATAREF_DATATYPE, webapi_logger, Dataref, Command +from .api import APIResult, CONNECTION_STATUS, DATAREF_DATATYPE, webapi_logger, Dataref, Command from .rest import REST_KW, XPRestAPI from .beacon import BeaconData +from .retry import sleep_before_retry # local logging logger = logging.getLogger(__name__) @@ -33,6 +36,10 @@ MAX_WARNING_COUNT = 5 +type DatarefBatch = Mapping[str, Dataref] | Iterable[Dataref] +type BulkDatarefValue = Dataref | list[Dataref] +type BulkDatarefBatch = Mapping[int, BulkDatarefValue] + # WEB API RETURN CODES class WS_RESPONSE_TYPE(Enum): @@ -60,9 +67,9 @@ class Request: r_id: int # Request id body: dict # Request body ts: datetime # timestamp of submission - ts_ack: Optional[datetime] = None # timestamp of reception - success: Optional[bool] = None # sucess of request, None if no feedback yet - error: Optional[str] = None # error message, if any + ts_ack: datetime | None = None # timestamp of reception + success: bool | None = None # sucess of request, None if no feedback yet + error: str | None = None # error message, if any def now() -> datetime: @@ -77,7 +84,8 @@ class XPWebsocketAPI(XPRestAPI): The XPWebsocketAPI is a client interface to X-Plane Web API, Websocket server. - The XPWebsocketAPI has a _connection monitor_ (XPWebsocketAPI.connection_monitor) that can be started (XPWebsocketAPI.connect) and stopped (XPWebsocketAPI.disconnect). + The XPWebsocketAPI has a _connection monitor_ (XPWebsocketAPI.connection_monitor) that can be started + (XPWebsocketAPI.connect) and stopped (XPWebsocketAPI.disconnect). The monitor tests for REST API reachability, and if reachable, creates a Websocket. If the websocket exists and is opened, requests can be made through it and responses expected. @@ -92,16 +100,36 @@ class XPWebsocketAPI(XPRestAPI): RECEIVE_TIMEOUT = 5 # seconds, assumes no awser if no message recevied withing that timeout BEACON_TIMEOUT = 60 # seconds, if no beacon for 60 seconds, stops to release resources - def __init__(self, host: str = "127.0.0.1", port: int = 8086, api: str = "api", api_version: str = "v2", use_rest: bool = False): + def __init__( + self, + host: str = "127.0.0.1", + port: int = 8086, + api: str = "api", + api_version: str = "v2", + use_rest: bool = False, + retry_attempts: int = 1, + retry_backoff: float = 0.0, + retry_backoff_max: float = 5.0, + ): # Open a UDP Socket to receive on Port 49000 - XPRestAPI.__init__(self, host=host, port=port, api=api, api_version=api_version, use_cache=True) + XPRestAPI.__init__( + self, + host=host, + port=port, + api=api, + api_version=api_version, + use_cache=True, + retry_attempts=retry_attempts, + retry_backoff=retry_backoff, + retry_backoff_max=retry_backoff_max, + ) self.use_rest = use_rest # setter in API hostname = socket.gethostname() self.local_ip = socket.gethostbyname(hostname) - self.ws: Client | None = None # None = no connection + self.ws: ClientConnection | None = None # None = no connection self.ws_lsnr_not_running = threading.Event() self.ws_lsnr_not_running.set() # means it is off self.ws_thread = None @@ -131,6 +159,10 @@ def __init__(self, host: str = "127.0.0.1", port: int = 8086, api: str = "api", self._on_request_feedback ) # Called on command request feedback, for each indivudua feedback, prototype: `func(request_id:int, payload: dict)` + def __enter__(self) -> Self: + self.connect() + return self + @property def ws_url(self) -> str: """URL for the Websocket API""" @@ -173,7 +205,7 @@ def execute_callbacks(self, cbtype: CALLBACK_TYPE, **kwargs) -> bool: try: self.inc("callback_" + cbtype.value) callback(**kwargs) - except: + except Exception: logger.error(f"callback {callback}", exc_info=True) ret = False return ret @@ -233,22 +265,25 @@ def connect_websocket(self): if self.ws is None: url = self.ws_url if url is not None: - try: - if self.rest_api_reachable: - self.ws = Client.connect(url) - self.status = CONNECTION_STATUS.WEBSOCKET_CONNNECTED - self.reload_caches() - logger.info(f"websocket opened at {url}") - self.execute_callbacks(CALLBACK_TYPE.ON_OPEN) - else: + for attempt in range(self.retry_config.attempts): + try: + if self.rest_api_reachable: + self.ws = connect(url, proxy=None) + self.status = CONNECTION_STATUS.WEBSOCKET_CONNNECTED + self.reload_caches() + logger.info(f"websocket opened at {url}") + self.execute_callbacks(CALLBACK_TYPE.ON_OPEN) + return if self._unreach_count <= MAX_WARNING_COUNT: last_warning = " (last warning)" if self._unreach_count == MAX_WARNING_COUNT else "" logger.warning(f"rest api unreachable{last_warning}") if self._unreach_count % 50 == 0: logger.warning("rest api unreachable") self._unreach_count = self._unreach_count + 1 - except: - logger.error("cannot connect", exc_info=True) + except Exception: + logger.error("cannot connect", exc_info=True) + if attempt < self.retry_config.attempts - 1: + sleep_before_retry(self.retry_config, attempt) else: logger.warning(f"web socket url is none {url}") else: @@ -260,7 +295,7 @@ def disconnect_websocket(self, silent: bool = False): self.ws.close() self.ws = None self.status = CONNECTION_STATUS.WEBSOCKET_DISCONNNECTED - dummy = super().connected # set REST API reachability status + super().connected if not silent: logger.info("websocket closed") self.execute_callbacks(CALLBACK_TYPE.ON_CLOSE) @@ -322,7 +357,7 @@ def connection_monitor(self): if number_of_timeouts >= MAX_TIMEOUT_COUNT and to_count % WARN_FREQ == 0: logger.error(f"..X-Plane instance not found on local network.. ({now().strftime('%H:%M:%S')})") to_count = to_count + 1 - except: + except Exception: logger.error(f"..X-Plane instance not found on local network.. ({now().strftime('%H:%M:%S')})", exc_info=True) # If still no connection (above attempt failed) # we wait before trying again @@ -380,6 +415,11 @@ def disconnect(self): logger.debug("..not connected") logger.info(f"disconnected.\nXP Web API Statistics: {self._stats}") + def close(self) -> None: + """Disconnect the websocket monitor and close the HTTP session.""" + self.disconnect() + super().close() + # ################################ # I/O # @@ -405,13 +445,20 @@ def send(self, payload: dict, mapping: dict = {}) -> int | bool: payload[REST_KW.REQID.value] = req_id self._requests[req_id] = Request(r_id=req_id, body=payload, ts=now()) self.inc("send") - self.ws.send(json.dumps(payload)) + if self.ws is not None: + self.ws.send(json.dumps(payload)) webapi_logger.info(f">>SENT {payload}") if len(mapping) > 0: maps = [f"{k}={v}" for k, v in mapping.items()] webapi_logger.info(f">> MAP {', '.join(maps)}") return req_id + def _dataref_batch_values(self, datarefs: DatarefBatch) -> list[Dataref]: + """Normalize supported dataref batch inputs to a concrete list.""" + if isinstance(datarefs, Mapping): + return list(cast(Mapping[str, Dataref], datarefs).values()) + return list(datarefs) + # Dataref operations # # Note: It is not possible get the the value of a dataref just once @@ -442,20 +489,23 @@ def split_dataref_path(path): if meta is None: logger.warning(f"dataref {path} not found in X-Plane datarefs database") return -1 - payload = { + dref_entry: dict = {REST_KW.IDENT.value: meta.ident, REST_KW.VALUE.value: value} + params: dict = {REST_KW.DATAREFS.value: [dref_entry]} + payload: dict = { REST_KW.TYPE.value: "dataref_set_values", - REST_KW.PARAMS.value: {REST_KW.DATAREFS.value: [{REST_KW.IDENT.value: meta.ident, REST_KW.VALUE.value: value}]}, + REST_KW.PARAMS.value: params, } mapping = {meta.ident: meta.name} if split: - payload[REST_KW.PARAMS.value][REST_KW.DATAREFS.value][0][REST_KW.INDEX.value] = index + drefs = params[REST_KW.DATAREFS.value] + drefs[0][REST_KW.INDEX.value] = index return self.send(payload, mapping) - def register_bulk_dataref_value_event(self, datarefs, on: bool = True) -> bool | int: + def register_bulk_dataref_value_event(self, datarefs: BulkDatarefBatch, on: bool = True) -> bool | int: drefs = [] for dataref in datarefs.values(): if type(dataref) is list: - meta = self.get_dataref_meta_by_id(dataref[0].ident) # we modify the global source info, not the local copy in the Dataref() + meta = dataref[0].meta # we modify the global source info, not the local copy in the Dataref() if meta is None: logger.warning(f"cannot register {dataref[0]}, no meta data") continue @@ -477,8 +527,10 @@ def register_bulk_dataref_value_event(self, datarefs, on: bool = True) -> bool | else: if dataref.is_array: logger.debug(f"dataref {dataref.name}: collecting whole array") - drefs.append({REST_KW.IDENT.value: dataref.ident}) - if len(datarefs) > 0: + ident = dataref.ident + if ident is not None: + drefs.append({REST_KW.IDENT.value: ident}) + if len(drefs) > 0: mapping = {} for d in datarefs.values(): if type(d) is list: @@ -488,9 +540,8 @@ def register_bulk_dataref_value_event(self, datarefs, on: bool = True) -> bool | mapping[d.ident] = d.name action = "dataref_subscribe_values" if on else "dataref_unsubscribe_values" return self.send({REST_KW.TYPE.value: action, REST_KW.PARAMS.value: {REST_KW.DATAREFS.value: drefs}}, mapping) - if on: - action = "register" if on else "unregister" - logger.warning(f"no bulk datarefs to {action}") + action = "register" if on else "unregister" + logger.warning(f"no bulk datarefs to {action}") return False # Command operations @@ -627,6 +678,172 @@ def _on_request_feedback(self, request_id: int, payload: dict): else: logger.debug(f"req. {request_id}: {REST_KW.SUCCESS.value if payload[REST_KW.SUCCESS.value] else FAILED}") + def _log_receive_timeout(self, to_count: int) -> None: + TO_COUNT_DEBUG = 10 + TO_COUNT_INFO = 50 + if to_count % TO_COUNT_INFO == 0: + logger.debug(f"..receive timeout ({self.RECEIVE_TIMEOUT} secs.), waiting for response from simulator..") + elif to_count % TO_COUNT_DEBUG == 0: + logger.debug(f"..receive timeout ({self.RECEIVE_TIMEOUT} secs.), waiting for response from simulator..") + + def _mark_first_websocket_message(self, lnow: datetime, start_time: datetime, attention: int) -> None: + logger.info(f"..first message at {lnow.replace(microsecond=0)} ({round((lnow - start_time).seconds, 2)} secs.).. {'<' * attention}") + self.status = CONNECTION_STATUS.RECEIVING_DATA + self.RECEIVE_TIMEOUT = 5 # when connected, check less often, message will arrive + + def _handle_result_response(self, data: dict, lnow: datetime) -> None: + self.inc("response_result") + webapi_logger.info(f"< None: + self.inc("response_command") + if REST_KW.DATA.value not in data: + logger.warning(f"no data: {data}") + return + + for ident, value in data[REST_KW.DATA.value].items(): + meta = self.get_command_meta_by_id(int(ident)) + if meta is not None: + webapi_logger.info(f"CMD : {meta.name}={value}") + self.execute_callbacks(CALLBACK_TYPE.ON_COMMAND_ACTIVE, command=meta.name, active=value) + elif self.all_commands is not None: + logger.warning(f"no command for id={self.all_commands.equiv(ident=int(ident))}") + else: + logger.warning(f"no command for id={ident}") + + def _log_missing_dataref(self, ident: int) -> None: + if self.all_datarefs is not None: + logger.debug( + f"no dataref for id={self.all_datarefs.equiv(ident=ident)} (this may be a previously requested dataref arriving late..., safely ignore)" + ) + else: + logger.debug(f"no dataref for id={ident} (this may be a previously requested dataref arriving late..., safely ignore)") + + def _log_bad_dataref_array_value(self, ident: int, value) -> None: + if self.all_datarefs is not None: + logger.warning(f"dataref array {self.all_datarefs.equiv(ident=ident)} value is not a list ({value}, {type(value)})") + else: + logger.warning(f"dataref array id={ident} value is not a list ({value}, {type(value)})") + + def _log_missing_dataref_array_meta(self, ident: int) -> None: + if self.all_datarefs is not None: + logger.warning(f"dataref array {self.all_datarefs.equiv(ident=ident)} meta data not found") + else: + logger.warning(f"dataref array id={ident} meta data not found") + + def _matching_dataref_array_indices(self, ident: int, meta, value: list): + current_indices = meta.indices + if len(value) == len(current_indices): + return current_indices + + if self.all_datarefs is not None: + logger.warning(f"dataref array {self.all_datarefs.equiv(ident=ident)}: size mismatch ({len(value)} vs {len(current_indices)})") + logger.warning(f"dataref array {self.all_datarefs.equiv(ident=ident)}: value: {value}, indices: {current_indices})") + else: + logger.warning(f"dataref array id={ident}: size mismatch ({len(value)} vs {len(current_indices)})") + logger.warning(f"dataref array id={ident}: value: {value}, indices: {current_indices})") + + last_indices = meta.last_indices() + if len(value) != len(last_indices): + logger.warning("no attempt with previously requested indices, no match") + return None + + logger.warning("attempt with previously requested indices (we have a match)..") + logger.warning(f"dataref array: current value: {value}, previous indices: {last_indices})") + return last_indices + + def _handle_dataref_array_update(self, ident: int, dataref: list, value: list) -> None: + if type(value) is not list: + self._log_bad_dataref_array_value(ident, value) + return + + meta = dataref[0].meta + if meta is None: + self._log_missing_dataref_array_meta(ident) + return + + current_indices = self._matching_dataref_array_indices(ident, meta, value) + if current_indices is None: + return + + for idx, v1 in zip(current_indices, value): + d1 = f"{meta.name}[{idx}]" + if self.changed(d1, v1): + self.inc(d1) + self.execute_callbacks(CALLBACK_TYPE.ON_DATAREF_UPDATE, dataref=d1, value=v1) + else: + self.inc("-" + d1) + + def _handle_dataref_scalar_update(self, dataref: Dataref, value) -> None: + parsed_value = dataref.parse_raw_value(value) + if self.changed(dataref.path, parsed_value): + self.inc(dataref.path) + self.execute_callbacks(CALLBACK_TYPE.ON_DATAREF_UPDATE, dataref=dataref.path, value=parsed_value) + else: + self.inc("-" + dataref.path) + + def _handle_dataref_update_response(self, data: dict) -> None: + self.inc("response_update") + if REST_KW.DATA.value not in data: + logger.warning(f"no data: {data}") + return + + for ident, value in data[REST_KW.DATA.value].items(): + ident = int(ident) + dataref = self._dataref_by_id.get(ident) + if dataref is None: + self._log_missing_dataref(ident) + continue + + self.inc("update_dataref") + if type(dataref) is list: + self._handle_dataref_array_update(ident, dataref, value) + else: + self._handle_dataref_scalar_update(cast(Dataref, dataref), value) + + def _handle_websocket_message(self, message: str | bytes, lnow: datetime) -> None: + data = {} + try: + data = json.loads(message) + resp_type = data[REST_KW.TYPE.value] + if resp_type == WS_RESPONSE_TYPE.RESULT.value: + self._handle_result_response(data, lnow) + elif resp_type == WS_RESPONSE_TYPE.COMMAND_ACTIVE.value: + self._handle_command_active_response(data) + elif resp_type == WS_RESPONSE_TYPE.DATAREF_UPDATE.value: + self._handle_dataref_update_response(data) + else: + logger.warning(f"invalid response type {resp_type}: {data}") + except Exception: + logger.warning(f"decode data {data} failed", exc_info=True) + + def _handle_websocket_closed(self) -> None: + logger.warning("websocket connection closed") + self.ws = None + self.ws_lsnr_not_running.set() + self.status = CONNECTION_STATUS.WEBSOCKET_DISCONNNECTED # should check rest api reachable + super().connected + self.execute_callbacks(CALLBACK_TYPE.ON_CLOSE) + + def _close_websocket_listener(self) -> None: + if self.ws is not None: # in case we did not receive a ConnectionClosed event + self.ws.close() + self.ws = None + self.status = CONNECTION_STATUS.WEBSOCKET_DISCONNNECTED # should check rest api reachable + super().connected + self.execute_callbacks(CALLBACK_TYPE.ON_CLOSE) + logger.info("..websocket listener terminated") + def ws_listener(self): """Read and decode websocket messages and calls back""" logger.info("starting websocket listener..") @@ -634,8 +851,6 @@ def ws_listener(self): total_reads = 0 attention = 10 to_count = 0 - TO_COUNT_DEBUG = 10 - TO_COUNT_INFO = 50 start_time = now() last_read_ts = start_time total_read_time = 0.0 @@ -645,23 +860,20 @@ def ws_listener(self): while self.websocket_listener_running: try: - message = self.ws.receive(timeout=self.RECEIVE_TIMEOUT) - self.inc("receive_raw") - # probably we don't receive messages because X-Plane has nothing to send... - if message is None: - if to_count % TO_COUNT_INFO == 0: - logger.debug(f"..receive timeout ({self.RECEIVE_TIMEOUT} secs.), waiting for response from simulator..") # at {now()}") - elif to_count % TO_COUNT_DEBUG == 0: - logger.debug(f"..receive timeout ({self.RECEIVE_TIMEOUT} secs.), waiting for response from simulator..") # at {now()}") + if self.ws is None: + continue + try: + message = self.ws.recv(timeout=self.RECEIVE_TIMEOUT) + except TimeoutError: + self._log_receive_timeout(to_count) to_count = to_count + 1 continue + self.inc("receive_raw") self.inc("receive") lnow = now() if total_reads == 0: - logger.info(f"..first message at {lnow.replace(microsecond=0)} ({round((lnow - start_time).seconds, 2)} secs.).. {'<'*attention}") - self.status = CONNECTION_STATUS.RECEIVING_DATA - self.RECEIVE_TIMEOUT = 5 # when connected, check less often, message will arrive + self._mark_first_websocket_message(lnow, start_time, attention) if to_count > 0: logger.debug("..receive ok..") @@ -671,135 +883,15 @@ def ws_listener(self): total_read_time = total_read_time + delta.microseconds / 1000000 last_read_ts = lnow - # Decode response - data = {} - resp_type = "" - try: - data = json.loads(message) - resp_type = data[REST_KW.TYPE.value] - # - # - if resp_type == WS_RESPONSE_TYPE.RESULT.value: - - self.inc("response_result") - webapi_logger.info(f"< bool: @@ -876,28 +968,29 @@ def wait_connection(self): time.sleep(1) logger.debug("..connected") - def monitor_datarefs(self, datarefs: dict, reason: str | None = None) -> Tuple[int | bool, Dict]: + def monitor_datarefs(self, datarefs: DatarefBatch, reason: str | None = None) -> tuple[int | bool, dict]: """Starts monitoring of supplied datarefs. - [description] + Sends a single WebSocket subscribe request for all newly monitored datarefs. Args: - datarefs (dict): {path: Dataref} dictionary of datarefs + datarefs (DatarefBatch): Mapping of {path: Dataref} or iterable of datarefs. reason (str | None): Documentation only string to identify call to function. Returns: - Tuple[int | bool, Dict]: [description] + tuple[int | bool, dict]: Request id or False, and effective datarefs by name. """ + requested = self._dataref_batch_values(datarefs) if not self.connected: - logger.debug(f"would add {datarefs.keys()}") + logger.debug(f"would add {[d.name for d in requested]}") return (False, {}) - if len(datarefs) == 0: + if len(requested) == 0: logger.debug("no dataref to add") return (False, {}) # Add those to monitor bulk = {} effectives = {} - for d in datarefs.values(): + for d in requested: if not d.is_monitored: ident = d.ident if ident is not None: @@ -926,28 +1019,29 @@ def monitor_datarefs(self, datarefs: dict, reason: str | None = None) -> Tuple[i logger.debug("no dataref to add") return ret, effectives - def unmonitor_datarefs(self, datarefs: dict, reason: str | None = None) -> Tuple[int | bool, Dict]: + def unmonitor_datarefs(self, datarefs: DatarefBatch, reason: str | None = None) -> tuple[int | bool, dict]: """Stops monitoring supplied datarefs. - [description] + Sends a single WebSocket unsubscribe request for all datarefs whose monitor count reaches zero. Args: - datarefs (dict): {path: Dataref} dictionary of datarefs + datarefs (DatarefBatch): Mapping of {path: Dataref} or iterable of datarefs. reason (str | None): Documentation only string to identify call to function. Returns: - Tuple[int | bool, Dict]: [description] + tuple[int | bool, dict]: Request id or False, and effective datarefs by name. """ + requested = self._dataref_batch_values(datarefs) if not self.connected: - logger.debug(f"would remove {datarefs.keys()}") + logger.debug(f"would remove {[d.name for d in requested]}") return (False, {}) - if len(datarefs) == 0: + if len(requested) == 0: logger.debug("no variable to remove") return (False, {}) # Add those to monitor bulk = {} effectives = {} - for d in datarefs.values(): + for d in requested: if d.is_monitored: effectives[d.name] = d if not d.dec_monitor(): # will be decreased by 1 in super().remove_simulator_variable_to_monitor() @@ -971,7 +1065,10 @@ def unmonitor_datarefs(self, datarefs: dict, reason: str | None = None) -> Tuple if i in self._dataref_by_id: del self._dataref_by_id[i] else: - logger.warning(f"no dataref for id={self.all_datarefs.equiv(ident=i)}") + if self.all_datarefs is not None: + logger.warning(f"no dataref for id={self.all_datarefs.equiv(ident=i)}") + else: + logger.warning(f"no dataref for id={i}") dlist = [] for d in bulk.values(): if type(d) is list: @@ -1015,7 +1112,7 @@ def unmonitor_dataref(self, dataref: Dataref) -> bool | int: ret = self.unmonitor_datarefs(datarefs={dataref.path: dataref}, reason="unmonitor_dataref") return ret[0] - def write_dataref(self, dataref: Dataref) -> bool | int: + def write_dataref(self, dataref: Dataref) -> APIResult: """Writes dataref value to simulator. Writing is done through REST API if use_rest is True, or Websocket API if use_rest is False and Websocket is opened. @@ -1061,7 +1158,7 @@ def unmonitor_command_active(self, command: Command) -> bool | int: # # deprecated, name is too common, too simple # return self.execute_command(command=command, duration=duration) - def execute_command(self, command: Command, duration: float = 0.0) -> bool | int: + def execute_command(self, command: Command, duration: float = 0.0) -> APIResult: """Execute command in simulator. Execution is done through REST API if use_rest is True, or Websocket API if use_rest is False and Websocket is opened. From 79598523b0b6b82273cacda9ea4a66f8ccff42a3 Mon Sep 17 00:00:00 2001 From: Jeffry Babb Date: Sat, 27 Jun 2026 08:57:52 -0500 Subject: [PATCH 2/6] fix: address async dataref autosave review --- README.md | 6 ++++-- tests/test_async_rest.py | 8 ++++++++ tests/test_documentation.py | 12 ++++++++++++ xpwebapi/async_rest.py | 2 ++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2787271..9d363a6 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,11 @@ See [X-Plane Web API](https://developer.x-plane.com/article/x-plane-web-api/). pip install 'xpwebapi @ git+https://github.com/devleaks/xplane-webapi.git' ``` -For development, add option `dev`: +For development, clone the repository and sync the development dependency group: ```sh -pip install 'xpwebapi[dev] @ git+https://github.com/devleaks/xplane-webapi.git' +git clone https://github.com/devleaks/xplane-webapi.git +cd xplane-webapi +uv sync ``` diff --git a/tests/test_async_rest.py b/tests/test_async_rest.py index f25385f..9782190 100644 --- a/tests/test_async_rest.py +++ b/tests/test_async_rest.py @@ -327,6 +327,14 @@ async def test_execute_command_returns_false_for_error_response(self): class TestAsyncXPRestAPIExports(AsyncRestAPITestCase): + async def test_dataref_rejects_auto_save_for_async_api(self): + api = self.make_api() + + with self.assertRaises(ValueError) as caught: + api.dataref("sim/test/value", auto_save=True) + + self.assertEqual(str(caught.exception), "auto_save is not supported by AsyncXPRestAPI") + async def test_package_factory_returns_async_rest_api(self): api = xpwebapi.async_rest_api() try: diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 442a7b9..129165e 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -4,6 +4,7 @@ import ast from pathlib import Path +import tomllib import unittest @@ -43,6 +44,17 @@ def test_examples_have_function_annotations(self) -> None: class TestDocumentationContent(unittest.TestCase): + def test_readme_development_install_matches_project_metadata(self) -> None: + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + dev_extra = pyproject.get("project", {}).get("optional-dependencies", {}).get("dev") + + if dev_extra is None: + self.assertNotIn("xpwebapi[dev]", readme) + self.assertIn("uv sync", readme) + else: + self.assertIn("xpwebapi[dev]", readme) + def test_usage_docs_include_required_patterns(self) -> None: usage = (DOCS_DIR / "usage" / "index.md").read_text(encoding="utf-8") diff --git a/xpwebapi/async_rest.py b/xpwebapi/async_rest.py index 53b6d31..7694bc2 100644 --- a/xpwebapi/async_rest.py +++ b/xpwebapi/async_rest.py @@ -160,6 +160,8 @@ def _url(self, protocol: str) -> str: def dataref(self, path: str, auto_save: bool = False) -> Dataref: """Create a Dataref bound to this async API.""" + if auto_save: + raise ValueError("auto_save is not supported by AsyncXPRestAPI") return Dataref(path=path, api=cast(API, self), auto_save=auto_save) def command(self, path: str) -> Command: From 4e881f82da6bf8f017948d0caea6cc42b753d145 Mon Sep 17 00:00:00 2001 From: Jeffry Babb Date: Sat, 27 Jun 2026 09:02:46 -0500 Subject: [PATCH 3/6] fix: avoid websocket ghost requests --- tests/test_ws.py | 16 ++++++++++++++++ xpwebapi/ws.py | 7 +++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/test_ws.py b/tests/test_ws.py index 14a162b..99dea39 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -71,6 +71,22 @@ def test_send_returns_false_for_empty_payload(self): self.assertFalse(api.send({})) api.ws.send.assert_not_called() + def test_send_returns_false_without_allocating_request_when_socket_disappears(self): + api = self.make_api() + websocket = api.ws + + def disconnect_during_check(): + api.ws = None + return True + + with patch.object(XPWebsocketAPI, "connected", new_callable=PropertyMock, side_effect=disconnect_during_check): + with patch("xpwebapi.ws.logger.warning"): + self.assertFalse(api.send({"type": "test"})) + + self.assertEqual(api.req_number, 0) + self.assertEqual(api._requests, {}) + websocket.send.assert_not_called() + class TestXPWebsocketAPIConnect(WebsocketAPITestCase): @patch("xpwebapi.ws.connect") diff --git a/xpwebapi/ws.py b/xpwebapi/ws.py index 3eca136..2af20e7 100644 --- a/xpwebapi/ws.py +++ b/xpwebapi/ws.py @@ -441,12 +441,15 @@ def send(self, payload: dict, mapping: dict = {}) -> int | bool: if payload is None or len(payload) == 0: logger.warning("no payload") return False + websocket = self.ws + if websocket is None: + logger.warning("not connected") + return False req_id = self.next_req payload[REST_KW.REQID.value] = req_id self._requests[req_id] = Request(r_id=req_id, body=payload, ts=now()) self.inc("send") - if self.ws is not None: - self.ws.send(json.dumps(payload)) + websocket.send(json.dumps(payload)) webapi_logger.info(f">>SENT {payload}") if len(mapping) > 0: maps = [f"{k}={v}" for k, v in mapping.items()] From 0cbe1ba240bc3506baf12ffe1e1e4191d0540882 Mon Sep 17 00:00:00 2001 From: Jeffry Babb Date: Sat, 27 Jun 2026 09:08:05 -0500 Subject: [PATCH 4/6] fix: avoid websocket listener busy wait --- tests/test_ws.py | 11 +++++++++++ xpwebapi/ws.py | 1 + 2 files changed, 12 insertions(+) diff --git a/tests/test_ws.py b/tests/test_ws.py index 99dea39..2773b5a 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -282,6 +282,17 @@ def test_ws_listener_handles_connection_closed(self): handle_closed.assert_called_once_with() + def test_ws_listener_sleeps_when_socket_missing(self): + api = self.make_api() + api.ws = None + + states = [True, False] + with patch.object(XPWebsocketAPI, "websocket_listener_running", new_callable=PropertyMock, side_effect=states): + with patch("xpwebapi.ws.time.sleep") as sleep: + api.ws_listener() + + sleep.assert_called_once_with(0.01) + class TestXPWebsocketAPIMonitoring(WebsocketAPITestCase): def test_monitor_datarefs_accepts_iterable_and_sends_one_subscribe_request(self): diff --git a/xpwebapi/ws.py b/xpwebapi/ws.py index 2af20e7..120e14b 100644 --- a/xpwebapi/ws.py +++ b/xpwebapi/ws.py @@ -864,6 +864,7 @@ def ws_listener(self): while self.websocket_listener_running: try: if self.ws is None: + time.sleep(0.01) continue try: message = self.ws.recv(timeout=self.RECEIVE_TIMEOUT) From 85f37094e370a7a17e3f5e8610ff9e51e53d7e8f Mon Sep 17 00:00:00 2001 From: Jeffry Babb Date: Sat, 27 Jun 2026 09:12:22 -0500 Subject: [PATCH 5/6] fix: guard UDP reuseport probe --- tests/test_udp.py | 19 +++++++++++++++++++ xpwebapi/udp.py | 4 +++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/test_udp.py b/tests/test_udp.py index e1f4eaf..5ca3c48 100644 --- a/tests/test_udp.py +++ b/tests/test_udp.py @@ -4,6 +4,7 @@ from tests.helpers import make_rref_packet from xpwebapi.api import Command, Dataref from xpwebapi.exceptions import XPPacketError +from xpwebapi import udp as udp_module from xpwebapi.udp import XPUDPAPI, XPlaneTimeout @@ -85,6 +86,24 @@ def test_execute_command_ignores_duration_for_udp_packet(self): self.assertIn(b"sim/test/command", message) +class TestXPUDPAPIConnectionProbe(UDPAPITestCase): + def test_simple_connection_probe_skips_reuseport_when_constant_missing(self): + api = self.make_api() + probe_socket = MagicMock() + probe_socket.recvfrom.side_effect = udp_module.socket.timeout + had_reuseport = hasattr(udp_module.socket, "SO_REUSEPORT") + reuseport = getattr(udp_module.socket, "SO_REUSEPORT", None) + if had_reuseport: + delattr(udp_module.socket, "SO_REUSEPORT") + self.addCleanup(setattr, udp_module.socket, "SO_REUSEPORT", reuseport) + + with patch("xpwebapi.udp.socket.socket", return_value=probe_socket): + self.assertFalse(api.simple_connection_probe()) + + for call in probe_socket.setsockopt.call_args_list: + self.assertNotEqual(call.args[0], udp_module.socket.SOL_SOCKET) + + class TestXPUDPAPIReadValues(UDPAPITestCase): def test_read_monitored_dataref_values_decodes_rref_packet(self): api = self.make_api() diff --git a/xpwebapi/udp.py b/xpwebapi/udp.py index 03365db..edfb8cc 100644 --- a/xpwebapi/udp.py +++ b/xpwebapi/udp.py @@ -111,7 +111,9 @@ def simple_connection_probe(self) -> bool: logger.warning("no beacon monitor, cannot test connection") sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - sock.setsockopt(socket.SOL_SOCKET, getattr(socket, "SO_REUSEPORT"), 1) # SO_REUSEPORT? + reuse_port = getattr(socket, "SO_REUSEPORT", None) + if reuse_port is not None: + sock.setsockopt(socket.SOL_SOCKET, reuse_port, 1) # SO_REUSEPORT? if platform.system() == "Windows": sock.bind(("", MCAST_PORT)) else: From 57be36aeaff735bf289afb52fcb4a016b38b4933 Mon Sep 17 00:00:00 2001 From: Jeffry Babb Date: Sat, 27 Jun 2026 09:17:12 -0500 Subject: [PATCH 6/6] fix: guard beacon reuseport setup --- tests/test_beacon.py | 20 ++++++++++++++++++++ xpwebapi/beacon.py | 4 +++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/test_beacon.py b/tests/test_beacon.py index 98e520b..4d20c97 100644 --- a/tests/test_beacon.py +++ b/tests/test_beacon.py @@ -1,3 +1,4 @@ +import importlib import socket import unittest from unittest.mock import MagicMock, patch @@ -6,6 +7,8 @@ from tests.helpers import make_beacon_packet from xpwebapi.beacon import BEACON_MONITOR_STATUS, BeaconData, XPBeaconMonitor, XPlaneNoBeacon, XPlaneVersionNotSupported +beacon_module = importlib.import_module("xpwebapi.beacon") + class BeaconMonitorTestCase(unittest.TestCase): def make_monitor(self): @@ -107,6 +110,23 @@ def test_get_beacon_raises_version_error_for_unsupported_packet(self): with self.assertRaises(XPlaneVersionNotSupported): monitor.get_beacon(timeout=1.0) + def test_get_beacon_skips_reuseport_when_constant_missing(self): + monitor = self.make_monitor() + socket_patch, beacon_socket = self.mock_sockets(socket.timeout("timed out")) + had_reuseport = hasattr(beacon_module.socket, "SO_REUSEPORT") + reuseport = getattr(beacon_module.socket, "SO_REUSEPORT", None) + if had_reuseport: + delattr(beacon_module.socket, "SO_REUSEPORT") + self.addCleanup(setattr, beacon_module.socket, "SO_REUSEPORT", reuseport) + + with socket_patch: + with patch("xpwebapi.beacon.platform.system", return_value="Linux"): + with self.assertRaises(XPlaneNoBeacon): + monitor.get_beacon(timeout=1.0) + + for call in beacon_socket.setsockopt.call_args_list: + self.assertNotEqual(call.args[0], beacon_module.socket.SOL_SOCKET) + class TestXPBeaconMonitorSameHost(BeaconMonitorTestCase): def test_same_host_returns_true_when_beacon_host_is_local(self): diff --git a/xpwebapi/beacon.py b/xpwebapi/beacon.py index b348355..3f1d662 100644 --- a/xpwebapi/beacon.py +++ b/xpwebapi/beacon.py @@ -265,7 +265,9 @@ class BeaconData: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(("", self.MCAST_PORT)) else: - sock.setsockopt(socket.SOL_SOCKET, getattr(socket, "SO_REUSEPORT"), 1) + reuse_port = getattr(socket, "SO_REUSEPORT", None) + if reuse_port is not None: + sock.setsockopt(socket.SOL_SOCKET, reuse_port, 1) sock.bind((self.MCAST_GRP, self.MCAST_PORT)) mreq = struct.pack("=4sl", socket.inet_aton(self.MCAST_GRP), socket.INADDR_ANY) sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)