diff --git a/av/rational.py b/av/rational.py index c1a37b3f5..181bffd31 100644 --- a/av/rational.py +++ b/av/rational.py @@ -1,11 +1,26 @@ # type: ignore +import sys +from decimal import Decimal from fractions import Fraction -from numbers import Rational +from numbers import Rational, Real import cython from cython.cimports import libav as lib _INT32_MAX: cython.longlong = 2147483647 +_INF: cython.double = float("inf") +_HASH_INF = sys.hash_info.inf +_C_NEG_INF: cython.int = -1 +_C_FINITE: cython.int = 0 +_C_POS_INF: cython.int = 1 +_C_UNDEF: cython.int = 2 +_C_NAN: cython.int = 100 # foreign NaN: unordered, every comparison is False +_C_UNKNOWN: cython.int = 101 # not a real number: NotImplemented + +_LT: cython.int = 0 +_LE: cython.int = 1 +_GT: cython.int = 2 +_GE: cython.int = 3 @cython.final @@ -76,7 +91,10 @@ def __float__(self): def __hash__(self): if self.den == 0: - return hash((self.num, 0)) + # ``1/0 == float("inf")``, so the two must hash alike. + if self.num == 0: + return hash((0, 0)) + return _HASH_INF if self.num > 0 else -_HASH_INF return hash(Fraction(self.num, self.den)) def __reduce__(self): @@ -87,19 +105,34 @@ def __eq__(self, other): o: AVRational = other return self.num == o.num and self.den == o.den if self.den == 0: - return False + cb: cython.int = _order_class(other) + if cb == _C_UNKNOWN: + return NotImplemented + return _order_class(self) == cb return Fraction(self.num, self.den).__eq__(other) def __lt__(self, other): + cb: cython.int = _order_class(other) + if self.den == 0 or cb != _C_FINITE: + return _cmp(self, cb, _LT) return Fraction(self.num, self.den).__lt__(other) def __le__(self, other): + cb: cython.int = _order_class(other) + if self.den == 0 or cb != _C_FINITE: + return _cmp(self, cb, _LE) return Fraction(self.num, self.den).__le__(other) def __gt__(self, other): + cb: cython.int = _order_class(other) + if self.den == 0 or cb != _C_FINITE: + return _cmp(self, cb, _GT) return Fraction(self.num, self.den).__gt__(other) def __ge__(self, other): + cb: cython.int = _order_class(other) + if self.den == 0 or cb != _C_FINITE: + return _cmp(self, cb, _GE) return Fraction(self.num, self.den).__ge__(other) def __neg__(self): @@ -144,6 +177,53 @@ def __rsub__(self, other): return Fraction(self.num, self.den).__rsub__(other) +@cython.cfunc +def _order_class(v) -> cython.int: + if type(v) is AVRational: + o: AVRational = v + if o.den != 0: + return _C_FINITE + if o.num == 0: + return _C_UNDEF + return _C_POS_INF if o.num > 0 else _C_NEG_INF + if isinstance(v, Rational): # int, Fraction, ... + return _C_FINITE + if isinstance(v, Decimal): + if v.is_nan(): + return _C_NAN + if v.is_infinite(): + return _C_POS_INF if v > 0 else _C_NEG_INF + return _C_FINITE + if isinstance(v, Real): # float, numpy scalars, ... + f: cython.double = v + if f != f: + return _C_NAN + if f == _INF: + return _C_POS_INF + if f == -_INF: + return _C_NEG_INF + return _C_FINITE + return _C_UNKNOWN + + +# Returns object, not bint: NotImplemented must reach Python intact so the +# reflected operator gets a turn. +@cython.cfunc +def _cmp(a: AVRational, cb: cython.int, op: cython.int): + if cb == _C_UNKNOWN: + return NotImplemented + if cb == _C_NAN: + return False + ca: cython.int = _order_class(a) + if op == _LT: + return ca < cb + if op == _LE: + return ca <= cb + if op == _GT: + return ca > cb + return ca >= cb + + @cython.cfunc def from_avrational(q: lib.AVRational) -> AVRational: obj: AVRational = AVRational.__new__(AVRational) diff --git a/tests/test_rational.py b/tests/test_rational.py index f534e7d68..d374bf476 100644 --- a/tests/test_rational.py +++ b/tests/test_rational.py @@ -1,4 +1,5 @@ import pickle +from decimal import Decimal from fractions import Fraction import pytest @@ -69,7 +70,7 @@ def test_setters_accept_avrational() -> None: import av cc = av.codec.CodecContext.create("mpeg4", "w") - cc.time_base = AVRational(1001, 30000) # type: ignore[assignment] + cc.time_base = AVRational(1001, 30000) assert cc.time_base == Fraction(1001, 30000) @@ -86,3 +87,138 @@ def test_pickle_and_repr() -> None: assert pickle.loads(pickle.dumps(r)) == r assert repr(r) == "AVRational(30000, 1001)" assert str(r) == "30000/1001" + + +def test_nonfinite_equality() -> None: + inf, nan = float("inf"), float("nan") + + # Equality between two AVRationals is structural. + assert AVRational(1, 0) == AVRational(2, 0) + assert AVRational(0, 0) == AVRational(0, 0) + assert AVRational(1, 0) != AVRational(-1, 0) + assert AVRational(1, 0) != AVRational(0, 0) + + # Against another numeric type it is by value, so the hashes must agree. + assert AVRational(1, 0) == inf and hash(AVRational(1, 0)) == hash(inf) + assert AVRational(-1, 0) == -inf and hash(AVRational(-1, 0)) == hash(-inf) + assert AVRational(1, 0) == Decimal("Infinity") + assert hash(AVRational(1, 0)) == hash(Decimal("Infinity")) + assert {AVRational(1, 0)} == {inf} + + # 0/0 equals no value of another type, not even a NaN. + assert AVRational(0, 0) != nan + assert AVRational(0, 0) != Decimal("NaN") + assert AVRational(0, 0) != 0 and AVRational(0, 0) != Fraction(0, 1) + + assert AVRational(1, 0) != "x" and AVRational(1, 0) is not None + + +def test_nonfinite_ordering() -> None: + inf = float("inf") + neg, pos, undef, half = ( + AVRational(-1, 0), + AVRational(1, 0), + AVRational(0, 0), + AVRational(1, 2), + ) + + assert neg < half < pos < undef + assert undef > pos > half > neg + assert not (pos < pos) and pos <= pos and pos >= pos + assert not (undef < undef) and undef <= undef and undef >= undef + + # A finite AVRational against a non-finite one, either way round. + assert half < pos and pos > half + assert half > neg and neg < half + assert not (half >= pos) and not (pos <= half) + + # Against plain numbers, including infinities of another type. + assert pos > 10**9 and pos > 1e308 and pos > Fraction(10**9) + assert neg < -(10**9) and neg < -1e308 + assert pos <= inf and pos >= inf and not (pos < inf) and not (pos > inf) + assert neg <= -inf and neg >= -inf + assert half < inf and half > -inf + assert undef > inf and undef > 0 and not (undef < inf) + + +def test_nonfinite_ordering_is_consistent() -> None: + values = [ + AVRational(0, 0), + AVRational(1, 0), + AVRational(-1, 0), + AVRational(1, 2), + AVRational(0, 1), + Fraction(1, 2), + 0, + 7, + -2.5, + float("inf"), + float("-inf"), + Decimal("0.5"), + Decimal("Infinity"), + ] + for a in (v for v in values if isinstance(v, AVRational)): + for b in values: + eq, lt, le, gt, ge = a == b, a < b, a <= b, a > b, a >= b + assert le == (lt or eq), (a, b) + assert ge == (gt or eq), (a, b) + assert not (lt and gt), (a, b) + if isinstance(b, AVRational): + assert (lt, le, eq) == (b > a, b >= a, b == a), (a, b) + assert lt + eq + gt == 1, (a, b) + if eq: + assert hash(a) == hash(b), (a, b) + + +def test_nan_is_unordered() -> None: + rationals = (AVRational(1, 2), AVRational(1, 0), AVRational(0, 0)) + for value in (float("nan"), Decimal("NaN"), Decimal("sNaN")): + for r in rationals: + assert not (r < value) and not (r <= value) + assert not (r > value) and not (r >= value) + + for value in (float("nan"), Decimal("NaN")): + for r in rationals: + assert r != value + + +def test_nonfinite_sorting() -> None: + values = [ + AVRational(1, 0), + AVRational(0, 0), + AVRational(1, 2), + AVRational(-1, 0), + AVRational(3, 1), + AVRational(-3, 1), + ] + expected = [ + AVRational(-1, 0), + AVRational(-3, 1), + AVRational(1, 2), + AVRational(3, 1), + AVRational(1, 0), + AVRational(0, 0), + ] + assert sorted(values) == expected + backwards = values[::-1] + assert sorted(backwards) == expected + assert max(values) == AVRational(0, 0) + assert min(values) == AVRational(-1, 0) + + +def test_unorderable_types() -> None: + for value in ("x", None, 1j, object()): + for r in (AVRational(1, 2), AVRational(1, 0), AVRational(0, 0)): + with pytest.raises(TypeError): + r < value # noqa: B015 + with pytest.raises(TypeError): + r >= value # noqa: B015 + + +def test_fraction_on_the_left_is_asymmetric() -> None: + assert Fraction(1, 2) < AVRational(1, 0) + assert Fraction(1, 2) > AVRational(-1, 0) + assert Fraction(1, 2) >= AVRational(0, 0) + assert not (AVRational(0, 0) <= Fraction(1, 2)) + assert not (Fraction(1, 2) < AVRational(0, 0)) + assert AVRational(0, 0) > Fraction(1, 2)