diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..ed17307 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,13 @@ +[run] +branch = True +omit = + kassalappy/cli.py + +[report] +exclude_lines = + pragma: no cover + def __repr__ + def __str__ + def __rich_console__ + if __name__ == .__main__.: + if TYPE_CHECKING diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2cafca1 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +run := poetry run + +.PHONY: test +test: + $(run) pytest tests/ $(ARGS) + +.PHONY: test-coverage +test-coverage: + $(run) pytest tests/ --cov-report term-missing --cov=kassalappy $(ARGS) + +.PHONY: coverage +coverage: + $(run) coverage html + +.PHONY: format +format: + $(run) ruff format kassalappy + +.PHONY: format-check +format-check: + $(run) ruff --check kassalappy + +.PHONY: setup +setup: + poetry install + +.PHONY: update +update: + poetry update + +.PHONY: repl +repl: + $(run) python + diff --git a/kassalappy/const.py b/kassalappy/const.py index 207840a..5e3c36b 100644 --- a/kassalappy/const.py +++ b/kassalappy/const.py @@ -1,10 +1,11 @@ """Constants used by kassalapp.""" from http import HTTPStatus -from importlib.metadata import version from typing import Final -VERSION = version("kassalappy") +from .version import __version__ + +VERSION = __version__ API_ENDPOINT: Final = "https://kassal.app/api/v1" DEFAULT_TIMEOUT: Final = 10 diff --git a/kassalappy/models.py b/kassalappy/models.py index 5cb62f3..f99bd95 100644 --- a/kassalappy/models.py +++ b/kassalappy/models.py @@ -3,6 +3,7 @@ from abc import ABC from dataclasses import dataclass, field from datetime import datetime +from enum import Enum import logging from typing import ClassVar, Literal @@ -11,10 +12,36 @@ from mashumaro.mixins.orjson import DataClassORJSONMixin from typing_extensions import TypedDict -from .typing import StrEnum - _LOGGER = logging.getLogger() + +# noinspection PyUnresolvedReferences +class StrEnum(str, Enum): + """A string enumeration of type `(str, Enum)`. + All members are compared via `upper()`. Defaults to UNKNOWN. + """ + + def __str__(self) -> str: + return str(self.value) + + def __eq__(self, other: str) -> bool: + other = other.upper() + return super().__eq__(other) + + @classmethod + def _missing_(cls, value) -> str: + has_unknown = False + for member in cls: + if member.name.upper() == "UNKNOWN": + has_unknown = True + if member.name.upper() == value.upper(): + return member + if has_unknown: + _LOGGER.warning("'%s' is not a valid '%s'", value, cls.__name__) + return cls.UNKNOWN + raise ValueError(f"'{value}' is not a valid {cls.__name__}") + + Unit = Literal[ "cl", "cm", diff --git a/kassalappy/typing.py b/kassalappy/typing.py deleted file mode 100644 index c55d0f2..0000000 --- a/kassalappy/typing.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from enum import Enum -import logging - -_LOGGER = logging.getLogger(__name__) - - -# noinspection PyUnresolvedReferences -class StrEnum(str, Enum): - """A string enumeration of type `(str, Enum)`. - All members are compared via `upper()`. Defaults to UNKNOWN. - """ - - def __str__(self) -> str: - return str(self.value) - - def __eq__(self, other: str) -> bool: - other = other.upper() - return super().__eq__(other) - - @classmethod - def _missing_(cls, value) -> str: - has_unknown = False - for member in cls: - if member.name.upper() == "UNKNOWN": - has_unknown = True - if member.name.upper() == value.upper(): - return member - if has_unknown: - _LOGGER.warning("'%s' is not a valid '%s'", value, cls.__name__) - return cls.UNKNOWN - raise ValueError(f"'{value}' is not a valid {cls.__name__}") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d1694aa --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,5 @@ +"""kassalappy tests.""" + +import pytest + +pytestmark = pytest.mark.asyncio(loop_scope="package") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..de61820 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import logging +from typing import Callable + +from aiohttp import ClientSession + +import pytest + +from kassalappy import Kassalapp + +_LOGGER = logging.getLogger(__name__) + + +@pytest.fixture +async def kassalapp_client(default_access_token) -> Callable[..., Kassalapp]: + """Return Politikontroller Client.""" + + def _kassalapp_client( + access_token: str | None = None, + session: ClientSession | None = None, + ) -> Kassalapp: + token = access_token if access_token is not None else default_access_token + return Kassalapp(access_token=token, websession=session) + + return _kassalapp_client + + +@pytest.fixture +def default_access_token(): + return "baba" diff --git a/tests/fixtures/health.json b/tests/fixtures/health.json new file mode 100644 index 0000000..873901f --- /dev/null +++ b/tests/fixtures/health.json @@ -0,0 +1 @@ +{"data":{"status":"ok"}} diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..ceb614a --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from pathlib import Path + +import orjson + +FIXTURE_DIR = Path(__file__).parent / "fixtures" + + +def load_fixture(name: str) -> str: + """Load a fixture.""" + path = FIXTURE_DIR / f"{name}.json" + if not path.exists(): # pragma: no cover + raise FileNotFoundError(f"Fixture {name} not found") + return path.read_text(encoding="utf-8") + + +def load_fixture_json(name: str) -> dict | list: + """Load a fixture as JSON.""" + data = load_fixture(name) + return orjson.loads(data) diff --git a/tests/ruff.toml b/tests/ruff.toml new file mode 100644 index 0000000..078aa64 --- /dev/null +++ b/tests/ruff.toml @@ -0,0 +1,17 @@ +# This extends our general Ruff rules specifically for tests +extend = "../pyproject.toml" + +lint.extend-select = [ + "PT", # Use @pytest.fixture without parentheses +] + +lint.extend-ignore = [ + "S101", + "S106", + "S108", + "SLF001", + "TCH002", + "PLR2004", +] + +lint.pylint.max-branches = 13 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..3f86963 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,25 @@ +"""Tests for NrkPodcastAPI.""" + +from __future__ import annotations + +import logging + +from aiohttp import ClientSession +from aiohttp.web_response import json_response +from aresponses import Response, ResponsesMockServer + +from kassalappy.models import StatusResponse +from .helpers import load_fixture_json + +logger = logging.getLogger(__name__) + + +async def test_health(aresponses: ResponsesMockServer, kassalapp_client): + aresponses.add( + response=json_response(load_fixture_json("health")), + ) + async with ClientSession() as session: + client = kassalapp_client(session=session) + result = await client.healthy() + assert isinstance(result, StatusResponse) + assert result.status == "ok"