6
\$\begingroup\$

After reading this question about returning the "next" string in some alphabet (the digits, followed by upper and lowercase ASCII letters) using Java, I thought, well that's just counting, so why not derive from int and make a nice representation, which is very easy in Python.

I borrowed the base conversion code from numpy.base_repr (extending it from max base 36 to 62), but this was not enough. When doing math with these integers, it would be nice if they remained in their base, so I added a class decorator that automatically wraps calls to special methods (in this case the math methods) with the class itself again (taking the base from the first number, in case both have a base). This decorator is adapted from this SO answer.

from functools import wraps
from string import digits, ascii_letters


def wrap_math_methods(cls):
    """Wraps a classes math modules with converter,
    so that the result is an instance of the class again."""
    methods = {"__abs__", "__add__", "__and__", "__ceil__", "__div__",
               "__floor__", "__floordiv__", "__invert__", "__lshift__",
               "__mod__", "__mul__", "__neg__", "__or__", "__pos__", "__pow__",
               "__radd__", "__rand__", "__rfloordiv__", "__rlshift__",
               "__rmod__", "__rmul__", "__ror__", "__rpow__", "__rrshift__",
               "__rshift__",  "__rsub__", "__rtruediv__",  "__rxor__",
               "__sub__", "__truediv__", "__xor__"}
    def method_wrapper(method):
        @wraps(method)
        def inner(self, *args, **kwargs):
            # Return a new instance of cls, copying the base
            return cls(method(self, *args, **kwargs), self.base)
        return inner

    for attr_name in dir(cls):
        if attr_name in methods:
            setattr(cls, attr_name, method_wrapper(getattr(cls, attr_name)))
    return cls


@wrap_math_methods
class BaseInt(int):
    """An integer class in arbitrary (up to 62) base.

    Automatically converts results of math operations."""
    digits = digits + ascii_letters

    def __new__(cls, value, base=10):
        if base > len(cls.digits):
            raise ValueError(f"Bases greater than {len(cls.digits)} not handled.")
        elif base < 2:
            raise ValueError("Bases less than 2 not handled.")
        n = super().__new__(cls, value)
        n.base = base
        return n

    def __str__(self):
        """Representation of the number in its base."""
        num = abs(self)
        res = []
        while num:
            res.append(self.digits[num % self.base])
            num //= self.base
        if self < 0:
            res.append('-')
        return ''.join(reversed(res or '0'))


if __name__ == "__main__":
    n = BaseInt(100, 62)
    print(n, repr(n))
    print(n + 1, repr(n + 1))
    print(int(n))

    import math
    assert isinstance(abs(n), BaseInt), "__abs__"
    assert isinstance(n + 1, BaseInt), "__add__"
    assert isinstance(BaseInt(0) and 2, BaseInt), "__and__"  # short circuit
    assert isinstance(math.ceil(n), BaseInt), "__ceil__"
    assert isinstance(n / 2, BaseInt), "__div__"
    assert isinstance(math.floor(n), BaseInt), "__floor__"
    assert isinstance(n // 2, BaseInt), "__floordiv__"
    assert isinstance(~n, BaseInt), "__invert__"
    assert isinstance(n << 2, BaseInt), "__lshift__"
    assert isinstance(n % 2, BaseInt), "__mod__"
    assert isinstance(n * 2, BaseInt), "__mul__"
    assert isinstance(-n, BaseInt), "__neq__"
    assert isinstance(n or 2, BaseInt), "__or__"
    assert isinstance(+n, BaseInt), "__pos__"
    assert isinstance(n ** 2, BaseInt), "__pow__"
    assert isinstance(2 + n, BaseInt), "__radd__"
    assert isinstance(2 and n, BaseInt), "__rand__"
    assert isinstance(2 // n, BaseInt), "__rfloordiv__"
    assert isinstance(2 << n, BaseInt), "__rlshift__"
    assert isinstance(2 % n, BaseInt), "__rmod__"
    assert isinstance(2 * n, BaseInt), "__rmul__"
    assert isinstance(0 or n, BaseInt), "__ror__"  # short circuit
    assert isinstance(2 ** n, BaseInt), "__rpow__"
    assert isinstance(2 >> n, BaseInt), "__rrshift__"
    assert isinstance(n >> 2, BaseInt), "__rshift__"
    assert isinstance(2 - n, BaseInt), "__rsub__"
    # assert isinstance(2 / n, BaseInt), "__rtruediv__"
    assert isinstance(2 ^ n, BaseInt), "__rxor__"
    assert isinstance(n - 2, BaseInt), "__sub__"
    # assert isinstance(n / 2, BaseInt), "__truediv__"
    assert isinstance(n ^ 2, BaseInt), "__xor__"

I purposefully wrapped only __str__ and not __repr__, so that one can still see the underlying integer without calls to int. Note that int(str(BaseInt(100, 62)), 62) fails, because int only supports up to base 36. int(BaseInt(100, 62)) however does not fail, since BaseInt inherits from int.

I am interested in general comments as well as comments on how to make defining and testing the math methods easier. Comments on the class decorator are also very welcome (would it have been clearer if I had used a metaclass, for example?).

(This class as is does not quite solve the Java question, mostly because of a different order of the digits, but that is fine with me.)

\$\endgroup\$
4
\$\begingroup\$

What strikes me the most with this implementation is that you should already have the decimal representation of the number you want. The base is then just artificial and its only purpose is to have a nice str of it.

Instead, you could mimic the int constructor:

|  Convert a number or string to an integer, or return 0 if no arguments
|  are given.  If x is a number, return x.__int__().  For floating point
|  numbers, this truncates towards zero.
|  
|  If x is not a number or if base is given, then x must be a string,
|  bytes, or bytearray instance representing an integer literal in the
|  given base.  The literal can be preceded by '+' or '-' and be surrounded
|  by whitespace.  The base defaults to 10.  Valid bases are 0 and 2-36.
|  Base 0 means to interpret the base from the string as an integer literal.
|  >>> int('0b100', base=0)
|  4

So, basically, your __new__ would look like the following:

def __new__(cls, value, base=None):
    if not isinstance(value, numbers.Number) and base is None:
        base = 10
    if base is not None:
        if base > len(cls.digits):
            raise ValueError(f"Bases greater than {len(cls.digits)} not handled.")
        elif base < 2:
            raise ValueError("Bases less than 2 not handled.")
        if not isinstance(value, (str, bytes, bytearray)):
            raise TypeError("can't convert non-string with explicit base")
        if not isinstance(value, str):
            value = bytes(value).decode()
        value = sum(self.digits.index(letter) * base ** i for i, letter in enumerate(reversed(value)))
    else:
        base = 10
    self = super().__new__(cls, value)
    self.base = base
    return self

This won't let you write BaseInt(100, 62) anymore. Instead you either use BaseInt('1C', 62) or i = BaseInt(100); i.base = 62. Or you could have a classmethod BaseInt.from_int(value, base=10) wrapping it for you.

But it feels neater since BaseInt(str(BaseInt(v, x)), x) would work as expected, which is IMHO nicer.

\$\endgroup\$
3
\$\begingroup\$

A little nitpicking: I'd change

for attr_name in dir(cls):
    if attr_name in methods:
        setattr(cls, attr_name, method_wrapper(getattr(cls, attr_name)))
return cls

into

for attr_name in methods:
    if hasattr(cls, attr_name):
        setattr(cls, attr_name, method_wrapper(getattr(cls, attr_name)))
return cls

The reason being, that your set of methods you're interested in overwriting is a constant. If you have a derived class with lots of properties, you'll avoid unnecessary iterations.

\$\endgroup\$
  • 1
    \$\begingroup\$ Thus I’d also recommend to change from using a set to using a list or a tuple. \$\endgroup\$ – 409_Conflict Jul 13 '18 at 14:35
  • \$\begingroup\$ Yeah. A tuple would definitely be more fitting here. Or maybe a frozenset. \$\endgroup\$ – Richard Neumann Jul 13 '18 at 14:36
  • \$\begingroup\$ A tuple would do fine. The methods to overwrite will not change (at runtime) and I only needed the set for fast in lookup. \$\endgroup\$ – Graipher Jul 13 '18 at 14:41

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.