diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f67dc51..988df30 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,10 +55,17 @@ jobs: python -mpip install wheel python -mpip install -r requirements-dev.txt - - name: Check stubs + - name: Check stubs with mypy if: (! startsWith(matrix.python-version, 'pypy-')) run: make mypy PYTHON=python + - name: Check stubs with pyrefly + if: (! startsWith(matrix.python-version, 'pypy-')) + run: make pyrefly PYTHON=python + + - name: Check stubs with pyright + if: (! startsWith(matrix.python-version, 'pypy-')) + run: make pyright PYTHON=python test: strategy: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 72b7db8..5298de0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,13 @@ repos: rev: v0.12.10 hooks: # Run the linter. - - id: ruff + - id: ruff-check args: [ --fix ] # Run the formatter. - id: ruff-format +- repo: https://github.com/asottile/pyupgrade + rev: v3.20.0 + hooks: + - id: pyupgrade + args: [ --py39-plus ] + exclude: src/uwwvb.py # CircuitPython prevaling standard! diff --git a/Makefile b/Makefile index 6e73c52..4a4a44f 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ ENVPYTHON ?= _env/bin/python3 endif .PHONY: default -default: coverage mypy +default: coverage mypy pyright pyrefly COVERAGE_INCLUDE=--include "src/**/*.py" .PHONY: coverage diff --git a/requirements-dev.txt b/requirements-dev.txt index e9d451c..21f32eb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,6 +7,8 @@ build click coverage >= 7.10.3 mypy; implementation_name=="cpython" +pyright; implementation_name=="cpython" +pyrefly; implementation_name=="cpython" click>=8.1.5; implementation_name=="cpython" leapseconddata platformdirs diff --git a/src/wwvb/__init__.py b/src/wwvb/__init__.py index e3627b8..22b773d 100644 --- a/src/wwvb/__init__.py +++ b/src/wwvb/__init__.py @@ -20,15 +20,30 @@ import enum import json import warnings from dataclasses import dataclass -from typing import ClassVar +from typing import ClassVar, Literal from . import iersdata from .tz import Mountain +WWVBChannel = Literal["amplitude", "phase", "both"] + TYPE_CHECKING = False if TYPE_CHECKING: from collections.abc import Generator - from typing import Any, Self, TextIO, TypeVar + from typing import NotRequired, Self, TextIO, TypedDict, TypeVar + + class JsonMinute(TypedDict): + """Implementation detail + + This is the Python object type that is serialized by `print_timecodes_json` + """ + + year: int + days: int + hour: int + minute: int + amplitude: NotRequired[str] + phase: NotRequired[str] T = TypeVar("T") @@ -927,7 +942,7 @@ styles = { def print_timecodes( w: WWVBMinute, minutes: int, - channel: str, + channel: WWVBChannel, style: str, file: TextIO, *, @@ -964,7 +979,7 @@ def print_timecodes( def print_timecodes_json( w: WWVBMinute, minutes: int, - channel: str, + channel: WWVBChannel, file: TextIO, ) -> None: """Print a range of timecodes in JSON format. @@ -984,7 +999,7 @@ def print_timecodes_json( """ result = [] for _ in range(minutes): - data: dict[str, Any] = { + data: JsonMinute = { "year": w.year, "days": w.days, "hour": w.hour, diff --git a/src/wwvb/decode.py b/src/wwvb/decode.py index d4e68e9..b6ac871 100644 --- a/src/wwvb/decode.py +++ b/src/wwvb/decode.py @@ -6,10 +6,10 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING import wwvb +TYPE_CHECKING = False if TYPE_CHECKING: from collections.abc import Generator diff --git a/src/wwvb/gen.py b/src/wwvb/gen.py index 4081a40..6565f61 100755 --- a/src/wwvb/gen.py +++ b/src/wwvb/gen.py @@ -9,15 +9,18 @@ from __future__ import annotations import datetime import sys -from typing import Any import click import dateutil.parser from . import WWVBMinute, WWVBMinuteIERS, print_timecodes, print_timecodes_json, styles +TYPE_CHECKING = False +if TYPE_CHECKING: + from . import WWVBChannel -def parse_timespec(ctx: Any, param: Any, value: list[str]) -> datetime.datetime: # noqa: ARG001 + +def parse_timespec(ctx: click.Context, param: click.Parameter, value: list[str]) -> datetime.datetime: # noqa: ARG001 """Parse a time specifier from the commandline""" try: if len(value) == 5: @@ -95,7 +98,7 @@ def main( dut1: int, minutes: int, style: str, - channel: str, + channel: WWVBChannel, all_timecodes: bool, timespec: datetime.datetime, ) -> None: diff --git a/src/wwvb/wwvbtk.py b/src/wwvb/wwvbtk.py index ef96892..fb6e5ce 100755 --- a/src/wwvb/wwvbtk.py +++ b/src/wwvb/wwvbtk.py @@ -8,13 +8,13 @@ from __future__ import annotations import datetime import functools -from tkinter import Canvas, TclError, Tk -from typing import TYPE_CHECKING, Any +from tkinter import Canvas, Event, TclError, Tk import click import wwvb +TYPE_CHECKING = False if TYPE_CHECKING: from collections.abc import Generator @@ -25,7 +25,7 @@ def _app() -> Tk: return Tk() -def validate_colors(ctx: Any, param: Any, value: str) -> list[str]: # noqa: ARG001 +def validate_colors(ctx: click.Context, param: click.Parameter, value: str) -> list[str]: # noqa: ARG001 """Check that all colors in a string are valid, splitting it to a list""" app = _app() colors = value.split() @@ -106,7 +106,7 @@ def main(colors: list[str], size: int, min_size: int | None) -> None: # noqa: P canvas.pack(fill="both", expand=True) app.wm_deiconify() - def resize_canvas(event: Any) -> None: + def resize_canvas(event: Event) -> None: """Keep the circle filling the window when it is resized""" sz = min(event.width, event.height) - 8 if sz < 0: @@ -141,10 +141,12 @@ def main(colors: list[str], size: int, min_size: int | None) -> None: # noqa: P controller = controller_func().__next__ + # pyrefly: ignore # bad-assignment def after_func() -> None: """Repeatedly run the controller after the desired interval""" app.after(controller(), after_func) + # pyrefly: ignore # bad-argument-type app.after_idle(after_func) app.mainloop() diff --git a/test/testcli.py b/test/testcli.py index e4e8c9a..eb761e7 100644 --- a/test/testcli.py +++ b/test/testcli.py @@ -1,4 +1,5 @@ #!/usr/bin/python3 + """Test most wwvblib commandline programs""" # ruff: noqa: N802 D102 @@ -6,13 +7,13 @@ # # SPDX-License-Identifier: GPL-3.0-only +from __future__ import annotations + import json import os import subprocess import sys import unittest -from collections.abc import Sequence -from typing import Any # These imports must remain, even though the module contents are not used directly! import wwvb.dut1table @@ -22,6 +23,12 @@ import wwvb.gen assert wwvb.dut1table.__name__ == "wwvb.dut1table" assert wwvb.gen.__name__ == "wwvb.gen" +TYPE_CHECKING = False +if TYPE_CHECKING: + from collections.abc import Sequence + + from wwvb import JsonMinute + class CLITestCase(unittest.TestCase): """Test various CLI commands within wwvbpy""" @@ -55,9 +62,10 @@ class CLITestCase(unittest.TestCase): def assertStarts(self, expected: str, actual: str, *args: str) -> None: self.assertMultiLineEqual(expected, actual[: len(expected)], f"args={args}") - def assertModuleJson(self, expected: Any, *args: str) -> None: + def assertModuleJson(self, expected: list[JsonMinute], *args: str) -> None: """Check the output from invoking a `python -m modulename` program matches the expected""" actual = self.moduleOutput(*args) + # Note: in mypy, revealed type of json.loads is typing.Any! self.assertEqual(json.loads(actual), expected) def assertModuleOutputStarts(self, expected: str, *args: str) -> None: diff --git a/test/testls.py b/test/testls.py index 209651e..73ccdb7 100755 --- a/test/testls.py +++ b/test/testls.py @@ -56,7 +56,3 @@ class TestLeapSecond(unittest.TestCase): assert not our_is_ls d = datetime.datetime.combine(nm, datetime.time(), tzinfo=datetime.timezone.utc) self.assertEqual(leap, bench) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/testpm.py b/test/testpm.py index 5bc5c93..54fe589 100755 --- a/test/testpm.py +++ b/test/testpm.py @@ -27,7 +27,3 @@ class TestPhaseModulation(unittest.TestCase): self.assertEqual(ref_am, test_am) self.assertEqual(ref_pm, test_pm) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/testuwwvb.py b/test/testuwwvb.py index f29f4b5..9b5df65 100644 --- a/test/testuwwvb.py +++ b/test/testuwwvb.py @@ -47,7 +47,7 @@ class WWVBRoundtrip(unittest.TestCase): any_leap_second = False for _ in range(20): timecode = minute.as_timecode() - decoded = None + decoded: uwwvb.WWVBMinute | None = None if len(timecode.am) == 61: any_leap_second = True for code in timecode.am: @@ -215,7 +215,3 @@ class WWVBRoundtrip(unittest.TestCase): datetime.datetime(2020, 12, 31, 17, 00, tzinfo=zoneinfo.ZoneInfo("America/Denver")), # Mountain time! uwwvb.as_datetime_local(decoded), ) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/testwwvb.py b/test/testwwvb.py index 8ba535c..e171bff 100755 --- a/test/testwwvb.py +++ b/test/testwwvb.py @@ -14,11 +14,12 @@ import io import pathlib import random import sys +import typing import unittest import uwwvb import wwvb -from wwvb import decode, iersdata, tz +from wwvb import WWVBChannel, decode, iersdata, tz class WWVBMinute2k(wwvb.WWVBMinute): @@ -44,11 +45,16 @@ class WWVBTestCase(unittest.TestCase): header = lines[0].split() timestamp = " ".join(header[:10]) options = header[10:] - channel = "amplitude" + channel: WWVBChannel = "amplitude" style = "default" for o in options: if o.startswith("--channel="): - channel = o[10:] + value = o[10:] + if value in {"both", "amplitude", "phase"}: + # pyrefly: ignore # redundant-cast + channel = typing.cast("WWVBChannel", value) + else: + raise ValueError(f"Unknown channel {o!r}") elif o.startswith("--style="): style = o[8:] else: @@ -430,7 +436,3 @@ class WWVBRoundtrip(unittest.TestCase): minute.am[57] = wwvb.AmplitudeModulation.MARK decoded_minute = wwvb.WWVBMinute.from_timecode_am(minute) assert decoded_minute is None - - -if __name__ == "__main__": - unittest.main()