Skip to content

Commit 8495b81

Browse files
authored
Hypothesis (#32)
* Introducing hypothesis, which already uncovered several bugs. * Quantity tests with Hypothesis, and tightening up floating point tolerances around the test suite.
1 parent 5362c66 commit 8495b81

14 files changed

+265
-109
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
build
44
.coverage
55
dist
6+
.hypothesis
67
.mypy_cache
78
.profiles
89
*.pyc

setup.cfg

+1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ dev =
5858
cloudpickle
5959
flake8
6060
flake8-black
61+
hypothesis
6162
icecream
6263
isort
6364
ipython

src/measured/__init__.py

+10-6
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,11 @@
165165
from .formatting import superscript
166166

167167
try:
168-
from icecream import ic
168+
from icecream import _ic
169169
except ImportError: # pragma: no cover
170-
ic = lambda *a: None if not a else (a[0] if len(a) == 1 else a) # noqa
170+
_ic = lambda *a: None if not a else (a[0] if len(a) == 1 else a) # noqa
171171

172+
ic = _ic
172173

173174
__version__ = version("measured")
174175

@@ -582,6 +583,9 @@ def __new__(
582583
name: Optional[str] = None,
583584
symbol: Optional[str] = None,
584585
) -> "Prefix":
586+
if base != 0 and exponent == 0:
587+
return IdentityPrefix
588+
585589
key = (base, exponent)
586590
if key in cls._known:
587591
return cls._known[key]
@@ -1074,7 +1078,7 @@ def parse(cls, string: str) -> "Unit":
10741078

10751079
def __str__(self) -> str:
10761080
if self.symbol:
1077-
return f"{self.prefix}{self.symbol}"
1081+
return self.symbol
10781082

10791083
return str(self.prefix) + "⋅".join(
10801084
f"{unit.prefix}{unit.symbol}{superscript(exponent)}"
@@ -1526,7 +1530,7 @@ def _approximation(self, other: "Quantity") -> Union[Numeric, bool]:
15261530

15271531
return abs(ratio)
15281532

1529-
def approximates(self, other: "Quantity", within: float = 1e-6) -> bool:
1533+
def approximates(self, other: "Quantity", within: float = 1e-7) -> bool:
15301534
"""Indicates whether this Quantity and another Quantity are close enough to
15311535
each other to be considered equal.
15321536
@@ -1546,7 +1550,7 @@ def approximates(self, other: "Quantity", within: float = 1e-6) -> bool:
15461550

15471551
return bool(approximation <= within)
15481552

1549-
def assert_approximates(self, other: "Quantity", within: float = 1e-6) -> None:
1553+
def assert_approximates(self, other: "Quantity", within: float = 1e-7) -> None:
15501554
"""Asserts whether this Quantity and another Quantity are close enough to
15511555
each other to be considered equal, with a helpful assertion message
15521556
@@ -1570,7 +1574,7 @@ def assert_approximates(self, other: "Quantity", within: float = 1e-6) -> None:
15701574
if approximation is True:
15711575
return
15721576

1573-
assert approximation, f"No conversion between {self} and {other}"
1577+
assert approximation is not False, f"No conversion between {self} and {other}"
15741578

15751579
message = " or ".join(
15761580
[

src/measured/hypothesis.py

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from hypothesis.strategies import (
2+
SearchStrategy,
3+
builds,
4+
floats,
5+
integers,
6+
one_of,
7+
sampled_from,
8+
)
9+
10+
from measured import systems # noqa: F401
11+
from measured import Dimension, Prefix, Quantity, Unit
12+
13+
DIMENSIONS = sorted(Dimension.fundamental(), key=lambda d: d.exponents)
14+
PREFIXES = sorted(Prefix._known.values(), key=lambda p: (p.base, p.exponent))
15+
BASE_UNITS = sorted(Unit.base(), key=lambda u: u.name or "")
16+
UNITS = sorted(Unit._known.values(), key=lambda u: u.name or "")
17+
for this in BASE_UNITS:
18+
UNITS.append(this**2)
19+
UNITS.append(this**3)
20+
UNITS.append(this**-2)
21+
UNITS.append(this**-3)
22+
for other in BASE_UNITS:
23+
UNITS.append(this / other)
24+
UNITS.append(this * other)
25+
UNITS_WITH_SYMBOLS = [u for u in UNITS if u.symbol]
26+
27+
28+
def dimensions() -> SearchStrategy[Dimension]:
29+
return sampled_from(DIMENSIONS)
30+
31+
32+
def prefixes() -> SearchStrategy[Prefix]:
33+
return sampled_from(PREFIXES)
34+
35+
36+
def units() -> SearchStrategy[Unit]:
37+
return sampled_from(UNITS)
38+
39+
40+
def base_units() -> SearchStrategy[Unit]:
41+
return sampled_from(BASE_UNITS)
42+
43+
44+
def units_with_symbols() -> SearchStrategy[Unit]:
45+
return sampled_from(UNITS_WITH_SYMBOLS)
46+
47+
48+
def quantities() -> SearchStrategy[Quantity]:
49+
return builds(
50+
Quantity,
51+
magnitude=one_of(
52+
floats(min_value=-1e30, max_value=-1e-30),
53+
sampled_from([0.0]),
54+
floats(min_value=1e-30, max_value=1e-30),
55+
integers(),
56+
),
57+
unit=units(),
58+
)

src/measured/si.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@
189189
Hertz = Unit.derive(One / Second, name="hertz", symbol="Hz")
190190

191191
Radian = PlaneAngle.unit(name="radian", symbol="rad")
192-
Steradian = PlaneAngle.unit(name="steradian", symbol="sr")
192+
Steradian = Unit.derive(Radian**2, name="steradian", symbol="sr")
193193

194194
Newton = Unit.derive((Kilogram) * Meter / Second**2, name="newton", symbol="N")
195195
Joule = Unit.derive(Meter * Newton, name="joule", symbol="J")

tests/natural/test_planck.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def test_hbar() -> None:
3232

3333
def test_length() -> None:
3434
assert PlanckLength.dimension is Length
35-
(1 * PlanckLength).assert_approximates(1.616255e-35 * Meter)
35+
(1 * PlanckLength).assert_approximates(1.616255e-35 * Meter, 1.3e-07)
3636

3737

3838
def test_mass() -> None:
+16-24
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,36 @@
11
import pytest
2+
from hypothesis import assume, given
23

34
from measured import One, Quantity
4-
from measured.si import Hertz, Meter, Second
5+
from measured.hypothesis import quantities
56

67

7-
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
8-
examples = [
9-
1 * Meter,
10-
2 * Second,
11-
3 * Hertz,
12-
4 * Meter / Second,
13-
5 * Meter**2,
14-
6 * Meter**2 / Second**2,
15-
]
16-
17-
for exemplar in ["a", "b", "c"]:
18-
if exemplar in metafunc.fixturenames:
19-
metafunc.parametrize(exemplar, examples)
20-
21-
22-
@pytest.fixture
8+
@pytest.fixture(scope="module")
239
def identity() -> Quantity:
2410
return 1 * One
2511

2612

27-
def test_multiplication_associativity(a: Quantity, b: Quantity, c: Quantity) -> None:
28-
assert (a * b) * c == a * (b * c)
13+
@given(a=quantities(), b=quantities(), c=quantities())
14+
def test_associativity(a: Quantity, b: Quantity, c: Quantity) -> None:
15+
((a * b) * c).assert_approximates(a * (b * c))
2916

3017

31-
def test_multiplication_identity(identity: Quantity, a: Quantity) -> None:
18+
@given(a=quantities())
19+
def test_identity(identity: Quantity, a: Quantity) -> None:
3220
assert a * identity == a
3321
assert identity * a == a
3422

3523

36-
def test_multiplication_inverse(identity: Quantity, a: Quantity) -> None:
24+
@given(a=quantities())
25+
def test_inverse(identity: Quantity, a: Quantity) -> None:
26+
assume(a.magnitude != 0)
27+
3728
inverse = a**-1
3829
assert inverse * a == a * inverse
39-
assert inverse * a == identity
40-
assert identity / a == inverse
30+
(inverse * a).assert_approximates(identity)
31+
(identity / a).assert_approximates(inverse)
4132

4233

43-
def test_multiplication_commutativity(a: Quantity, b: Quantity) -> None:
34+
@given(a=quantities(), b=quantities())
35+
def test_commutativity(a: Quantity, b: Quantity) -> None:
4436
assert a * b == b * a

tests/si/test_angles.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ def test_steradian_is_dimensionless_but_unique() -> None:
3636
(2 * π * Radian, 360 * Degree),
3737
(1 * Arcminute, 60 * Arcsecond),
3838
(1 * Degree, 60 * Arcminute),
39-
(1 * Arcsecond, 0.000004848136 * Radian),
40-
(1 * Arcminute, 0.000290888 * Radian),
39+
(1 * Arcsecond, 0.000004848136811 * Radian),
40+
(1 * Arcminute, 0.000290888208665 * Radian),
4141
],
4242
)
4343
def test_angle_conversions(left: Quantity, right: Quantity) -> None:

tests/test_dimension.py

+16-13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from fractions import Fraction
22

33
import pytest
4+
from hypothesis import given
45

56
from measured import (
67
AmountOfSubstance,
@@ -22,25 +23,15 @@
2223
Time,
2324
Volume,
2425
)
26+
from measured.hypothesis import dimensions
2527

2628

27-
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
28-
fundamental = Dimension.fundamental()
29-
ids = [d.name for d in fundamental]
30-
31-
for exemplar in ["a", "b", "c"]:
32-
if exemplar in metafunc.fixturenames:
33-
metafunc.parametrize(exemplar, fundamental, ids=ids)
34-
35-
if "dimension" in metafunc.fixturenames:
36-
metafunc.parametrize("dimension", fundamental, ids=ids)
37-
38-
39-
@pytest.fixture
29+
@pytest.fixture(scope="module")
4030
def identity() -> Dimension:
4131
return Number
4232

4333

34+
@given(a=dimensions(), b=dimensions())
4435
def test_homogenous_under_addition(a: Dimension, b: Dimension) -> None:
4536
# https://en.wikipedia.org/wiki/Dimensional_analysis#Dimensional_homogeneity
4637
#
@@ -53,6 +44,7 @@ def test_homogenous_under_addition(a: Dimension, b: Dimension) -> None:
5344
a + b
5445

5546

47+
@given(a=dimensions(), b=dimensions())
5648
def test_homogenous_under_subtraction(a: Dimension, b: Dimension) -> None:
5749
# https://en.wikipedia.org/wiki/Dimensional_analysis#Dimensional_homogeneity
5850
#
@@ -65,41 +57,49 @@ def test_homogenous_under_subtraction(a: Dimension, b: Dimension) -> None:
6557
a - b
6658

6759

60+
@given(a=dimensions(), b=dimensions(), c=dimensions())
6861
def test_abelian_associativity(a: Dimension, b: Dimension, c: Dimension) -> None:
6962
# https://en.wikipedia.org/wiki/Abelian_group
7063
assert (a * b) * c == a * (b * c)
7164

7265

66+
@given(a=dimensions())
7367
def test_abelian_identity(identity: Dimension, a: Dimension) -> None:
7468
assert identity * a == a
7569

7670

71+
@given(a=dimensions())
7772
def test_abelian_inverse(identity: Dimension, a: Dimension) -> None:
7873
inverse = a**-1
7974
assert inverse * a == a * inverse
8075
assert inverse * a == identity
8176
assert identity / a == inverse
8277

8378

79+
@given(a=dimensions(), b=dimensions())
8480
def test_abelian_commutativity(a: Dimension, b: Dimension) -> None:
8581
assert a * b == b * a
8682

8783

84+
@given(dimension=dimensions())
8885
def test_no_dimensional_exponentation(dimension: Dimension) -> None:
8986
with pytest.raises(TypeError):
9087
dimension**dimension # type: ignore
9188

9289

90+
@given(dimension=dimensions())
9391
def test_no_floating_point_exponentation(dimension: Dimension) -> None:
9492
with pytest.raises(TypeError):
9593
dimension**0.5 # type: ignore
9694

9795

96+
@given(dimension=dimensions())
9897
def test_no_fractional_exponentation(dimension: Dimension) -> None:
9998
with pytest.raises(TypeError):
10099
dimension ** Fraction(1, 2) # type: ignore
101100

102101

102+
@given(dimension=dimensions())
103103
def test_only_dimensional_multiplication(dimension: Dimension) -> None:
104104
with pytest.raises(TypeError):
105105
5 * dimension # type: ignore
@@ -108,6 +108,7 @@ def test_only_dimensional_multiplication(dimension: Dimension) -> None:
108108
dimension * 5 # type: ignore
109109

110110

111+
@given(dimension=dimensions())
111112
def test_only_dimensional_division(dimension: Dimension) -> None:
112113
with pytest.raises(TypeError):
113114
dimension / 5 # type: ignore
@@ -116,6 +117,7 @@ def test_only_dimensional_division(dimension: Dimension) -> None:
116117
5 / dimension # type: ignore
117118

118119

120+
@given(dimension=dimensions())
119121
def test_repr(dimension: Dimension) -> None:
120122
r = repr(dimension)
121123
assert r.startswith("Dimension(exponents=(0,")
@@ -124,6 +126,7 @@ def test_repr(dimension: Dimension) -> None:
124126
assert r.endswith(")")
125127

126128

129+
@given(dimension=dimensions())
127130
def test_repr_roundtrips(dimension: Dimension) -> None:
128131
assert eval(repr(dimension)) is dimension
129132

0 commit comments

Comments
 (0)