Merge pull request #154 from jepler/small-tweaks

This commit is contained in:
Jeff Epler 2025-08-27 09:06:49 -05:00 committed by GitHub
commit a416d2e760
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 72 additions and 39 deletions

View file

@ -55,10 +55,17 @@ jobs:
python -mpip install wheel python -mpip install wheel
python -mpip install -r requirements-dev.txt python -mpip install -r requirements-dev.txt
- name: Check stubs - name: Check stubs with mypy
if: (! startsWith(matrix.python-version, 'pypy-')) if: (! startsWith(matrix.python-version, 'pypy-'))
run: make mypy PYTHON=python 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: test:
strategy: strategy:

View file

@ -24,7 +24,13 @@ repos:
rev: v0.12.10 rev: v0.12.10
hooks: hooks:
# Run the linter. # Run the linter.
- id: ruff - id: ruff-check
args: [ --fix ] args: [ --fix ]
# Run the formatter. # Run the formatter.
- id: ruff-format - 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!

View file

@ -24,7 +24,7 @@ ENVPYTHON ?= _env/bin/python3
endif endif
.PHONY: default .PHONY: default
default: coverage mypy default: coverage mypy pyright pyrefly
COVERAGE_INCLUDE=--include "src/**/*.py" COVERAGE_INCLUDE=--include "src/**/*.py"
.PHONY: coverage .PHONY: coverage

View file

@ -7,6 +7,8 @@ build
click click
coverage >= 7.10.3 coverage >= 7.10.3
mypy; implementation_name=="cpython" mypy; implementation_name=="cpython"
pyright; implementation_name=="cpython"
pyrefly; implementation_name=="cpython"
click>=8.1.5; implementation_name=="cpython" click>=8.1.5; implementation_name=="cpython"
leapseconddata leapseconddata
platformdirs platformdirs

View file

@ -20,15 +20,30 @@ import enum
import json import json
import warnings import warnings
from dataclasses import dataclass from dataclasses import dataclass
from typing import ClassVar from typing import ClassVar, Literal
from . import iersdata from . import iersdata
from .tz import Mountain from .tz import Mountain
WWVBChannel = Literal["amplitude", "phase", "both"]
TYPE_CHECKING = False TYPE_CHECKING = False
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Generator 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") T = TypeVar("T")
@ -927,7 +942,7 @@ styles = {
def print_timecodes( def print_timecodes(
w: WWVBMinute, w: WWVBMinute,
minutes: int, minutes: int,
channel: str, channel: WWVBChannel,
style: str, style: str,
file: TextIO, file: TextIO,
*, *,
@ -964,7 +979,7 @@ def print_timecodes(
def print_timecodes_json( def print_timecodes_json(
w: WWVBMinute, w: WWVBMinute,
minutes: int, minutes: int,
channel: str, channel: WWVBChannel,
file: TextIO, file: TextIO,
) -> None: ) -> None:
"""Print a range of timecodes in JSON format. """Print a range of timecodes in JSON format.
@ -984,7 +999,7 @@ def print_timecodes_json(
""" """
result = [] result = []
for _ in range(minutes): for _ in range(minutes):
data: dict[str, Any] = { data: JsonMinute = {
"year": w.year, "year": w.year,
"days": w.days, "days": w.days,
"hour": w.hour, "hour": w.hour,

View file

@ -6,10 +6,10 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
from typing import TYPE_CHECKING
import wwvb import wwvb
TYPE_CHECKING = False
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Generator from collections.abc import Generator

View file

@ -9,15 +9,18 @@ from __future__ import annotations
import datetime import datetime
import sys import sys
from typing import Any
import click import click
import dateutil.parser import dateutil.parser
from . import WWVBMinute, WWVBMinuteIERS, print_timecodes, print_timecodes_json, styles 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""" """Parse a time specifier from the commandline"""
try: try:
if len(value) == 5: if len(value) == 5:
@ -95,7 +98,7 @@ def main(
dut1: int, dut1: int,
minutes: int, minutes: int,
style: str, style: str,
channel: str, channel: WWVBChannel,
all_timecodes: bool, all_timecodes: bool,
timespec: datetime.datetime, timespec: datetime.datetime,
) -> None: ) -> None:

View file

@ -8,13 +8,13 @@ from __future__ import annotations
import datetime import datetime
import functools import functools
from tkinter import Canvas, TclError, Tk from tkinter import Canvas, Event, TclError, Tk
from typing import TYPE_CHECKING, Any
import click import click
import wwvb import wwvb
TYPE_CHECKING = False
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Generator from collections.abc import Generator
@ -25,7 +25,7 @@ def _app() -> Tk:
return 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""" """Check that all colors in a string are valid, splitting it to a list"""
app = _app() app = _app()
colors = value.split() 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) canvas.pack(fill="both", expand=True)
app.wm_deiconify() 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""" """Keep the circle filling the window when it is resized"""
sz = min(event.width, event.height) - 8 sz = min(event.width, event.height) - 8
if sz < 0: 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__ controller = controller_func().__next__
# pyrefly: ignore # bad-assignment
def after_func() -> None: def after_func() -> None:
"""Repeatedly run the controller after the desired interval""" """Repeatedly run the controller after the desired interval"""
app.after(controller(), after_func) app.after(controller(), after_func)
# pyrefly: ignore # bad-argument-type
app.after_idle(after_func) app.after_idle(after_func)
app.mainloop() app.mainloop()

View file

@ -1,4 +1,5 @@
#!/usr/bin/python3 #!/usr/bin/python3
"""Test most wwvblib commandline programs""" """Test most wwvblib commandline programs"""
# ruff: noqa: N802 D102 # ruff: noqa: N802 D102
@ -6,13 +7,13 @@
# #
# SPDX-License-Identifier: GPL-3.0-only # SPDX-License-Identifier: GPL-3.0-only
from __future__ import annotations
import json import json
import os import os
import subprocess import subprocess
import sys import sys
import unittest import unittest
from collections.abc import Sequence
from typing import Any
# These imports must remain, even though the module contents are not used directly! # These imports must remain, even though the module contents are not used directly!
import wwvb.dut1table import wwvb.dut1table
@ -22,6 +23,12 @@ import wwvb.gen
assert wwvb.dut1table.__name__ == "wwvb.dut1table" assert wwvb.dut1table.__name__ == "wwvb.dut1table"
assert wwvb.gen.__name__ == "wwvb.gen" 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): class CLITestCase(unittest.TestCase):
"""Test various CLI commands within wwvbpy""" """Test various CLI commands within wwvbpy"""
@ -55,9 +62,10 @@ class CLITestCase(unittest.TestCase):
def assertStarts(self, expected: str, actual: str, *args: str) -> None: def assertStarts(self, expected: str, actual: str, *args: str) -> None:
self.assertMultiLineEqual(expected, actual[: len(expected)], f"args={args}") 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""" """Check the output from invoking a `python -m modulename` program matches the expected"""
actual = self.moduleOutput(*args) actual = self.moduleOutput(*args)
# Note: in mypy, revealed type of json.loads is typing.Any!
self.assertEqual(json.loads(actual), expected) self.assertEqual(json.loads(actual), expected)
def assertModuleOutputStarts(self, expected: str, *args: str) -> None: def assertModuleOutputStarts(self, expected: str, *args: str) -> None:

View file

@ -56,7 +56,3 @@ class TestLeapSecond(unittest.TestCase):
assert not our_is_ls assert not our_is_ls
d = datetime.datetime.combine(nm, datetime.time(), tzinfo=datetime.timezone.utc) d = datetime.datetime.combine(nm, datetime.time(), tzinfo=datetime.timezone.utc)
self.assertEqual(leap, bench) self.assertEqual(leap, bench)
if __name__ == "__main__":
unittest.main()

View file

@ -27,7 +27,3 @@ class TestPhaseModulation(unittest.TestCase):
self.assertEqual(ref_am, test_am) self.assertEqual(ref_am, test_am)
self.assertEqual(ref_pm, test_pm) self.assertEqual(ref_pm, test_pm)
if __name__ == "__main__":
unittest.main()

View file

@ -47,7 +47,7 @@ class WWVBRoundtrip(unittest.TestCase):
any_leap_second = False any_leap_second = False
for _ in range(20): for _ in range(20):
timecode = minute.as_timecode() timecode = minute.as_timecode()
decoded = None decoded: uwwvb.WWVBMinute | None = None
if len(timecode.am) == 61: if len(timecode.am) == 61:
any_leap_second = True any_leap_second = True
for code in timecode.am: 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! datetime.datetime(2020, 12, 31, 17, 00, tzinfo=zoneinfo.ZoneInfo("America/Denver")), # Mountain time!
uwwvb.as_datetime_local(decoded), uwwvb.as_datetime_local(decoded),
) )
if __name__ == "__main__":
unittest.main()

View file

@ -14,11 +14,12 @@ import io
import pathlib import pathlib
import random import random
import sys import sys
import typing
import unittest import unittest
import uwwvb import uwwvb
import wwvb import wwvb
from wwvb import decode, iersdata, tz from wwvb import WWVBChannel, decode, iersdata, tz
class WWVBMinute2k(wwvb.WWVBMinute): class WWVBMinute2k(wwvb.WWVBMinute):
@ -44,11 +45,16 @@ class WWVBTestCase(unittest.TestCase):
header = lines[0].split() header = lines[0].split()
timestamp = " ".join(header[:10]) timestamp = " ".join(header[:10])
options = header[10:] options = header[10:]
channel = "amplitude" channel: WWVBChannel = "amplitude"
style = "default" style = "default"
for o in options: for o in options:
if o.startswith("--channel="): 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="): elif o.startswith("--style="):
style = o[8:] style = o[8:]
else: else:
@ -430,7 +436,3 @@ class WWVBRoundtrip(unittest.TestCase):
minute.am[57] = wwvb.AmplitudeModulation.MARK minute.am[57] = wwvb.AmplitudeModulation.MARK
decoded_minute = wwvb.WWVBMinute.from_timecode_am(minute) decoded_minute = wwvb.WWVBMinute.from_timecode_am(minute)
assert decoded_minute is None assert decoded_minute is None
if __name__ == "__main__":
unittest.main()