Skip to content
This repository was archived by the owner on Sep 8, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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!
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 20 additions & 5 deletions src/wwvb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,30 @@
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")

Expand Down Expand Up @@ -927,7 +942,7 @@ def to_both_string(self, charset: list[str]) -> str:
def print_timecodes(
w: WWVBMinute,
minutes: int,
channel: str,
channel: WWVBChannel,
style: str,
file: TextIO,
*,
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/wwvb/decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 6 additions & 3 deletions src/wwvb/gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,18 @@

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:
Expand Down Expand Up @@ -95,7 +98,7 @@ def main(
dut1: int,
minutes: int,
style: str,
channel: str,
channel: WWVBChannel,
all_timecodes: bool,
timespec: datetime.datetime,
) -> None:
Expand Down
10 changes: 6 additions & 4 deletions src/wwvb/wwvbtk.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@

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

Expand All @@ -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()
Expand Down Expand Up @@ -106,7 +106,7 @@ def wwvbsmarttick() -> Generator[tuple[datetime.datetime, wwvb.AmplitudeModulati
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:
Expand Down Expand Up @@ -141,10 +141,12 @@ def controller_func() -> Generator[int]:

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()

Expand Down
14 changes: 11 additions & 3 deletions test/testcli.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
#!/usr/bin/python3

"""Test most wwvblib commandline programs"""

# ruff: noqa: N802 D102
# SPDX-FileCopyrightText: 2021-2024 Jeff Epler
#
# 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
Expand All @@ -22,6 +23,12 @@
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"""
Expand Down Expand Up @@ -55,9 +62,10 @@ def assertModuleOutput(self, expected: str, *args: str) -> None:
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:
Expand Down
4 changes: 0 additions & 4 deletions test/testls.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,3 @@ def test_leap(self) -> None:
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()
4 changes: 0 additions & 4 deletions test/testpm.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,3 @@ def test_pm(self) -> None:

self.assertEqual(ref_am, test_am)
self.assertEqual(ref_pm, test_pm)


if __name__ == "__main__":
unittest.main()
6 changes: 1 addition & 5 deletions test/testuwwvb.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def test_decode(self) -> None:
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:
Expand Down Expand Up @@ -215,7 +215,3 @@ def test_near_year_bug(self) -> None:
datetime.datetime(2020, 12, 31, 17, 00, tzinfo=zoneinfo.ZoneInfo("America/Denver")), # Mountain time!
uwwvb.as_datetime_local(decoded),
)


if __name__ == "__main__":
unittest.main()
16 changes: 9 additions & 7 deletions test/testwwvb.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@
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):
Expand All @@ -44,11 +45,16 @@ def test_cases(self) -> None:
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:
Expand Down Expand Up @@ -430,7 +436,3 @@ def test_invalid_mark(self) -> None:
minute.am[57] = wwvb.AmplitudeModulation.MARK
decoded_minute = wwvb.WWVBMinute.from_timecode_am(minute)
assert decoded_minute is None


if __name__ == "__main__":
unittest.main()
Loading