diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5929ff21..bbc6cf76 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,8 +37,12 @@ repos: - id: mypy additional_dependencies: [ + anyio==4.11.0, + fastapi==0.117.1, + joserfc==1.3.4, pydantic==2.11.9, pydantic-settings==2.10.1, + pytest-asyncio==1.2.0, types-pyyaml==6.0.12.20250915, ] - repo: local diff --git a/conftest.py b/conftest.py index 691cf80f..5c84e388 100644 --- a/conftest.py +++ b/conftest.py @@ -2,9 +2,11 @@ import os import tempfile from pathlib import Path +from typing import Any, AsyncGenerator, Dict import pytest_asyncio from aiocache import caches +from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from tortoise import Tortoise from tortoise.contrib.fastapi import RegisterTortoise @@ -12,7 +14,7 @@ from goosebit import app from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS from goosebit.db.models import UpdateModeEnum, UpdateStateEnum -from goosebit.settings import PWD_CXT +from goosebit.settings import PWD_CXT # type: ignore[attr-defined] # Configure logging logging.basicConfig(level=logging.WARN) @@ -28,13 +30,13 @@ @pytest_asyncio.fixture(scope="function", autouse=True) -async def clear_cache(): +async def clear_cache() -> AsyncGenerator[None, None]: await caches.get("default").clear() yield @pytest_asyncio.fixture(scope="function") -async def test_app(): +async def test_app() -> AsyncGenerator[FastAPI, None]: from goosebit.users import create_initial_user async with RegisterTortoise( @@ -48,7 +50,7 @@ async def test_app(): @pytest_asyncio.fixture(scope="function") -async def async_client(test_app): +async def async_client(test_app: FastAPI) -> AsyncGenerator[AsyncClient, None]: async with AsyncClient( transport=ASGITransport(app=test_app), base_url="http://test", follow_redirects=True ) as client: @@ -63,7 +65,7 @@ async def async_client(test_app): @pytest_asyncio.fixture(scope="function") -async def db(): +async def db() -> AsyncGenerator[None, None]: await Tortoise.init(config=TORTOISE_CONF) await Tortoise.generate_schemas() yield @@ -72,7 +74,7 @@ async def db(): @pytest_asyncio.fixture(scope="function") -async def test_data(db): +async def test_data(db: None) -> AsyncGenerator[Dict[str, Any], None]: from goosebit.db.models import Device, Hardware, Rollout, Software, User # Create a temporary directory diff --git a/docker/demo/device/myscript.py b/docker/demo/device/myscript.py index d3573d91..b27022d1 100755 --- a/docker/demo/device/myscript.py +++ b/docker/demo/device/myscript.py @@ -3,7 +3,7 @@ from time import sleep -def main(): +def main() -> None: while True: print("Hello!", flush=True) sleep(5) diff --git a/goosebit/__init__.py b/goosebit/__init__.py index 0cc25777..b5a791cf 100644 --- a/goosebit/__init__.py +++ b/goosebit/__init__.py @@ -1,13 +1,13 @@ import importlib.metadata from contextlib import asynccontextmanager from logging import getLogger -from typing import Annotated +from typing import Annotated, AsyncGenerator, Awaitable, Callable from fastapi import Depends, FastAPI, HTTPException from fastapi.exception_handlers import http_exception_handler from fastapi.openapi.docs import get_swagger_ui_html from fastapi.requests import Request -from fastapi.responses import RedirectResponse +from fastapi.responses import RedirectResponse, Response from fastapi.security import OAuth2PasswordRequestForm from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as Instrumentor from starlette.exceptions import HTTPException as StarletteHTTPException @@ -16,7 +16,7 @@ from goosebit import api, db, plugins, ui, updater from goosebit.auth import get_user_from_request, login_user, redirect_if_authenticated from goosebit.device_manager import DeviceManager -from goosebit.settings import PWD_CXT, config +from goosebit.settings import PWD_CXT, config # type: ignore[attr-defined] from goosebit.ui.nav import nav from goosebit.ui.static import static from goosebit.ui.templates import templates @@ -26,7 +26,7 @@ @asynccontextmanager -async def lifespan(_: FastAPI): +async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: db_ready = await db.init() if not db_ready: logger.exception("DB does not exist, try running `poetry run aerich upgrade`.") @@ -57,16 +57,16 @@ async def lifespan(_: FastAPI): } ], ) -app.include_router(updater.router) -app.include_router(ui.router) -app.include_router(api.router) +app.include_router(updater.router) # type: ignore[attr-defined] +app.include_router(ui.router) # type: ignore[attr-defined] +app.include_router(api.router) # type: ignore[attr-defined] app.mount("/static", static, name="static") Instrumentor.instrument_app(app) for plugin in plugins.load(): if plugin.middleware is not None: logger.info(f"Adding middleware for plugin: {plugin.name}") - app.add_middleware(plugin.middleware) + app.add_middleware(plugin.middleware) # type: ignore[arg-type] if plugin.router is not None: logger.info(f"Adding routing handler for plugin: {plugin.name}") app.include_router(router=plugin.router, prefix=plugin.url_prefix) @@ -78,7 +78,7 @@ async def lifespan(_: FastAPI): app.mount(f"{plugin.url_prefix}/static", plugin.static_files, name=plugin.static_files_name) if plugin.templates is not None: logger.info(f"Adding template handler for plugin: {plugin.name}") - templates.add_template_handler(plugin.templates) + templates.add_template_handler(plugin.templates) # type: ignore[attr-defined] if plugin.update_source_hook is not None: DeviceManager.add_update_source(plugin.update_source_hook) if plugin.config_data_hook is not None: @@ -87,70 +87,70 @@ async def lifespan(_: FastAPI): # Custom exception handler for Tortoise ValidationError @app.exception_handler(ValidationError) -async def tortoise_validation_exception_handler(request: Request, exc: ValidationError): +async def tortoise_validation_exception_handler(request: Request, exc: ValidationError) -> None: raise HTTPException(422, str(exc)) # Extend default handler to do logging @app.exception_handler(StarletteHTTPException) -async def custom_http_exception_handler(request, exc): +async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException) -> Response: logger.warning(f"HTTPException, request={request.url}, status={exc.status_code}, detail={exc.detail}") return await http_exception_handler(request, exc) @app.middleware("http") -async def attach_user(request: Request, call_next): +async def attach_user(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: request.scope["user"] = await get_user_from_request(request) return await call_next(request) @app.middleware("http") -async def attach_nav(request: Request, call_next): +async def attach_nav(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: request.scope["nav"] = nav.get() return await call_next(request) @app.middleware("http") -async def attach_config(request: Request, call_next): +async def attach_config(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: request.scope["config"] = config return await call_next(request) @app.get("/", include_in_schema=False) -def root_redirect(request: Request): +def root_redirect(request: Request) -> RedirectResponse: return RedirectResponse(request.url_for("ui_root")) @app.get("/login", include_in_schema=False, dependencies=[Depends(redirect_if_authenticated)]) -async def login_get(request: Request): +async def login_get(request: Request) -> Response: return templates.TemplateResponse(request, "login.html.jinja") @app.post("/login", tags=["login"]) -async def login_post(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]): +async def login_post(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]) -> dict[str, str]: return {"access_token": await login_user(form_data.username, form_data.password), "token_type": "bearer"} @app.get("/setup", include_in_schema=False) -async def setup_get(request: Request): +async def setup_get(request: Request) -> Response: return templates.TemplateResponse(request, "setup.html.jinja") @app.post("/setup", include_in_schema=False) -async def setup_post(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]): +async def setup_post(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]) -> dict[str, str]: await create_initial_user(form_data.username, PWD_CXT.hash(form_data.password)) return {"access_token": await login_user(form_data.username, form_data.password), "token_type": "bearer"} @app.get("/logout", include_in_schema=False) -async def logout(request: Request): +async def logout(request: Request) -> RedirectResponse: resp = RedirectResponse(request.url_for("login_get"), status_code=302) resp.delete_cookie(key="session_id") return resp @app.get("/docs", include_in_schema=False) -async def swagger_docs(request: Request): +async def swagger_docs(request: Request) -> Response: return get_swagger_ui_html( title="gooseBit docs", openapi_url="/openapi.json", diff --git a/goosebit/api/routes.py b/goosebit/api/routes.py index 56cf669d..8dcb455f 100644 --- a/goosebit/api/routes.py +++ b/goosebit/api/routes.py @@ -5,5 +5,5 @@ from . import telemetry, v1 router = APIRouter(prefix="/api", dependencies=[Depends(validate_current_user)]) -router.include_router(telemetry.router) -router.include_router(v1.router) +router.include_router(telemetry.router) # type: ignore[attr-defined] +router.include_router(v1.router) # type: ignore[attr-defined] diff --git a/goosebit/api/telemetry/metrics.py b/goosebit/api/telemetry/metrics.py index 4925f76b..206f9697 100644 --- a/goosebit/api/telemetry/metrics.py +++ b/goosebit/api/telemetry/metrics.py @@ -9,7 +9,7 @@ readers = [] if config.metrics.prometheus.enable: - readers.append(prometheus.reader) + readers.append(prometheus.reader) # type: ignore[attr-defined] resource = Resource(attributes={SERVICE_NAME: "goosebit"}) diff --git a/goosebit/api/telemetry/routes.py b/goosebit/api/telemetry/routes.py index 97bfb155..66edc826 100644 --- a/goosebit/api/telemetry/routes.py +++ b/goosebit/api/telemetry/routes.py @@ -6,4 +6,4 @@ router = APIRouter(prefix="/telemetry") if config.metrics.prometheus.enable: - router.include_router(prometheus.router) + router.include_router(prometheus.router) # type: ignore[attr-defined] diff --git a/goosebit/api/v1/devices/device/routes.py b/goosebit/api/v1/devices/device/routes.py index 43cafdad..706f91d1 100644 --- a/goosebit/api/v1/devices/device/routes.py +++ b/goosebit/api/v1/devices/device/routes.py @@ -6,7 +6,7 @@ from goosebit.api.v1.devices.device.responses import DeviceLogResponse from goosebit.auth import validate_user_permissions from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS -from goosebit.db import Device +from goosebit.db import Device # type: ignore[attr-defined] from goosebit.device_manager import get_device from goosebit.schema.devices import DeviceSchema diff --git a/goosebit/api/v1/devices/routes.py b/goosebit/api/v1/devices/routes.py index 867eaa26..10c5a665 100644 --- a/goosebit/api/v1/devices/routes.py +++ b/goosebit/api/v1/devices/routes.py @@ -29,7 +29,7 @@ async def devices_get(_: Request) -> DevicesResponse: devices = await Device.all().prefetch_related("hardware", "assigned_software", "assigned_software__compatibility") response = DevicesResponse(devices=devices) - async def set_assigned_sw(d: DeviceSchema): + async def set_assigned_sw(d: DeviceSchema) -> DeviceSchema: device = await get_device(d.id) _, target = await DeviceManager.get_update(device) if target is not None: @@ -108,4 +108,4 @@ async def devices_put(_: Request, config: DevicesPutRequest) -> StatusResponse: return StatusResponse(success=True) -router.include_router(device.router) +router.include_router(device.router) # type: ignore[attr-defined] diff --git a/goosebit/api/v1/download/routes.py b/goosebit/api/v1/download/routes.py index 8c83c7cc..ef61de86 100644 --- a/goosebit/api/v1/download/routes.py +++ b/goosebit/api/v1/download/routes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Response from fastapi.requests import Request from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse @@ -9,7 +9,7 @@ @router.get("/{file_id}") -async def download_file(_: Request, file_id: int): +async def download_file(_: Request, file_id: int) -> Response: software = await Software.get_or_none(id=file_id) if software is None: raise HTTPException(404) diff --git a/goosebit/api/v1/routes.py b/goosebit/api/v1/routes.py index 90bfad1d..564d51c6 100644 --- a/goosebit/api/v1/routes.py +++ b/goosebit/api/v1/routes.py @@ -3,8 +3,8 @@ from . import devices, download, rollouts, settings, software router = APIRouter(prefix="/v1") -router.include_router(software.router) -router.include_router(devices.router) -router.include_router(rollouts.router) -router.include_router(download.router) -router.include_router(settings.router) +router.include_router(software.router) # type: ignore[attr-defined] +router.include_router(devices.router) # type: ignore[attr-defined] +router.include_router(rollouts.router) # type: ignore[attr-defined] +router.include_router(download.router) # type: ignore[attr-defined] +router.include_router(settings.router) # type: ignore[attr-defined] diff --git a/goosebit/api/v1/settings/routes.py b/goosebit/api/v1/settings/routes.py index 00decedf..b076d863 100644 --- a/goosebit/api/v1/settings/routes.py +++ b/goosebit/api/v1/settings/routes.py @@ -6,7 +6,7 @@ router = APIRouter(prefix="/settings", tags=["settings"]) -router.include_router(users.router) +router.include_router(users.router) # type: ignore[attr-defined] @router.get("/permissions", response_model_exclude_none=True) diff --git a/goosebit/api/v1/software/routes.py b/goosebit/api/v1/software/routes.py index 7b2637d3..a2df55e2 100644 --- a/goosebit/api/v1/software/routes.py +++ b/goosebit/api/v1/software/routes.py @@ -3,7 +3,7 @@ import random import string -from anyio import Path, open_file +from anyio import open_file from fastapi import APIRouter, File, Form, HTTPException, Security, UploadFile from fastapi.requests import Request @@ -11,6 +11,7 @@ from goosebit.auth import validate_user_permissions from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS from goosebit.db.models import Rollout, Software +from goosebit.schema.software import SoftwareSchema from goosebit.storage import storage from goosebit.updates import create_software_update from goosebit.util.path import validate_filename @@ -27,7 +28,7 @@ ) async def software_get(_: Request) -> SoftwareResponse: software = await Software.all().prefetch_related("compatibility") - return SoftwareResponse(software=software) + return SoftwareResponse(software=[SoftwareSchema.model_validate(s) for s in software]) @router.delete( @@ -61,7 +62,7 @@ async def software_delete(_: Request, delete_req: SoftwareDeleteRequest) -> Stat "", dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["write"]()])], ) -async def post_update(_: Request, file: UploadFile | None = File(None), url: str | None = Form(None)): +async def post_update(_: Request, file: UploadFile | None = File(None), url: str | None = Form(None)) -> dict[str, int]: if url is not None: # remote file software = await Software.get_or_none(uri=url) @@ -75,9 +76,9 @@ async def post_update(_: Request, file: UploadFile | None = File(None), url: str software = await create_software_update(url, None) elif file is not None: # local file - temp_dir = Path(storage.get_temp_dir()) + temp_dir = await storage.get_temp_dir() try: - file_path = await validate_filename(file.filename, temp_dir) + file_path = await validate_filename(file.filename or "unknown", temp_dir) except ValueError as e: raise HTTPException(400, f"Invalid filename: {e}") tmp_file_path = temp_dir.joinpath("".join(random.choices(string.ascii_lowercase, k=12)) + ".tmp") diff --git a/goosebit/auth/__init__.py b/goosebit/auth/__init__.py index 243b760b..ae9ce8f5 100644 --- a/goosebit/auth/__init__.py +++ b/goosebit/auth/__init__.py @@ -11,7 +11,7 @@ from joserfc.errors import BadSignatureError from goosebit.db.models import User -from goosebit.settings import PWD_CXT, config +from goosebit.settings import PWD_CXT, config # type: ignore[attr-defined] from goosebit.users import UserManager logger = logging.getLogger(__name__) @@ -20,11 +20,11 @@ oauth2_bearer = OAuth2PasswordBearer(tokenUrl="login", auto_error=False) -async def oauth2_auth(connection: HTTPConnection): - return await oauth2_bearer(connection) +async def oauth2_auth(request: Request) -> str | None: + return await oauth2_bearer(request) -async def session_auth(connection: HTTPConnection) -> str: +async def session_auth(connection: HTTPConnection) -> str | None: return connection.cookies.get("session_id") @@ -78,13 +78,14 @@ async def get_current_user( return user -# using | Request because oauth2_auth.__call__ expects is -async def get_user_from_request(connection: HTTPConnection | Request) -> User | None: - token = await session_auth(connection) or await oauth2_auth(connection) +async def get_user_from_request(request: Request) -> User | None: + token = await session_auth(request) or await oauth2_auth(request) return await get_user_from_token(token) -async def redirect_if_unauthenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]): +async def redirect_if_unauthenticated( + connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)] +) -> None: if user is None: raise HTTPException( status_code=302, @@ -99,7 +100,9 @@ async def redirect_if_unauthenticated(connection: HTTPConnection, user: Annotate ) -async def redirect_if_authenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]): +async def redirect_if_authenticated( + connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)] +) -> None: if user is not None: if not user.enabled: return @@ -116,7 +119,7 @@ async def redirect_if_authenticated(connection: HTTPConnection, user: Annotated[ ) -async def redirect_if_users_exist(connection: HTTPConnection): +async def redirect_if_users_exist(connection: HTTPConnection) -> None: if await User.all().count() > 0: raise HTTPException( status_code=302, @@ -125,7 +128,7 @@ async def redirect_if_users_exist(connection: HTTPConnection): ) -async def validate_current_user(user: Annotated[User, Depends(get_current_user)]): +async def validate_current_user(user: Annotated[User, Depends(get_current_user)]) -> User: if user is None: raise HTTPException( status_code=401, diff --git a/goosebit/auth/permissions.py b/goosebit/auth/permissions.py index 058c3219..708ba6c6 100644 --- a/goosebit/auth/permissions.py +++ b/goosebit/auth/permissions.py @@ -1,21 +1,21 @@ -from typing import Optional +from typing import Any, Optional from pydantic import BaseModel, Field, computed_field class Permission(BaseModel): - def model_post_init(self, ctx): + def model_post_init(self, ctx: Any) -> None: if self.sub_permissions is None: return for permission in self.sub_permissions: permission.parent_permission = self - def __call__(self, *args, **kwargs) -> str: + def __call__(self, *args: Any, **kwargs: Any) -> str: if self.parent_permission is None: return self.name return ".".join([self.parent_permission(), self.name]) - def __getitem__(self, item): + def __getitem__(self, item: str) -> "Permission": return self.sub_permissions_by_name[item] @property @@ -24,12 +24,12 @@ def sub_permissions_by_name(self) -> dict[str, "Permission"]: return {} return {item.name: item for item in self.sub_permissions} - @computed_field # type: ignore[misc] + @computed_field # type: ignore[prop-decorator] @property def value(self) -> str: return self() - @computed_field # type: ignore[misc] + @computed_field # type: ignore[prop-decorator] @property def parent(self) -> str | None: if self.parent_permission is not None: diff --git a/goosebit/db/__init__.py b/goosebit/db/__init__.py index e187f781..be20356e 100644 --- a/goosebit/db/__init__.py +++ b/goosebit/db/__init__.py @@ -18,5 +18,5 @@ async def init() -> bool: return True -async def close(): +async def close() -> None: await Tortoise.close_connections() diff --git a/goosebit/db/config.py b/goosebit/db/config.py index 3d4e7f81..b31a6eeb 100644 --- a/goosebit/db/config.py +++ b/goosebit/db/config.py @@ -5,7 +5,7 @@ from goosebit.settings import config -def add_models(models_path: str): +def add_models(models_path: str) -> None: models.append(models_path) @@ -35,7 +35,7 @@ def add_models(models_path: str): ssl_context = PostgresSSLContext() # set certificate file - ssl_context.load_verify_locations(config.db_ssl_crt) + ssl_context.load_verify_locations(str(config.db_ssl_crt)) # parse and set verify-flags if params.get("verifyflags") is not None: diff --git a/goosebit/db/models.py b/goosebit/db/models.py index 68405991..2fa0243b 100644 --- a/goosebit/db/models.py +++ b/goosebit/db/models.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import IntEnum -from typing import Self +from typing import Any, Self, cast from urllib.parse import unquote, urlparse from urllib.request import url2pathname @@ -20,11 +20,11 @@ class UpdateModeEnum(IntEnum): ROLLOUT = 3 ASSIGNED = 4 - def __str__(self): + def __str__(self) -> str: return self.name.capitalize() @classmethod - def from_str(cls, name): + def from_str(cls, name: str) -> "UpdateModeEnum": try: return cls[name.upper()] except KeyError: @@ -39,23 +39,23 @@ class UpdateStateEnum(IntEnum): ERROR = 4 FINISHED = 5 - def __str__(self): + def __str__(self) -> str: return self.name.capitalize() @classmethod - def from_str(cls, name): + def from_str(cls, name: str) -> "UpdateStateEnum": try: return cls[name.upper()] except KeyError: return cls.NONE -class Tag(Model): +class Tag(Model): # type: ignore[misc] id = fields.IntField(primary_key=True) name = fields.CharField(max_length=255) -class Device(Model): +class Device(Model): # type: ignore[misc] id = fields.CharField(max_length=255, primary_key=True) name = fields.CharField(max_length=255, null=True) assigned_software = fields.ForeignKeyField( @@ -75,7 +75,7 @@ class Device(Model): auth_token = fields.CharField(max_length=32, null=True) tags = fields.ManyToManyField("models.Tag", related_name="devices", through="device_tags") - async def save(self, *args, **kwargs): + async def save(self, *args: Any, **kwargs: Any) -> None: # ensure if using rollout that feed is set if self.update_mode == UpdateModeEnum.ROLLOUT: if self.feed is None: @@ -93,20 +93,20 @@ async def save(self, *args, **kwargs): if is_new: await self.notify_created() - async def delete(self, *args, **kwargs): + async def delete(self, *args: Any, **kwargs: Any) -> None: await super().delete(*args, **kwargs) await self.notify_deleted() @staticmethod - async def notify_created(): + async def notify_created() -> None: devices_count.set(await Device.all().count()) @staticmethod - async def notify_deleted(): + async def notify_deleted() -> None: devices_count.set(await Device.all().count()) -class Rollout(Model): +class Rollout(Model): # type: ignore[misc] id = fields.IntField(primary_key=True) created_at = fields.DatetimeField(auto_now_add=True) name = fields.CharField(max_length=255, null=True) @@ -117,7 +117,7 @@ class Rollout(Model): failure_count = fields.IntField(default=0) -class Hardware(Model): +class Hardware(Model): # type: ignore[misc] id = fields.IntField(primary_key=True) model = fields.CharField(max_length=255) revision = fields.CharField(max_length=255) @@ -127,18 +127,18 @@ class SoftwareImageFormat(IntEnum): SWU = 0 RAUC = 1 - def __str__(self): + def __str__(self) -> str: return self.name.upper() @classmethod - def from_str(cls, name): + def from_str(cls, name: str) -> "SoftwareImageFormat": try: return cls[name.upper()] except KeyError: return cls.SWU -class Software(Model): +class Software(Model): # type: ignore[misc] id = fields.IntField(primary_key=True) uri = fields.CharField(max_length=255) size = fields.BigIntField() @@ -156,11 +156,14 @@ async def latest(cls, device: Device) -> Self | None: updates = await cls.filter(compatibility__devices__id=device.id) if len(updates) == 0: return None - return sorted( - updates, - key=lambda x: Version.parse(x.version), - reverse=True, - )[0] + return cast( + Self, + sorted( + updates, + key=lambda x: Version.parse(x.version), + reverse=True, + )[0], + ) @property def path(self) -> Path: @@ -168,21 +171,21 @@ def path(self) -> Path: @property def local(self) -> bool: - return urlparse(self.uri).scheme == "file" + return cast(bool, urlparse(self.uri).scheme == "file") @property def path_user(self) -> str: if self.local: - return self.path.name + return str(self.path.name) else: - return self.uri + return cast(str, self.uri) @property def parsed_version(self) -> Version: return Version.parse(self.version) -class User(Model): +class User(Model): # type: ignore[misc] username = fields.CharField(max_length=255, primary_key=True, null=False) hashed_pwd = fields.CharField(max_length=255, null=False) permissions: fields.JSONField[list[str]] = fields.JSONField(default=[]) diff --git a/goosebit/db/pg_ssl_context.py b/goosebit/db/pg_ssl_context.py index 189e4044..e6675241 100644 --- a/goosebit/db/pg_ssl_context.py +++ b/goosebit/db/pg_ssl_context.py @@ -5,11 +5,11 @@ class PostgresSSLContext: context: ssl.SSLContext - def __init__(self): + def __init__(self) -> None: # create ssl context in server-auth mode: this sets verify_mode = required and check_hostname = True self.context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH) - def parse_ssl_mode(self, sslmode: str): + def parse_ssl_mode(self, sslmode: str) -> None: match sslmode: case "none": self.context.check_hostname = False @@ -25,7 +25,7 @@ def parse_ssl_mode(self, sslmode: str): # parse and set verify-flags according to postgres string attributes # default as defined in python3.13 lib/ssl.py: https://github.com/python/cpython/blob/3.13/Lib/ssl.py#L713) # is the following: (ssl.VERIFY_X509_PARTIAL_CHAIN | ssl.VERIFY_X509_STRICT) - def parse_verify_flags(self, verifyflags: str): + def parse_verify_flags(self, verifyflags: str) -> None: self.context.verify_flags = ssl.VerifyFlags(0) for f in verifyflags.split("|"): match f: @@ -47,5 +47,5 @@ def parse_verify_flags(self, verifyflags: str): logging.error(f"verify-flag is undefined: {f}") exit(1) - def load_verify_locations(self, file): + def load_verify_locations(self, file: str) -> None: self.context.load_verify_locations(file) diff --git a/goosebit/device_manager.py b/goosebit/device_manager.py index 9510e595..ba4703d0 100644 --- a/goosebit/device_manager.py +++ b/goosebit/device_manager.py @@ -46,7 +46,7 @@ async def get_device(dev_id: str) -> Device: cache = caches.get("default") device = await cache.get(dev_id) if device: - return device + return device # type: ignore[no-any-return] hardware = DeviceManager._hardware_default if hardware is None: @@ -57,10 +57,10 @@ async def get_device(dev_id: str) -> Device: result = await cache.set(device.id, device, ttl=600) assert result, "device being cached" - return device + return device # type: ignore[no-any-return] @staticmethod - async def save_device(device: Device, update_fields: list[str]): + async def save_device(device: Device, update_fields: list[str]) -> None: await device.save(update_fields=update_fields) # only update cache after a successful database save @@ -105,7 +105,7 @@ async def update_last_connection(device: Device, last_seen: int, last_ip: str | await DeviceManager.save_device(device, update_fields=["last_seen", "last_ip"]) @staticmethod - async def update_update(device: Device, update_mode: UpdateModeEnum, software: Software | None): + async def update_update(device: Device, update_mode: UpdateModeEnum, software: Software | None) -> None: device.assigned_software = software device.update_mode = update_mode if not update_mode == UpdateModeEnum.ROLLOUT: @@ -113,25 +113,25 @@ async def update_update(device: Device, update_mode: UpdateModeEnum, software: S await DeviceManager.save_device(device, update_fields=["assigned_software_id", "update_mode", "feed"]) @staticmethod - async def update_name(device: Device, name: str): + async def update_name(device: Device, name: str) -> None: device.name = name await DeviceManager.save_device(device, update_fields=["name"]) @staticmethod - async def update_feed(device: Device, feed: str): + async def update_feed(device: Device, feed: str) -> None: device.feed = feed await DeviceManager.save_device(device, update_fields=["feed"]) @staticmethod - def add_config_callback(callback: Callable[[Device, dict[str, Any]], Awaitable[None]]): + def add_config_callback(callback: Callable[[Device, dict[str, Any]], Awaitable[None]]) -> None: DeviceManager._config_callbacks.append(callback) @staticmethod - def remove_config_callback(callback: Callable[[Device, dict[str, Any]], Awaitable[None]]): + def remove_config_callback(callback: Callable[[Device, dict[str, Any]], Awaitable[None]]) -> None: DeviceManager._config_callbacks.remove(callback) @staticmethod - async def update_config_data(device: Device, **kwargs: dict[str, Any]): + async def update_config_data(device: Device, **kwargs: dict[str, Any]) -> None: model = kwargs.get("hw_boardname") or "default" revision = kwargs.get("hw_revision") or "default" sw_version = kwargs.get("sw_version") @@ -159,20 +159,20 @@ async def update_config_data(device: Device, **kwargs: dict[str, Any]): await DeviceManager.save_device(device, update_fields=["hardware_id", "last_state", "sw_version"]) @staticmethod - async def deployment_action_start(device: Device): + async def deployment_action_start(device: Device) -> None: device.last_log = "" device.progress = 0 await DeviceManager.save_device(device, update_fields=["last_log", "progress"]) @staticmethod - async def deployment_action_success(device: Device): + async def deployment_action_success(device: Device) -> None: device.progress = 100 await DeviceManager.save_device(device, update_fields=["progress"]) @staticmethod async def get_rollout(device: Device) -> Rollout | None: if device.update_mode == UpdateModeEnum.ROLLOUT: - return ( + return ( # type: ignore[no-any-return] await Rollout.filter( feed=device.feed, paused=False, @@ -192,10 +192,10 @@ async def _get_software(device: Device) -> Software | None: if not rollout or rollout.paused: return None await rollout.fetch_related("software") - return rollout.software + return rollout.software # type: ignore[no-any-return] if device.update_mode == UpdateModeEnum.ASSIGNED: await device.fetch_related("assigned_software") - return device.assigned_software + return device.assigned_software # type: ignore[no-any-return] if device.update_mode == UpdateModeEnum.LATEST: return await Software.latest(device) @@ -204,7 +204,9 @@ async def _get_software(device: Device) -> Software | None: return None @staticmethod - def add_update_source(source: Callable[[Request, Device], Awaitable[tuple[HandlingType, UpdateChunk | None]]]): + def add_update_source( + source: Callable[[Request, Device], Awaitable[tuple[HandlingType, UpdateChunk | None]]], + ) -> None: DeviceManager._update_sources.append(source) @staticmethod @@ -247,7 +249,7 @@ async def update_log(device: Device, log_data: str) -> None: await DeviceManager.save_device(device, update_fields=["progress", "last_log"]) @staticmethod - async def delete_devices(ids: list[str]): + async def delete_devices(ids: list[str]) -> None: await Device.filter(id__in=ids).delete() for dev_id in ids: result = await caches.get("default").delete(dev_id) @@ -259,4 +261,4 @@ async def get_device(dev_id: str) -> Device: async def get_device_or_none(dev_id: str) -> Optional[Device]: - return await Device.get_or_none(id=dev_id) + return await Device.get_or_none(id=dev_id) # type: ignore[no-any-return] diff --git a/goosebit/plugins/__init__.py b/goosebit/plugins/__init__.py index c6faca7c..cf3a5ae8 100644 --- a/goosebit/plugins/__init__.py +++ b/goosebit/plugins/__init__.py @@ -7,7 +7,7 @@ logger = logging.getLogger(__name__) -def load() -> list: +def load() -> list[PluginSchema]: plugin_configs = [] logger.info("Checking for plugins to be loaded...") if len(config.plugins) == 0: diff --git a/goosebit/schema/devices.py b/goosebit/schema/devices.py index 76b8ddfe..0ca5e5e0 100644 --- a/goosebit/schema/devices.py +++ b/goosebit/schema/devices.py @@ -14,7 +14,7 @@ class ConvertableEnum(StrEnum): @classmethod - def convert(cls, value: IntEnum): + def convert(cls, value: IntEnum) -> ConvertableEnum: return cls(str(value)) @@ -48,12 +48,12 @@ class DeviceSchema(BaseModel): ] auth_token: str | None - @computed_field # type: ignore[misc] + @computed_field # type: ignore[prop-decorator] @property def polling(self) -> bool | None: return self.last_seen < (self.poll_seconds + 10) if self.last_seen is not None else None - @computed_field # type: ignore[misc] + @computed_field # type: ignore[prop-decorator] @property def poll_seconds(self) -> int: time_obj = datetime.strptime(config.poll_time, "%H:%M:%S") diff --git a/goosebit/schema/plugins.py b/goosebit/schema/plugins.py index 9bd288de..029e39ac 100644 --- a/goosebit/schema/plugins.py +++ b/goosebit/schema/plugins.py @@ -13,13 +13,13 @@ YamlConfigSettingsSource, ) -from goosebit.db import Device +from goosebit.db import Device # type: ignore[attr-defined] from goosebit.device_manager import HandlingType from goosebit.schema.updates import UpdateChunk from goosebit.settings import config -def get_module_name(): +def get_module_name() -> str: module = inspect.getmodule(inspect.stack()[2][0]) if module is not None: return module.__name__.split(".")[0] @@ -41,12 +41,12 @@ class Config: update_source_hook: Callable[[Request, Device], Awaitable[tuple[HandlingType, UpdateChunk | None]]] | None = None config_data_hook: Callable[[Device, dict[str, Any]], Awaitable[None]] | None = None - @computed_field # type: ignore[misc] + @computed_field # type: ignore[prop-decorator] @property def url_prefix(self) -> str: return f"/plugins/{self.name}" - @computed_field # type: ignore[misc] + @computed_field # type: ignore[prop-decorator] @property def static_files_name(self) -> str: return f"{self.name}_static" diff --git a/goosebit/schema/rollouts.py b/goosebit/schema/rollouts.py index ccd6a779..ef66a21e 100644 --- a/goosebit/schema/rollouts.py +++ b/goosebit/schema/rollouts.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime +from typing import Any from pydantic import BaseModel, ConfigDict, field_serializer @@ -20,5 +21,5 @@ class RolloutSchema(BaseModel): failure_count: int @field_serializer("created_at") - def serialize_created_at(self, created_at: datetime, _info): + def serialize_created_at(self, created_at: datetime, _info: Any) -> int: return int(created_at.timestamp() * 1000) diff --git a/goosebit/schema/software.py b/goosebit/schema/software.py index 7b9bb228..945ab3d9 100644 --- a/goosebit/schema/software.py +++ b/goosebit/schema/software.py @@ -33,7 +33,7 @@ def path(self) -> Path: def local(self) -> bool: return urlparse(self.uri).scheme == "file" - @computed_field # type: ignore[misc] + @computed_field # type: ignore[prop-decorator] @property def name(self) -> str: if self.local: diff --git a/goosebit/settings/schema.py b/goosebit/settings/schema.py index 5b730699..5f50f9a3 100644 --- a/goosebit/settings/schema.py +++ b/goosebit/settings/schema.py @@ -1,11 +1,10 @@ import os -import secrets from enum import StrEnum from pathlib import Path -from typing import Annotated +from typing import Any from joserfc.jwk import OctKey -from pydantic import BaseModel, BeforeValidator, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator from pydantic_settings import ( BaseSettings, PydanticBaseSettingsSource, @@ -36,7 +35,7 @@ class DeviceAuthSettings(BaseModel): external_json_key: str = "token" @model_validator(mode="after") - def validate_external_mode_config(self): + def validate_external_mode_config(self) -> "DeviceAuthSettings": if self.mode == DeviceAuthMode.EXTERNAL: if self.external_url is None: raise ValueError("External URL is required when using external authentication mode") @@ -85,7 +84,7 @@ class GooseBitSettings(BaseSettings): device_auth: DeviceAuthSettings = DeviceAuthSettings() - secret_key: Annotated[OctKey, BeforeValidator(OctKey.import_key)] = secrets.token_hex(16) + secret_key: OctKey = Field(default_factory=OctKey.generate_key) plugins: list[str] = Field(default_factory=list) @@ -98,10 +97,17 @@ class GooseBitSettings(BaseSettings): metrics: MetricsSettings = MetricsSettings() - logging: dict = LOGGING_DEFAULT + logging: dict[str, Any] = LOGGING_DEFAULT track_device_ip: bool = True + @field_validator("secret_key", mode="before") + @classmethod + def import_secret_key(cls, v: Any) -> OctKey: + if isinstance(v, OctKey): + return v + return OctKey.import_key(v) + @classmethod def settings_customise_sources( cls, diff --git a/goosebit/storage/__init__.py b/goosebit/storage/__init__.py index 721ffa79..090b8d11 100644 --- a/goosebit/storage/__init__.py +++ b/goosebit/storage/__init__.py @@ -1,6 +1,7 @@ -from pathlib import Path from typing import AsyncIterable +from anyio import Path + from goosebit.settings import config from goosebit.settings.schema import GooseBitSettings, StorageType from goosebit.storage.base import StorageProtocol @@ -17,11 +18,11 @@ def __init__(self, config: GooseBitSettings): def _create_backend(self) -> StorageProtocol: if self.config.storage.backend == StorageType.FILESYSTEM: - return FilesystemStorageBackend(base_path=self.config.artifacts_dir) + return FilesystemStorageBackend(base_path=Path(self.config.artifacts_dir)) elif self.config.storage.backend == StorageType.S3: if self.config.storage.s3 is None: - return FilesystemStorageBackend(base_path=self.config.artifacts_dir) + return FilesystemStorageBackend(base_path=Path(self.config.artifacts_dir)) s3_config = self.config.storage.s3 return S3StorageBackend( @@ -51,8 +52,8 @@ async def get_file_stream(self, uri: str) -> AsyncIterable[bytes]: async def get_download_url(self, uri: str) -> str: return await self.backend.get_download_url(uri) - def get_temp_dir(self) -> Path: - return self.backend.get_temp_dir() + async def get_temp_dir(self) -> Path: + return await self.backend.get_temp_dir() async def delete_file(self, uri: str) -> bool: return await self.backend.delete_file(uri) diff --git a/goosebit/storage/base.py b/goosebit/storage/base.py index 15766a38..ab529534 100644 --- a/goosebit/storage/base.py +++ b/goosebit/storage/base.py @@ -1,6 +1,7 @@ -from pathlib import Path from typing import AsyncIterable, Protocol +from anyio import Path + class StorageProtocol(Protocol): async def store_file(self, source_path: Path, dest_path: Path) -> str: ... @@ -11,4 +12,4 @@ async def get_download_url(self, uri: str) -> str: ... async def delete_file(self, uri: str) -> bool: ... - def get_temp_dir(self) -> Path: ... + async def get_temp_dir(self) -> Path: ... diff --git a/goosebit/storage/filesystem.py b/goosebit/storage/filesystem.py index 3cb81653..8986cbab 100644 --- a/goosebit/storage/filesystem.py +++ b/goosebit/storage/filesystem.py @@ -1,11 +1,9 @@ import shutil -from pathlib import Path from typing import AsyncIterable from urllib.parse import urlparse import httpx -from anyio import Path as AnyioPath -from anyio import open_file +from anyio import Path, open_file from .base import StorageProtocol @@ -13,15 +11,15 @@ class FilesystemStorageBackend(StorageProtocol): def __init__(self, base_path: Path): self.base_path = Path(base_path) - self.base_path.mkdir(parents=True, exist_ok=True) async def store_file(self, source_path: Path, dest_path: Path) -> str: - final_dest_path = self._validate_dest_path(dest_path) - final_dest_path.parent.mkdir(parents=True, exist_ok=True) + final_dest_path = await self._validate_dest_path(dest_path) + await final_dest_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source_path, final_dest_path) - return final_dest_path.resolve().as_uri() + final_dest_path_resolved = await final_dest_path.resolve() + return final_dest_path_resolved.as_uri() async def get_file_stream(self, uri: str) -> AsyncIterable[bytes]: # type: ignore[override] parsed = urlparse(uri) @@ -55,17 +53,18 @@ async def get_download_url(self, uri: str) -> str: elif parsed.scheme == "file": file_path = self._extract_path_from_uri(uri) - if not file_path.exists(): + if not await file_path.exists(): raise FileNotFoundError(f"File not found: {file_path}") - return file_path.resolve().as_uri() + file_path_resolved = await file_path.resolve() + return file_path_resolved.as_uri() else: raise ValueError(f"Unsupported URI scheme '{parsed.scheme}' for filesystem backend: {uri}") - def get_temp_dir(self) -> Path: + async def get_temp_dir(self) -> Path: temp_dir = self.base_path / "tmp" - temp_dir.mkdir(parents=True, exist_ok=True) + await temp_dir.mkdir(parents=True, exist_ok=True) return temp_dir async def delete_file(self, uri: str) -> bool: @@ -73,8 +72,8 @@ async def delete_file(self, uri: str) -> bool: if parsed.scheme == "file": file_path = self._extract_path_from_uri(uri) - if file_path.exists(): - file_path.unlink() + if await file_path.exists(): + await file_path.unlink() return True return False else: @@ -88,20 +87,17 @@ def _extract_path_from_uri(self, uri: str) -> Path: return Path(parsed.path) - def _validate_dest_path(self, dest_path: Path) -> Path: - if not isinstance(dest_path, (Path, AnyioPath)): + async def _validate_dest_path(self, dest_path: Path) -> Path: + if not isinstance(dest_path, Path): raise ValueError("Destination path must be a Path object") - if isinstance(dest_path, AnyioPath): - dest_path = Path(str(dest_path)) - if dest_path.is_absolute(): raise ValueError("Destination path cannot be absolute") final_dest_path = self.base_path / dest_path - resolved_dest = final_dest_path.resolve() - resolved_base = self.base_path.resolve() + resolved_dest = await final_dest_path.resolve() + resolved_base = await self.base_path.resolve() try: resolved_dest.relative_to(resolved_base) diff --git a/goosebit/storage/s3.py b/goosebit/storage/s3.py index 505aa001..94e651db 100644 --- a/goosebit/storage/s3.py +++ b/goosebit/storage/s3.py @@ -1,8 +1,8 @@ import asyncio -from pathlib import Path from typing import AsyncIterable from urllib.parse import urlparse +from anyio import Path from boto3.session import Session from botocore.config import Config from botocore.exceptions import ClientError @@ -76,11 +76,11 @@ async def get_download_url(self, uri: str) -> str: else: raise ValueError(f"Fallback to streaming as S3 service might not be exposed externally: {uri}") - def get_temp_dir(self) -> Path: + async def get_temp_dir(self) -> Path: import tempfile temp_dir = Path(tempfile.gettempdir()).joinpath("goosebit-s3-temp") - temp_dir.mkdir(parents=True, exist_ok=True) + await temp_dir.mkdir(parents=True, exist_ok=True) return temp_dir async def delete_file(self, uri: str) -> bool: diff --git a/goosebit/ui/bff/common/requests.py b/goosebit/ui/bff/common/requests.py index 4d609556..fdbbb9f4 100644 --- a/goosebit/ui/bff/common/requests.py +++ b/goosebit/ui/bff/common/requests.py @@ -20,7 +20,7 @@ class DataTableOrderSchema(BaseModel): dir: DataTableOrderDirection | None = None name: str | None = None - @computed_field # type: ignore[misc] + @computed_field # type: ignore[prop-decorator] @property def direction(self) -> str: return "-" if self.dir == DataTableOrderDirection.DESCENDING else "" @@ -33,7 +33,7 @@ class DataTableRequest(BaseModel): length: int | None = None search: DataTableSearchSchema = DataTableSearchSchema() - @computed_field # type: ignore[misc] + @computed_field # type: ignore[prop-decorator] @property def order_query(self) -> str | None: try: diff --git a/goosebit/ui/bff/common/util.py b/goosebit/ui/bff/common/util.py index 9268e7ba..51bdd462 100644 --- a/goosebit/ui/bff/common/util.py +++ b/goosebit/ui/bff/common/util.py @@ -1,12 +1,14 @@ +from typing import Any + from fastapi.requests import Request from goosebit.ui.bff.common.requests import DataTableRequest -def parse_datatables_query(request: Request): +def parse_datatables_query(request: Request) -> DataTableRequest: # parsing adapted from https://github.com/ziiiio/datatable_ajax_request_parser - result = {} + result: dict[str, Any] = {} for key, value in request.query_params.items(): key_list = key.replace("][", ";").replace("[", ";").replace("]", "").split(";") @@ -17,7 +19,7 @@ def parse_datatables_query(request: Request): result[key] = value[0] if len(value) == 1 else value continue - temp_dict = result + temp_dict: dict[str, Any] = result for inner_key in key_list[:-1]: if inner_key not in temp_dict: temp_dict.update({inner_key: {}}) @@ -25,8 +27,10 @@ def parse_datatables_query(request: Request): temp_dict[key_list[-1]] = value[0] if len(value) == 1 else value if result.get("columns"): - result["columns"] = [result["columns"][str(idx)] for idx, _ in enumerate(result["columns"])] + columns_dict = result["columns"] + result["columns"] = [columns_dict[str(idx)] for idx, _ in enumerate(columns_dict)] if result.get("order"): - result["order"] = [result["order"][str(idx)] for idx, _ in enumerate(result["order"])] + order_dict = result["order"] + result["order"] = [order_dict[str(idx)] for idx, _ in enumerate(order_dict)] return DataTableRequest.model_validate(result) diff --git a/goosebit/ui/bff/devices/responses.py b/goosebit/ui/bff/devices/responses.py index 9a06f9bc..70f00530 100644 --- a/goosebit/ui/bff/devices/responses.py +++ b/goosebit/ui/bff/devices/responses.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Callable +from typing import Any, Callable from pydantic import BaseModel, Field from tortoise.queryset import QuerySet @@ -16,7 +16,9 @@ class BFFDeviceResponse(BaseModel): records_filtered: int = Field(serialization_alias="recordsFiltered") @classmethod - async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable): + async def convert( + cls, dt_query: DataTableRequest, query: QuerySet[Any], search_filter: Callable[[str], Any] + ) -> "BFFDeviceResponse": total_records = await query.count() if dt_query.search.value: query = query.filter(search_filter(dt_query.search.value)) diff --git a/goosebit/ui/bff/devices/routes.py b/goosebit/ui/bff/devices/routes.py index e3acf58a..0e1b1031 100644 --- a/goosebit/ui/bff/devices/routes.py +++ b/goosebit/ui/bff/devices/routes.py @@ -25,7 +25,7 @@ from .responses import BFFDeviceResponse router = APIRouter(prefix="/devices") -router.include_router(device.router) +router.include_router(device.router) # type: ignore[attr-defined] @router.get( @@ -33,7 +33,7 @@ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])], ) async def devices_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFDeviceResponse: - def search_filter(search_value: str): + def search_filter(search_value: str) -> Q: return ( Q(id__icontains=search_value) | Q(name__icontains=search_value) @@ -51,7 +51,7 @@ def search_filter(search_value: str): response = await BFFDeviceResponse.convert(dt_query, query, search_filter) - async def set_assigned_sw(d: DeviceSchema): + async def set_assigned_sw(d: DeviceSchema) -> DeviceSchema: device = await get_device(d.id) _, target = await DeviceManager.get_update(device) if target is not None: @@ -59,7 +59,8 @@ async def set_assigned_sw(d: DeviceSchema): d.assigned_software = SoftwareSchema.model_validate(target) return d - response.data = await asyncio.gather(*[set_assigned_sw(d) for d in response.data]) + updated_devices: list[DeviceSchema] = await asyncio.gather(*[set_assigned_sw(d) for d in response.data]) + response.data = updated_devices return response diff --git a/goosebit/ui/bff/download/routes.py b/goosebit/ui/bff/download/routes.py index 44f6fa96..fe46f202 100644 --- a/goosebit/ui/bff/download/routes.py +++ b/goosebit/ui/bff/download/routes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Response from fastapi.requests import Request from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse @@ -9,7 +9,7 @@ @router.get("/{file_id}") -async def download_file(_: Request, file_id: int): +async def download_file(_: Request, file_id: int) -> Response: software = await Software.get_or_none(id=file_id) if software is None: raise HTTPException(404) diff --git a/goosebit/ui/bff/rollouts/responses.py b/goosebit/ui/bff/rollouts/responses.py index 68211f12..48633030 100644 --- a/goosebit/ui/bff/rollouts/responses.py +++ b/goosebit/ui/bff/rollouts/responses.py @@ -1,4 +1,4 @@ -from typing import Callable +from typing import Any, Callable from pydantic import BaseModel, Field from tortoise.queryset import QuerySet @@ -14,7 +14,9 @@ class BFFRolloutsResponse(BaseModel): records_filtered: int = Field(serialization_alias="recordsFiltered") @classmethod - async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable): + async def convert( + cls, dt_query: DataTableRequest, query: QuerySet[Any], search_filter: Callable[[str], Any] + ) -> "BFFRolloutsResponse": total_records = await query.count() if dt_query.search.value: query = query.filter(search_filter(dt_query.search.value)) diff --git a/goosebit/ui/bff/rollouts/routes.py b/goosebit/ui/bff/rollouts/routes.py index 9a9bedec..813669fe 100644 --- a/goosebit/ui/bff/rollouts/routes.py +++ b/goosebit/ui/bff/rollouts/routes.py @@ -22,7 +22,7 @@ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["read"]()])], ) async def rollouts_get(dt_query: Annotated[DataTableRequest, Depends(parse_datatables_query)]) -> BFFRolloutsResponse: - def search_filter(search_value): + def search_filter(search_value: str) -> Q: return ( Q(name__icontains=search_value) | Q(feed__icontains=search_value) diff --git a/goosebit/ui/bff/routes.py b/goosebit/ui/bff/routes.py index ef1791ab..cfaf6b10 100644 --- a/goosebit/ui/bff/routes.py +++ b/goosebit/ui/bff/routes.py @@ -7,8 +7,8 @@ from . import devices, download, rollouts, settings, software router = APIRouter(prefix="/bff", tags=["bff"], dependencies=[Depends(validate_current_user)]) -router.include_router(devices.router) -router.include_router(software.router) -router.include_router(rollouts.router) -router.include_router(download.router) -router.include_router(settings.router) +router.include_router(devices.router) # type: ignore[attr-defined] +router.include_router(software.router) # type: ignore[attr-defined] +router.include_router(rollouts.router) # type: ignore[attr-defined] +router.include_router(download.router) # type: ignore[attr-defined] +router.include_router(settings.router) # type: ignore[attr-defined] diff --git a/goosebit/ui/bff/settings/routes.py b/goosebit/ui/bff/settings/routes.py index 273f0c99..bbe1c2a3 100644 --- a/goosebit/ui/bff/settings/routes.py +++ b/goosebit/ui/bff/settings/routes.py @@ -8,7 +8,7 @@ router = APIRouter(prefix="/settings") -router.include_router(users.router) +router.include_router(users.router) # type: ignore[attr-defined] router.add_api_route( "/permissions", diff --git a/goosebit/ui/bff/settings/users/responses.py b/goosebit/ui/bff/settings/users/responses.py index 46bf9ba8..a7428f95 100644 --- a/goosebit/ui/bff/settings/users/responses.py +++ b/goosebit/ui/bff/settings/users/responses.py @@ -1,4 +1,4 @@ -from typing import Callable +from typing import Any, Callable from pydantic import BaseModel, Field from tortoise.queryset import QuerySet @@ -14,7 +14,9 @@ class BFFSettingsUsersResponse(BaseModel): records_filtered: int = Field(serialization_alias="recordsFiltered") @classmethod - async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable): + async def convert( + cls, dt_query: DataTableRequest, query: QuerySet[Any], search_filter: Callable[[str], Any] + ) -> "BFFSettingsUsersResponse": total_records = await query.count() if dt_query.search.value: query = query.filter(search_filter(dt_query.search.value)) diff --git a/goosebit/ui/bff/settings/users/routes.py b/goosebit/ui/bff/settings/users/routes.py index cca37994..7ba9e333 100644 --- a/goosebit/ui/bff/settings/users/routes.py +++ b/goosebit/ui/bff/settings/users/routes.py @@ -27,7 +27,7 @@ async def settings_users_get( ) -> BFFSettingsUsersResponse: filters: list[Q] = [] - def search_filter(search_value): + def search_filter(search_value: str) -> Q: base_filter = Q(Q(username__icontains=search_value), join_type="OR") return Q(base_filter, *filters, join_type="AND") diff --git a/goosebit/ui/bff/software/responses.py b/goosebit/ui/bff/software/responses.py index e423acf9..cea08e73 100644 --- a/goosebit/ui/bff/software/responses.py +++ b/goosebit/ui/bff/software/responses.py @@ -1,4 +1,4 @@ -from typing import Callable +from typing import Any, Callable from pydantic import BaseModel, Field from tortoise.expressions import Q @@ -16,7 +16,9 @@ class BFFSoftwareResponse(BaseModel): records_filtered: int = Field(serialization_alias="recordsFiltered") @classmethod - async def convert(cls, dt_query: DataTableRequest, query: QuerySet, search_filter: Callable, alt_filter: Q): + async def convert( + cls, dt_query: DataTableRequest, query: QuerySet[Any], search_filter: Callable[[str], Any], alt_filter: Q + ) -> "BFFSoftwareResponse": total_records = await query.count() query = query.filter(alt_filter) if dt_query.search.value: diff --git a/goosebit/ui/bff/software/routes.py b/goosebit/ui/bff/software/routes.py index 0543e4a7..045453f5 100644 --- a/goosebit/ui/bff/software/routes.py +++ b/goosebit/ui/bff/software/routes.py @@ -34,7 +34,7 @@ async def software_get( ) -> BFFSoftwareResponse: filters: list[Q] = [] - def search_filter(search_value): + def search_filter(search_value: str) -> Q: base_filter = Q(Q(uri__icontains=search_value), Q(version__icontains=search_value), join_type="OR") return Q(base_filter, *filters, join_type="AND") @@ -67,7 +67,7 @@ async def post_update( init: bool = Form(default=None), done: bool = Form(default=None), filename: str = Form(default=None), -): +) -> None: if url is not None: # remote file software = await Software.get_or_none(uri=url) diff --git a/goosebit/ui/nav.py b/goosebit/ui/nav.py index fadbd09a..e2b3a854 100644 --- a/goosebit/ui/nav.py +++ b/goosebit/ui/nav.py @@ -1,25 +1,29 @@ +from typing import Any, Callable + from pydantic import BaseModel class NavigationItem(BaseModel): function: str text: str - permissions: list[str] + permissions: list[str] | None show: bool class Navigation: - def __init__(self): - self.items = [] + def __init__(self) -> None: + self.items: list[NavigationItem] = [] - def route(self, text: str, permissions: list[str] | None = None, show: bool = True): - def decorator(func): + def route( + self, text: str, permissions: list[str] | None = None, show: bool = True + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: self.items.append(NavigationItem(function=func.__name__, text=text, permissions=permissions, show=show)) return func return decorator - def get(self): + def get(self) -> list[NavigationItem]: return self.items diff --git a/goosebit/ui/routes.py b/goosebit/ui/routes.py index e529d08e..11052508 100644 --- a/goosebit/ui/routes.py +++ b/goosebit/ui/routes.py @@ -1,6 +1,6 @@ import logging -from fastapi import APIRouter, Depends, HTTPException, Security +from fastapi import APIRouter, Depends, HTTPException, Response, Security from fastapi.requests import HTTPConnection, Request from fastapi.responses import RedirectResponse from fastapi.security import SecurityScopes @@ -18,7 +18,7 @@ from .templates import templates router = APIRouter(prefix="/ui", include_in_schema=False) -router.include_router(bff.router) +router.include_router(bff.router) # type: ignore[attr-defined] logger = logging.getLogger(__name__) @@ -27,7 +27,7 @@ def validate_user_permissions_with_nav_redirect( connection: HTTPConnection, security: SecurityScopes, user: User = Depends(get_current_user), -): +) -> HTTPConnection: if not check_permissions(security.scopes, user.permissions): logger.warning(f"{user.username} does not have sufficient permissions") for item in nav.items: @@ -45,7 +45,7 @@ def validate_user_permissions_with_nav_redirect( @router.get("", dependencies=[Depends(redirect_if_unauthenticated)]) -async def ui_root(request: Request): +async def ui_root(request: Request) -> RedirectResponse: return RedirectResponse(request.url_for("devices_ui")) @@ -57,7 +57,7 @@ async def ui_root(request: Request): ], ) @nav.route("Devices", permissions=[GOOSEBIT_PERMISSIONS["device"]["read"]()]) -async def devices_ui(request: Request): +async def devices_ui(request: Request) -> Response: return templates.TemplateResponse(request, "devices.html.jinja", context={"title": "Devices"}) @@ -69,7 +69,7 @@ async def devices_ui(request: Request): ], ) @nav.route("Software", permissions=[GOOSEBIT_PERMISSIONS["software"]["read"]()]) -async def software_ui(request: Request): +async def software_ui(request: Request) -> Response: return templates.TemplateResponse(request, "software.html.jinja", context={"title": "Software"}) @@ -81,7 +81,7 @@ async def software_ui(request: Request): ], ) @nav.route("Rollouts", permissions=[GOOSEBIT_PERMISSIONS["rollout"]["read"]()]) -async def rollouts_ui(request: Request): +async def rollouts_ui(request: Request) -> Response: return templates.TemplateResponse(request, "rollouts.html.jinja", context={"title": "Rollouts"}) @@ -92,7 +92,7 @@ async def rollouts_ui(request: Request): Security(validate_user_permissions_with_nav_redirect, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()]), ], ) -async def logs_ui(request: Request, dev_id: str): +async def logs_ui(request: Request, dev_id: str) -> Response: return templates.TemplateResponse(request, "logs.html.jinja", context={"title": "Log", "device": dev_id}) @@ -104,5 +104,5 @@ async def logs_ui(request: Request, dev_id: str): ], ) @nav.route("Settings", permissions=[GOOSEBIT_PERMISSIONS["settings"]()], show=False) -async def settings_ui(request: Request): +async def settings_ui(request: Request) -> Response: return templates.TemplateResponse(request, "settings.html.jinja", context={"title": "Settings"}) diff --git a/goosebit/ui/templates/__init__.py b/goosebit/ui/templates/__init__.py index cb0c055f..5524b7ba 100644 --- a/goosebit/ui/templates/__init__.py +++ b/goosebit/ui/templates/__init__.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Any import jinja2 from fastapi.requests import Request @@ -7,7 +8,7 @@ from goosebit.auth import check_permissions -def attach_permissions_comparison(_: Request): +def attach_permissions_comparison(_: Request) -> dict[str, Any]: return {"compare_permissions": check_permissions} @@ -15,8 +16,9 @@ def attach_permissions_comparison(_: Request): templates = Jinja2Templates(context_processors=[attach_permissions_comparison], env=env) -def add_template_handler(handler: Jinja2Templates): - templates.env.loader.loaders.append(handler.env.loader) +def add_template_handler(handler: Jinja2Templates) -> None: + if hasattr(templates.env.loader, "loaders") and hasattr(handler.env.loader, "loaders"): + templates.env.loader.loaders.append(handler.env.loader) -templates.add_template_handler = add_template_handler +templates.add_template_handler = add_template_handler # type: ignore[attr-defined] diff --git a/goosebit/updater/controller/routes.py b/goosebit/updater/controller/routes.py index 584ea1fc..159c1cee 100644 --- a/goosebit/updater/controller/routes.py +++ b/goosebit/updater/controller/routes.py @@ -3,4 +3,4 @@ from . import v1 router = APIRouter(prefix="/controller") -router.include_router(v1.router) +router.include_router(v1.router) # type: ignore[attr-defined] diff --git a/goosebit/updater/controller/v1/routes.py b/goosebit/updater/controller/v1/routes.py index 15c9b12d..1a9db13e 100644 --- a/goosebit/updater/controller/v1/routes.py +++ b/goosebit/updater/controller/v1/routes.py @@ -1,4 +1,5 @@ import logging +from typing import Any from fastapi import APIRouter, Depends, HTTPException from fastapi.requests import Request @@ -28,7 +29,7 @@ @router.get("/{dev_id}") -async def polling(request: Request, device: Device = Depends(get_device)): +async def polling(request: Request, device: Device = Depends(get_device)) -> dict[str, Any]: links: dict[str, dict[str, str]] = {} if device is None: @@ -93,7 +94,7 @@ async def polling(request: Request, device: Device = Depends(get_device)): @router.put("/{dev_id}/configData") -async def config_data(_: Request, cfg: ConfigDataSchema, device: Device = Depends(get_device)): +async def config_data(_: Request, cfg: ConfigDataSchema, device: Device = Depends(get_device)) -> dict[str, bool | str]: await DeviceManager.update_config_data(device, **cfg.data) logger.info(f"Updating config data, device={device.id}") return {"success": True, "message": "Updated swupdate data."} @@ -104,7 +105,7 @@ async def deployment_base( request: Request, action_id: int, device: Device = Depends(get_device), -): +) -> dict[str, Any] | None: handling_type, software = await DeviceManager.get_update(device) logger.info(f"Request deployment base, device={device.id}") @@ -130,10 +131,13 @@ async def deployment_base( "chunks": [chunk.model_dump(by_alias=True)], }, } + return None @router.post("/{dev_id}/deploymentBase/{action_id}/feedback") -async def deployment_feedback(_: Request, data: FeedbackSchema, action_id: int, device: Device = Depends(get_device)): +async def deployment_feedback( + _: Request, data: FeedbackSchema, action_id: int, device: Device = Depends(get_device) +) -> dict[str, str]: if data.status.execution == FeedbackStatusExecutionState.PROCEEDING: if device and device.last_state != UpdateStateEnum.RUNNING: await DeviceManager.deployment_action_start(device) @@ -201,8 +205,8 @@ async def deployment_feedback(_: Request, data: FeedbackSchema, action_id: int, @router.head("/{dev_id}/download") -async def download_artifact_head(_: Request, device: Device = Depends(get_device)): - _, software = await DeviceManager.get_update(device) +async def download_artifact_head(_: Request, device: Device = Depends(get_device)) -> Response: + handling_type, software = await DeviceManager.get_update(device) if software is None: raise HTTPException(404) @@ -212,8 +216,8 @@ async def download_artifact_head(_: Request, device: Device = Depends(get_device @router.get("/{dev_id}/download") -async def download_artifact(_: Request, device: Device = Depends(get_device)): - _, software = await DeviceManager.get_update(device) +async def download_artifact(_: Request, device: Device = Depends(get_device)) -> Response: + handling_type, software = await DeviceManager.get_update(device) if software is None: raise HTTPException(404) diff --git a/goosebit/updater/routes.py b/goosebit/updater/routes.py index 701d1984..24b4193c 100644 --- a/goosebit/updater/routes.py +++ b/goosebit/updater/routes.py @@ -8,23 +8,24 @@ from goosebit.settings import config from goosebit.settings.schema import DeviceAuthMode, ExternalAuthMode -from ..db import Device +from ..db.models import Device from . import controller -async def log_last_connection(request: Request, dev_id: str): +async def log_last_connection(request: Request, dev_id: str) -> None: device = await get_device_or_none(dev_id) if not device: return if request.scope["config"].track_device_ip: - await DeviceManager.update_last_connection(device, round(time.time()), request.client.host) + host = request.client.host if request.client else None + await DeviceManager.update_last_connection(device, round(time.time()), host) else: await DeviceManager.update_last_connection(device, round(time.time())) -async def validate_device_token(request: Request, dev_id: str): +async def validate_device_token(request: Request, dev_id: str) -> None: if not request.scope["config"].device_auth.enable: return @@ -57,10 +58,10 @@ async def validate_device_token(request: Request, dev_id: str): if device_token is None: raise HTTPException(401, "Device authentication token is required in strict mode.") # do not create a device in strict mode - device = await Device.get_or_none(id=dev_id) - if device is None: + device_obj = await Device.get_or_none(id=dev_id) + if device_obj is None: raise HTTPException(401, "Cannot register a new device in strict mode.") - if not device.auth_token == device_token: + if not device_obj.auth_token == device_token: raise HTTPException(401, "Device authentication token does not match.") # external mode should check the token with an external service @@ -97,4 +98,4 @@ async def validate_device_token(request: Request, dev_id: str): dependencies=[Depends(log_last_connection), Depends(validate_device_token)], tags=["ddi"], ) -router.include_router(controller.router) +router.include_router(controller.router) # type: ignore[attr-defined] diff --git a/goosebit/updates/__init__.py b/goosebit/updates/__init__.py index 4d0b434a..2ade1c2f 100644 --- a/goosebit/updates/__init__.py +++ b/goosebit/updates/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import Any from urllib.parse import unquote, urlparse from urllib.request import url2pathname @@ -51,7 +52,7 @@ async def create_software_update(uri: str, temp_file: Path | None) -> Software: filename = Path(url2pathname(unquote(parsed_uri.path))).name dest_path = Path(update_info["hash"]).joinpath(filename) - uri = await storage.store_file(temp_file, dest_path) + uri = await storage.store_file(Path(temp_file), dest_path) # create software software = await Software.create( @@ -68,10 +69,10 @@ async def create_software_update(uri: str, temp_file: Path | None) -> Software: revision = comp.get("hw_revision", "default") await software.compatibility.add((await Hardware.get_or_create(model=model, revision=revision))[0]) await software.save() - return software + return software # type: ignore[no-any-return] -async def _is_software_colliding(update_info): +async def _is_software_colliding(update_info: dict[str, Any]) -> bool: version = update_info["version"] compatibility = update_info["compatibility"] @@ -89,7 +90,7 @@ async def _is_software_colliding(update_info): # Check if any existing software with the same version is compatible with any of these hardware IDs is_colliding = await Software.filter(version=version, compatibility__in=hardware_ids).exists() - return is_colliding + return is_colliding # type: ignore[no-any-return] async def generate_chunk(request: Request, device: Device) -> list[UpdateChunk]: diff --git a/goosebit/updates/swdesc/__init__.py b/goosebit/updates/swdesc/__init__.py index c46dcdaa..52c0bce9 100644 --- a/goosebit/updates/swdesc/__init__.py +++ b/goosebit/updates/swdesc/__init__.py @@ -1,6 +1,7 @@ import logging import random import string +from typing import Any import httpx from anyio import Path, open_file @@ -13,10 +14,10 @@ logger = logging.getLogger(__name__) -async def parse_remote(url: str): +async def parse_remote(url: str) -> dict[str, Any]: async with httpx.AsyncClient() as c: file = await c.get(url) - temp_dir = Path(storage.get_temp_dir()) + temp_dir = await storage.get_temp_dir() tmp_file_path = temp_dir.joinpath("".join(random.choices(string.ascii_lowercase, k=12)) + ".tmp") try: async with await open_file(tmp_file_path, "w+b") as f: @@ -29,7 +30,7 @@ async def parse_remote(url: str): return file_data -async def parse_file(file: Path): +async def parse_file(file: Path) -> dict[str, Any]: async with await open_file(file, "r+b") as f: magic = await f.read(4) if magic == swu.MAGIC: @@ -39,7 +40,7 @@ async def parse_file(file: Path): image_format = SoftwareImageFormat.RAUC attributes = await rauc.parse_file(file) else: - logger.warning(f"Unknown file format, magic={magic}") - raise ValueError(f"Unknown file format, magic={magic}") - attributes["image_format"] = image_format - return attributes + logger.warning(f"Unknown file format, magic={magic.decode()}") + raise ValueError(f"Unknown file format, magic={magic.decode()}") + attributes["image_format"] = image_format # type: ignore[index] + return attributes # type: ignore[return-value] diff --git a/goosebit/updates/swdesc/func.py b/goosebit/updates/swdesc/func.py index 779ea468..ba8ccaca 100644 --- a/goosebit/updates/swdesc/func.py +++ b/goosebit/updates/swdesc/func.py @@ -3,7 +3,7 @@ from anyio import AsyncFile -async def sha1_hash_file(fileobj: AsyncFile): +async def sha1_hash_file(fileobj: AsyncFile[bytes]) -> str: last = await fileobj.tell() await fileobj.seek(0) sha1_hash = hashlib.sha1() diff --git a/goosebit/updates/swdesc/rauc.py b/goosebit/updates/swdesc/rauc.py index 7aa6435d..09e8dd4d 100644 --- a/goosebit/updates/swdesc/rauc.py +++ b/goosebit/updates/swdesc/rauc.py @@ -1,6 +1,7 @@ import configparser import logging import re +from typing import Any import semver from anyio import Path, open_file @@ -13,7 +14,7 @@ logger = logging.getLogger(__name__) -async def parse_file(file: Path): +async def parse_file(file: Path) -> dict[str, Any]: async with await open_file(file, "r+b") as f: image_data = await f.read() @@ -30,7 +31,7 @@ async def parse_file(file: Path): return swdesc_attrs -def parse_descriptor(manifest: configparser.ConfigParser): +def parse_descriptor(manifest: configparser.ConfigParser) -> dict[str, Any]: swdesc_attrs = {} try: swdesc_attrs["version"] = semver.Version.parse(manifest["update"].get("version")) diff --git a/goosebit/updates/swdesc/swu.py b/goosebit/updates/swdesc/swu.py index be9895e5..b8f8e6ad 100644 --- a/goosebit/updates/swdesc/swu.py +++ b/goosebit/updates/swdesc/swu.py @@ -13,7 +13,7 @@ MAGIC = b"0707" -def _append_compatibility(boardname, value, compatibility): +def _append_compatibility(boardname: str, value: Any, compatibility: list[dict[str, str]]) -> None: if not isinstance(value, dict): return if "hardware-compatibility" in value: @@ -21,8 +21,8 @@ def _append_compatibility(boardname, value, compatibility): compatibility.append({"hw_model": boardname, "hw_revision": revision}) -def parse_descriptor(swdesc: libconf.AttrDict[Any, Any | None]): - swdesc_attrs = {} +def parse_descriptor(swdesc: libconf.AttrDict[Any, Any | None]) -> dict[str, Any]: + swdesc_attrs: dict[str, Any] = {} try: swdesc_attrs["version"] = Version.parse(swdesc["software"]["version"]) compatibility: list[dict[str, str]] = [] @@ -48,7 +48,7 @@ def parse_descriptor(swdesc: libconf.AttrDict[Any, Any | None]): return swdesc_attrs -async def parse_file(file: Path): +async def parse_file(file: Path) -> dict[str, Any] | None: async with await open_file(file, "r+b") as f: # get file size size = int((await f.read(110))[54:62], 16) diff --git a/goosebit/users/__init__.py b/goosebit/users/__init__.py index a66f5aa5..9e2a909f 100644 --- a/goosebit/users/__init__.py +++ b/goosebit/users/__init__.py @@ -2,7 +2,7 @@ from goosebit.api.telemetry.metrics import users_count from goosebit.db.models import User -from goosebit.settings import PWD_CXT +from goosebit.settings import PWD_CXT # type: ignore[attr-defined] async def create_user(username: str, password: str, permissions: list[str]) -> User: @@ -39,24 +39,24 @@ async def setup_user(cls, username: str, hashed_pwd: str, permissions: list[str] ) )[0] users_count.set(await User.all().count()) - return user + return user # type: ignore[no-any-return] @staticmethod async def get_user(username: str) -> User: cache = caches.get("default") user = await cache.get(username) if user: - return user + return user # type: ignore[no-any-return] user = await User.get_or_none(username=username) if user is not None: result = await cache.set(user.username, user, ttl=600) assert result, "user being cached" - return user + return user # type: ignore[no-any-return] @staticmethod - async def delete_users(usernames: list[str]): + async def delete_users(usernames: list[str]) -> None: await User.filter(username__in=usernames).delete() for username in usernames: await caches.get("default").delete(username) diff --git a/goosebit/util/version.py b/goosebit/util/version.py index c3387c61..d4fbfb86 100644 --- a/goosebit/util/version.py +++ b/goosebit/util/version.py @@ -11,15 +11,15 @@ class Version: version_str: str default_version: int | None - sem_version: SemVersion + sem_version: SemVersion | None - def __init__(self, version_str: str, default_version: int | None = None, sem_version: SemVersion = None): - self.version_str = version_str - self.default_version = default_version - self.sem_version = sem_version + def __init__(self, version_str: str, default_version: int | None = None, sem_version: SemVersion | None = None): + self.version_str: str = version_str + self.default_version: int | None = default_version + self.sem_version: SemVersion | None = sem_version @staticmethod - def parse(version_str: str): + def parse(version_str: str) -> "Version": default_version = Version._default_version_to_number(version_str) sem_version = None try: @@ -32,10 +32,10 @@ def parse(version_str: str): return Version(version_str, default_version, sem_version) - def __str__(self): + def __str__(self) -> str: return self.version_str - def __eq__(self, other): + def __eq__(self, other: object) -> bool: # support comparison with strings as a convenience if isinstance(other, str): try: @@ -50,12 +50,12 @@ def __eq__(self, other): return self.default_version == other.default_version if self.sem_version and other.sem_version: - return self.sem_version == other.sem_version + return bool(self.sem_version == other.sem_version) # fallback to lexical comparison of no of the same type return self.version_str == other.version_str - def __lt__(self, other): + def __lt__(self, other: object) -> bool: if not isinstance(other, Version): return NotImplemented @@ -63,7 +63,7 @@ def __lt__(self, other): return self.default_version < other.default_version if self.sem_version and other.sem_version: - return self.sem_version < other.sem_version + return bool(self.sem_version < other.sem_version) # fallback to lexical comparison of no of the same type return self.version_str < other.version_str diff --git a/plugins/goosebit_forwarded_header/goosebit_forwarded_header/__init__.py b/plugins/goosebit_forwarded_header/goosebit_forwarded_header/__init__.py index dee8c125..e5f97b63 100644 --- a/plugins/goosebit_forwarded_header/goosebit_forwarded_header/__init__.py +++ b/plugins/goosebit_forwarded_header/goosebit_forwarded_header/__init__.py @@ -3,5 +3,5 @@ from . import middleware config = PluginSchema( - middleware=middleware.ForwardedHeaderMiddleware, + middleware=middleware.ForwardedHeaderMiddleware, # type: ignore[attr-defined] ) diff --git a/plugins/goosebit_simple_stats/goosebit_simple_stats/__init__.py b/plugins/goosebit_simple_stats/goosebit_simple_stats/__init__.py index e9312bea..f40950fa 100644 --- a/plugins/goosebit_simple_stats/goosebit_simple_stats/__init__.py +++ b/plugins/goosebit_simple_stats/goosebit_simple_stats/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from fastapi import APIRouter, FastAPI @@ -8,16 +9,16 @@ @asynccontextmanager -async def lifespan(_: FastAPI): +async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: yield simple_stats_router = APIRouter(lifespan=lifespan) -simple_stats_router.include_router(ui.router) -simple_stats_router.include_router(api.router) +simple_stats_router.include_router(ui.router) # type: ignore[attr-defined] +simple_stats_router.include_router(api.router) # type: ignore[attr-defined] config = PluginSchema( router=simple_stats_router, - static_files=ui.static, - templates=ui.templates, + static_files=ui.static, # type: ignore[attr-defined] + templates=ui.templates, # type: ignore[attr-defined] ) diff --git a/plugins/goosebit_simple_stats/goosebit_simple_stats/api/routes.py b/plugins/goosebit_simple_stats/goosebit_simple_stats/api/routes.py index 397c335e..67b0e3ce 100644 --- a/plugins/goosebit_simple_stats/goosebit_simple_stats/api/routes.py +++ b/plugins/goosebit_simple_stats/goosebit_simple_stats/api/routes.py @@ -3,4 +3,4 @@ from . import v1 router = APIRouter(prefix="/api") -router.include_router(v1.router) +router.include_router(v1.router) # type: ignore[attr-defined] diff --git a/plugins/goosebit_simple_stats/goosebit_simple_stats/api/v1/routes.py b/plugins/goosebit_simple_stats/goosebit_simple_stats/api/v1/routes.py index 773c5d5f..fe0ff9bf 100644 --- a/plugins/goosebit_simple_stats/goosebit_simple_stats/api/v1/routes.py +++ b/plugins/goosebit_simple_stats/goosebit_simple_stats/api/v1/routes.py @@ -5,4 +5,4 @@ from . import stats router = APIRouter(prefix="/v1") -router.include_router(stats.router) +router.include_router(stats.router) # type: ignore[attr-defined] diff --git a/plugins/goosebit_simple_stats/goosebit_simple_stats/ui/bff/__init__.py b/plugins/goosebit_simple_stats/goosebit_simple_stats/ui/bff/__init__.py index d6331dfa..e50e8a71 100644 --- a/plugins/goosebit_simple_stats/goosebit_simple_stats/ui/bff/__init__.py +++ b/plugins/goosebit_simple_stats/goosebit_simple_stats/ui/bff/__init__.py @@ -5,4 +5,4 @@ from . import stats router = APIRouter(prefix="/bff", tags=["bff"], dependencies=[Depends(validate_current_user)]) -router.include_router(stats.router) +router.include_router(stats.router) # type: ignore[attr-defined] diff --git a/plugins/goosebit_simple_stats/goosebit_simple_stats/ui/routes.py b/plugins/goosebit_simple_stats/goosebit_simple_stats/ui/routes.py index 9fe803a8..bd264506 100644 --- a/plugins/goosebit_simple_stats/goosebit_simple_stats/ui/routes.py +++ b/plugins/goosebit_simple_stats/goosebit_simple_stats/ui/routes.py @@ -1,7 +1,7 @@ -from fastapi import APIRouter, Depends, Security +from fastapi import APIRouter, Depends, Response, Security from fastapi.requests import Request -from goosebit import nav +from goosebit import nav # type: ignore[attr-defined] from goosebit.auth import redirect_if_unauthenticated, validate_user_permissions from goosebit.ui.templates import templates @@ -20,5 +20,5 @@ ], ) @nav.route("Stats", permissions=["stats.read"]) -async def stats_ui(request: Request): +async def stats_ui(request: Request) -> Response: return templates.TemplateResponse(request, "stats.html.jinja", context={"title": "Simple Stats"}) diff --git a/pyproject.toml b/pyproject.toml index 81b7bf37..5bcc40e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,7 @@ location = "goosebit/db/migrations" src_folder = "./goosebit" [tool.mypy] +strict = true explicit_package_bases = true plugins = ["pydantic.mypy"] diff --git a/sample_data.py b/sample_data.py index 5ef8a6da..1713c393 100644 --- a/sample_data.py +++ b/sample_data.py @@ -17,7 +17,7 @@ fake.add_provider(company) -async def generate_sample_data(): +async def generate_sample_data() -> None: await Rollout.all().delete() await Device.all().delete() await Software.all().delete() @@ -75,7 +75,7 @@ async def generate_sample_data(): print("Sample data created!") -async def run(): +async def run() -> None: db_ready = await goosebit.db.init() if db_ready: await generate_sample_data() @@ -84,5 +84,5 @@ async def run(): print("Failed to initialize database") -def main(): +def main() -> None: asyncio.run(run()) diff --git a/tests/e2e/docker/auth_server/server.py b/tests/e2e/docker/auth_server/server.py index f627da68..c3424bc3 100644 --- a/tests/e2e/docker/auth_server/server.py +++ b/tests/e2e/docker/auth_server/server.py @@ -6,19 +6,19 @@ class AuthHandler(BaseHTTPRequestHandler): - def do_GET(self): + def do_GET(self) -> None: if self.path == "/health": self._send_response(200, "ok") else: self.send_error(404, "Not Found") - def do_POST(self): + def do_POST(self) -> None: if self.path == "/api/v1/auth": self._handle_auth() else: self.send_error(404, "Not found") - def _handle_auth(self): + def _handle_auth(self) -> None: auth_header = self.headers.get("Authorization") if auth_header: @@ -51,7 +51,7 @@ def _handle_auth(self): except Exception: self._send_response(500, "Internal server error") - def _send_response(self, code: int = 200, message: str = "Unathorized"): + def _send_response(self, code: int = 200, message: str = "Unathorized") -> None: self.send_response(code) self.send_header("Content-type", "application/json") self.end_headers() diff --git a/tests/e2e/docker/swupdate/myscript.py b/tests/e2e/docker/swupdate/myscript.py index d3573d91..b27022d1 100755 --- a/tests/e2e/docker/swupdate/myscript.py +++ b/tests/e2e/docker/swupdate/myscript.py @@ -3,7 +3,7 @@ from time import sleep -def main(): +def main() -> None: while True: print("Hello!", flush=True) sleep(5) diff --git a/tests/e2e/external_auth/tests/test_e2e_external_auth.py b/tests/e2e/external_auth/tests/test_e2e_external_auth.py index bf1a9ab2..654928f0 100644 --- a/tests/e2e/external_auth/tests/test_e2e_external_auth.py +++ b/tests/e2e/external_auth/tests/test_e2e_external_auth.py @@ -2,6 +2,7 @@ import sys import time from pathlib import Path +from typing import Any, Generator import httpx import pytest @@ -14,16 +15,16 @@ COMPOSE_FILE = Path(__file__).resolve().parents[1].joinpath("docker-compose.yml") -def _compose_up_build(): +def _compose_up_build() -> None: compose_up_build(COMPOSE_FILE) -def _compose_down(): +def _compose_down() -> None: compose_down(COMPOSE_FILE, remove_orphans=True) @pytest.fixture(scope="module", autouse=True) -def compose_lifecycle(): +def compose_lifecycle() -> Generator[None, None, None]: # Ensure a clean slate for this module, then bring up compose try: _compose_down() @@ -41,7 +42,7 @@ def compose_lifecycle(): @pytest.fixture(scope="module") -def ensure_services_ready(compose_lifecycle): +def ensure_services_ready(compose_lifecycle: Any) -> bool: ok, err = wait_for_service(f"{AUTH_SERVICE_BASE_URL}/health", timeout_seconds=20) assert ok, f"auth service not ready: {err}" @@ -51,7 +52,7 @@ def ensure_services_ready(compose_lifecycle): return True -def test_e2e_external_auth_smoke_and_health(ensure_services_ready): +def test_e2e_external_auth_smoke_and_health(ensure_services_ready: Any) -> None: """Smoke test for external auth setup and goosebit endpoints""" with httpx.Client(base_url=AUTH_SERVICE_BASE_URL, follow_redirects=True, timeout=10.0) as client: @@ -70,7 +71,7 @@ def test_e2e_external_auth_smoke_and_health(ensure_services_ready): assert api_resp.status_code == 200, api_resp.text -def test_e2e_device_registration_with_external_auth(ensure_services_ready): +def test_e2e_device_registration_with_external_auth(ensure_services_ready: Any) -> None: """Test device registration with external authentication - verify device state is REGISTERED not UNKNOWN""" with httpx.Client(base_url=BASE_URL, follow_redirects=True, timeout=20.0) as client: diff --git a/tests/e2e/external_auth/tests/test_e2e_external_auth_bearer_token.py b/tests/e2e/external_auth/tests/test_e2e_external_auth_bearer_token.py index 323ed81b..d72484a1 100644 --- a/tests/e2e/external_auth/tests/test_e2e_external_auth_bearer_token.py +++ b/tests/e2e/external_auth/tests/test_e2e_external_auth_bearer_token.py @@ -2,6 +2,7 @@ import sys import time from pathlib import Path +from typing import Any, Generator import httpx import pytest @@ -14,16 +15,16 @@ COMPOSE_FILE = Path(__file__).resolve().parents[1].joinpath("docker-compose-bearer.yml") -def _compose_up_build(): +def _compose_up_build() -> None: compose_up_build(COMPOSE_FILE) -def _compose_down(): +def _compose_down() -> None: compose_down(COMPOSE_FILE, remove_orphans=True) @pytest.fixture(scope="module", autouse=True) -def compose_lifecycle(): +def compose_lifecycle() -> Generator[None, None, None]: # Ensure a clean slate for this module, then bring up compose try: _compose_down() @@ -41,7 +42,7 @@ def compose_lifecycle(): @pytest.fixture(scope="module") -def ensure_services_ready(compose_lifecycle): +def ensure_services_ready(compose_lifecycle: Any) -> bool: ok, err = wait_for_service(f"{AUTH_SERVICE_BASE_URL}/health", timeout_seconds=20) assert ok, f"auth service not ready: {err}" @@ -51,7 +52,7 @@ def ensure_services_ready(compose_lifecycle): return True -def test_e2e_external_auth_smoke_and_health(ensure_services_ready): +def test_e2e_external_auth_smoke_and_health(ensure_services_ready: Any) -> None: """Smoke test for external auth setup and goosebit endpoints""" with httpx.Client(base_url=AUTH_SERVICE_BASE_URL, follow_redirects=True, timeout=10.0) as client: @@ -70,7 +71,7 @@ def test_e2e_external_auth_smoke_and_health(ensure_services_ready): assert api_resp.status_code == 200, api_resp.text -def test_e2e_device_registration_with_external_auth_bearer_token(ensure_services_ready): +def test_e2e_device_registration_with_external_auth_bearer_token(ensure_services_ready: Any) -> None: """Test device registration with external authentication - verify device state is REGISTERED not UNKNOWN""" with httpx.Client(base_url=BASE_URL, follow_redirects=True, timeout=20.0) as client: diff --git a/tests/e2e/external_auth/tests/test_e2e_external_auth_invalid_token.py b/tests/e2e/external_auth/tests/test_e2e_external_auth_invalid_token.py index 141a15d8..21de830f 100644 --- a/tests/e2e/external_auth/tests/test_e2e_external_auth_invalid_token.py +++ b/tests/e2e/external_auth/tests/test_e2e_external_auth_invalid_token.py @@ -1,6 +1,7 @@ import os import sys from pathlib import Path +from typing import Any, Generator import httpx import pytest @@ -13,16 +14,16 @@ COMPOSE_FILE = Path(__file__).resolve().parents[1].joinpath("docker-compose-invalid.yml") -def _compose_up_build(): +def _compose_up_build() -> None: compose_up_build(COMPOSE_FILE) -def _compose_down(): +def _compose_down() -> None: compose_down(COMPOSE_FILE, remove_orphans=True) @pytest.fixture(scope="module", autouse=True) -def compose_lifecycle(): +def compose_lifecycle() -> Generator[None, None, None]: # Ensure a clean slate for this module, then bring up compose try: _compose_down() @@ -40,7 +41,7 @@ def compose_lifecycle(): @pytest.fixture(scope="module") -def ensure_services_ready(compose_lifecycle): +def ensure_services_ready(compose_lifecycle: Any) -> bool: ok, err = wait_for_service(f"{AUTH_SERVICE_BASE_URL}/health", timeout_seconds=20) assert ok, f"auth service not ready: {err}" @@ -50,7 +51,7 @@ def ensure_services_ready(compose_lifecycle): return True -def test_e2e_external_auth_smoke_and_health(ensure_services_ready): +def test_e2e_external_auth_smoke_and_health(ensure_services_ready: Any) -> None: """Smoke test for external auth setup and goosebit endpoints""" with httpx.Client(base_url=AUTH_SERVICE_BASE_URL, follow_redirects=True, timeout=10.0) as client: @@ -69,7 +70,7 @@ def test_e2e_external_auth_smoke_and_health(ensure_services_ready): assert api_resp.status_code == 200, api_resp.text -def test_e2e_device_registration_with_invalid_token_should_not_work(ensure_services_ready): +def test_e2e_device_registration_with_invalid_token_should_not_work(ensure_services_ready: Any) -> None: """Verify device with invalid token does not appear in the database or list""" with httpx.Client(base_url=BASE_URL, follow_redirects=True, timeout=20.0) as client: diff --git a/tests/e2e/s3/tests/test_e2e_s3.py b/tests/e2e/s3/tests/test_e2e_s3.py index 364ef8f0..842cbd9d 100644 --- a/tests/e2e/s3/tests/test_e2e_s3.py +++ b/tests/e2e/s3/tests/test_e2e_s3.py @@ -2,6 +2,7 @@ import sys import time from pathlib import Path +from typing import Any, Generator import boto3 import httpx @@ -19,16 +20,16 @@ COMPOSE_FILE = Path(__file__).resolve().parents[1].joinpath("docker-compose.yml") -def _compose_up_build(): +def _compose_up_build() -> None: compose_up_build(COMPOSE_FILE) -def _compose_down(): +def _compose_down() -> None: compose_down(COMPOSE_FILE, remove_orphans=True) @pytest.fixture(scope="module", autouse=True) -def compose_lifecycle(): +def compose_lifecycle() -> Generator[None, None, None]: # Ensure a clean slate for this module, then bring up compose try: _compose_down() @@ -46,7 +47,7 @@ def compose_lifecycle(): @pytest.fixture(scope="module") -def ensure_services_ready(compose_lifecycle): +def ensure_services_ready(compose_lifecycle: Generator[None, None, None]) -> bool: # Wait for services once per module ok, err = wait_for_service(f"{BASE_URL}/docs", timeout_seconds=180) assert ok, f"goosebit not ready: {err}" @@ -56,7 +57,7 @@ def ensure_services_ready(compose_lifecycle): return True -def ensure_minio_bucket(): +def ensure_minio_bucket() -> None: s3 = boto3.resource( "s3", endpoint_url=MINIO_URL, @@ -79,7 +80,7 @@ def ensure_minio_bucket(): # --------------------- -def _ensure_artifact(client: httpx.Client, token: str) -> tuple[dict, str]: +def _ensure_artifact(client: httpx.Client, token: str) -> tuple[dict[str, Any], str]: """Ensure a software artifact exists (upload if needed). Return the chosen software dict and its version.""" list_resp = client.get("/api/v1/software", headers={"Authorization": f"Bearer {token}"}) assert list_resp.status_code == 200 @@ -105,7 +106,7 @@ def _ensure_artifact(client: httpx.Client, token: str) -> tuple[dict, str]: return sw, sw_version -def _ensure_artifact_and_rollout(client: httpx.Client, token: str, feed: str = "default") -> tuple[dict, str]: +def _ensure_artifact_and_rollout(client: httpx.Client, token: str, feed: str = "default") -> tuple[dict[str, Any], str]: """Ensure an artifact exists and create a rollout targeting the given feed. Returns the software dict and its version. """ @@ -121,7 +122,7 @@ def _ensure_artifact_and_rollout(client: httpx.Client, token: str, feed: str = " return sw, version -def test_e2e_smoke_setup_login_and_basic_routes(ensure_services_ready): +def test_e2e_smoke_setup_login_and_basic_routes(ensure_services_ready: bool) -> None: with httpx.Client(base_url=BASE_URL, follow_redirects=True, timeout=20.0) as client: token = auth_token(client) @@ -141,7 +142,7 @@ def test_e2e_smoke_setup_login_and_basic_routes(ensure_services_ready): assert "gooseBit" in root_resp.text or "Devices" in root_resp.text -def test_e2e_artifact_upload_and_minio_presence(ensure_services_ready): +def test_e2e_artifact_upload_and_minio_presence(ensure_services_ready: bool) -> None: with httpx.Client(base_url=BASE_URL, follow_redirects=True, timeout=20.0) as client: token = auth_token(client) sw, _ = _ensure_artifact(client, token) @@ -171,7 +172,7 @@ def test_e2e_artifact_upload_and_minio_presence(ensure_services_ready): raise AssertionError(f"Object not found in MinIO bucket={MINIO_BUCKET}, key={key}: {last_exc}") -def test_e2e_device_update_rollout_to_version(ensure_services_ready): +def test_e2e_device_update_rollout_to_version(ensure_services_ready: bool) -> None: # Sanity: service docs should be reachable ok, err = wait_for_service(f"{BASE_URL}/docs", timeout_seconds=180) assert ok, f"Service not ready: {err}" @@ -219,7 +220,7 @@ def test_e2e_device_update_rollout_to_version(ensure_services_ready): ) -def test_e2e_artifact_delete_removes_from_minio(ensure_services_ready): +def test_e2e_artifact_delete_removes_from_minio(ensure_services_ready: bool) -> None: with httpx.Client(base_url=BASE_URL, follow_redirects=True, timeout=20.0) as client: token = auth_token(client) diff --git a/tests/e2e/utils.py b/tests/e2e/utils.py index d580a3e8..697ebbd1 100644 --- a/tests/e2e/utils.py +++ b/tests/e2e/utils.py @@ -59,7 +59,7 @@ def auth_token(client: httpx.Client) -> str: creds = {"username": "admin@goosebit.local", "password": "admin"} setup_resp = client.post("/setup", data=creds, headers={"Content-Type": "application/x-www-form-urlencoded"}) if setup_resp.status_code == 200: - return setup_resp.json()["access_token"] + return str(setup_resp.json()["access_token"]) login_resp = client.post("/login", data=creds, headers={"Content-Type": "application/x-www-form-urlencoded"}) assert login_resp.status_code == 200, login_resp.text - return login_resp.json()["access_token"] + return str(login_resp.json()["access_token"]) diff --git a/tests/unit/api/v1/settings/test_users_routes.py b/tests/unit/api/v1/settings/test_users_routes.py index d5561322..64893f1a 100644 --- a/tests/unit/api/v1/settings/test_users_routes.py +++ b/tests/unit/api/v1/settings/test_users_routes.py @@ -1,17 +1,19 @@ +from typing import Any + import pytest from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS @pytest.mark.asyncio -async def test_create_user(async_client, test_data): +async def test_create_user(async_client: Any, test_data: Any) -> None: user_to_create = {"username": "created@goosebit.test", "permissions": [GOOSEBIT_PERMISSIONS["device"]["read"]()]} - response = await async_client.post(f"/api/v1/settings/users", json={"password": "testcreated", **user_to_create}) + response = await async_client.post("/api/v1/settings/users", json={"password": "testcreated", **user_to_create}) assert response.status_code == 200 - user_to_create["enabled"] = True + user_to_create["enabled"] = True # type: ignore[assignment] - response = await async_client.get(f"/api/v1/settings/users") + response = await async_client.get("/api/v1/settings/users") users = response.json() assert user_to_create in users["users"] diff --git a/tests/unit/api/v1/software/test_routes.py b/tests/unit/api/v1/software/test_routes.py index 41bcbbc1..761af884 100644 --- a/tests/unit/api/v1/software/test_routes.py +++ b/tests/unit/api/v1/software/test_routes.py @@ -1,3 +1,5 @@ +from typing import Any + import pytest from anyio import Path, open_file @@ -5,13 +7,13 @@ @pytest.mark.asyncio -async def test_create_software_local(async_client, test_data): +async def test_create_software_local(async_client: Any, test_data: Any) -> None: resolved = await Path(__file__).resolve() path = resolved.parent / "software-header.swu" async with await open_file(path, "rb") as file: files = {"file": await file.read()} - response = await async_client.post(f"/api/v1/software", files=files) + response = await async_client.post("/api/v1/software", files=files) assert response.status_code == 200 software = response.json() @@ -19,22 +21,22 @@ async def test_create_software_local(async_client, test_data): @pytest.mark.asyncio -async def test_create_software_local_twice(async_client, test_data): +async def test_create_software_local_twice(async_client: Any, test_data: Any) -> None: resolved = await Path(__file__).resolve() path = resolved.parent / "software-header.swu" with open(path, "rb") as file: files = {"file": file} - response = await async_client.post(f"/api/v1/software", files=files) + response = await async_client.post("/api/v1/software", files=files) assert response.status_code == 200 with open(path, "rb") as file: files = {"file": file} - response = await async_client.post(f"/api/v1/software", files=files) + response = await async_client.post("/api/v1/software", files=files) assert response.status_code == 409 @pytest.mark.asyncio -async def test_create_software_remote(async_client, httpserver, test_data): +async def test_create_software_remote(async_client: Any, httpserver: Any, test_data: Any) -> None: resolved = await Path(__file__).resolve() path = resolved.parent / "software-header.swu" async with await open_file(path, "rb") as file: @@ -43,7 +45,7 @@ async def test_create_software_remote(async_client, httpserver, test_data): httpserver.expect_request("/software-header.swu").respond_with_data(byte_array) software_url = httpserver.url_for("/software-header.swu") - response = await async_client.post(f"/api/v1/software", data={"url": software_url}) + response = await async_client.post("/api/v1/software", data={"url": software_url}) assert response.status_code == 200 software = response.json() @@ -51,7 +53,9 @@ async def test_create_software_remote(async_client, httpserver, test_data): @pytest.mark.asyncio -async def test_create_software_remote_twice_same_content_different_url(async_client, httpserver, test_data): +async def test_create_software_remote_twice_same_content_different_url( + async_client: Any, httpserver: Any, test_data: Any +) -> None: response = await _upload_software(async_client, httpserver, "software-header.swu", "/software-header.swu") assert response.status_code == 200 @@ -60,7 +64,9 @@ async def test_create_software_remote_twice_same_content_different_url(async_cli @pytest.mark.asyncio -async def test_create_software_remote_twice_different_content_different_url(async_client, httpserver, test_data): +async def test_create_software_remote_twice_different_content_different_url( + async_client: Any, httpserver: Any, test_data: Any +) -> None: response = await _upload_software(async_client, httpserver, "software-header.swu", "/software-header.swu") assert response.status_code == 200 software = response.json() @@ -73,7 +79,9 @@ async def test_create_software_remote_twice_different_content_different_url(asyn @pytest.mark.asyncio -async def test_create_software_remote_twice_different_content_same_url(async_client, httpserver, test_data): +async def test_create_software_remote_twice_different_content_same_url( + async_client: Any, httpserver: Any, test_data: Any +) -> None: response = await _upload_software(async_client, httpserver, "software-header.swu", "/software-header.swu") assert response.status_code == 200 software = response.json() @@ -87,25 +95,25 @@ async def test_create_software_remote_twice_different_content_same_url(async_cli @pytest.mark.asyncio async def test_create_software_remote_twice_different_content_same_url_referenced_by_rollout( - async_client, httpserver, test_data -): + async_client: Any, httpserver: Any, test_data: Any +) -> None: response = await _upload_software(async_client, httpserver, "software-header.swu", "/software-header.swu") assert response.status_code == 200 software = response.json() software_id = software["id"] - await Rollout.create(name=f"Test rollout", software_id=software_id) + await Rollout.create(name="Test rollout", software_id=software_id) response = await _upload_software(async_client, httpserver, "software-header-2.swu", "/software-header.swu") assert response.status_code == 409 -async def _upload_software(async_client, httpserver, software_file, download_url): +async def _upload_software(async_client: Any, httpserver: Any, software_file: str, download_url: str) -> Any: resolved = await Path(__file__).resolve() path = resolved.parent / software_file with open(path, "rb") as file: byte_array = file.read() httpserver.expect_request(download_url).respond_with_data(byte_array) software_url = httpserver.url_for(download_url) - response = await async_client.post(f"/api/v1/software", data={"url": software_url}) + response = await async_client.post("/api/v1/software", data={"url": software_url}) return response diff --git a/tests/unit/auth/test_permissions.py b/tests/unit/auth/test_permissions.py index aea6d464..492d577e 100644 --- a/tests/unit/auth/test_permissions.py +++ b/tests/unit/auth/test_permissions.py @@ -2,37 +2,37 @@ from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS -def test_single_permission(): +def test_single_permission() -> None: assert check_permissions([GOOSEBIT_PERMISSIONS["device"]["read"]()], ["goosebit.device.read"]) -def test_wildcard_sub_permission(): +def test_wildcard_sub_permission() -> None: assert check_permissions([GOOSEBIT_PERMISSIONS["device"]["read"]()], ["goosebit.device.*"]) -def test_root_permission(): +def test_root_permission() -> None: assert check_permissions([GOOSEBIT_PERMISSIONS["device"]["read"]()], ["goosebit.device"]) -def test_root_wildcard_permission(): +def test_root_wildcard_permission() -> None: assert check_permissions([GOOSEBIT_PERMISSIONS["device"]["read"]()], ["*"]) -def test_multiple_single_permissions(): +def test_multiple_single_permissions() -> None: assert check_permissions( [GOOSEBIT_PERMISSIONS["device"]["read"](), GOOSEBIT_PERMISSIONS["device"]["write"]()], ["goosebit.device.read", "goosebit.device.write"], ) -def test_invalid_multiple_single_permissions(): +def test_invalid_multiple_single_permissions() -> None: assert not check_permissions( [GOOSEBIT_PERMISSIONS["device"]["read"](), GOOSEBIT_PERMISSIONS["device"]["write"]()], ["goosebit.device.read", "goosebit.device.read"], ) -def test_multiple_root_wildcard_permissions(): +def test_multiple_root_wildcard_permissions() -> None: assert check_permissions( [ GOOSEBIT_PERMISSIONS["device"]["write"](), diff --git a/tests/unit/ui/bff/devices/test_routes.py b/tests/unit/ui/bff/devices/test_routes.py index 832a8af1..0ebed7f1 100644 --- a/tests/unit/ui/bff/devices/test_routes.py +++ b/tests/unit/ui/bff/devices/test_routes.py @@ -1,9 +1,12 @@ +from typing import Any + import pytest +from httpx import AsyncClient @pytest.mark.asyncio -async def test_list_devices_id_asc(async_client, test_data): - response = await async_client.get(f"/ui/bff/devices?order[0][dir]=asc&order[0][name]=id") +async def test_list_devices_id_asc(async_client: AsyncClient, test_data: dict[str, Any]) -> None: + response = await async_client.get("/ui/bff/devices?order[0][dir]=asc&order[0][name]=id") assert response.status_code == 200 devices = response.json()["data"] @@ -13,8 +16,8 @@ async def test_list_devices_id_asc(async_client, test_data): @pytest.mark.asyncio -async def test_list_devices_id_desc(async_client, test_data): - response = await async_client.get(f"/ui/bff/devices?order[0][dir]=desc&order[0][name]=id") +async def test_list_devices_id_desc(async_client: AsyncClient, test_data: dict[str, Any]) -> None: + response = await async_client.get("/ui/bff/devices?order[0][dir]=desc&order[0][name]=id") assert response.status_code == 200 devices = response.json()["data"] diff --git a/tests/unit/ui/bff/settings/test_users_routes.py b/tests/unit/ui/bff/settings/test_users_routes.py index db558315..c970bdb4 100644 --- a/tests/unit/ui/bff/settings/test_users_routes.py +++ b/tests/unit/ui/bff/settings/test_users_routes.py @@ -1,9 +1,12 @@ +from typing import Any + import pytest +from httpx import AsyncClient @pytest.mark.asyncio -async def test_list_users_username_asc(async_client, test_data): - response = await async_client.get(f"/ui/bff/settings/users?order[0][dir]=asc&order[0][name]=username") +async def test_list_users_username_asc(async_client: AsyncClient, test_data: dict[str, Any]) -> None: + response = await async_client.get("/ui/bff/settings/users?order[0][dir]=asc&order[0][name]=username") assert response.status_code == 200 users = response.json()["data"] @@ -13,8 +16,8 @@ async def test_list_users_username_asc(async_client, test_data): @pytest.mark.asyncio -async def test_list_users_username_desc(async_client, test_data): - response = await async_client.get(f"/ui/bff/settings/users?order[0][dir]=desc&order[0][name]=username") +async def test_list_users_username_desc(async_client: AsyncClient, test_data: dict[str, Any]) -> None: + response = await async_client.get("/ui/bff/settings/users?order[0][dir]=desc&order[0][name]=username") assert response.status_code == 200 users = response.json()["data"] diff --git a/tests/unit/ui/bff/software/test_routes.py b/tests/unit/ui/bff/software/test_routes.py index a0813765..590d7d8a 100644 --- a/tests/unit/ui/bff/software/test_routes.py +++ b/tests/unit/ui/bff/software/test_routes.py @@ -1,9 +1,12 @@ +from typing import Any + import pytest +from httpx import AsyncClient @pytest.mark.asyncio -async def test_list_software_version_asc(async_client, test_data): - response = await async_client.get(f"/ui/bff/software?order[0][dir]=asc&order[0][name]=version") +async def test_list_software_version_asc(async_client: AsyncClient, test_data: dict[str, Any]) -> None: + response = await async_client.get("/ui/bff/software?order[0][dir]=asc&order[0][name]=version") assert response.status_code == 200 software = response.json()["data"] @@ -14,8 +17,8 @@ async def test_list_software_version_asc(async_client, test_data): @pytest.mark.asyncio -async def test_list_software_version_desc(async_client, test_data): - response = await async_client.get(f"/ui/bff/software?order[0][dir]=desc&order[0][name]=version") +async def test_list_software_version_desc(async_client: AsyncClient, test_data: dict[str, Any]) -> None: + response = await async_client.get("/ui/bff/software?order[0][dir]=desc&order[0][name]=version") assert response.status_code == 200 software = response.json()["data"] diff --git a/tests/unit/updater/controller/test_device_auth.py b/tests/unit/updater/controller/test_device_auth.py index 8c2e2ed6..3f392f8e 100644 --- a/tests/unit/updater/controller/test_device_auth.py +++ b/tests/unit/updater/controller/test_device_auth.py @@ -1,26 +1,34 @@ +from typing import Any, Dict + import pytest +from httpx import AsyncClient +from goosebit.db.models import Device from goosebit.settings import config from goosebit.settings.schema import DeviceAuthMode -async def _api_device_get(device_auth_async_client, dev_id): +async def _api_device_get(device_auth_async_client: AsyncClient, dev_id: str) -> Dict[str, Any]: response = await device_auth_async_client.get("/api/v1/devices") assert response.status_code == 200 devices = response.json()["devices"] return next(d for d in devices if d["id"] == dev_id) -async def _api_device_update(device_auth_async_client, device, update_attribute, update_value): +async def _api_device_update( + device_auth_async_client: AsyncClient, device: Device, update_attribute: str, update_value: Any +) -> None: response = await device_auth_async_client.patch( - f"/ui/bff/devices", + "/ui/bff/devices", json={"devices": [f"{device.id}"], update_attribute: update_value}, ) assert response.status_code == 200 @pytest.mark.asyncio -async def test_poll_strict_with_no_auth_device_with_no_auth(async_client, test_data, monkeypatch): +async def test_poll_strict_with_no_auth_device_with_no_auth( + async_client: AsyncClient, test_data: Dict[str, Any], monkeypatch: Any +) -> None: device = test_data["device_no_authentication"] monkeypatch.setattr(config.device_auth, "enable", True) monkeypatch.setattr(config.device_auth, "mode", DeviceAuthMode.STRICT) @@ -30,7 +38,9 @@ async def test_poll_strict_with_no_auth_device_with_no_auth(async_client, test_d @pytest.mark.asyncio -async def test_poll_strict_with_no_auth_device_with_auth(async_client, test_data, monkeypatch): +async def test_poll_strict_with_no_auth_device_with_auth( + async_client: AsyncClient, test_data: Dict[str, Any], monkeypatch: Any +) -> None: device = test_data["device_authentication"] monkeypatch.setattr(config.device_auth, "enable", True) monkeypatch.setattr(config.device_auth, "mode", DeviceAuthMode.STRICT) @@ -40,7 +50,9 @@ async def test_poll_strict_with_no_auth_device_with_auth(async_client, test_data @pytest.mark.asyncio -async def test_poll_strict_with_auth_device_with_auth(async_client, test_data, monkeypatch): +async def test_poll_strict_with_auth_device_with_auth( + async_client: AsyncClient, test_data: Dict[str, Any], monkeypatch: Any +) -> None: device = test_data["device_authentication"] monkeypatch.setattr(config.device_auth, "enable", True) monkeypatch.setattr(config.device_auth, "mode", DeviceAuthMode.STRICT) @@ -52,7 +64,9 @@ async def test_poll_strict_with_auth_device_with_auth(async_client, test_data, m @pytest.mark.asyncio -async def test_poll_lax_with_no_auth_device_with_no_auth(async_client, test_data, monkeypatch): +async def test_poll_lax_with_no_auth_device_with_no_auth( + async_client: AsyncClient, test_data: Dict[str, Any], monkeypatch: Any +) -> None: device = test_data["device_no_authentication"] monkeypatch.setattr(config.device_auth, "enable", True) monkeypatch.setattr(config.device_auth, "mode", DeviceAuthMode.LAX) @@ -62,7 +76,9 @@ async def test_poll_lax_with_no_auth_device_with_no_auth(async_client, test_data @pytest.mark.asyncio -async def test_poll_lax_with_no_auth_device_with_auth(async_client, test_data, monkeypatch): +async def test_poll_lax_with_no_auth_device_with_auth( + async_client: AsyncClient, test_data: Dict[str, Any], monkeypatch: Any +) -> None: device = test_data["device_authentication"] monkeypatch.setattr(config.device_auth, "enable", True) monkeypatch.setattr(config.device_auth, "mode", DeviceAuthMode.LAX) @@ -72,7 +88,9 @@ async def test_poll_lax_with_no_auth_device_with_auth(async_client, test_data, m @pytest.mark.asyncio -async def test_poll_lax_with_auth_device_with_auth(async_client, test_data, monkeypatch): +async def test_poll_lax_with_auth_device_with_auth( + async_client: AsyncClient, test_data: Dict[str, Any], monkeypatch: Any +) -> None: device = test_data["device_authentication"] monkeypatch.setattr(config.device_auth, "enable", True) monkeypatch.setattr(config.device_auth, "mode", DeviceAuthMode.LAX) @@ -84,7 +102,7 @@ async def test_poll_lax_with_auth_device_with_auth(async_client, test_data, monk @pytest.mark.asyncio -async def test_poll_setup_with_no_auth(async_client, test_data, monkeypatch): +async def test_poll_setup_with_no_auth(async_client: AsyncClient, test_data: Dict[str, Any], monkeypatch: Any) -> None: device = test_data["device_no_authentication"] monkeypatch.setattr(config.device_auth, "enable", True) monkeypatch.setattr(config.device_auth, "mode", DeviceAuthMode.SETUP) @@ -98,7 +116,9 @@ async def test_poll_setup_with_no_auth(async_client, test_data, monkeypatch): @pytest.mark.asyncio -async def test_poll_setup_with_auth_add_device_auth(async_client, test_data, monkeypatch): +async def test_poll_setup_with_auth_add_device_auth( + async_client: AsyncClient, test_data: Dict[str, Any], monkeypatch: Any +) -> None: device = test_data["device_no_authentication"] monkeypatch.setattr(config.device_auth, "enable", True) monkeypatch.setattr(config.device_auth, "mode", DeviceAuthMode.SETUP) @@ -116,7 +136,9 @@ async def test_poll_setup_with_auth_add_device_auth(async_client, test_data, mon @pytest.mark.asyncio -async def test_poll_setup_with_auth_update_device_auth(async_client, test_data, monkeypatch): +async def test_poll_setup_with_auth_update_device_auth( + async_client: AsyncClient, test_data: Dict[str, Any], monkeypatch: Any +) -> None: device = test_data["device_authentication"] old_token = device.auth_token @@ -136,7 +158,9 @@ async def test_poll_setup_with_auth_update_device_auth(async_client, test_data, @pytest.mark.asyncio -async def test_poll_setup_with_no_auth_no_change(async_client, test_data, monkeypatch): +async def test_poll_setup_with_no_auth_no_change( + async_client: AsyncClient, test_data: Dict[str, Any], monkeypatch: Any +) -> None: device = test_data["device_authentication"] monkeypatch.setattr(config.device_auth, "enable", True) diff --git a/tests/unit/updater/controller/v1/test_routes.py b/tests/unit/updater/controller/v1/test_routes.py index b12a1a1c..1f0c6539 100644 --- a/tests/unit/updater/controller/v1/test_routes.py +++ b/tests/unit/updater/controller/v1/test_routes.py @@ -1,63 +1,66 @@ +from typing import Any, Dict + import pytest +from httpx import AsyncClient -from goosebit.db.models import Hardware, Software +from goosebit.db.models import Device, Hardware, Software from goosebit.device_manager import DeviceManager, get_device -from goosebit.settings import GooseBitSettings +from goosebit.settings import config DEVICE_ID = "221326d9-7873-418e-960c-c074026a3b7c" -config = GooseBitSettings() - -async def _api_device_update(async_client, device, update_attribute, update_value): +async def _api_device_update( + async_client: AsyncClient, device: Device, update_attribute: str, update_value: Any +) -> None: response = await async_client.patch( - f"/ui/bff/devices", + "/ui/bff/devices", json={"devices": [f"{device.id}"], update_attribute: update_value}, ) assert response.status_code == 200 -async def _api_device_get(async_client, dev_id): +async def _api_device_get(async_client: AsyncClient, dev_id: str) -> Dict[str, Any]: response = await async_client.get("/api/v1/devices") assert response.status_code == 200 devices = response.json()["devices"] return next(d for d in devices if d["id"] == dev_id) -async def _api_rollout_create(async_client, feed, software, paused): +async def _api_rollout_create(async_client: AsyncClient, feed: str, software: Software, paused: bool) -> None: response = await async_client.post( - f"/api/v1/rollouts", + "/api/v1/rollouts", json={"name": "", "feed": feed, "software_id": software.id}, ) assert response.status_code == 200 rollout_id = response.json()["id"] response = await async_client.patch( - f"/api/v1/rollouts", + "/api/v1/rollouts", json={"ids": [rollout_id], "paused": paused}, ) assert response.status_code == 200 -async def _api_rollouts_get(async_client): +async def _api_rollouts_get(async_client: AsyncClient) -> Any: response = await async_client.get("/api/v1/rollouts") assert response.status_code == 200 return response.json() -async def _poll_first_time(async_client): +async def _poll_first_time(async_client: AsyncClient) -> str: response = await async_client.get(f"/DEFAULT/controller/v1/{DEVICE_ID}") assert response.status_code == 200 data = response.json() assert "config" in data assert data["config"]["polling"]["sleep"] == "00:00:10" assert "_links" in data - config_url = data["_links"]["configData"]["href"] + config_url: str = data["_links"]["configData"]["href"] assert config_url == f"http://test/DEFAULT/controller/v1/{DEVICE_ID}/configData" return config_url -async def _register(async_client, config_url): +async def _register(async_client: AsyncClient, config_url: str) -> None: # register device response = await async_client.put( config_url, @@ -80,7 +83,9 @@ async def _register(async_client, config_url): assert data["message"] == "Updated swupdate data." -async def _poll(async_client, device_id, software: Software | None, expect_update=True): +async def _poll( + async_client: AsyncClient, device_id: str, software: Software | None, expect_update: bool = True +) -> str | None: response = await async_client.get(f"/DEFAULT/controller/v1/{device_id}") assert response.status_code == 200 @@ -88,7 +93,7 @@ async def _poll(async_client, device_id, software: Software | None, expect_updat if expect_update: assert data["config"]["polling"]["sleep"] == config.poll_time assert "deploymentBase" in data["_links"], "expected update, but none available" - deployment_base = data["_links"]["deploymentBase"]["href"] + deployment_base: str = data["_links"]["deploymentBase"]["href"] assert software is not None assert deployment_base == f"http://test/DEFAULT/controller/v1/{device_id}/deploymentBase/{software.id}" return deployment_base @@ -98,7 +103,9 @@ async def _poll(async_client, device_id, software: Software | None, expect_updat return None -async def _retrieve_software_url(async_client, device_id, deployment_base, software): +async def _retrieve_software_url( + async_client: AsyncClient, device_id: str, deployment_base: str, software: Software +) -> str: response = await async_client.get(deployment_base) assert response.status_code == 200 data = response.json() @@ -112,10 +119,13 @@ async def _retrieve_software_url(async_client, device_id, deployment_base, softw assert data["deployment"]["chunks"][0]["artifacts"][0]["hashes"]["sha1"] == software.hash assert data["deployment"]["chunks"][0]["artifacts"][0]["size"] == software.size - return data["deployment"]["chunks"][0]["artifacts"][0]["_links"]["download"]["href"] + download_url: str = data["deployment"]["chunks"][0]["artifacts"][0]["_links"]["download"]["href"] + return download_url -async def _feedback(async_client, device_id, software, finished, execution, details=""): +async def _feedback( + async_client: AsyncClient, device_id: str, software: Software, finished: str, execution: str, details: str = "" +) -> None: response = await async_client.post( f"/DEFAULT/controller/v1/{device_id}/deploymentBase/{software.id}/feedback", json={ @@ -131,7 +141,7 @@ async def _feedback(async_client, device_id, software, finished, execution, deta @pytest.mark.asyncio -async def test_register_device(async_client, test_data): +async def test_register_device(async_client: AsyncClient, test_data: Dict[str, Any]) -> None: config_url = await _poll_first_time(async_client) await _register(async_client, config_url) @@ -147,12 +157,13 @@ async def test_register_device(async_client, test_data): @pytest.mark.asyncio @pytest.mark.parametrize("delete_software", [False, True]) -async def test_rollout_full(async_client, test_data, delete_software): +async def test_rollout_full(async_client: AsyncClient, test_data: Dict[str, Any], delete_software: bool) -> None: device = test_data["device_rollout"] software = test_data["software_release"] rollout = test_data["rollout_default"] deployment_base = await _poll(async_client, device.id, software) + assert deployment_base is not None await _retrieve_software_url(async_client, device.id, deployment_base, software) @@ -180,11 +191,12 @@ async def test_rollout_full(async_client, test_data, delete_software): @pytest.mark.asyncio -async def test_rollout_signalling_download_failure(async_client, test_data): +async def test_rollout_signalling_download_failure(async_client: AsyncClient, test_data: Dict[str, Any]) -> None: device = test_data["device_rollout"] software = test_data["software_release"] deployment_base = await _poll(async_client, device.id, software) + assert deployment_base is not None software_url = await _retrieve_software_url(async_client, device.id, deployment_base, software) @@ -209,7 +221,7 @@ async def test_rollout_signalling_download_failure(async_client, test_data): @pytest.mark.asyncio -async def test_rollout_selection(async_client, test_data): +async def test_rollout_selection(async_client: AsyncClient, test_data: Dict[str, Any]) -> None: device = test_data["device_rollout"] await _api_device_update(async_client, device, "feed", "qa") @@ -233,13 +245,14 @@ async def test_rollout_selection(async_client, test_data): @pytest.mark.asyncio -async def test_latest(async_client, test_data): +async def test_latest(async_client: AsyncClient, test_data: Dict[str, Any]) -> None: device = test_data["device_rollout"] software = test_data["software_release"] await _api_device_update(async_client, device, "software", "latest") deployment_base = await _poll(async_client, device.id, software) + assert deployment_base is not None await _retrieve_software_url(async_client, device.id, deployment_base, software) @@ -256,7 +269,7 @@ async def test_latest(async_client, test_data): @pytest.mark.asyncio -async def test_latest_with_no_software_available(async_client, test_data): +async def test_latest_with_no_software_available(async_client: AsyncClient, test_data: Dict[str, Any]) -> None: device = test_data["device_rollout"] await _api_device_update(async_client, device, "software", "latest") @@ -269,7 +282,7 @@ async def test_latest_with_no_software_available(async_client, test_data): @pytest.mark.asyncio -async def test_pinned(async_client, test_data): +async def test_pinned(async_client: AsyncClient, test_data: Dict[str, Any]) -> None: device = test_data["device_rollout"] await _api_device_update(async_client, device, "pinned", True) @@ -278,7 +291,7 @@ async def test_pinned(async_client, test_data): @pytest.mark.asyncio -async def test_up_to_date(async_client, test_data): +async def test_up_to_date(async_client: AsyncClient, test_data: Dict[str, Any]) -> None: device = test_data["device_rollout"] software = test_data["software_release"] @@ -290,7 +303,7 @@ async def test_up_to_date(async_client, test_data): await _poll(async_client, device.id, None, False) -async def _assert_log_lines(async_client, device, expected_line_count): +async def _assert_log_lines(async_client: AsyncClient, device: Device, expected_line_count: int) -> None: response = await async_client.get(f"/ui/bff/devices/{device.id}/log") assert response.status_code == 200 @@ -306,13 +319,14 @@ async def _assert_log_lines(async_client, device, expected_line_count): @pytest.mark.asyncio -async def test_update_logs_and_progress(async_client, test_data): +async def test_update_logs_and_progress(async_client: AsyncClient, test_data: Dict[str, Any]) -> None: device = test_data["device_rollout"] software = test_data["software_release"] await _api_device_update(async_client, device, "software", "latest") deployment_base = await _poll(async_client, device.id, software) + assert deployment_base is not None await _assert_log_lines(async_client, device, 0) await _retrieve_software_url(async_client, device.id, deployment_base, software) diff --git a/tests/unit/updates/rauc/test_swdesc.py b/tests/unit/updates/rauc/test_swdesc.py index fe4db2f9..02ec79a5 100644 --- a/tests/unit/updates/rauc/test_swdesc.py +++ b/tests/unit/updates/rauc/test_swdesc.py @@ -5,7 +5,7 @@ @pytest.mark.asyncio -async def test_parse_software_header(): +async def test_parse_software_header() -> None: resolved = await Path(__file__).resolve() swdesc_attrs = await parse_file(resolved.parent / "software-header.raucb") assert str(swdesc_attrs["version"]) == "8.8.1-11-g8c926e5+188370" diff --git a/tests/unit/updates/swu/test_swdesc.py b/tests/unit/updates/swu/test_swdesc.py index 29c164f2..8b3f0103 100644 --- a/tests/unit/updates/swu/test_swdesc.py +++ b/tests/unit/updates/swu/test_swdesc.py @@ -5,7 +5,7 @@ from goosebit.updates.swdesc.swu import parse_descriptor, parse_file -def test_parse_descriptor_no_compatibility_defined(): +def test_parse_descriptor_no_compatibility_defined() -> None: desc = AttrDict( { "software": { @@ -22,7 +22,7 @@ def test_parse_descriptor_no_compatibility_defined(): ] -def test_parse_descriptor_simple(): +def test_parse_descriptor_simple() -> None: # simplified example from https://sbabic.github.io/swupdate/sw-description.html#introduction desc = AttrDict( { @@ -42,7 +42,7 @@ def test_parse_descriptor_simple(): ] -def test_parse_descriptor_boardname(): +def test_parse_descriptor_boardname() -> None: # GARDENA device desc = AttrDict( { @@ -67,7 +67,7 @@ def test_parse_descriptor_boardname(): ] -def test_parse_descriptor_boardname_and_software_collection(): +def test_parse_descriptor_boardname_and_software_collection() -> None: # simplified example from https://sbabic.github.io/swupdate/sw-description.html#using-links desc = AttrDict( { @@ -86,7 +86,7 @@ def test_parse_descriptor_boardname_and_software_collection(): ] -def test_parse_descriptor_several_boardname(): +def test_parse_descriptor_several_boardname() -> None: desc = { "software": { "version": "8.8.1-12-g302f635+189128", @@ -116,7 +116,7 @@ def test_parse_descriptor_several_boardname(): ] -def test_parse_descriptor_with_toplevel_extras(): +def test_parse_descriptor_with_toplevel_extras() -> None: desc = { "reboot": True, "software": { @@ -149,9 +149,10 @@ def test_parse_descriptor_with_toplevel_extras(): @pytest.mark.asyncio -async def test_parse_software_header(): +async def test_parse_software_header() -> None: resolved = await Path(__file__).resolve() swdesc_attrs = await parse_file(resolved.parent / "software-header.swu") + assert swdesc_attrs is not None assert str(swdesc_attrs["version"]) == "8.8.1-11-g8c926e5+188370" assert swdesc_attrs["compatibility"] == [ {"hw_model": "smart-gateway-mt7688", "hw_revision": "0.5"}, diff --git a/tests/unit/util/test_version.py b/tests/unit/util/test_version.py index 60db2457..98a58dcb 100644 --- a/tests/unit/util/test_version.py +++ b/tests/unit/util/test_version.py @@ -1,7 +1,7 @@ from goosebit.util.version import Version -def test_swupdate_default_numbering(): +def test_swupdate_default_numbering() -> None: version_1 = Version.parse("1.2.3.4") version_2 = Version.parse("1.2.4.4") version_3 = Version.parse("1.3.3.4") @@ -12,7 +12,7 @@ def test_swupdate_default_numbering(): assert version_3 < version_4 -def test_swupdate_default_numbering_ignore_additional_fields(): +def test_swupdate_default_numbering_ignore_additional_fields() -> None: version_variant_1 = Version.parse("1.2.3.4") version_variant_2 = Version.parse("1.2.3.4.5") version_variant_3 = Version.parse("1.2.3.4.5.6") @@ -21,21 +21,21 @@ def test_swupdate_default_numbering_ignore_additional_fields(): assert version_variant_2 == version_variant_3 -def test_semver_ordering(): +def test_semver_ordering() -> None: version_1 = Version.parse("1.2.3-beta") version_2 = Version.parse("1.2.4-alpha") assert version_1 < version_2 -def test_semver_equal(): +def test_semver_equal() -> None: version_1 = Version.parse("1") version_2 = Version.parse("1.0.0+build20") assert version_1 == version_2 -def test_lexical_order_fallback(): +def test_lexical_order_fallback() -> None: version_1 = Version.parse("1.2.3.4") version_2 = Version.parse("1.2.3-beta") version_3 = Version.parse("1.2.3+tag1")