renumbering chapters >= 19
This commit is contained in:
115
24-class-metaprog/hours/hours.py
Normal file
115
24-class-metaprog/hours/hours.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Abusing ``__class_getitem__`` to make a nano-DSL for working
|
||||
with hours, minutes, and seconds--these last two in base 60.
|
||||
|
||||
``H`` is an alias for the ``Hours`` class::
|
||||
|
||||
>>> H[1]
|
||||
1:00
|
||||
>>> H[1:30]
|
||||
1:30
|
||||
>>> H[1::5]
|
||||
1:00:05
|
||||
>>> H[::5]
|
||||
0:00:05
|
||||
|
||||
An ``H`` instance can be converted to a float number of hours::
|
||||
|
||||
>>> float(H[1:15])
|
||||
1.25
|
||||
>>> float(H[1:30:30]) # doctest: +ELLIPSIS
|
||||
1.5083333...
|
||||
>>> float(H[1::5]) # doctest: +ELLIPSIS
|
||||
1.0013888...
|
||||
|
||||
The ``H`` constructor accepts hours, minutes, and/or seconds::
|
||||
|
||||
>>> H(1.5)
|
||||
1:30
|
||||
>>> H(1.9)
|
||||
1:54
|
||||
>>> H(1, 30, 30)
|
||||
1:30:30
|
||||
>>> H(s = 7205)
|
||||
2:00:05
|
||||
>>> H(1/3)
|
||||
0:20
|
||||
>>> H(1/1000)
|
||||
0:00:03.6
|
||||
|
||||
An ``H`` instance is iterable, for convenient unpacking::
|
||||
|
||||
>>> hms = H[1:22:33]
|
||||
>>> h, m, s = hms
|
||||
>>> h, m, s
|
||||
(1, 22, 33)
|
||||
>>> tuple(hms)
|
||||
(1, 22, 33)
|
||||
|
||||
|
||||
``H`` instances can be added::
|
||||
|
||||
>>> H[1:45:12] + H[2:15:50]
|
||||
4:01:02
|
||||
"""
|
||||
|
||||
from typing import Tuple, Union
|
||||
|
||||
|
||||
def normalize(s: float) -> Tuple[int, int, float]:
|
||||
h, r = divmod(s, 3600)
|
||||
m, s = divmod(r, 60)
|
||||
return int(h), int(m), s
|
||||
|
||||
|
||||
def valid_base_60(n, unit):
|
||||
if not (0 <= n < 60):
|
||||
raise ValueError(f'invalid {unit} {n}')
|
||||
return n
|
||||
|
||||
|
||||
class Hours:
|
||||
h: int
|
||||
_m: int
|
||||
_s: float
|
||||
|
||||
def __class_getitem__(cls, parts: Union[slice, float]) -> 'Hours':
|
||||
if isinstance(parts, slice):
|
||||
h = parts.start or 0
|
||||
m = valid_base_60(parts.stop or 0, 'minutes')
|
||||
s = valid_base_60(parts.step or 0, 'seconds')
|
||||
else:
|
||||
h, m, s = normalize(parts * 3600)
|
||||
return Hours(h, m, s)
|
||||
|
||||
def __init__(self, h: float = 0, m: float = 0, s: float = 0):
|
||||
if h < 0 or m < 0 or s < 0:
|
||||
raise ValueError('invalid negative argument')
|
||||
self.h, self.m, self.s = normalize(h * 3600 + m * 60 + s)
|
||||
|
||||
def __repr__(self):
|
||||
h, m, s = self
|
||||
display_s = f'{s:06.3f}'
|
||||
display_s = display_s.rstrip('0').rstrip('.')
|
||||
if display_s == '00':
|
||||
return f'{h}:{m:02d}'
|
||||
return f'{h}:{m:02d}:{display_s}'
|
||||
|
||||
def __float__(self):
|
||||
return self.h + self.m / 60 + self.s / 3600
|
||||
|
||||
def __eq__(self, other):
|
||||
return repr(self) == repr(other)
|
||||
|
||||
def __iter__(self):
|
||||
yield self.h
|
||||
yield self.m
|
||||
yield self.s
|
||||
|
||||
def __add__(self, other):
|
||||
if not isinstance(other, Hours):
|
||||
return NotImplemented
|
||||
return Hours(*(a + b for a, b in zip(self, other)))
|
||||
|
||||
|
||||
H = Hours
|
||||
71
24-class-metaprog/hours/hours_test.py
Normal file
71
24-class-metaprog/hours/hours_test.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# content of test_expectation.py
|
||||
from math import isclose
|
||||
|
||||
import pytest
|
||||
|
||||
from hours import normalize, H
|
||||
|
||||
HOURS_TO_HMS = [
|
||||
[1, (1, 0, 0.0)],
|
||||
[1.5, (1, 30, 0.0)],
|
||||
[1.1, (1, 6, 0.0)],
|
||||
[1.9, (1, 54, 0.0)],
|
||||
[1.01, (1, 0, 36.0)],
|
||||
[1.09, (1, 5, 24.0)],
|
||||
[2 + 1/60, (2, 1, 0.0)],
|
||||
[3 + 1/3600, (3, 0, 1.0)],
|
||||
[1.251, (1, 15, 3.6)],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('hours, expected', HOURS_TO_HMS)
|
||||
def test_normalize(hours, expected):
|
||||
h, m, s = expected
|
||||
got_h, got_m, got_s = normalize(hours * 3600)
|
||||
assert (h, m) == (got_h, got_m)
|
||||
assert isclose(s, got_s, abs_tol=1e-12)
|
||||
got_hours = got_h + got_m / 60 + got_s / 3600
|
||||
assert isclose(hours, got_hours)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('h, expected', [
|
||||
(H[1], '1:00'),
|
||||
(H[1:0], '1:00'),
|
||||
(H[1:3], '1:03'),
|
||||
(H[1:59], '1:59'),
|
||||
(H[1:0:0], '1:00'),
|
||||
(H[1:2:3], '1:02:03'),
|
||||
(H[1:2:3.4], '1:02:03.4'),
|
||||
(H[1:2:0.1], '1:02:00.1'),
|
||||
(H[1:2:0.01], '1:02:00.01'),
|
||||
(H[1:2:0.001], '1:02:00.001'),
|
||||
(H[1:2:0.0001], '1:02'),
|
||||
])
|
||||
def test_repr(h, expected):
|
||||
assert expected == repr(h), f'seconds: {h.s}'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('expected, hms', HOURS_TO_HMS)
|
||||
def test_float(expected, hms):
|
||||
got = float(H[slice(*hms)])
|
||||
assert isclose(expected, got)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('hms, units', [
|
||||
((0, 60, 0), 'minutes'),
|
||||
((0, 0, 60), 'seconds'),
|
||||
((0, 60, 60), 'minutes'),
|
||||
])
|
||||
def test_class_getitem_errors(hms, units):
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
H[slice(*hms)]
|
||||
assert units in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('hms1, hms2, expected', [
|
||||
(H[0:30], H[0:15], H[0:45]),
|
||||
(H[0:30], H[0:30], H[1:00]),
|
||||
(H[0:59:59], H[0:00:1], H[1:00]),
|
||||
])
|
||||
def test_add(hms1, hms2, expected):
|
||||
assert expected == hms1 + hms2
|
||||
Reference in New Issue
Block a user