From f50300da24db962a1be7cda2a368cb56c604cd3e Mon Sep 17 00:00:00 2001 From: Jake Howard Date: Mon, 18 Mar 2024 13:14:56 +0000 Subject: [PATCH] Add mypy --- .github/workflows/ci.yml | 27 +++++++++++++++++ calmerge/__init__.py | 2 +- calmerge/__main__.py | 16 +++++----- calmerge/calendars.py | 6 ++-- calmerge/config.py | 6 ++-- calmerge/utils.py | 2 +- calmerge/views.py | 6 ++-- poetry.lock | 59 ++++++++++++++++++++++++++++++++++++- pyproject.toml | 15 ++++++++++ tests/conftest.py | 9 ++++-- tests/test_calendar_view.py | 15 +++++----- tests/test_config.py | 30 ++++++++++++++----- tests/test_health_view.py | 5 +++- 13 files changed, 161 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f23eb82..cad5fde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,32 @@ jobs: - name: Format run: poetry run ruff format . --check + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: "3.11" + + - uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: pip-3.11-${{ hashFiles('poetry.lock') }} + + - name: Install poetry + uses: abatilo/actions-poetry@v2 + with: + poetry-version: 1.8.2 + + - name: Install Dependencies + run: poetry install + + - name: Check types + run: poetry run mypy . + test: runs-on: ubuntu-latest steps: @@ -67,6 +93,7 @@ jobs: needs: - lint - test + - typecheck steps: - uses: actions/checkout@v4 diff --git a/calmerge/__init__.py b/calmerge/__init__.py index e0d1206..4ae5bdf 100644 --- a/calmerge/__init__.py +++ b/calmerge/__init__.py @@ -4,7 +4,7 @@ from .config import Config -def get_aiohttp_app(config: Config): +def get_aiohttp_app(config: Config) -> web.Application: app = web.Application() app["config"] = config diff --git a/calmerge/__main__.py b/calmerge/__main__.py index 71d04fe..37ca496 100644 --- a/calmerge/__main__.py +++ b/calmerge/__main__.py @@ -9,22 +9,22 @@ from .config import Config -def file_path(path: str): - path = Path(path).resolve() +def file_path(path: str) -> Path: + path_obj = Path(path).resolve() - if not path.is_file(): + if not path_obj.is_file(): raise argparse.ArgumentTypeError(f"File not found: {path}") - return path + return path_obj -def serve(args: argparse.Namespace): +def serve(args: argparse.Namespace) -> None: config = Config.from_file(args.config) print(f"Found {len(config.calendars)} calendar(s)") run_app(get_aiohttp_app(config), port=int(os.environ.get("PORT", args.port))) -def validate_config(args: argparse.Namespace): +def validate_config(args: argparse.Namespace) -> None: try: Config.from_file(args.config) except ValidationError as e: @@ -33,6 +33,8 @@ def validate_config(args: argparse.Namespace): else: print("Config is valid!") + return None + def get_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="calmerge") @@ -51,7 +53,7 @@ def get_parser() -> argparse.ArgumentParser: return parser -def main(): +def main() -> None: parser = get_parser() args = parser.parse_args() diff --git a/calmerge/calendars.py b/calmerge/calendars.py index 42a3e7a..0602aa3 100644 --- a/calmerge/calendars.py +++ b/calmerge/calendars.py @@ -10,7 +10,7 @@ fetch_cache = Cache(Cache.MEMORY, ttl=3600) -async def fetch_calendar(session: ClientSession, url: str): +async def fetch_calendar(session: ClientSession, url: str) -> icalendar.Calendar: cache_key = "calendar_" + url cached_calendar_data = await fetch_cache.get(cache_key) @@ -22,7 +22,7 @@ async def fetch_calendar(session: ClientSession, url: str): return icalendar.Calendar.from_ical(cached_calendar_data) -async def fetch_merged_calendar(calendar_config: CalendarConfig): +async def fetch_merged_calendar(calendar_config: CalendarConfig) -> icalendar.Calendar: merged_calendar = icalendar.Calendar() async with ClientSession() as session: @@ -36,7 +36,7 @@ async def fetch_merged_calendar(calendar_config: CalendarConfig): return merged_calendar -def offset_calendar(calendar: icalendar.Calendar, offset_days: int): +def offset_calendar(calendar: icalendar.Calendar, offset_days: int) -> None: """ Mutate a calendar and move events by a given offset """ diff --git a/calmerge/config.py b/calmerge/config.py index 44e6f77..e431304 100644 --- a/calmerge/config.py +++ b/calmerge/config.py @@ -22,7 +22,7 @@ def expand_vars(cls, v: str) -> str: def as_basic_auth(self) -> BasicAuth: return BasicAuth(self.username, self.password) - def validate_header(self, auth_header) -> bool: + def validate_header(self, auth_header: str) -> bool: try: parsed_auth_header = BasicAuth.decode(auth_header) except ValueError: @@ -57,11 +57,11 @@ class Config(BaseModel): calendars: list[CalendarConfig] = Field(alias="calendar", default_factory=list) @classmethod - def from_file(cls, path: Path): + def from_file(cls, path: Path) -> "Config": with path.open(mode="rb") as f: return Config.model_validate(tomllib.load(f)) - def get_calendar_by_name(self, name: str): + def get_calendar_by_name(self, name: str) -> CalendarConfig | None: return next( (calendar for calendar in self.calendars if calendar.name == name), None ) diff --git a/calmerge/utils.py b/calmerge/utils.py index 7465d42..e706dc1 100644 --- a/calmerge/utils.py +++ b/calmerge/utils.py @@ -1,4 +1,4 @@ -def try_parse_int(val: str): +def try_parse_int(val: str) -> int | None: try: return int(val) except (ValueError, TypeError): diff --git a/calmerge/views.py b/calmerge/views.py index 379ff37..840bebf 100644 --- a/calmerge/views.py +++ b/calmerge/views.py @@ -5,11 +5,11 @@ from .utils import try_parse_int -async def healthcheck(request): +async def healthcheck(request: web.Request) -> web.Response: return web.Response(text="") -async def calendar(request): +async def calendar(request: web.Request) -> web.Response: config = request.app["config"] calendar_config = config.get_calendar_by_name(request.match_info["name"]) @@ -25,7 +25,7 @@ async def calendar(request): calendar = await fetch_merged_calendar(calendar_config) if calendar_config.allow_custom_offset and ( - offset_days := try_parse_int(request.query.get("offset_days")) + offset_days := try_parse_int(request.query.get("offset_days", "")) ): if abs(offset_days) > MAX_OFFSET: raise web.HTTPBadRequest( diff --git a/poetry.lock b/poetry.lock index 52a53f5..104bae9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -452,6 +452,63 @@ files = [ {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, ] +[[package]] +name = "mypy" +version = "1.9.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"}, + {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"}, + {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"}, + {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"}, + {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"}, + {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"}, + {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"}, + {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"}, + {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"}, + {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"}, + {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"}, + {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"}, + {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"}, + {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"}, + {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"}, + {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"}, + {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"}, + {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"}, + {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + [[package]] name = "packaging" version = "24.0" @@ -842,4 +899,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "0045f03aa44eea8e9c57c8006e2ff9e6711a72cc6b87a01bae92b624f348eca0" +content-hash = "c21f6caac54945360b5e0bdc2f129e13ef555c2046ddb1a89622f94248d615e3" diff --git a/pyproject.toml b/pyproject.toml index df80db9..6a09064 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ pytest = "^8.1.1" pytest-aiohttp = "^1.0.5" pytest-asyncio = "^0.23.5.post1" pytest-cov = "^4.1.0" +mypy = "^1.9.0" [build-system] requires = ["poetry-core"] @@ -36,3 +37,17 @@ ignore = ["E501"] [tool.pytest.ini_options] asyncio_mode = "auto" + +[tool.mypy] +warn_unused_ignores = true +warn_return_any = true +show_error_codes = true +strict_optional = true +implicit_optional = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +check_untyped_defs = true +ignore_missing_imports = true diff --git a/tests/conftest.py b/tests/conftest.py index 3b757af..f33d906 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,9 @@ +from asyncio import AbstractEventLoop from pathlib import Path +from typing import Callable import pytest +from aiohttp.test_utils import TestClient from calmerge import get_aiohttp_app from calmerge.config import Config @@ -12,5 +15,7 @@ def config() -> Config: @pytest.fixture -def client(event_loop, aiohttp_client, config): - return event_loop.run_until_complete(aiohttp_client(get_aiohttp_app(config))) +def client( + event_loop: AbstractEventLoop, aiohttp_client: Callable, config: Config +) -> TestClient: + return event_loop.run_until_complete(aiohttp_client(get_aiohttp_app(config))) # type: ignore diff --git a/tests/test_calendar_view.py b/tests/test_calendar_view.py index ce5a4be..9b1a4e2 100644 --- a/tests/test_calendar_view.py +++ b/tests/test_calendar_view.py @@ -3,11 +3,12 @@ import icalendar import pytest from aiohttp import BasicAuth +from aiohttp.test_utils import TestClient from calmerge.config import MAX_OFFSET -async def test_retrieves_calendars(client): +async def test_retrieves_calendars(client: TestClient) -> None: response = await client.get("/python.ics") assert response.status == 200 @@ -15,12 +16,12 @@ async def test_retrieves_calendars(client): assert not calendar.is_broken -async def test_404_without_auth(client): +async def test_404_without_auth(client: TestClient) -> None: response = await client.get("/python-authed.ics") assert response.status == 404 -async def test_requires_auth(client): +async def test_requires_auth(client: TestClient) -> None: response = await client.get( "/python-authed.ics", auth=BasicAuth("user", "password") ) @@ -30,7 +31,7 @@ async def test_requires_auth(client): assert not calendar.is_broken -async def test_offset(client): +async def test_offset(client: TestClient) -> None: response = await client.get("/python-offset.ics") assert response.status == 200 @@ -38,7 +39,7 @@ async def test_offset(client): assert not calendar.is_broken -async def test_offset_calendar_matches(client): +async def test_offset_calendar_matches(client: TestClient) -> None: offset_response = await client.get("/python-offset.ics") offset_calendar = icalendar.Calendar.from_ical(await offset_response.text()) @@ -75,7 +76,7 @@ async def test_offset_calendar_matches(client): @pytest.mark.parametrize("offset", [100, -100, MAX_OFFSET, -MAX_OFFSET]) -async def test_custom_offset(client, offset): +async def test_custom_offset(client: TestClient, offset: int) -> None: offset_response = await client.get( "/python-custom-offset.ics", params={"offset_days": offset}, @@ -107,7 +108,7 @@ async def test_custom_offset(client, offset): @pytest.mark.parametrize("offset", [MAX_OFFSET + 1, -MAX_OFFSET - 1]) -async def test_out_of_bounds_custom_offset(client, offset): +async def test_out_of_bounds_custom_offset(client: TestClient, offset: int) -> None: response = await client.get( "/python-custom-offset.ics", params={"offset_days": offset}, diff --git a/tests/test_config.py b/tests/test_config.py index 13f757b..c7ff74b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,26 +1,32 @@ +from pathlib import Path + import pytest from pydantic import ValidationError from pydantic_core import Url +from tomllib import TOMLDecodeError from calmerge.config import AuthConfig, CalendarConfig, Config -def test_non_unique_urls(): +def test_non_unique_urls() -> None: with pytest.raises(ValidationError) as e: - CalendarConfig(name="test", urls=["https://example.com"] * 10) + CalendarConfig(name="test", urls=["https://example.com"] * 10) # type: ignore [list-item] assert e.value.errors()[0]["msg"] == "URLs must be unique" -def test_urls_expand_env_var(monkeypatch): +def test_urls_expand_env_var(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("FOO", "BAR") - calendar_config = CalendarConfig(name="test", urls=["https://example.com/$FOO"]) + calendar_config = CalendarConfig( + name="test", + urls=["https://example.com/$FOO"], # type: ignore [list-item] + ) assert calendar_config.urls[0] == Url("https://example.com/BAR") -def test_auth_expand_env_var(monkeypatch): +def test_auth_expand_env_var(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("FOO", "BAR") auth_config = AuthConfig(username="$FOO", password="${FOO}BAR") @@ -29,12 +35,20 @@ def test_auth_expand_env_var(monkeypatch): assert auth_config.password == "BARBAR" -def test_expand_unknown_var(): +def test_expand_unknown_var() -> None: auth_config = AuthConfig(username="$FOO", password="${FOO}BAR") assert auth_config.username == "$FOO" assert auth_config.password == "${FOO}BAR" -def test_empty_config(): - Config() +def test_empty_config(tmp_path: Path) -> None: + test_config_file = tmp_path / "test.toml" + test_config_file.touch() + + Config.from_file(test_config_file) + + +def test_invalid_file() -> None: + with pytest.raises(TOMLDecodeError): + Config.from_file(Path(__file__).resolve()) diff --git a/tests/test_health_view.py b/tests/test_health_view.py index 34b23ae..ebaf7d4 100644 --- a/tests/test_health_view.py +++ b/tests/test_health_view.py @@ -1,4 +1,7 @@ -async def test_health_view(client): +from aiohttp.test_utils import TestClient + + +async def test_health_view(client: TestClient) -> None: response = await client.get("/.health/") assert response.status == 200 assert await response.text() == ""