Skip to content

Commit df44ddf

Browse files
authored
Merge pull request #60 from CoMPaTech/upd
## 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 more predictable return types. ## Tests Substantially expanded and hardened test coverage, especially for discovery TLV edge cases and error paths. ## Chores Enabled type checking in CI and made coverage depend on it; added a mypy pre-commit hook, an environment helper script, test deps, and bumped version to 0.2.9.
2 parents 2cced00 + 58d50bf commit df44ddf

16 files changed

+341
-112
lines changed

.github/workflows/verify.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,6 @@ jobs:
106106
include-hidden-files: true
107107

108108
mypy:
109-
if: false # disables the job --> "Code is not up to par for mypy, skipping"
110109
runs-on: ubuntu-latest
111110
name: Run mypy
112111
needs:
@@ -135,7 +134,7 @@ jobs:
135134
needs:
136135
- ruff
137136
- pytest
138-
# - mypy
137+
- mypy
139138
steps:
140139
- name: Check out committed code
141140
uses: actions/checkout@v4

.pre-commit-config.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,12 @@ repos:
8080
rev: v0.45.0
8181
hooks:
8282
- id: markdownlint
83+
- repo: local
84+
hooks:
85+
- id: mypy
86+
name: mypy
87+
entry: script/run-in-env.sh mypy
88+
language: script
89+
require_serial: true
90+
types_or: [python, pyi]
91+
files: ^(airos|tests|scripts)/.+\.(py|pyi)$

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

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

5+
## [0.2.9] - 2025-08-12
6+
7+
### Changed
8+
9+
- Bug Fixes
10+
- 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.
11+
- Refactor
12+
- Tightened type hints and clarified method signatures for predictable return types and safer usage.
13+
- Tests
14+
- Substantially expanded coverage, especially for discovery edge cases and error paths.
15+
- Chores
16+
- Enabled type checking in CI and gated coverage on it; added pre-commit hook and supporting environment script; updated test dependencies.
17+
518
## [0.2.8] - 2025-08-12
619

720
### Changed

airos/airos8.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def __init__(
5454
self._status_cgi_url = f"{self.base_url}/status.cgi" # AirOS 8
5555
self._stakick_cgi_url = f"{self.base_url}/stakick.cgi" # AirOS 8
5656
self._provmode_url = f"{self.base_url}/api/provmode" # AirOS 8
57-
self.current_csrf_token = None
57+
self.current_csrf_token: str | None = None
5858

5959
self._use_json_for_login_post = False
6060

@@ -87,7 +87,7 @@ async def login(self) -> bool:
8787

8888
login_request_headers = {**self._common_headers}
8989

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

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

196-
def derived_data(
197-
self, response: dict[str, Any] | None = None
198-
) -> dict[str, Any] | None:
196+
def derived_data(self, response: dict[str, Any] = {}) -> dict[str, Any]:
199197
"""Add derived data to the device response."""
200-
derived = {
198+
derived: dict[str, Any] = {
201199
"station": False,
202200
"access_point": False,
203201
"ptp": False,
@@ -302,14 +300,14 @@ async def status(self) -> AirOSData:
302300
response_text,
303301
)
304302
raise AirOSDeviceConnectionError
305-
except (TimeoutError, aiohttp.client_exceptions.ClientError) as err:
303+
except (TimeoutError, aiohttp.ClientError) as err:
306304
_LOGGER.exception("Status API call failed: %s", err)
307305
raise AirOSDeviceConnectionError from err
308306
except asyncio.CancelledError:
309307
_LOGGER.info("API status retrieval task was cancelled")
310308
raise
311309

312-
async def stakick(self, mac_address: str = None) -> bool:
310+
async def stakick(self, mac_address: str | None = None) -> bool:
313311
"""Reconnect client station."""
314312
if not self.connected:
315313
_LOGGER.error("Not connected, login first")
@@ -340,7 +338,7 @@ async def stakick(self, mac_address: str = None) -> bool:
340338
log = f"Unable to restart connection response status {response.status} with {response_text}"
341339
_LOGGER.error(log)
342340
return False
343-
except (TimeoutError, aiohttp.client_exceptions.ClientError) as err:
341+
except (TimeoutError, aiohttp.ClientError) as err:
344342
_LOGGER.exception("Error during call to reconnect remote: %s", err)
345343
raise AirOSDeviceConnectionError from err
346344
except asyncio.CancelledError:
@@ -379,7 +377,7 @@ async def provmode(self, active: bool = False) -> bool:
379377
log = f"Unable to change provisioning mode response status {response.status} with {response_text}"
380378
_LOGGER.error(log)
381379
return False
382-
except (TimeoutError, aiohttp.client_exceptions.ClientError) as err:
380+
except (TimeoutError, aiohttp.ClientError) as err:
383381
_LOGGER.exception("Error during call to change provisioning mode: %s", err)
384382
raise AirOSDeviceConnectionError from err
385383
except asyncio.CancelledError:

airos/data.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def is_ip_address(value: str) -> bool:
3939
return False
4040

4141

42-
def redact_data_smart(data: dict) -> dict:
42+
def redact_data_smart(data: dict[str, Any]) -> dict[str, Any]:
4343
"""Recursively redacts sensitive keys in a dictionary."""
4444
sensitive_keys = {
4545
"hostname",
@@ -56,10 +56,7 @@ def redact_data_smart(data: dict) -> dict:
5656
"platform",
5757
}
5858

59-
def _redact(d: dict):
60-
if not isinstance(d, dict):
61-
return d
62-
59+
def _redact(d: dict[str, Any]) -> dict[str, Any]:
6360
redacted_d = {}
6461
for k, v in d.items():
6562
if k in sensitive_keys:
@@ -73,15 +70,15 @@ def _redact(d: dict):
7370
isinstance(i, str) and is_ip_address(i) for i in v
7471
):
7572
# Redact list of IPs to a dummy list
76-
redacted_d[k] = ["127.0.0.3"]
73+
redacted_d[k] = ["127.0.0.3"] # type: ignore[assignment]
7774
else:
7875
redacted_d[k] = "REDACTED"
7976
elif isinstance(v, dict):
80-
redacted_d[k] = _redact(v)
77+
redacted_d[k] = _redact(v) # type: ignore[assignment]
8178
elif isinstance(v, list):
8279
redacted_d[k] = [
8380
_redact(item) if isinstance(item, dict) else item for item in v
84-
]
81+
] # type: ignore[assignment]
8582
else:
8683
redacted_d[k] = v
8784
return redacted_d

airos/discovery.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class AirOSDiscoveryProtocol(asyncio.DatagramProtocol):
2929
3030
"""
3131

32-
def __init__(self, callback: Callable[[dict[str, Any]], None]) -> None:
32+
def __init__(self, callback: Callable[[dict[str, Any]], Any]) -> None:
3333
"""Initialize AirOSDiscoveryProtocol.
3434
3535
Args:
@@ -43,7 +43,7 @@ def __init__(self, callback: Callable[[dict[str, Any]], None]) -> None:
4343
def connection_made(self, transport: asyncio.BaseTransport) -> None:
4444
"""Set up the UDP socket for broadcasting and reusing the address."""
4545
self.transport = transport # type: ignore[assignment] # transport is DatagramTransport
46-
sock: socket.socket = self.transport.get_extra_info("socket")
46+
sock: socket.socket = transport.get_extra_info("socket")
4747
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
4848
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
4949
log = f"AirOS discovery listener (low-level) started on UDP port {DISCOVERY_PORT}."

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "airos"
7-
version = "0.2.8"
7+
version = "0.2.9"
88
license = "MIT"
99
description = "Ubiquity airOS module(s) for Python 3."
1010
readme = "README.md"
@@ -396,6 +396,12 @@ warn_return_any = true
396396
warn_unreachable = true
397397
exclude = []
398398

399+
[[tool.mypy.overrides]]
400+
module = "tests.*"
401+
ignore_missing_imports = true # You'll likely need this for test-only dependencies
402+
disallow_untyped_decorators = false # The fix for your current errors
403+
check_untyped_defs = false
404+
399405
[tool.coverage.run]
400406
source = [ "airos" ]
401407
omit= [

requirements-test.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ aioresponses
77
aioresponses==0.7.8
88
aiofiles==24.1.0
99
radon==6.0.1
10+
types-aiofiles==24.1.0.20250809
11+
mypy==1.17.1

script/generate_ha_fixture.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515

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

2021

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

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

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

5051
with open(new_fixture_path, "w") as new:

script/mashumaro-step-debug.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
_LOGGER = logging.getLogger(__name__)
1919

2020

21-
def main():
21+
def main() -> None:
2222
"""Debug data."""
2323
if len(sys.argv) <= 1:
2424
_LOGGER.info("Use with file to check")

0 commit comments

Comments
 (0)