Index: Lib/rational.py =================================================================== --- Lib/rational.py (revision 59974) +++ Lib/rational.py (working copy) @@ -7,6 +7,7 @@ import math import numbers import operator +import re __all__ = ["Rational"] @@ -76,6 +77,10 @@ return (top, 2 ** -e) +_RATIONAL_FORMAT = re.compile( + r'^\s*(?P[-+]?)(?P\d+)(?:/(?P\d+))?\s*$') + + class Rational(RationalAbc): """This class implements rational numbers. @@ -88,15 +93,29 @@ __slots__ = ('_numerator', '_denominator') - def __init__(self, numerator=0, denominator=1): - if (not isinstance(numerator, numbers.Integral) and - isinstance(numerator, RationalAbc) and - denominator == 1): - # Handle copies from other rationals. - other_rational = numerator - numerator = other_rational.numerator - denominator = other_rational.denominator + # We're immutable, so use __new__ not __init__ + def __new__(cls, numerator=0, denominator=1): + self = super(Rational, cls).__new__(cls) + if denominator == 1: + if isinstance(numerator, basestring): + input = numerator + m = _RATIONAL_FORMAT.match(input) + if m is None: + raise ValueError('Invalid literal for Rational: ' + input) + numerator = int(m.group('num')) + # Default denominator to 1. That's the only optional group. + denominator = int(m.group('denom') or 1) + if m.group('sign') == '-': + numerator = -numerator + + if (not isinstance(numerator, numbers.Integral) and + isinstance(numerator, RationalAbc)): + # Handle copies from other rationals. + other_rational = numerator + numerator = other_rational.numerator + denominator = other_rational.denominator + if (not isinstance(numerator, numbers.Integral) or not isinstance(denominator, numbers.Integral)): raise TypeError("Rational(%(numerator)s, %(denominator)s):" @@ -108,6 +127,7 @@ g = _gcd(numerator, denominator) self._numerator = int(numerator // g) self._denominator = int(denominator // g) + return self @classmethod def from_float(cls, f): @@ -119,6 +139,26 @@ raise TypeError("Cannot convert %r to %s." % (f, cls.__name__)) return cls(*_binary_float_to_ratio(f)) + @classmethod + def from_decimal(cls, dec): + """Converts a Decimal instance to a rational number, exactly.""" + from decimal import Decimal + if not isinstance(dec, Decimal): + raise TypeError( + "%s.from_decimal() only takes Decimals, not %r (%s)" % + (cls.__name__, dec, type(dec).__name__)) + sign, digits, exp = dec.as_tuple() + if exp in ('n', 'N', 'F'): + # Decimal provides no other public way to detect nan and infinity. + raise TypeError("Cannot convert %s to %s." % (dec, cls.__name__)) + digits = int(''.join(map(str, digits))) + if sign: + digits = -digits + if exp >= 0: + return cls(digits * 10 ** exp) + else: + return cls(digits, 10 ** -exp) + @property def numerator(a): return a._numerator @@ -129,15 +169,14 @@ def __repr__(self): """repr(self)""" - return ('rational.Rational(%r,%r)' % - (self.numerator, self.denominator)) + return ('Rational(%r,%r)' % (self.numerator, self.denominator)) def __str__(self): """str(self)""" if self.denominator == 1: return str(self.numerator) else: - return '(%s/%s)' % (self.numerator, self.denominator) + return '%s/%s' % (self.numerator, self.denominator) def _operator_fallbacks(monomorphic_operator, fallback_operator): """Generates forward and reverse operators given a purely-rational Index: Lib/test/test_rational.py =================================================================== --- Lib/test/test_rational.py (revision 59974) +++ Lib/test/test_rational.py (working copy) @@ -45,6 +45,44 @@ self.assertRaises(TypeError, R, 1.5) self.assertRaises(TypeError, R, 1.5 + 3j) + self.assertRaises(TypeError, R, R(1, 2), 3) + self.assertRaises(TypeError, R, "3/2", 3) + + def testFromString(self): + self.assertEquals((5, 1), _components(R("5"))) + self.assertEquals((3, 2), _components(R("3/2"))) + self.assertEquals((3, 2), _components(R(" \n +3/2"))) + self.assertEquals((-3, 2), _components(R("-3/2 "))) + self.assertEquals((3, 2), _components(R(" 03/02 \n "))) + self.assertEquals((3, 2), _components(R(u" 03/02 \n "))) + + self.assertRaisesMessage( + ZeroDivisionError, "Rational(3, 0)", + R, "3/0") + self.assertRaisesMessage( + ValueError, "Invalid literal for Rational: 3/", + R, "3/") + self.assertRaisesMessage( + ValueError, "Invalid literal for Rational: 3 /2", + R, "3 /2") + self.assertRaisesMessage( + # Denominators don't need a sign. + ValueError, "Invalid literal for Rational: 3/+2", + R, "3/+2") + self.assertRaisesMessage( + # Imitate float's parsing. + ValueError, "Invalid literal for Rational: + 3/2", + R, "+ 3/2") + self.assertRaisesMessage( + # Only parse fractions, not decimals. + ValueError, "Invalid literal for Rational: 3.2", + R, "3.2") + + def testImmutable(self): + r = R(7, 3) + r.__init__(2, 15) + self.assertEquals((7, 3), _components(r)) + def testFromFloat(self): self.assertRaisesMessage( TypeError, "Rational.from_float() only takes floats, not 3 (int)", @@ -72,6 +110,31 @@ TypeError, "Cannot convert nan to Rational.", R.from_float, nan) + def testFromDecimal(self): + self.assertRaisesMessage( + TypeError, + "Rational.from_decimal() only takes Decimals, not 3 (int)", + R.from_decimal, 3) + self.assertEquals(R(0), R.from_decimal(Decimal("-0"))) + self.assertEquals(R(5, 10), R.from_decimal(Decimal("0.5"))) + self.assertEquals(R(5, 1000), R.from_decimal(Decimal("5e-3"))) + self.assertEquals(R(5000), R.from_decimal(Decimal("5e3"))) + self.assertEquals(1 - R(1, 10**30), + R.from_decimal(Decimal("0." + "9" * 30))) + + self.assertRaisesMessage( + TypeError, "Cannot convert Infinity to Rational.", + R.from_decimal, Decimal("inf")) + self.assertRaisesMessage( + TypeError, "Cannot convert -Infinity to Rational.", + R.from_decimal, Decimal("-inf")) + self.assertRaisesMessage( + TypeError, "Cannot convert NaN to Rational.", + R.from_decimal, Decimal("nan")) + self.assertRaisesMessage( + TypeError, "Cannot convert sNaN to Rational.", + R.from_decimal, Decimal("snan")) + def testConversions(self): self.assertTypedEquals(-1, trunc(R(-11, 10))) self.assertTypedEquals(-2, R(-11, 10).__floor__()) @@ -173,7 +236,7 @@ self.assertTypedEquals(1.0 + 0j, (1.0 + 0j) ** R(1, 10)) def testMixingWithDecimal(self): - """Decimal refuses mixed comparisons.""" + # Decimal refuses mixed comparisons. self.assertRaisesMessage( TypeError, "unsupported operand type(s) for +: 'Rational' and 'Decimal'", @@ -236,8 +299,8 @@ self.assertFalse(R(5, 2) == 2) def testStringification(self): - self.assertEquals("rational.Rational(7,3)", repr(R(7, 3))) - self.assertEquals("(7/3)", str(R(7, 3))) + self.assertEquals("Rational(7,3)", repr(R(7, 3))) + self.assertEquals("7/3", str(R(7, 3))) self.assertEquals("7", str(R(7, 1))) def testHash(self):