8
\$\begingroup\$

Inspired by and created because of python3 utility: convert safely to int and driven partly by the 'refactor' in my answer.

The aforementioned question and my answer drove me to go and do a thing while bored at work - I created a new object class called StrictInt that behaves much like the function created by the original poster of that question/thread, but as an object class instead.

This object class is a subclass of int (which is the superclass. It's designed to work just like an int, but when you cast a str, int, or float to the StrictInt object, it will only convert the value if it is indeed an integer.

Because I'm crazy, you'll also need the typing module, but most Python 3 installs have that, if not, grab it from pip3.

This is the code used for the StrictInt class, and any required imports, in the strictint.py file:

from typing import Union, Any


class StrictInt(int):
    def __new__(cls, value, *args, **kwargs):
        # type: (Union[int, float, str], Any, Any) -> int

        if not isinstance(value, (int, StrictInt, float, str)):
            t = str(type(value)).replace("<class '", "").replace("'>", "")
            raise TypeError("Cannot convert type '{type}' to strict integer".format(type=t))

        try:
            f = float(value)
        except ValueError:
            f = None

        if not f:
            raise ValueError("Cannot convert a non-number to a strict integer.")

        if not f.is_integer():
            raise ValueError("Cannot convert value due to non-integer parts.")

        return super(StrictInt, cls).__new__(cls, int(f))

There is a set of unit tests I've been using as well (tests.py):

from strictint import StrictInt
import unittest


class TestStrictInt(unittest.TestCase):

    def test_float_conversion(self):
        # Non-integer parts present in a float, should raise ValueError
        self.assertRaises(ValueError, StrictInt, 3.14159)
        # Float that is equal to an int should be equal.
        self.assertEqual(3.0, StrictInt(3.0))

    def test_ints(self):
        # int(3) should equal StrictInt(3).
        self.assertEqual(3, StrictInt(3))

    def test_nonnumeric_string(self):
        # Not a number at all.
        self.assertRaises(ValueError, StrictInt, "I Am A Teapot")
        # Number with an invalid character in it, so Not a Number.
        self.assertRaises(ValueError, StrictInt, " 3.14159")
        # Has numeric content, but not a valid number due to dots.
        self.assertRaises(ValueError, StrictInt, "3.14.156")

    def test_numeric_string(self):
        # int('3') should equal StrictInt('3')
        self.assertEqual(int('3'), StrictInt('3'))
        # int(float('3.0')) is equal to int(3.0), and should equal StrictInt('3.0')
        self.assertEqual(int(float('3.0')), StrictInt('3.0'))
        # String with a number that has a decimal part should raise ValueError
        self.assertRaises(ValueError, StrictInt, '3.14159')


if __name__ == '__main__':
    unittest.main(warnings='ignore')

Any and all improvement suggestions are welcome. This works pretty well and fairly quickly from what I've tested, but I value everyone's opinions.

\$\endgroup\$
15
\$\begingroup\$
  1. Converting everything via a float means that you get the wrong result whenever the input cannot be represented exactly as a double-precision floating-point number. For example, this is surely not acceptable:

    >>> StrictInt(10**23)
    99999999999999991611392
    
  2. There's an OverflowError when the input is too large to be represented as a float:

    >>> StrictInt(10**400)
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
      File "cr195375.py", line 13, in __new__
        f = float(value)
    OverflowError: int too large to convert to float
    
  3. The strategy of enumerating the allowable types (int, StrictInt, float, str) means that many plausible use cases are disallowed, for example with fractions.Fraction:

    >>> from fractions import Fraction
    >>> StrictInt(Fraction(10, 1))
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
      File "cr195375.py", line 10, in __new__
        raise TypeError("Cannot convert type '{type}' to strict integer".format(type=t))
    TypeError: Cannot convert type 'fractions.Fraction' to strict integer
    

    and similarly with decimal.Decimal.

  4. The Pythonic approach is to use duck typing — that is, instead of testing whether input belongs to a fixed collection of types, you call the appropriate methods on the input. Here we want to determine whether the input has a non-zero fractional part, and otherwise to use the integer part. Hence we need Python's built-in divmod function:

    quotient, remainder = divmod(value, 1)
    if remainder:
        raise ValueError("could not convert value due to non-zero "
                         f"fractional part: {val!r}")
    
  5. There is no point taking *args and **kwargs if you are not going to use them.

Putting this together:

class StrictInt(int):
    "Subclass of int that refuses to coerce non-integer values."
    def __new__(cls, value):
        if isinstance(value, str):
            for converter in (int, float, complex):
                try:
                    value = converter(value)
                    break
                except ValueError:
                    pass
            else:
                raise ValueError(f"invalid literal for {cls.__name__}(): "
                                 f"{value!r}")
        if value.imag:
            raise ValueError("could not convert value due to non-zero "
                             f"imaginary part: {value!r}")
        quotient, remainder = divmod(value.real, 1)
        if remainder:
            raise ValueError("could not convert value due to non-zero "
                             f"fractional part: {value!r}")
        return super(StrictInt, cls).__new__(cls, int(quotient))

This handles a wider ranges of input types:

>>> StrictInt(Fraction(10, 2))
5
>>> StrictInt(Decimal('11.00000'))
11
>>> StrictInt(5+0j)
5

and it copes with integers that are too large to be represented as floats:

>>> StrictInt(10**400) == 10**400
True
\$\endgroup\$
  • \$\begingroup\$ Do we also have a Python 3.5 compatible version of the value error strings? f-prefix isn't supported in there and is one of the Python languages I have a CI running for this whenever I upload changes to the private repo. Would that just be the long-string with a .format() at the end? \$\endgroup\$ – Thomas Ward May 29 '18 at 13:28
  • \$\begingroup\$ Yes, if you don't have f-strings you can use format instead. \$\endgroup\$ – Gareth Rees May 29 '18 at 13:33
  • \$\begingroup\$ Thanks. Just for thoroughness, the value!r string wasn't supported for some reason in the .format() method. As such, I went digging in PEP 498 and found that value!r is equivalent to repr(value), so I had to use this in place of value!r. Maybe my Python is being stupid/weird, but meh. \$\endgroup\$ – Thomas Ward May 29 '18 at 13:50
  • 1
    \$\begingroup\$ You can write "{value!r}".format(value=value), referring to the parameter by name, or "{0!r}".format(value), referring to the parameter by numbered position, or just "{!r}".format(value), since positions are implicitly numbered starting at 0. See the Format String Syntax documentation. \$\endgroup\$ – Gareth Rees May 29 '18 at 13:55
5
\$\begingroup\$

naming

I try to avoid 1-letter variable names, unless it are x, y, z for coordinates, or i during an iteration, so I would rename f to float_value or something, but that is a matter of taste.

short_circuit

if value is an int or StrictInt already, you can return early. This way, you also

failing test-cases

there are a few cases that fail the current implementation that are integers

StrictInt(0)
StrictInt(0.0)
StrictInt(3 + 0j)
StrictInt('3 + 0j')

fixing the first is as easy as changing if not f: to if f is None

Complex

adding support for complex is rather easy, and eliminates the need to call float

class StrictInt(int):
    def __new__(cls, value, *args, **kwargs):
        # type: (Union[int, float, str], Any, Any) -> int

        if isinstance(value, (int, StrictInt)):
            return super(StrictInt, cls).__new__(cls, value)

        if isinstance(value, str):
            value = value.replace(' ', '')
        elif not isinstance(value, (float, complex)):
            type_str = str(type(value)).replace("<class '", "").replace("'>", "")
            raise TypeError("Cannot convert type '{type}' to strict integer".format(type=type_str))

        try:
            complex_value = complex(value)
        except ValueError:
            raise ValueError("Cannot convert a non-number to a strict integer.")

        if complex_value.imag:
            raise ValueError('Cannot convert complex number with imaginary part')

        float_value = complex_value.real
        if not float_value.is_integer():
            raise ValueError("Cannot convert value due to non-integer parts.")

        return super(StrictInt, cls).__new__(cls, int(float_value))

Python 2

If you want to include python 2, unicode should also be accepted as type

\$\endgroup\$
  • \$\begingroup\$ Regarding Python 2: I wrote it to be Py3 compliant. Otherwise I have to throw in the platform module and do additional logical testing to protect against a Py3 crash when it sees 'unicode' as a type test. \$\endgroup\$ – Thomas Ward May 29 '18 at 11:33
  • \$\begingroup\$ Also, the string '3 + 0j' isn't actually a number, it's a string with spaces. Are you saying this needs to be eval'd before we handle whether it can be converted? \$\endgroup\$ – Thomas Ward May 29 '18 at 13:21
  • \$\begingroup\$ This is handled fairly well in the other answer, but thank you for your review :) \$\endgroup\$ – Thomas Ward May 29 '18 at 14:36
  • \$\begingroup\$ I have no problem admitting the other answer is better \$\endgroup\$ – Maarten Fabré May 29 '18 at 14:48
0
\$\begingroup\$

Some thoughts in no particular order.

First of all, I'm not sure of the benefit of making it a class. Given that it is a class, it should override __repr__ so it is clear when someone is working with a "StrictInt object" rather than an int. Also, StrictInt need not be given in the list of types passed to isinstance, since it's a subclass of int - and of Number - but others have pointed out flaws in the type-checking approach anyway.

Support for the Decimal and Fraction types may be useful, and in fact you could support any type that implements the Number ABC. Gareth Rees's answer covers this reasonably well. The numeric side can be simplified a great deal, though - other than strings, the only types you are contemplating supporting are all numeric types. Numeric types are required to ensure they have correct comparison (and hash value) to other numeric types, and it's unlikely they'd get it wrong for int of all things, so you can leverage this by simply checking if the original input is equal to the integer.

if isinstance(value, str):
    ...logic for string conversion
else:
    v = int(value)
    if v != value: raise...

Note that the int() conversion will not allow complex types, so you do have some additional work to do if you want to support "complex but imaginary is zero".

Handling strings totally correctly is actually more ambitious than it sounds - others have already pointed out some of the problems with the float approach. The fractions.Fraction constructor is available for handling full decimal notation (so 1.00, 1.2e3, 1e23, 1e999*, will all be integers with their correct values, and 1.0000000000000001, 1.234e2 will not be), and you can simply check that the result has a denominator of 1.

*However, if you're handling untrusted input, you should be aware that unlimited exponents present a potential denial of service attack, and use a different approach, possibly duplicating some of the logic from the fractions module.

\$\endgroup\$
  • \$\begingroup\$ I disagree on the __repr__ override, if only because a StrictInt is still an integer, but with specific rules about how you can cast things as StrictInt. While it is its own type of object, it's still an int, so if you do print(StrictInt(12.0)) or repr(StrictInt(12.0)) you should get the same output as if you do print(int(12.0)) or repr(int(12.0)), which is 12. \$\endgroup\$ – Thomas Ward May 30 '18 at 15:31
  • \$\begingroup\$ @ThomasWard While the first case is reasonable (and hence why I didn't suggest overriding __str__), I expect repr to provide enough information to determine the actual type of an object. A bool is still an int too, but repr(True) isn't 1. But without more information about why you think it's useful for it to be a type, I can't comment further. \$\endgroup\$ – Random832 May 30 '18 at 15:49
  • \$\begingroup\$ As I said, I was bored, and needed something to do; this just happened to pop into my mind. (about 40% of my projects end up being the brainspawn of an idle mind) \$\endgroup\$ – Thomas Ward May 30 '18 at 16:14

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.