Compare commits

..

1 commit

Author SHA1 Message Date
pre-commit-ci[bot]
f36fa32a81
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.12.4 → v0.12.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.4...v0.12.5)
2025-07-28 20:41:40 +00:00
19 changed files with 97 additions and 161 deletions

View file

@ -55,17 +55,10 @@ 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 with mypy - name: Check stubs
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

@ -8,7 +8,7 @@ default_language_version:
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0 rev: v5.0.0
hooks: hooks:
- id: check-yaml - id: check-yaml
- id: end-of-file-fixer - id: end-of-file-fixer
@ -21,16 +21,10 @@ repos:
- id: reuse - id: reuse
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.12.10 rev: v0.12.5
hooks: hooks:
# Run the linter. # Run the linter.
- id: ruff-check - id: ruff
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,13 +24,13 @@ ENVPYTHON ?= _env/bin/python3
endif endif
.PHONY: default .PHONY: default
default: coverage mypy pyright pyrefly default: coverage mypy
COVERAGE_INCLUDE=--include "src/**/*.py" COVERAGE_INCLUDE=--include "src/**/*.py"
.PHONY: coverage .PHONY: coverage
coverage: coverage:
$(Q)$(PYTHON) -mcoverage erase $(Q)$(PYTHON) -mcoverage erase
$(Q)env PYTHONPATH=src $(PYTHON) -mcoverage run -p -m unittest discover -s test $(Q)env PYTHONPATH=src $(PYTHON) -mcoverage run --branch -p -m unittest discover -s test
$(Q)$(PYTHON) -mcoverage combine -q $(Q)$(PYTHON) -mcoverage combine -q
$(Q)$(PYTHON) -mcoverage html $(COVERAGE_INCLUDE) $(Q)$(PYTHON) -mcoverage html $(COVERAGE_INCLUDE)
$(Q)$(PYTHON) -mcoverage xml $(COVERAGE_INCLUDE) $(Q)$(PYTHON) -mcoverage xml $(COVERAGE_INCLUDE)
@ -46,15 +46,6 @@ test_venv:
mypy: mypy:
$(Q)mypy --strict --no-warn-unused-ignores src test $(Q)mypy --strict --no-warn-unused-ignores src test
.PHONY: pyright
pyright:
$(Q)pyright src test
.PHONY: pyrefly
pyrefly:
$(Q)pyrefly check src test
.PHONY: update .PHONY: update
update: update:
$(Q)env PYTHONPATH=src $(PYTHON) -mwwvb.updateiers --dist $(Q)env PYTHONPATH=src $(PYTHON) -mwwvb.updateiers --dist

View file

@ -4,6 +4,7 @@ SPDX-FileCopyrightText: 2021-2024 Jeff Epler
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-only
--> -->
[![Test wwvbgen](https://github.com/jepler/wwvbpy/actions/workflows/test.yml/badge.svg)](https://github.com/jepler/wwvbpy/actions/workflows/test.yml) [![Test wwvbgen](https://github.com/jepler/wwvbpy/actions/workflows/test.yml/badge.svg)](https://github.com/jepler/wwvbpy/actions/workflows/test.yml)
[![codecov](https://codecov.io/gh/jepler/wwvbpy/branch/main/graph/badge.svg?token=Exx0c3Gp65)](https://codecov.io/gh/jepler/wwvbpy)
[![Update DUT1 data](https://github.com/jepler/wwvbpy/actions/workflows/cron.yml/badge.svg)](https://github.com/jepler/wwvbpy/actions/workflows/cron.yml) [![Update DUT1 data](https://github.com/jepler/wwvbpy/actions/workflows/cron.yml/badge.svg)](https://github.com/jepler/wwvbpy/actions/workflows/cron.yml)
[![PyPI](https://img.shields.io/pypi/v/wwvb)](https://pypi.org/project/wwvb) [![PyPI](https://img.shields.io/pypi/v/wwvb)](https://pypi.org/project/wwvb)
[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/jepler/wwvbpy/main.svg)](https://results.pre-commit.ci/latest/github/jepler/wwvbpy/main) [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/jepler/wwvbpy/main.svg)](https://results.pre-commit.ci/latest/github/jepler/wwvbpy/main)

0
codecov.yml Normal file
View file

View file

@ -51,6 +51,5 @@ wwvbtk = "wwvb.wwvbtk:main"
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = ["adafruit_datetime"] module = ["adafruit_datetime"]
follow_untyped_imports = true follow_untyped_imports = true
[tool.coverage.run] [tool.coverage.report]
patch=["subprocess"] exclude_also=["if TYPE_CHECKING:"]
branch=true

View file

@ -5,10 +5,8 @@ adafruit-circuitpython-datetime
beautifulsoup4 beautifulsoup4
build build
click click
coverage >= 7.10.3 coverage >= 7.1.0
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

@ -19,36 +19,25 @@ import datetime
import enum import enum
import json import json
import warnings import warnings
from dataclasses import dataclass from typing import TYPE_CHECKING, Any, NamedTuple, TextIO, TypeVar
from typing import ClassVar, Literal
from typing_extensions import Self
from . import iersdata from . import iersdata
from .tz import Mountain from .tz import Mountain
WWVBChannel = Literal["amplitude", "phase", "both"]
TYPE_CHECKING = False
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Generator from collections.abc import Generator
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")
HOUR = datetime.timedelta(seconds=3600) HOUR = datetime.timedelta(seconds=3600)
SECOND = datetime.timedelta(seconds=1) SECOND = datetime.timedelta(seconds=1)
T = TypeVar("T")
def _removeprefix(s: str, p: str) -> str:
if s.startswith(p):
return s[len(p) :]
return s
def _date(dt: datetime.date) -> datetime.date: def _date(dt: datetime.date) -> datetime.date:
@ -352,13 +341,8 @@ class DstStatus(enum.IntEnum):
"""DST in effect all day today""" """DST in effect all day today"""
@dataclass(frozen=True) class _WWVBMinute(NamedTuple):
class WWVBMinute: """(implementation detail)"""
"""Uniquely identifies a minute of time in the WWVB system.
To use ``ut1`` and ``ls`` information from IERS, create a `WWVBMinuteIERS`
object instead.
"""
year: int year: int
"""2-digit year within the WWVB epoch""" """2-digit year within the WWVB epoch"""
@ -369,7 +353,7 @@ class WWVBMinute:
hour: int hour: int
"""UTC hour of day""" """UTC hour of day"""
minute: int min: int
"""Minute of hour""" """Minute of hour"""
dst: DstStatus dst: DstStatus
@ -384,10 +368,18 @@ class WWVBMinute:
ly: bool ly: bool
"""Leap year flag""" """Leap year flag"""
epoch: ClassVar[int] = 1970
def __init__( class WWVBMinute(_WWVBMinute):
self, """Uniquely identifies a minute of time in the WWVB system.
To use ``ut1`` and ``ls`` information from IERS, create a `WWVBMinuteIERS`
object instead.
"""
epoch: int = 1970
def __new__(
cls,
year: int, year: int,
days: int, days: int,
hour: int, hour: int,
@ -397,7 +389,7 @@ class WWVBMinute:
*, *,
ls: bool | None = None, ls: bool | None = None,
ly: bool | None = None, ly: bool | None = None,
) -> None: ) -> Self:
"""Construct a WWVBMinute """Construct a WWVBMinute
:param year: The 2- or 4-digit year. This parameter is converted by the `full_year` method. :param year: The 2- or 4-digit year. This parameter is converted by the `full_year` method.
@ -411,23 +403,15 @@ class WWVBMinute:
:param ls: Leap second warning flag :param ls: Leap second warning flag
:param ly: Leap year flag :param ly: Leap year flag
""" """
dst = self.get_dst(year, days) if dst is None else DstStatus(dst) dst = cls.get_dst(year, days) if dst is None else DstStatus(dst)
if ut1 is None and ls is None: if ut1 is None and ls is None:
ut1, ls = self._get_dut1_info(year, days) ut1, ls = cls._get_dut1_info(year, days)
elif ut1 is None or ls is None: elif ut1 is None or ls is None:
raise ValueError("sepecify both ut1 and ls or neither one") raise ValueError("sepecify both ut1 and ls or neither one")
year = self.full_year(year) year = cls.full_year(year)
if ly is None: if ly is None:
ly = isly(year) ly = isly(year)
return _WWVBMinute.__new__(cls, year, days, hour, minute, dst, ut1, ls, ly)
super().__setattr__("year", year)
super().__setattr__("days", days)
super().__setattr__("hour", hour)
super().__setattr__("minute", minute)
super().__setattr__("dst", dst)
super().__setattr__("ut1", ut1)
super().__setattr__("ls", ls)
super().__setattr__("ly", ly)
@classmethod @classmethod
def full_year(cls, year: int) -> int: def full_year(cls, year: int) -> int:
@ -461,7 +445,7 @@ class WWVBMinute:
"""Implement str()""" """Implement str()"""
return ( return (
f"year={self.year:4d} days={self.days:03d} hour={self.hour:02d} " f"year={self.year:4d} days={self.days:03d} hour={self.hour:02d} "
f"min={self.minute:02d} dst={self.dst} ut1={self.ut1} ly={int(self.ly)} " f"min={self.min:02d} dst={self.dst} ut1={self.ut1} ly={int(self.ly)} "
f"ls={int(self.ls)}" f"ls={int(self.ls)}"
) )
@ -471,7 +455,7 @@ class WWVBMinute:
The returned object has ``tzinfo=datetime.timezone.utc``. The returned object has ``tzinfo=datetime.timezone.utc``.
""" """
d = datetime.datetime(self.year, 1, 1, tzinfo=datetime.timezone.utc) d = datetime.datetime(self.year, 1, 1, tzinfo=datetime.timezone.utc)
d += datetime.timedelta(self.days - 1, self.hour * 3600 + self.minute * 60) d += datetime.timedelta(self.days - 1, self.hour * 3600 + self.min * 60)
return d return d
as_datetime = as_datetime_utc as_datetime = as_datetime_utc
@ -525,7 +509,7 @@ class WWVBMinute:
return 60 return 60
if not self._is_end_of_month(): if not self._is_end_of_month():
return 60 return 60
if self.hour != 23 or self.minute != 59: if self.hour != 23 or self.min != 59:
return 60 return 60
if self.ut1 > 0: if self.ut1 > 0:
return 59 return 59
@ -569,7 +553,7 @@ class WWVBMinute:
t.am[60] = AmplitudeModulation.MARK t.am[60] = AmplitudeModulation.MARK
for i in [4, 10, 11, 14, 20, 21, 24, 34, 35, 44, 54]: for i in [4, 10, 11, 14, 20, 21, 24, 34, 35, 44, 54]:
t.am[i] = AmplitudeModulation.ZERO t.am[i] = AmplitudeModulation.ZERO
t._put_am_bcd(self.minute, 1, 2, 3, 5, 6, 7, 8) t._put_am_bcd(self.min, 1, 2, 3, 5, 6, 7, 8)
t._put_am_bcd(self.hour, 12, 13, 15, 16, 17, 18) t._put_am_bcd(self.hour, 12, 13, 15, 16, 17, 18)
t._put_am_bcd(self.days, 22, 23, 25, 26, 27, 28, 30, 31, 32, 33) t._put_am_bcd(self.days, 22, 23, 25, 26, 27, 28, 30, 31, 32, 33)
ut1_sign = self.ut1 >= 0 ut1_sign = self.ut1 >= 0
@ -583,14 +567,14 @@ class WWVBMinute:
def _fill_pm_timecode_extended(self, t: WWVBTimecode) -> None: def _fill_pm_timecode_extended(self, t: WWVBTimecode) -> None:
"""During minutes 10..15 and 40..45, the amplitude signal holds 'extended information'""" """During minutes 10..15 and 40..45, the amplitude signal holds 'extended information'"""
assert 10 <= self.minute < 16 or 40 <= self.minute < 46 assert 10 <= self.min < 16 or 40 <= self.min < 46
minno = self.minute % 10 minno = self.min % 10
assert minno < 6 assert minno < 6
dst = self.dst dst = self.dst
# Note that these are 1 different than Table 11 # Note that these are 1 different than Table 11
# because our LFSR sequence is zero-based # because our LFSR sequence is zero-based
seqno = (self.minute // 30) * 2 seqno = (self.min // 30) * 2
if dst == 0: if dst == 0:
pass pass
elif dst == 3: elif dst == 3:
@ -673,17 +657,17 @@ class WWVBMinute:
def _fill_pm_timecode(self, t: WWVBTimecode) -> None: def _fill_pm_timecode(self, t: WWVBTimecode) -> None:
"""Fill the phase portion of a timecode object""" """Fill the phase portion of a timecode object"""
if 10 <= self.minute < 16 or 40 <= self.minute < 46: if 10 <= self.min < 16 or 40 <= self.min < 46:
self._fill_pm_timecode_extended(t) self._fill_pm_timecode_extended(t)
else: else:
self._fill_pm_timecode_regular(t) self._fill_pm_timecode_regular(t)
def next_minute(self, *, newut1: int | None = None, newls: bool | None = None) -> Self: def next_minute(self, *, newut1: int | None = None, newls: bool | None = None) -> WWVBMinute:
"""Return an object representing the next minute""" """Return an object representing the next minute"""
d = self.as_datetime() + datetime.timedelta(minutes=1) d = self.as_datetime() + datetime.timedelta(minutes=1)
return self.from_datetime(d, newut1=newut1, newls=newls, old_time=self) return self.from_datetime(d, newut1=newut1, newls=newls, old_time=self)
def previous_minute(self, *, newut1: int | None = None, newls: bool | None = None) -> Self: def previous_minute(self, *, newut1: int | None = None, newls: bool | None = None) -> WWVBMinute:
"""Return an object representing the previous minute""" """Return an object representing the previous minute"""
d = self.as_datetime() - datetime.timedelta(minutes=1) d = self.as_datetime() - datetime.timedelta(minutes=1)
return self.from_datetime(d, newut1=newut1, newls=newls, old_time=self) return self.from_datetime(d, newut1=newut1, newls=newls, old_time=self)
@ -702,9 +686,9 @@ class WWVBMinute:
return 0, False return 0, False
@classmethod @classmethod
def fromstring(cls, s: str) -> Self: def fromstring(cls, s: str) -> WWVBMinute:
"""Construct a WWVBMinute from a string representation created by print_timecodes""" """Construct a WWVBMinute from a string representation created by print_timecodes"""
s = s.removeprefix("WWVB timecode: ") s = _removeprefix(s, "WWVB timecode: ")
d: dict[str, int] = {} d: dict[str, int] = {}
for part in s.split(): for part in s.split():
k, v = part.split("=") k, v = part.split("=")
@ -718,7 +702,7 @@ class WWVBMinute:
dst = d.pop("dst", None) dst = d.pop("dst", None)
ut1 = d.pop("ut1", None) ut1 = d.pop("ut1", None)
ls = d.pop("ls", None) ls = d.pop("ls", None)
d.pop("ly", None) # Always use calculated ly flag d.pop("ly", None)
if d: if d:
raise ValueError(f"Invalid options: {d}") raise ValueError(f"Invalid options: {d}")
return cls(year, days, hour, minute, dst, ut1=ut1, ls=None if ls is None else bool(ls)) return cls(year, days, hour, minute, dst, ut1=ut1, ls=None if ls is None else bool(ls))
@ -731,7 +715,7 @@ class WWVBMinute:
newut1: int | None = None, newut1: int | None = None,
newls: bool | None = None, newls: bool | None = None,
old_time: WWVBMinute | None = None, old_time: WWVBMinute | None = None,
) -> Self: ) -> WWVBMinute:
"""Construct a WWVBMinute from a datetime, possibly specifying ut1/ls data or propagating it from an old time""" """Construct a WWVBMinute from a datetime, possibly specifying ut1/ls data or propagating it from an old time"""
u = d.utctimetuple() u = d.utctimetuple()
if newls is None and newut1 is None: if newls is None and newut1 is None:
@ -739,7 +723,7 @@ class WWVBMinute:
return cls(u.tm_year, u.tm_yday, u.tm_hour, u.tm_min, ut1=newut1, ls=newls) return cls(u.tm_year, u.tm_yday, u.tm_hour, u.tm_min, ut1=newut1, ls=newls)
@classmethod @classmethod
def from_timecode_am(cls, t: WWVBTimecode) -> Self | None: # noqa: PLR0912 def from_timecode_am(cls, t: WWVBTimecode) -> WWVBMinute | None: # noqa: PLR0912
"""Construct a WWVBMinute from a WWVBTimecode""" """Construct a WWVBMinute from a WWVBTimecode"""
for i in (0, 9, 19, 29, 39, 49, 59): for i in (0, 9, 19, 29, 39, 49, 59):
if t.am[i] != AmplitudeModulation.MARK: if t.am[i] != AmplitudeModulation.MARK:
@ -782,15 +766,6 @@ class WWVBMinute:
return None return None
return cls(year, days, hour, minute, dst, ut1, ls=ls, ly=ly) return cls(year, days, hour, minute, dst, ut1, ls=ls, ly=ly)
@property
def min(self) -> int:
"""Deprecated alias for `WWVBMinute.minute`
Update your code to use the `minute` property instead of the `min` property.
"""
warnings.warn("WWVBMinute.min property is deprecated", category=DeprecationWarning, stacklevel=1)
return self.minute
class WWVBMinuteIERS(WWVBMinute): class WWVBMinuteIERS(WWVBMinute):
"""A WWVBMinute that uses a database of DUT1 information""" """A WWVBMinute that uses a database of DUT1 information"""
@ -942,7 +917,7 @@ styles = {
def print_timecodes( def print_timecodes(
w: WWVBMinute, w: WWVBMinute,
minutes: int, minutes: int,
channel: WWVBChannel, channel: str,
style: str, style: str,
file: TextIO, file: TextIO,
*, *,
@ -961,7 +936,7 @@ def print_timecodes(
print(file=file) print(file=file)
print(f"WWVB timecode: {w!s}{channel_text}{style_text}", file=file) print(f"WWVB timecode: {w!s}{channel_text}{style_text}", file=file)
first = False first = False
pfx = f"{w.year:04d}-{w.days:03d} {w.hour:02d}:{w.minute:02d} " pfx = f"{w.year:04d}-{w.days:03d} {w.hour:02d}:{w.min:02d} "
tc = w.as_timecode() tc = w.as_timecode()
if len(style_chars) == 6: if len(style_chars) == 6:
print(f"{pfx} {tc.to_both_string(style_chars)}", file=file) print(f"{pfx} {tc.to_both_string(style_chars)}", file=file)
@ -979,7 +954,7 @@ def print_timecodes(
def print_timecodes_json( def print_timecodes_json(
w: WWVBMinute, w: WWVBMinute,
minutes: int, minutes: int,
channel: WWVBChannel, channel: str,
file: TextIO, file: TextIO,
) -> None: ) -> None:
"""Print a range of timecodes in JSON format. """Print a range of timecodes in JSON format.
@ -999,11 +974,11 @@ def print_timecodes_json(
""" """
result = [] result = []
for _ in range(minutes): for _ in range(minutes):
data: JsonMinute = { data: dict[str, Any] = {
"year": w.year, "year": w.year,
"days": w.days, "days": w.days,
"hour": w.hour, "hour": w.hour,
"minute": w.minute, "minute": w.min,
} }
tc = w.as_timecode() tc = w.as_timecode()

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,18 +9,15 @@ 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:
@ -98,7 +95,7 @@ def main(
dut1: int, dut1: int,
minutes: int, minutes: int,
style: str, style: str,
channel: WWVBChannel, channel: str,
all_timecodes: bool, all_timecodes: bool,
timespec: datetime.datetime, timespec: datetime.datetime,
) -> None: ) -> None:

View file

@ -1 +1 @@
{"START": "1972-01-01", "OFFSETS_GZ": "H4sIAOvijWgC/+2aa3LDMAiEL5uHLTuxnN5/pn/aTmfSSiAWhGy+E2SWZQE58zwiH/1YivB/96vMXiIX2Io8CTyIrDSWGqlMRdrpDa6aJFnr0m4wYZkCE2UmSF0V+13vBveStK6JTfQyW3O86HLJf0RvDgy5u4FCI+WVKTsVoUdHzsrRoWRfYHIItZ5EEgu0Beu58EgEpMpO9zf4/s3iNO4y7/hqEwOZIPu3+PuO2T7Ic5E8GxsnZHvUYOtELxW1WP+0yx/caFxpyAooq6lq06UEr+UkLeXOIDPZ6EBrqb5K8Tvu6/B9CdnZqFQL05s2KauWy/IeF/tJGAisjK9MgGyDuUkRq4G1gRE+VjA30uZNPsdantkgMq58QO4fw+sqzj+A2/16mmvnyy9UzDvMktDgKYlnkFeB2rx+wNANG40aA4OgsY03AWoDCVs/XMmkyQ0+0jWaUqPdwA0m/MRuccGjCwirHToWzbcs8P7U1nZZLSYdHapWu5HqVg1YjK2fPEwvPZPzLPUF848tyid2u7dh8B7h+wVQ923Q+kqxZe3JclSSB+YTM3nnHrjgFth/vzgZzw6cbOMYa4bHFPU/DR3mp/ubKM4cgwMnHZW4GFxFprOVcevAKGva6oExn1MOmyGDJQPm0rpU8bjqdOo993O6Xz9ofToZela5vwrWoTn4l4o5CIIaKejCEgSnJv784V+zUyyvbb/gE8h8bi3oTQAA"} {"START": "1972-01-01", "OFFSETS_GZ": "H4sIAIYEZWgC/+2aa3LDMAiEL5uHLTuxnN5/pn/aTmfSSiAWhGy+E2SWZQE58zwiH/1YivB/96vMXiIX2Io8CTyIrDSWGqlMRdrpDa6aJFnr0m4wYZkCE2UmSF0V+13vBveStK6JTfQyW3O86HLJf0RvDgy5u4FCI+WVKTsVoUdHzsrRoWRfYHIItZ5EEgu0Beu58EgEpMpO9zf4/s3iNO4y7/hqEwOZIPu3+PuO2T7Ic5E8GxsnZHvUYOtELxW1WP+0yx/caFxpyAooq6lq06UEr+UkLeXOIDPZ6EBrqb5K8Tvu6/B9CdnZqFQL05s2KauWy/IeF/tJGAisjK9MgGyDuUkRq4G1gRE+VjA30uZNPsdantkgMq58QO4fw+sqzj+A2/16mmvnyy9UzDvMktDgKYlnkFeB2rx+wNANG40aA4OgsY03AWoDCVs/XMmkyQ0+0jWaUqPdwA0m/MRuccGjCwirHToWzbcs8P7U1nZZLSYdHapWu5HqVg1YjK2fPEwvPZPzLPUF848tyid2u7dh8B7h+wVQ923Q+kqxZe3JclSSB+YTM3nnHrjgFth/vzgZzw6cbOMYa4bHFPU/DR3mp/ubKM4cgwMnHZW4GFxFprOVcevAKGva6oExn1MOmyGDJQPm0rpU8bjqdOo993O6Xz9ofToZela5vwrWoTn4l4o5CIIaKejCEgSnJv784V6y0/IJeROtycVNAAA="}

View file

@ -47,7 +47,6 @@ def update_iersdata( # noqa: PLR0915
"""Update iersdata.py""" """Update iersdata.py"""
offsets: list[int] = [] offsets: list[int] = []
iersdata_text = _get_text(IERS_URL) iersdata_text = _get_text(IERS_URL)
table_start: datetime.date | None = None
for r in csv.DictReader(io.StringIO(iersdata_text), delimiter=";"): for r in csv.DictReader(io.StringIO(iersdata_text), delimiter=";"):
jd = float(r["MJD"]) jd = float(r["MJD"])
offs_str = r["UT1-UTC"] offs_str = r["UT1-UTC"]
@ -80,8 +79,6 @@ def update_iersdata( # noqa: PLR0915
offsets.append(offs) offsets.append(offs)
assert table_start is not None
wwvb_text = _get_text(NIST_URL) wwvb_text = _get_text(NIST_URL)
wwvb_data = bs4.BeautifulSoup(wwvb_text, features="html.parser") wwvb_data = bs4.BeautifulSoup(wwvb_text, features="html.parser")
wwvb_dut1_table = wwvb_data.findAll("table")[2] wwvb_dut1_table = wwvb_data.findAll("table")[2]
@ -91,7 +88,6 @@ def update_iersdata( # noqa: PLR0915
wwvb_data_stamp = datetime.datetime.fromisoformat(meta.attrs["content"]).replace(tzinfo=None).date() wwvb_data_stamp = datetime.datetime.fromisoformat(meta.attrs["content"]).replace(tzinfo=None).date()
def patch(patch_start: datetime.date, patch_end: datetime.date, val: int) -> None: def patch(patch_start: datetime.date, patch_end: datetime.date, val: int) -> None:
assert table_start is not None
off_start = (patch_start - table_start).days off_start = (patch_start - table_start).days
off_end = (patch_end - table_start).days off_end = (patch_end - table_start).days
offsets[off_start:off_end] = [val] * (off_end - off_start) offsets[off_start:off_end] = [val] * (off_end - off_start)

View file

@ -8,13 +8,13 @@ from __future__ import annotations
import datetime import datetime
import functools import functools
from tkinter import Canvas, Event, TclError, Tk from tkinter import Canvas, 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: click.Context, param: click.Parameter, value: str) -> list[str]: # noqa: ARG001 def validate_colors(ctx: Any, param: Any, 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()
@ -69,7 +69,7 @@ def main(colors: list[str], size: int, min_size: int | None) -> None: # noqa: P
def deadline_ms(deadline: datetime.datetime) -> int: def deadline_ms(deadline: datetime.datetime) -> int:
"""Compute the number of ms until a deadline""" """Compute the number of ms until a deadline"""
now = datetime.datetime.now(datetime.timezone.utc) now = datetime.datetime.now(datetime.timezone.utc)
return int(max(0.0, (deadline - now).total_seconds()) * 1000) return int(max(0, (deadline - now).total_seconds()) * 1000)
def wwvbtick() -> Generator[tuple[datetime.datetime, wwvb.AmplitudeModulation]]: def wwvbtick() -> Generator[tuple[datetime.datetime, wwvb.AmplitudeModulation]]:
"""Yield consecutive values of the WWVB amplitude signal, going from minute to minute""" """Yield consecutive values of the WWVB amplitude signal, going from minute to minute"""
@ -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: Event) -> None: def resize_canvas(event: Any) -> 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,12 +141,10 @@ 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,5 +1,4 @@
#!/usr/bin/python3 #!/usr/bin/python3
"""Test most wwvblib commandline programs""" """Test most wwvblib commandline programs"""
# ruff: noqa: N802 D102 # ruff: noqa: N802 D102
@ -7,13 +6,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
@ -23,11 +22,7 @@ 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 coverage_add = ("-m", "coverage", "run", "--branch", "-p") if "COVERAGE_RUN" in os.environ else ()
if TYPE_CHECKING:
from collections.abc import Sequence
from wwvb import JsonMinute
class CLITestCase(unittest.TestCase): class CLITestCase(unittest.TestCase):
@ -39,10 +34,10 @@ class CLITestCase(unittest.TestCase):
return subprocess.check_output(args, stdin=subprocess.DEVNULL, encoding="utf-8", env=env) return subprocess.check_output(args, stdin=subprocess.DEVNULL, encoding="utf-8", env=env)
def moduleArgs(self, *args: str) -> Sequence[str]: def moduleArgs(self, *args: str) -> Sequence[str]:
return (sys.executable, "-m", *args) return (sys.executable, *coverage_add, "-m", *args)
def moduleOutput(self, *args: str) -> str: def moduleOutput(self, *args: str) -> str:
return self.programOutput(sys.executable, "-m", *args) return self.programOutput(sys.executable, *coverage_add, "-m", *args)
def assertProgramOutput(self, expected: str, *args: str) -> None: def assertProgramOutput(self, expected: str, *args: str) -> None:
"""Check the output from invoking a program matches the expected""" """Check the output from invoking a program matches the expected"""
@ -62,10 +57,9 @@ 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: list[JsonMinute], *args: str) -> None: def assertModuleJson(self, expected: Any, *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,3 +56,7 @@ 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,3 +27,7 @@ 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: uwwvb.WWVBMinute | None = None decoded = 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,3 +215,7 @@ 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

@ -18,7 +18,7 @@ import unittest
import uwwvb import uwwvb
import wwvb import wwvb
from wwvb import WWVBChannel, decode, iersdata, tz from wwvb import decode, iersdata, tz
class WWVBMinute2k(wwvb.WWVBMinute): class WWVBMinute2k(wwvb.WWVBMinute):
@ -44,15 +44,11 @@ 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: WWVBChannel = "amplitude" channel = "amplitude"
style = "default" style = "default"
for o in options: for o in options:
if o == "--channel=both": if o.startswith("--channel="):
channel = "both" channel = o[10:]
elif o == "--channel=amplitude":
channel = "amplitude"
elif o == "--channel=phase":
channel = "phase"
elif o.startswith("--style="): elif o.startswith("--style="):
style = o[8:] style = o[8:]
else: else:
@ -310,11 +306,6 @@ class WWVBRoundtrip(unittest.TestCase):
wwvb._maybe_warn_update(datetime.date(1970, 1, 1)) wwvb._maybe_warn_update(datetime.date(1970, 1, 1))
wwvb._maybe_warn_update(datetime.datetime(1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)) wwvb._maybe_warn_update(datetime.datetime(1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc))
def test_deprecated_min(self) -> None:
"""Ensure that the 'maybe_warn_update' function is covered"""
with self.assertWarnsRegex(DeprecationWarning, "min property"):
self.assertEqual(wwvb.WWVBMinute(2021, 1, 1, 1).min, wwvb.WWVBMinute(2021, 1, 1, 1).minute)
def test_undefined(self) -> None: def test_undefined(self) -> None:
"""Ensure that the check for unset elements in am works""" """Ensure that the check for unset elements in am works"""
with self.assertWarnsRegex(Warning, "is unset"): with self.assertWarnsRegex(Warning, "is unset"):
@ -434,3 +425,7 @@ 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()

View file

@ -1,14 +1,7 @@
# SPDX-FileCopyrightText: 2021 Jeff Epler # SPDX-FileCopyrightText: 2021 Jeff Epler
# #
# SPDX-License-Identifier: CC0-1.0 # SPDX-License-Identifier: CC0-1.0
#
# "For six minutes each half hour, from 1016 and 4046 minutes past each hour,
# one-minute frames are replaced by a special extended time frame. Rather than
# transmitting 35 bits of information in one minute, this transmits 7 bits
# (time of day and DST status only) over 6 minutes, giving 30 times as much
# energy per transmitted bit, a 14.8 dB improvement in the link budget compared
# to the standard one-minute time code." (wikipedia)
#
WWVB timecode: year=2021 days=311 hour=08 min=10 dst=1 ut1=-100 ly=0 ls=0 --channel=phase WWVB timecode: year=2021 days=311 hour=08 min=10 dst=1 ut1=-100 ly=0 ls=0 --channel=phase
2021-311 08:10 010000110100000111110110000001010110111111100110110101010001 2021-311 08:10 010000110100000111110110000001010110111111100110110101010001
2021-311 08:11 001001100111100011101110101111010010110010100111001000110001 2021-311 08:11 001001100111100011101110101111010010110010100111001000110001