Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ jobs:
include-hidden-files: true

mypy:
if: false # disables the job --> "Code is not up to par for mypy, skipping"
runs-on: ubuntu-latest
name: Run mypy
needs:
Expand Down Expand Up @@ -135,7 +134,7 @@ jobs:
needs:
- ruff
- pytest
# - mypy
- mypy
steps:
- name: Check out committed code
uses: actions/checkout@v4
Expand Down
9 changes: 9 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,12 @@ repos:
rev: v0.45.0
hooks:
- id: markdownlint
- repo: local
hooks:
- id: mypy
name: mypy
entry: script/run-in-env.sh mypy
language: script
require_serial: true
types_or: [python, pyi]
files: ^(airos|tests|scripts)/.+\.(py|pyi)$
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

All notable changes to this project will be documented in this file.

## [0.2.9] - 2025-08-12
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Add/verify compare-link reference for [0.2.9] at the bottom of the file.
Keeps bracketed version tags clickable and consistent with common changelog practice.

Run this to check if a compare link is present; if missing, add one like “[0.2.9]: https://github.com///compare/v0.2.8...v0.2.9”:


🏁 Script executed:

#!/bin/bash
# Verify that a compare link for [0.2.9] exists somewhere in the repo (typically at the end of CHANGELOG.md)
rg -n $'^\\[0\\.2\\.9\\]:' || echo "Missing [0.2.9] compare link reference"

Length of output: 110


I’ll fetch the repo’s origin URL and the existing [0.2.8] link so we can craft the exact compare URL for [0.2.9].


🏁 Script executed:

#!/bin/bash
set -e
# Get the GitHub org/repo from origin
remote_url=$(git remote get-url origin)
echo "Remote URL: $remote_url"

# Show the existing [0.2.8] compare link
echo "Existing [0.2.8] compare link reference:"
rg -n '^\[0\.2\.8\]:' CHANGELOG.md || echo "Missing [0.2.8] compare link reference"

Length of output: 436


Add compare-link reference for [0.2.9] in CHANGELOG.md

Please append the following line at the bottom of CHANGELOG.md to enable clickable version tags:

+[0.2.9]: https://github.com/CoMPaTech/python-airos/compare/v0.2.8...v0.2.9

• File: CHANGELOG.md
• Location: end of file

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## [0.2.9] - 2025-08-12
[0.2.9]: https://github.com/CoMPaTech/python-airos/compare/v0.2.8...v0.2.9
🤖 Prompt for AI Agents
In CHANGELOG.md around line 5 (end of file), add a Markdown reference link for
the new release tag [0.2.9] so the version header becomes clickable; append a
single line of the form "[0.2.9]:
https://github.com/<owner>/<repo>/compare/v0.2.8...v0.2.9" (replace
<owner>/<repo> and previous tag as needed) as the last line of the file.


### Changed

- Bug Fixes
- More consistent error handling across login, status, stakick, and provmode; login now returns False when an auth token is missing. Improved discovery transport setup and resilience.
- Refactor
- Tightened type hints and clarified method signatures for predictable return types and safer usage.
- Tests
- Substantially expanded coverage, especially for discovery edge cases and error paths.
- Chores
- Enabled type checking in CI and gated coverage on it; added pre-commit hook and supporting environment script; updated test dependencies.
Comment on lines +7 to +16
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Restructure section to follow Keep a Changelog categories (avoid nesting under “Changed”).
Use top-level sections like Fixed/Changed/Tests/Chore; split combined items; fix “provmode” naming; format identifiers as code.

Apply this diff:

-### Changed
-
-- Bug Fixes
-  - More consistent error handling across login, status, stakick, and provmode; login now returns False when an auth token is missing. Improved discovery transport setup and resilience.
-- Refactor
-  - Tightened type hints and clarified method signatures for predictable return types and safer usage.
-- Tests
-  - Substantially expanded coverage, especially for discovery edge cases and error paths.
-- Chores
-  - Enabled type checking in CI and gated coverage on it; added pre-commit hook and supporting environment script; updated test dependencies.
+### Fixed
+- More consistent error handling across `login`, `status`, `stakick`, and provisioning mode; `login` now returns `False` when an auth token is missing.
+- Improved discovery transport setup and resilience.
+
+### Changed
+- Tightened type hints and clarified method signatures for predictable return types and safer usage.
+
+### Tests
+- Substantially expanded coverage, especially for discovery edge cases and error paths.
+
+### Chore
+- Enabled type checking in CI and gated coverage on it.
+- Added pre-commit hook and supporting environment script.
+- Updated test dependencies.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Changed
- Bug Fixes
- More consistent error handling across login, status, stakick, and provmode; login now returns False when an auth token is missing. Improved discovery transport setup and resilience.
- Refactor
- Tightened type hints and clarified method signatures for predictable return types and safer usage.
- Tests
- Substantially expanded coverage, especially for discovery edge cases and error paths.
- Chores
- Enabled type checking in CI and gated coverage on it; added pre-commit hook and supporting environment script; updated test dependencies.
### Fixed
- More consistent error handling across `login`, `status`, `stakick`, and provisioning mode; `login` now returns `False` when an auth token is missing.
- Improved discovery transport setup and resilience.
### Changed
- Tightened type hints and clarified method signatures for predictable return types and safer usage.
### Tests
- Substantially expanded coverage, especially for discovery edge cases and error paths.
### Chore
- Enabled type checking in CI and gated coverage on it.
- Added pre-commit hook and supporting environment script.
- Updated test dependencies.
🤖 Prompt for AI Agents
In CHANGELOG.md around lines 7 to 16, the release notes currently nest multiple
items under a single "Changed" header and use inconsistent naming/formatting
(e.g., "provmode" and plain identifiers); restructure the section to follow Keep
a Changelog by creating top-level headers such as "Fixed", "Changed", "Tests",
and "Chores", split the combined bullet points into these appropriate top-level
sections, correct the name "provmode" to `prov-mode`, and format identifiers
like `login`, `status`, `stakick`, `auth token`, and `prov-mode` as inline code;
ensure each bullet is placed under the correct header and remove the nested "Bug
Fixes" subheading so entries are directly under their respective top-level
categories.


## [0.2.8] - 2025-08-12

### Changed
Expand Down
24 changes: 11 additions & 13 deletions airos/airos8.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def __init__(
self._status_cgi_url = f"{self.base_url}/status.cgi" # AirOS 8
self._stakick_cgi_url = f"{self.base_url}/stakick.cgi" # AirOS 8
self._provmode_url = f"{self.base_url}/api/provmode" # AirOS 8
self.current_csrf_token = None
self.current_csrf_token: str | None = None

self._use_json_for_login_post = False

Expand Down Expand Up @@ -87,7 +87,7 @@ async def login(self) -> bool:

login_request_headers = {**self._common_headers}

post_data = None
post_data: dict[str, str] | str | None = None
if self._use_json_for_login_post:
login_request_headers["Content-Type"] = "application/json"
post_data = json.dumps(login_payload)
Expand All @@ -114,7 +114,7 @@ async def login(self) -> bool:
# If the AIROS_ cookie was parsed but isn't automatically added to the jar, add it manually
if (
morsel.key.startswith("AIROS_")
and morsel.key not in self.session.cookie_jar
and morsel.key not in self.session.cookie_jar # type: ignore[operator]
):
# `SimpleCookie`'s Morsel objects are designed to be compatible with cookie jars.
# We need to set the domain if it's missing, otherwise the cookie might not be sent.
Expand Down Expand Up @@ -152,7 +152,7 @@ async def login(self) -> bool:
if new_csrf_token:
self.current_csrf_token = new_csrf_token
else:
return
return False

# Re-check cookies in self.session.cookie_jar AFTER potential manual injection
airos_cookie_found = False
Expand Down Expand Up @@ -186,18 +186,16 @@ async def login(self) -> bool:
log = f"Login failed with status {response.status}. Full Response: {response.text}"
_LOGGER.error(log)
raise AirOSConnectionAuthenticationError from None
except (TimeoutError, aiohttp.client_exceptions.ClientError) as err:
except (TimeoutError, aiohttp.ClientError) as err:
_LOGGER.exception("Error during login")
raise AirOSDeviceConnectionError from err
except asyncio.CancelledError:
_LOGGER.info("Login task was cancelled")
raise

def derived_data(
self, response: dict[str, Any] | None = None
) -> dict[str, Any] | None:
def derived_data(self, response: dict[str, Any] = {}) -> dict[str, Any]:
"""Add derived data to the device response."""
derived = {
derived: dict[str, Any] = {
"station": False,
"access_point": False,
"ptp": False,
Expand Down Expand Up @@ -302,14 +300,14 @@ async def status(self) -> AirOSData:
response_text,
)
raise AirOSDeviceConnectionError
except (TimeoutError, aiohttp.client_exceptions.ClientError) as err:
except (TimeoutError, aiohttp.ClientError) as err:
_LOGGER.exception("Status API call failed: %s", err)
raise AirOSDeviceConnectionError from err
except asyncio.CancelledError:
_LOGGER.info("API status retrieval task was cancelled")
raise

async def stakick(self, mac_address: str = None) -> bool:
async def stakick(self, mac_address: str | None = None) -> bool:
"""Reconnect client station."""
if not self.connected:
_LOGGER.error("Not connected, login first")
Expand Down Expand Up @@ -340,7 +338,7 @@ async def stakick(self, mac_address: str = None) -> bool:
log = f"Unable to restart connection response status {response.status} with {response_text}"
_LOGGER.error(log)
return False
except (TimeoutError, aiohttp.client_exceptions.ClientError) as err:
except (TimeoutError, aiohttp.ClientError) as err:
_LOGGER.exception("Error during call to reconnect remote: %s", err)
raise AirOSDeviceConnectionError from err
except asyncio.CancelledError:
Expand Down Expand Up @@ -379,7 +377,7 @@ async def provmode(self, active: bool = False) -> bool:
log = f"Unable to change provisioning mode response status {response.status} with {response_text}"
_LOGGER.error(log)
return False
except (TimeoutError, aiohttp.client_exceptions.ClientError) as err:
except (TimeoutError, aiohttp.ClientError) as err:
_LOGGER.exception("Error during call to change provisioning mode: %s", err)
raise AirOSDeviceConnectionError from err
except asyncio.CancelledError:
Expand Down
13 changes: 5 additions & 8 deletions airos/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def is_ip_address(value: str) -> bool:
return False


def redact_data_smart(data: dict) -> dict:
def redact_data_smart(data: dict[str, Any]) -> dict[str, Any]:
"""Recursively redacts sensitive keys in a dictionary."""
sensitive_keys = {
"hostname",
Expand All @@ -56,10 +56,7 @@ def redact_data_smart(data: dict) -> dict:
"platform",
}

def _redact(d: dict):
if not isinstance(d, dict):
return d

def _redact(d: dict[str, Any]) -> dict[str, Any]:
redacted_d = {}
for k, v in d.items():
if k in sensitive_keys:
Expand All @@ -73,15 +70,15 @@ def _redact(d: dict):
isinstance(i, str) and is_ip_address(i) for i in v
):
# Redact list of IPs to a dummy list
redacted_d[k] = ["127.0.0.3"]
redacted_d[k] = ["127.0.0.3"] # type: ignore[assignment]
else:
redacted_d[k] = "REDACTED"
elif isinstance(v, dict):
redacted_d[k] = _redact(v)
redacted_d[k] = _redact(v) # type: ignore[assignment]
elif isinstance(v, list):
redacted_d[k] = [
_redact(item) if isinstance(item, dict) else item for item in v
]
] # type: ignore[assignment]
else:
redacted_d[k] = v
return redacted_d
Expand Down
4 changes: 2 additions & 2 deletions airos/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class AirOSDiscoveryProtocol(asyncio.DatagramProtocol):

"""

def __init__(self, callback: Callable[[dict[str, Any]], None]) -> None:
def __init__(self, callback: Callable[[dict[str, Any]], Any]) -> None:
"""Initialize AirOSDiscoveryProtocol.

Args:
Expand All @@ -43,7 +43,7 @@ def __init__(self, callback: Callable[[dict[str, Any]], None]) -> None:
def connection_made(self, transport: asyncio.BaseTransport) -> None:
"""Set up the UDP socket for broadcasting and reusing the address."""
self.transport = transport # type: ignore[assignment] # transport is DatagramTransport
sock: socket.socket = self.transport.get_extra_info("socket")
sock: socket.socket = transport.get_extra_info("socket")
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
log = f"AirOS discovery listener (low-level) started on UDP port {DISCOVERY_PORT}."
Expand Down
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "airos"
version = "0.2.8"
version = "0.2.9"
license = "MIT"
description = "Ubiquity airOS module(s) for Python 3."
readme = "README.md"
Expand Down Expand Up @@ -396,6 +396,12 @@ warn_return_any = true
warn_unreachable = true
exclude = []

[[tool.mypy.overrides]]
module = "tests.*"
ignore_missing_imports = true # You'll likely need this for test-only dependencies
disallow_untyped_decorators = false # The fix for your current errors
check_untyped_defs = false

[tool.coverage.run]
source = [ "airos" ]
omit= [
Expand Down
2 changes: 2 additions & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ aioresponses
aioresponses==0.7.8
aiofiles==24.1.0
radon==6.0.1
types-aiofiles==24.1.0.20250809
mypy==1.17.1
7 changes: 4 additions & 3 deletions script/generate_ha_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@

# NOTE: This assumes the airos module is correctly installed or available in the project path.
# If not, you might need to adjust the import statement.
from airos.airos8 import AirOS, AirOSData # noqa: E402
from airos.airos8 import AirOS # noqa: E402
from airos.data import AirOS8Data as AirOSData # noqa: E402


def generate_airos_fixtures():
def generate_airos_fixtures() -> None:
"""Process all (intended) JSON files from the userdata directory to potential fixtures."""

# Define the paths to the directories
Expand All @@ -44,7 +45,7 @@ def generate_airos_fixtures():
with open(base_fixture_path) as source:
source_data = json.loads(source.read())

derived_data = AirOS.derived_data(None, source_data)
derived_data = AirOS.derived_data(None, source_data) # type: ignore[arg-type]
new_data = AirOSData.from_dict(derived_data)

with open(new_fixture_path, "w") as new:
Expand Down
2 changes: 1 addition & 1 deletion script/mashumaro-step-debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
_LOGGER = logging.getLogger(__name__)


def main():
def main() -> None:
"""Debug data."""
if len(sys.argv) <= 1:
_LOGGER.info("Use with file to check")
Expand Down
32 changes: 32 additions & 0 deletions script/run-in-env.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env sh
set -eu

# Used in venv activate script.
# Would be an error if undefined.
OSTYPE="${OSTYPE-}"

# Activate pyenv and virtualenv if present, then run the specified command

# pyenv, pyenv-virtualenv
if [ -s .python-version ]; then
PYENV_VERSION=$(head -n 1 .python-version)
export PYENV_VERSION
fi

# shellcheck source=/dev/null
if [ -n "${VIRTUAL_ENV-}" ] && [ -f "${VIRTUAL_ENV}/bin/activate" ]; then
# shellcheck source=/dev/null
. "${VIRTUAL_ENV}/bin/activate"
else
# other common virtualenvs
my_path=$(git rev-parse --show-toplevel)

for venv in venv .venv .; do
if [ -f "${my_path}/${venv}/bin/activate" ]; then
. "${my_path}/${venv}/bin/activate"
break
fi
done
fi

exec "$@"
9 changes: 6 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Ubiquity AirOS test fixtures."""

from _collections_abc import AsyncGenerator, Generator
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch

Expand All @@ -11,13 +12,13 @@


@pytest.fixture
def base_url():
def base_url() -> str:
"""Return a testing url."""
return "http://device.local"


@pytest.fixture
async def airos_device(base_url):
async def airos_device(base_url: str) -> AsyncGenerator[AirOS, None]:
"""AirOS device fixture."""
session = aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar())
instance = AirOS(base_url, "username", "password", session, use_ssl=False)
Expand All @@ -26,7 +27,9 @@ async def airos_device(base_url):


@pytest.fixture
def mock_datagram_endpoint():
def mock_datagram_endpoint() -> Generator[
tuple[asyncio.DatagramTransport, AirOSDiscoveryProtocol], None, None
]:
"""Fixture to mock the creation of the UDP datagram endpoint."""
# Define the mock objects FIRST, so they are in scope
mock_transport = MagicMock(spec=asyncio.DatagramTransport)
Expand Down
Loading