Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Python type-hint friendly type that constrains possible values

I want a python type-hint friendly way to create a Type that has constrained range of values.

For example, a URL Type based on type str that would only accept strings that look like an "http" URL.

# this code is made up and will not compile
class URL(typing.NewType('_URL', str)):
    def __init__(self, value: str, *args, **kwargs):
        if not (value.startswith('http://') or value.startswith('https://')):
            raise ValueError('string is not an acceptable URL')
like image 794
JamesThomasMoon Avatar asked Feb 09 '19 23:02

JamesThomasMoon


2 Answers

overriding built-in immutable types works well

overriding str; http URL strings

Here is an example overriding str. This does not require the typing module but still works with type-hinting.

This str derived class asserts the initialized string looks like an http URL string.

class URL(str):
    def __new__(cls, *value):
        if value:
            v0 = value[0]
            if not type(v0) is str:
                raise TypeError('Unexpected type for URL: "%s"' % type(v0))
            if not (v0.startswith('http://') or v0.startswith('https://')):
                raise ValueError('Passed string value "%s" is not an'
                                 ' "http*://" URL' % (v0,))
        # else allow None to be passed. This allows an "empty" URL instance, e.g. `URL()`
        # `URL()` evaluates False

        return str.__new__(cls, *value)

This results in a class that will only allow some strings. Otherwise, it behaves like an immutable str instance.

# these are okay
URL()
URL('http://example.com')
URL('https://example.com')
URL('https://')

# these raise ValueError
URL('example')  # ValueError: Passed string value "example" is not an "http*://" URL
URL('')  # ValueError: Passed string value "" is not an "http*://" URL

# these evaluate as you would expect
for url in (URL(),  # 'False'
            URL('https://'),  # 'True'
            URL('https://example.com'),  # 'True'
           ):
    print('True') if url else print('False')

(update: later on I found the purl Python library)

Another example,

overriding int; constrained integer range Number

This int derived class only allows values 1 through 9 inclusive.

This has a special feature, too. In case an instance is initialized with nothing (Number()) then that value equates to 0 (this behavior is derived from the int class). In that case, the __str__ should be a '.' (program requirement).

class Number(int):
    """integer type with constraints; part of a Sudoku game"""

    MIN = 1  # minimum
    MAX = 9  # maximum

    def __new__(cls, *value):
        if value:
            v0 = int(value[0])
            if not (cls.MIN <= v0 <= cls.MAX):
                raise ValueError('Bad value "%s" is not acceptable in'
                                 ' Sudoku' % (v0,))
        # else:
        #    allow None to be passed. This allows an "empty" Number instance that
        #    evaluates False, e.g. `Number()`

        return int.__new__(cls, *value)

    def __str__(self):
        """print the Number accounting for an "empty" value"""
        if self == 0:
            return '.'
        return int.__str__(self)

This ensures errant inputs are handled sooner rather than later. Otherwise, it behaves just like an int.

# these are okay
Number(1)
Number(9)
Number('9')

# this will evaluate True, just like an int
Number(9) == int(9)
Number('9') == int(9)
Number('9') == float(9)

# this is okay, it will evaluate False
Number()
print('True') if Number() else print('False')  # 'False'

# these raise ValueError
Number(0)  # ValueError: Bad value "0" is not acceptable in Sudoku
Number(11)  # ValueError: Bad value "11" is not acceptable in Sudoku
Number('11')  # ValueError: Bad value "11" is not acceptable in Sudoku

And the special "feature"

print(Number(1)) # '1' (expected)
print(Number())  # '.' (special feature)




Technique for inheriting immutable types is derived from this SO answer.

like image 194
JamesThomasMoon Avatar answered Nov 14 '22 22:11

JamesThomasMoon


Subclassing builtin types can lead to some odd cases (consider code which checks exactly type(...) is str)

Here is a pure-typing approach which is typesafe and fully preserves the type of your strings:

from typing import NewType

_Url = NewType('_Url', str)

def URL(s: str) -> _Url:
    if not s.startswith('https://'):
        raise AssertionError(s)
    return _Url(s)

print(type(URL('https://example.com')) is str)  # prints `True`

The approach here "hides" the runtime checking behind a function which looks like a constructor from an api perspective, but in reality is just a tiny type (I couldn't find a canonical reference to "tiny types" this appears to just be the best resource I could find).

like image 45
Anthony Sottile Avatar answered Nov 14 '22 23:11

Anthony Sottile