├── .gitignore ├── .pylintrc ├── .travis.yml ├── LICENSE ├── README.rst ├── pyproject.toml ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── py36 │ ├── __init__.py │ └── test_typed_objects.py └── test_types.py ├── tox.ini └── typet ├── __init__.py ├── meta.py ├── objects.py ├── path.py ├── types.py ├── typing.py └── validation.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .retox.json 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | venv/ 84 | ENV/ 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | 89 | # Rope project settings 90 | .ropeproject 91 | 92 | # Sublime project settings 93 | .sublimelinterrc 94 | *.sublime-project 95 | *.sublime-workspace 96 | 97 | # VS Code project settings 98 | *.code-workspace 99 | .vscode 100 | 101 | # mypy 102 | .mypy_cache 103 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | disable=useless-object-inheritance,bad-mcs-classmethod-argument,no-value-for-parameter,protected-access,too-few-public-methods,bad-continuation,invalid-name,no-member,locally-disabled,locally-enabled 3 | reports=no 4 | known-standard-library=typing 5 | 6 | [SIMILARITIES] 7 | min-similarity-lines=10 8 | ignore-comments=yes 9 | ignore-docstrings=yes 10 | ignore-imports=yes 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | python: 4 | - '3.5' 5 | - '3.6' 6 | - '3.7' 7 | - '3.8' 8 | install: 9 | - pip install tox-travis 10 | script: 11 | - tox 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 contains.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Type[T] 2 | ======= 3 | 4 | |PyPI| |Python Versions| |Build Status| |Coverage Status| |Code Quality| 5 | 6 | *Types that make coding in Python quick and safe.* 7 | 8 | Type[T] works best with Python 3.6 or later. Prior to 3.6, object types must 9 | use comment type hint syntax. 10 | 11 | 12 | Installation 13 | ------------ 14 | 15 | Install it using pip: 16 | 17 | :: 18 | 19 | pip install typet 20 | 21 | 22 | Features 23 | -------- 24 | 25 | - An Object base class that eliminates boilerplate code and verifies and 26 | coerces types when possible. 27 | - Validation types that, when instantiated, create an instance of a specific 28 | type and verify that they are within the user defined boundaries for the 29 | type. 30 | 31 | 32 | Quick Start: Creating a `Person` 33 | -------------------------------- 34 | 35 | Import the Type[T] types that you will use. 36 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 37 | 38 | .. code-block:: python 39 | 40 | from typet import Bounded, Object, String 41 | 42 | * `Object`, for composing complex objects 43 | * `Bound` to describe a type that validates its value is of the correct type and within bounds upon instantiation 44 | * `String`, which will validate that it is instantiated with a string with a length within the defined bounds. 45 | 46 | Create Type Aliases That Describe the Intent of the Type 47 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 48 | 49 | .. code-block:: python 50 | 51 | Age = Bounded[int, 0:150] 52 | Name = String[1:50] 53 | Hobby = String[1:300] 54 | 55 | In this example, a `Person` has an `Age`, which is an integer between 0 and 56 | 150, inclusive; a `Name` which must be a non-empty string with no more than 57 | 50 characters; and finally, a `Hobby`, which is a non-empty string with no more 58 | than 300 characters. 59 | 60 | Compose a `Person` object Using Type Aliases 61 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 62 | 63 | .. code-block:: python 64 | 65 | class Person(Object): 66 | name: Name 67 | surname: Name 68 | age: Age 69 | hobby: Hobby = None 70 | 71 | Assigning a class attribute sets that value as the default value for instances 72 | of the `Object`. In this instance, `hobby` is assigned a default value of 73 | `None`; by convention, this tells Python that the type is `Optional[Hobby]`, 74 | and Type[T] will allow `None` in addition to strings of the correct length. 75 | 76 | 77 | Put It All Together 78 | ~~~~~~~~~~~~~~~~~~~ 79 | 80 | .. code-block:: python 81 | 82 | from typet import Bounded, Object, String 83 | 84 | Age = Bounded[int, 0:150] 85 | Name = String[1:50] 86 | Hobby = String[1:300] 87 | 88 | class Person(Object): 89 | name: Name 90 | surname: Name 91 | age: Age 92 | hobby: Hobby = None 93 | 94 | `Person` is now a clearly defined and typed object with an intuitive 95 | constructor, hash method, comparison operators and bounds checking. 96 | 97 | Positional arguments will be in the order of the definition of class 98 | attributes, and keyword arguments are also acceptable. 99 | 100 | .. code-block:: python 101 | 102 | jim = Person('Jim', 'Coder', 23, 'Python') 103 | bob = Person('Robert', 'Coder', hobby='C++', age=51) 104 | 105 | 106 | Python 2.7 to 3.5 107 | ~~~~~~~~~~~~~~~~~ 108 | 109 | Type[T] supports PEP 484 class comment type hints for defining an `Object`. 110 | 111 | .. code-block:: python 112 | 113 | from typing import Optional 114 | 115 | from typet import Bounded, Object, String 116 | 117 | Age = Bounded[int, 0:150] 118 | Name = String[1:50] 119 | Hobby = String[1:300] 120 | 121 | class Person(Object): 122 | name = None # type: Name 123 | surname = None # type: Name 124 | age = None # type: Age 125 | hobby = None # type: Optional[Hobby] 126 | 127 | Note that, because Python prior to 3.6 cannot annotate an attribute without 128 | defining it, by convention, assigning the attribute to `None` will not imply 129 | that it is optional; it must be specified explicitly in the type hint comment. 130 | 131 | 132 | `Object` Types 133 | -------------- 134 | 135 | `Object` 136 | ~~~~~~~~ 137 | 138 | One of the cooler features of Type[T] is the ability to create complex 139 | objects with very little code. The following code creates an object that 140 | generates properties from the annotated class attributes that will ensure that 141 | only values of *int* or that can be coerced into *int* can be set. It also 142 | generates a full suite of common comparison methods. 143 | 144 | .. code-block:: python 145 | 146 | from typet import Object 147 | 148 | class Point(Object): 149 | x: int 150 | y: int 151 | 152 | Point objects can be used intuitively because they generate a standard 153 | `__init__` method that will allow positional and keyword arguments. 154 | 155 | .. code-block:: python 156 | 157 | p1 = Point(0, 0) # Point(x=0, y=0) 158 | p2 = Point('2', 2.5) # Point(x=2, y=2) 159 | p3 = Point(y=5, x=2) # Point(x=2, y=5) 160 | assert p1 < p2 # True 161 | assert p2 < p1 # AssertionError 162 | 163 | 164 | A close equivalent traditional class would be much larger, would have to be 165 | updated for any new attributes, and wouldn't support more advanced casting, 166 | such as to types annotated using the typing_ module: 167 | 168 | .. code-block:: python 169 | 170 | class Point(object): 171 | 172 | def __init__(self, x, y): 173 | self.x = x 174 | self.y = y 175 | 176 | def __repr__(self): 177 | return 'Point(x={x}, y={y})'.format(x=self.x, y=self.y) 178 | 179 | def __setattr__(self, name, value): 180 | if name in ('x', 'y'): 181 | value = int(value) 182 | super(Point, self).__setattr__(name, value) 183 | 184 | def __eq__(self, other): 185 | if other.__class__ is not self.__class__: 186 | return NotImplemented 187 | return (self.x, self.y) == (other.x, other.y) 188 | 189 | def __ne__(self, other): 190 | if other.__class__ is not self.__class__: 191 | return NotImplemented 192 | return (self.x, self.y) != (other.x, other.y) 193 | 194 | def __lt__(self, other): 195 | if other.__class__ is not self.__class__: 196 | return NotImplemented 197 | return (self.x, self.y) < (other.x, other.y) 198 | 199 | def __le__(self, other): 200 | if other.__class__ is not self.__class__: 201 | return NotImplemented 202 | return (self.x, self.y) <= (other.x, other.y) 203 | 204 | def __gt__(self, other): 205 | if other.__class__ is not self.__class__: 206 | return NotImplemented 207 | return (self.x, self.y) > (other.x, other.y) 208 | 209 | def __ge__(self, other): 210 | if other.__class__ is not self.__class__: 211 | return NotImplemented 212 | return (self.x, self.y) >= (other.x, other.y) 213 | 214 | def __hash__(self): 215 | return hash((self.x, self.y)) 216 | 217 | 218 | Attributes can be declared optional either manually, by using typing.Optional_ 219 | or by using the PEP 484 implicit optional of a default value of `None`. 220 | 221 | .. code-block:: python 222 | 223 | from typing import Optional 224 | 225 | from typet import Object 226 | 227 | class Point(Object): 228 | x: Optional[int] 229 | y: int = None 230 | 231 | p1 = Point() # Point(x=None, y=None) 232 | p2 = Point(5) # Point(x=5, y=None) 233 | 234 | 235 | `StrictObject` 236 | ~~~~~~~~~~~~~~ 237 | 238 | By default, `Object` will use `cast` from typingplus_ to attempt to coerce 239 | any values supplied to attributes to the annotated type. In some cases, it may 240 | be preferred to disallow casting and only allow types that are already of the 241 | correct type. `StrictObject` has all of the features of `Object`, but will not 242 | coerce values into the annotated type. 243 | 244 | .. code-block:: python 245 | 246 | from typet import StrictObject 247 | 248 | class Point(StrictObject): 249 | x: int 250 | y: int 251 | 252 | Point(0, 0) # Okay 253 | Point('2', 2.5) # Raises TypeError 254 | 255 | `StrictObject` uses `is_instance` from typingplus_ to check types, so it's 256 | possible to use types from the typing_ library for stricter checking. 257 | 258 | .. code-block:: python 259 | 260 | from typing import List 261 | 262 | from typet import StrictObject 263 | 264 | class IntegerContainer(StrictObject): 265 | integers: List[int] 266 | 267 | IntegerContainer([0, 1, 2, 3]) # Okay 268 | IntegerContainer(['a', 'b', 'c', 'd']) # Raises TypeError 269 | 270 | 271 | Validation Types 272 | ---------------- 273 | 274 | Type[T] contains a suite of sliceable classes that will create bounded, or 275 | validated, versions of those types that always assert their values are within 276 | bounds; however, when an instance of a bounded type is instantiated, the 277 | instance will be of the original type. 278 | 279 | `Bounded` 280 | ~~~~~~~~~ 281 | 282 | `Bounded` can be sliced with either two arguments or three. The first argument 283 | is the type being bound. The second is a `slice` containing the upper and lower 284 | bounds used for comparison during instantiation. 285 | 286 | .. code-block:: python 287 | 288 | from typet import Bounded 289 | 290 | BoundedInt = Bounded[int, 10:20] 291 | 292 | BoundedInt(15) # Okay 293 | type(x) # 294 | BoundedInt(5) # Raises ValueError 295 | 296 | Optionally, a third argument, a function, may be supplied that will be run on 297 | the value before the comparison. 298 | 299 | .. code-block:: python 300 | 301 | from typet import Bounded 302 | 303 | LengthBoundedString = Bounded[str, 1:3, len] 304 | 305 | LengthBoundedString('ab') # Okay 306 | LengthBoundedString('') # Raises ValueError 307 | LengthBoundedString('abcd') # Raises ValueError 308 | 309 | 310 | `Length` 311 | ~~~~~~~~ 312 | 313 | Because `len` is a common comparison method, there is a shortcut type, `Length` 314 | that takes two arguments and uses `len` as the comparison method. 315 | 316 | .. code-block:: python 317 | 318 | from typing import List 319 | 320 | from typet import Length 321 | 322 | LengthBoundedList = Length[List[int], 1:3] 323 | 324 | LengthBoundedList([1, 2]) # Okay 325 | LengthBoundedList([]) # Raises ValueError 326 | LengthBoundedList([1, 2, 3, 4]) # Raises ValueError 327 | 328 | 329 | `String` 330 | ~~~~~~~~ 331 | 332 | `str` and `len` are commonly used together so a special type, `String`, has 333 | been added to simplify binding strings to specific lengths. 334 | 335 | .. code-block:: python 336 | 337 | from typet import String 338 | 339 | ShortString = String[1:3] 340 | 341 | ShortString('ab') # Okay 342 | ShortString('') # Raises ValueError 343 | ShortString('abcd') # Raises ValueError 344 | 345 | Note that, on Python 2, `String` instantiates `unicode` objects and not `str`. 346 | 347 | 348 | Metaclasses and Utilities 349 | ------------------------- 350 | 351 | Singleton 352 | ~~~~~~~~~ 353 | 354 | `Singleton` will cause a class to allow only one instance. 355 | 356 | .. code-block:: python 357 | 358 | from typet import Singleton 359 | 360 | class Config(metaclass=Singleton): 361 | pass 362 | 363 | c1 = Config() 364 | c2 = Config() 365 | assert c1 is c2 # Okay 366 | 367 | `Singleton` supports an optional `__singleton__` method on the class that will 368 | allow the instance to update if given new parameters. 369 | 370 | .. code-block:: python 371 | 372 | from typet import Singleton 373 | 374 | class Config(metaclass=Singleton): 375 | 376 | def __init__(self, x): 377 | self.x = x 378 | 379 | def __singleton__(self, x=None): 380 | if x: 381 | self.x = x 382 | 383 | c1 = Config(1) 384 | c1.x # 1 385 | c2 = Config() # Okay because __init__ is not called. 386 | c2.x # 1 387 | c3 = Config(3) # Calls __singleton__ if it exists. 388 | c1.x # 3 389 | c2.x # 3 390 | c3.x # 3 391 | assert c1 is c2 is c3 # Okay 392 | 393 | 394 | @singleton 395 | ~~~~~~~~~~ 396 | 397 | Additionally, there is a decorator, `@singleton` that can be used make a class 398 | a singleton, even if it already uses another metaclass. This is convenient for 399 | creating singleton Objects. 400 | 401 | .. code-block:: python 402 | 403 | from typet import Object, singleton 404 | 405 | @singleton 406 | class Config(Object): 407 | x: int 408 | 409 | c1 = Config(1) 410 | c2 = Config() # Okay because __init__ is not called. 411 | assert c1 is c2 # Okay 412 | 413 | 414 | @metaclass 415 | ~~~~~~~~~~ 416 | 417 | Type[T] contains a class decorator, `@metaclass`, that will create a derivative 418 | metaclass from the given metaclasses and the metaclass used by the decorated 419 | class and recreate the class with the derived metaclass. 420 | 421 | Most metaclasses are not designed to be used in such a way, so careful testing 422 | must be performed when this decorator is to be used. It is primarily intended 423 | to ease use of additional metaclasses with Objects. 424 | 425 | .. code-block:: python 426 | 427 | from typet import metaclass, Object, Singleton 428 | 429 | @metaclass(Singleton) 430 | class Config(Object): 431 | x: int 432 | 433 | c1 = Config(1) 434 | c2 = Config() # Okay because __init__ is not called. 435 | assert c1 is c2 # Okay 436 | 437 | 438 | .. _typingplus: https://github.com/contains-io/typingplus/ 439 | .. _typing: https://docs.python.org/3/library/typing.html 440 | .. _typing.Optional: https://docs.python.org/3/library/typing.html#typing.Optional 441 | 442 | .. |Build Status| image:: https://travis-ci.org/contains-io/typet.svg?branch=master 443 | :target: https://travis-ci.org/contains-io/typet 444 | .. |Coverage Status| image:: https://coveralls.io/repos/github/contains-io/typet/badge.svg?branch=master 445 | :target: https://coveralls.io/github/contains-io/typet?branch=master 446 | .. |PyPI| image:: https://img.shields.io/pypi/v/typet.svg 447 | :target: https://pypi.python.org/pypi/typet/ 448 | .. |Python Versions| image:: https://img.shields.io/pypi/pyversions/typet.svg 449 | :target: https://pypi.python.org/pypi/typet/ 450 | .. |Code Quality| image:: https://api.codacy.com/project/badge/Grade/dae19ee1767b492e8bdf5edb16409f65 451 | :target: https://www.codacy.com/app/contains-io/typet?utm_source=github.com&utm_medium=referral&utm_content=contains-io/typet&utm_campaign=Badge_Grade 452 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 79 3 | exclude = ''' 4 | /( 5 | \.eggs 6 | | \.git 7 | | \.mypy_cache 8 | | \.pytest_cache 9 | | \.tox 10 | | \.venv 11 | | _build 12 | | buck-out 13 | | build 14 | | dist 15 | )/ 16 | ''' 17 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | 4 | [bdist_wheel] 5 | universal = 1 6 | 7 | [flake8] 8 | ignore = 9 | D202, 10 | D203, 11 | E203, 12 | F403, 13 | F405, 14 | F821, 15 | W503, 16 | W504, 17 | exclude = 18 | .git, 19 | __pycache__, 20 | .mypy_cache, 21 | *.egg-info, 22 | .eggs, 23 | .tox, 24 | build, 25 | dist, 26 | examples, 27 | docs, 28 | vendor 29 | max-complexity = 12 30 | 31 | [pep257] 32 | add-ignore=D202 33 | match-dir = typet|tests|. 34 | match = .*\.py 35 | 36 | [tool:pytest] 37 | addopts = -vvra 38 | 39 | [mypy] 40 | ignore_missing_imports=True 41 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Install typet.""" 4 | 5 | from __future__ import unicode_literals 6 | 7 | from setuptools import setup 8 | from setuptools import find_packages 9 | 10 | 11 | setup( 12 | name="typet", 13 | use_scm_version=True, 14 | description="A library of types that simplify working with typed Python.", 15 | long_description=open("README.rst").read(), 16 | author="Melissa Nuno", 17 | author_email="melissa@contains.io", 18 | url="https://github.com/contains-io/typet", 19 | keywords=[ 20 | "typing", 21 | "schema", 22 | "validation", 23 | "types", 24 | "annotation", 25 | "PEP 483", 26 | "PEP 484", 27 | "PEP 526", 28 | ], 29 | license="MIT", 30 | packages=find_packages(exclude=["tests", "docs"]), 31 | install_requires=["pathlib2"], 32 | setup_requires=["pytest-runner", "setuptools_scm"], 33 | tests_require=["pytest >= 3.2"], 34 | classifiers=[ 35 | "Development Status :: 3 - Alpha", 36 | "License :: OSI Approved :: MIT License", 37 | "Operating System :: OS Independent", 38 | "Programming Language :: Python", 39 | "Programming Language :: Python :: 3", 40 | "Programming Language :: Python :: 3.5", 41 | "Programming Language :: Python :: 3.6", 42 | "Programming Language :: Python :: 3.7", 43 | "Programming Language :: Python :: 3.8", 44 | "Programming Language :: Python :: Implementation :: PyPy", 45 | "Intended Audience :: Developers", 46 | "Topic :: Software Development", 47 | ], 48 | ) 49 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """The primary test package.""" 3 | -------------------------------------------------------------------------------- /tests/py36/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Test package that should only run against python 3.6+.""" 3 | -------------------------------------------------------------------------------- /tests/py36/test_typed_objects.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for py3.6+ annotation type hint casting.""" 3 | 4 | from __future__ import unicode_literals 5 | 6 | import pytest 7 | 8 | import typing 9 | import typet 10 | 11 | 12 | def test_strict_object(): 13 | """Simple test to verify basic StrictObject functionality.""" 14 | 15 | class X(typet.StrictObject): 16 | x: str 17 | 18 | x = X("initial") 19 | x.x = "hello" 20 | assert isinstance(x.x, str) 21 | assert x.x == "hello" 22 | with pytest.raises(TypeError): 23 | x.x = 5 24 | 25 | 26 | def test_object(): 27 | """Simple test to verify basic Object functionality.""" 28 | 29 | class X(typet.Object): 30 | x: str = None 31 | 32 | x = X() 33 | x.x = 5 34 | assert isinstance(x.x, str) 35 | assert x.x == "5" 36 | 37 | 38 | def test_object_failure(): 39 | """Simple test to verify basic Object failure functionality.""" 40 | 41 | class X(typet.Object): 42 | x: int = None 43 | 44 | x = X() 45 | x.x = None 46 | with pytest.raises(TypeError): 47 | x.x = "not an integer" 48 | 49 | 50 | def test_optional_unassigned(): 51 | """Verify that an unassigned Optional attribute is optional in __init__.""" 52 | 53 | class X(typet.Object): 54 | x: typing.Optional[int] 55 | 56 | X() 57 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for annotation type hint casting.""" 3 | 4 | from typing import ( # noqa: F401 pylint: disable=unused-import 5 | Any, 6 | Optional, 7 | ) 8 | import os.path 9 | import uuid 10 | 11 | import pytest 12 | import six 13 | 14 | from typet.typing import is_instance 15 | import typet 16 | 17 | 18 | def test_bounded_type(): 19 | """Test the bounded type object.""" 20 | with pytest.raises(TypeError): 21 | BoundedInt = typet.Bounded[int] 22 | with pytest.raises(TypeError): 23 | BoundedInt = typet.Bounded[int, 10:20, lambda x: x, None] 24 | BoundedInt = typet.Bounded[int, 10:20] 25 | with pytest.raises(ValueError): 26 | BoundedInt(5) 27 | assert BoundedInt(10) == 10 28 | assert BoundedInt(15) == 15 29 | assert BoundedInt(20) == 20 30 | with pytest.raises(ValueError): 31 | BoundedInt(25) 32 | BoundedStr = typet.Bounded[str, 1:5, len] 33 | with pytest.raises(ValueError): 34 | BoundedStr("") 35 | assert BoundedStr("abc") == "abc" 36 | with pytest.raises(ValueError): 37 | BoundedStr("abcdef") 38 | assert str(BoundedInt) == "typet.validation.Bounded[int, 10:20]" 39 | assert typet.Bounded[Any, 10:20](15) == 15 40 | assert typet.Bounded["int", 20](15) == 15 41 | assert typet.Bounded["int", 10:](15) == 15 42 | 43 | 44 | def test_length_type(): 45 | """Test the bounded length type object.""" 46 | with pytest.raises(TypeError): 47 | LengthBoundedStr = typet.Length[str] 48 | with pytest.raises(TypeError): 49 | LengthBoundedStr = typet.Length[str, 10:20, lambda x: x] 50 | LengthBoundedStr = typet.Length[str, 1:5] 51 | with pytest.raises(ValueError): 52 | LengthBoundedStr("") 53 | assert LengthBoundedStr("a") == "a" 54 | assert LengthBoundedStr("abcde") == "abcde" 55 | with pytest.raises(ValueError): 56 | LengthBoundedStr("abcdef") 57 | LengthBoundedList = typet.Length[list, 1:1] 58 | with pytest.raises(ValueError): 59 | LengthBoundedList([]) 60 | assert LengthBoundedList([1]) == [1] 61 | with pytest.raises(ValueError): 62 | LengthBoundedList([1, 2]) 63 | assert str(LengthBoundedStr) == "typet.validation.Length[str, 1:5]" 64 | assert typet.Length[Any, 1:5]("abc") == "abc" 65 | assert typet.Length["str", 20]("abc") == "abc" 66 | 67 | 68 | def test_string_type(): 69 | """Test the bounded string type object.""" 70 | with pytest.raises(TypeError): 71 | BoundedStr = typet.String[10:20, lambda x: x] 72 | BoundedStr = typet.String[1:5] 73 | with pytest.raises(ValueError): 74 | BoundedStr("") 75 | assert BoundedStr("a") == "a" 76 | assert BoundedStr("abcde") == "abcde" 77 | with pytest.raises(ValueError): 78 | BoundedStr("abcdef") 79 | assert str(BoundedStr) == "typet.validation.String[1:5]" 80 | assert typet.String("hello") == "hello" 81 | 82 | 83 | def test_validation_type(): 84 | """Test that the validation type validates content.""" 85 | ValidFile = typet.Valid[os.path.isfile] 86 | assert ValidFile(__file__) == __file__ 87 | with pytest.raises(TypeError): 88 | typet.Valid[int, int, int] 89 | 90 | 91 | def test_path_types(): 92 | """Test that the supplied path validation paths work.""" 93 | assert str(typet.File(__file__)) == __file__ 94 | with pytest.raises(ValueError): 95 | typet.File(str(uuid.uuid4())) 96 | assert str(typet.Dir(os.path.dirname(__file__))) == os.path.dirname( 97 | __file__ 98 | ) 99 | with pytest.raises(ValueError): 100 | typet.Dir(str(uuid.uuid4())) 101 | assert str(typet.ExistingPath(__file__)) == __file__ 102 | assert str( 103 | typet.ExistingPath(os.path.dirname(__file__)) 104 | ) == os.path.dirname(__file__) 105 | with pytest.raises(ValueError): 106 | typet.ExistingPath(str(uuid.uuid4())) 107 | 108 | 109 | def test_none_type(): 110 | """Verify that NoneType is type(None).""" 111 | assert typet.NoneType is type(None) 112 | 113 | 114 | def test_singleton(): 115 | """Test that a singleton only allows a single instance of a class.""" 116 | 117 | @six.add_metaclass(typet.Singleton) 118 | class TestClass(object): 119 | pass 120 | 121 | assert TestClass() is TestClass() 122 | 123 | 124 | def test_uninstantiable(): 125 | """Test that an uninstantiable class cannot be instantiated.""" 126 | 127 | @six.add_metaclass(typet.Uninstantiable) 128 | class TestClass(object): 129 | pass 130 | 131 | with pytest.raises(TypeError): 132 | TestClass() 133 | 134 | 135 | def test_isinstance(): 136 | """Test that instances of sliced type are instances of validation type.""" 137 | Age = typet.Bounded[int, 0:150] 138 | assert isinstance(25, Age) is True 139 | assert isinstance(-5, Age) is False 140 | assert isinstance(200, Age) is False 141 | assert isinstance("not an int", Age) is False 142 | 143 | 144 | def test_strict_object(): 145 | """Simple test to verify basic StrictObject functionality.""" 146 | 147 | class X(typet.StrictObject): 148 | x = None # type: str 149 | 150 | x = X("initial") 151 | x.x = "hello" 152 | assert is_instance(x.x, str) 153 | assert x.x == "hello" 154 | with pytest.raises(TypeError): 155 | x.x = 5 156 | 157 | 158 | def test_object(): 159 | """Simple test to verify basic Object functionality.""" 160 | 161 | class X(typet.Object): 162 | x = None # type: Optional[str] 163 | 164 | x = X() 165 | x.x = 5 166 | assert is_instance(x.x, str) 167 | assert x.x == "5" 168 | 169 | 170 | def test_object_with_default(): 171 | """Test that Object uses a default value.""" 172 | 173 | class X(typet.Object): 174 | x = 5 # type: Optional[int] 175 | 176 | x = X() 177 | assert x.x == 5 178 | 179 | 180 | def test_object_comments(): 181 | """Simple test to verify basic Object functionality with comment hints.""" 182 | 183 | class X(typet.Object): 184 | x = None # type: str 185 | 186 | with pytest.raises(TypeError): 187 | X() 188 | x = X(5) 189 | assert is_instance(x.x, str) 190 | assert x.x == "5" 191 | 192 | 193 | def test_object_failure(): 194 | """Simple test to verify basic Object failure functionality.""" 195 | 196 | class X(typet.Object): 197 | x = None # type: Optional[int] 198 | 199 | x = X() 200 | x.x = None 201 | with pytest.raises(TypeError): 202 | x.x = "not an integer" 203 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = True 3 | envlist = py{35,36,37,38} 4 | 5 | [testenv] 6 | setenv = 7 | PYTHONDONTWRITEBYTECODE=1 8 | usedevelop = True 9 | passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH 10 | deps = 11 | pytest >= 3.2, < 4 12 | pytest-cov 13 | coveralls 14 | pep257 15 | flake8 16 | pylint >= 1.7 17 | py{34,35,36}: mypy >= 0.501 18 | whitelist_externals = 19 | sh 20 | commands = 21 | py{27,34,35,py,py3}: py.test --cov=typet --basetemp={envtmpdir} --ignore=tests/py36 {posargs} 22 | py{36}: py.test --cov=typet --basetemp={envtmpdir} {posargs} 23 | - sh -c "coveralls 2>/dev/null" 24 | pep257 setup.py typet 25 | flake8 setup.py typet 26 | pylint setup.py typet 27 | #py{34,35,36}: - mypy --config=setup.cfg setup.py typet 28 | -------------------------------------------------------------------------------- /typet/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pragma pylint: disable=wildcard-import,redefined-builtin 3 | """Contains all typet classes and functions.""" 4 | 5 | from __future__ import unicode_literals 6 | 7 | from .meta import * # noqa: F401 8 | from .objects import * # noqa: F401 9 | from .path import * # noqa: F401 10 | from .types import * # noqa: F401 11 | from .validation import * # noqa: F401 12 | -------------------------------------------------------------------------------- /typet/meta.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """A module containing common metaclasses and utilites for working with them. 3 | 4 | Metaclasses: 5 | Singleton: A metaclass to force a class to only ever be instantiated once. 6 | IdempotentSingleton: A metaclass that will force a class to only create one 7 | instance, but will call __init__ on the instance when new instantiation 8 | attempts occur. 9 | Uninstantiable: A metaclass that causes a class to be uninstantiable. 10 | 11 | Decorators: 12 | metaclass: A class decorator that will create the class using multiple 13 | metaclasses. 14 | singleton: A class decorator that will make the class a singleton, even if 15 | the class already has a metaclass. 16 | """ 17 | 18 | from __future__ import unicode_literals 19 | 20 | 21 | from typing import ( # noqa: F401 pylint: disable=unused-import 22 | Any, 23 | Callable, 24 | ) 25 | import collections 26 | 27 | import six 28 | 29 | 30 | __all__ = ( 31 | "metaclass", 32 | "Singleton", 33 | "singleton", 34 | "IdempotentSingleton", 35 | "Uninstantiable", 36 | ) 37 | 38 | 39 | def metaclass(*metaclasses): 40 | # type: (*type) -> Callable[[type], type] 41 | """Create the class using all metaclasses. 42 | 43 | Args: 44 | metaclasses: A tuple of metaclasses that will be used to generate and 45 | replace a specified class. 46 | 47 | Returns: 48 | A decorator that will recreate the class using the specified 49 | metaclasses. 50 | """ 51 | 52 | def _inner(cls): 53 | # pragma pylint: disable=unused-variable 54 | metabases = tuple( 55 | collections.OrderedDict( # noqa: F841 56 | (c, None) for c in (metaclasses + (type(cls),)) 57 | ).keys() 58 | ) 59 | # pragma pylint: enable=unused-variable 60 | _Meta = metabases[0] 61 | for base in metabases[1:]: 62 | 63 | class _Meta(base, _Meta): # pylint: disable=function-redefined 64 | pass 65 | 66 | return six.add_metaclass(_Meta)(cls) 67 | 68 | return _inner 69 | 70 | 71 | class Singleton(type): 72 | """A metaclass to turn a class into a singleton. 73 | 74 | If the instance already exists, Singleton will attempt to call 75 | __singleton__ on the instance to allow the instance to update if necessary. 76 | """ 77 | 78 | __instance__ = None # type: type 79 | 80 | def __call__(cls, *args, **kwargs): 81 | # type: (*Any, **Any) -> type 82 | """Instantiate the class only once.""" 83 | if not cls.__instance__: 84 | cls.__instance__ = super(Singleton, cls).__call__(*args, **kwargs) 85 | else: 86 | try: 87 | cls.__instance__.__singleton__(*args, **kwargs) # type: ignore 88 | except (AttributeError, TypeError): 89 | pass 90 | return cls.__instance__ 91 | 92 | 93 | class IdempotentSingleton(Singleton): 94 | """A metaclass to turn a class into a singleton. 95 | 96 | If the instance already exists, IdempotentSingleton will call __init__ on 97 | the existing instance with the arguments given. 98 | """ 99 | 100 | def __call__(cls, *args, **kwargs): 101 | # type: (*Any, **Any) -> type 102 | """Create one instance of the class and reinstantiate as necessary.""" 103 | if not cls.__instance__: 104 | cls.__instance__ = super(IdempotentSingleton, cls).__call__( 105 | *args, **kwargs 106 | ) 107 | else: 108 | try: 109 | cls.__instance__.__init__(*args, **kwargs) # type: ignore 110 | except (AttributeError, TypeError): 111 | pass 112 | return cls.__instance__ 113 | 114 | 115 | singleton = metaclass(Singleton) 116 | 117 | 118 | class Uninstantiable(type): 119 | """A metaclass that disallows instantiation.""" 120 | 121 | def __call__(cls, *args, **kwargs): 122 | # type: (*Any, **Any) -> None 123 | """Do not allow the class to be instantiated.""" 124 | raise TypeError("Type {} cannot be instantiated.".format(cls.__name__)) 125 | -------------------------------------------------------------------------------- /typet/objects.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """A module containing base objects for creating typed classes. 3 | 4 | Classes: 5 | BaseStrictObject: An object that asserts all annotated attributes are of 6 | the correct type. 7 | StrictObject: A derivative of BaseStrictObject that implements the default 8 | comparison operators and hash. 9 | BaseObject: An object that coerces all annotated attributes to the 10 | correct type. 11 | Object: A derivative of BaseObject that implements the default 12 | comparison operators and hash. 13 | """ 14 | 15 | from __future__ import absolute_import 16 | from __future__ import unicode_literals 17 | 18 | from typing import ( # noqa: F401 pylint: disable=unused-import 19 | Any, 20 | Callable, 21 | Dict, 22 | List, 23 | Optional, 24 | Type, 25 | TypeVar, 26 | ) 27 | import inspect 28 | import re 29 | import tokenize 30 | import types 31 | 32 | import six 33 | 34 | from .types import NoneType # pylint: disable=redefined-builtin 35 | from .typing import ( 36 | cast, 37 | get_type_hints, 38 | is_instance 39 | ) 40 | 41 | 42 | __all__ = ("BaseStrictObject", "StrictObject", "BaseObject", "Object") 43 | 44 | 45 | _T = TypeVar("_T") 46 | 47 | 48 | def _get_type_name(type_): 49 | # type: (type) -> str 50 | """Return a displayable name for the type. 51 | 52 | Args: 53 | type_: A class object. 54 | 55 | Returns: 56 | A string value describing the class name that can be used in a natural 57 | language sentence. 58 | """ 59 | name = repr(type_) 60 | if name.startswith("<"): 61 | name = getattr(type_, "__qualname__", getattr(type_, "__name__", "")) 62 | return name.rsplit(".", 1)[-1] or repr(type_) 63 | 64 | 65 | def _get_class_frame_source(class_name): 66 | # type: (str) -> Optional[str] 67 | """Return the source code for a class by checking the frame stack. 68 | 69 | This is necessary because it is not possible to get the source of a class 70 | being created by a metaclass directly. 71 | 72 | Args: 73 | class_name: The class to look for on the stack. 74 | 75 | Returns: 76 | The source code for the requested class if the class was found and the 77 | source was accessible. 78 | """ 79 | for frame_info in inspect.stack(): 80 | try: 81 | with open(frame_info[1]) as fp: 82 | src = "".join(fp.readlines()[frame_info[2] - 1 :]) 83 | except IOError: 84 | continue 85 | if re.search(r"\bclass\b\s+\b{}\b".format(class_name), src): 86 | reader = six.StringIO(src).readline 87 | tokens = tokenize.generate_tokens(reader) 88 | source_tokens = [] 89 | indent_level = 0 90 | base_indent_level = 0 91 | has_base_level = False 92 | for token, value, _, _, _ in tokens: # type: ignore 93 | source_tokens.append((token, value)) 94 | if token == tokenize.INDENT: 95 | indent_level += 1 96 | elif token == tokenize.DEDENT: 97 | indent_level -= 1 98 | if has_base_level and indent_level <= base_indent_level: 99 | return ( 100 | tokenize.untokenize(source_tokens), 101 | frame_info[0].f_globals, 102 | frame_info[0].f_locals, 103 | ) 104 | elif not has_base_level: 105 | has_base_level = True 106 | base_indent_level = indent_level 107 | raise TypeError( 108 | 'Unable to retrieve source for class "{}"'.format(class_name) 109 | ) 110 | 111 | 112 | def _is_propertyable( 113 | names, # type: List[str] 114 | attrs, # type: Dict[str, Any] 115 | annotations, # type: Dict[str, type] 116 | attr, # Dict[str, Any] 117 | ): 118 | # type: (...) -> bool 119 | """Determine if an attribute can be replaced with a property. 120 | 121 | Args: 122 | names: The complete list of all attribute names for the class. 123 | attrs: The attribute dict returned by __prepare__. 124 | annotations: A mapping of all defined annotations for the class. 125 | attr: The attribute to test. 126 | 127 | Returns: 128 | True if the attribute can be replaced with a property; else False. 129 | """ 130 | return ( 131 | attr in annotations 132 | and not attr.startswith("_") 133 | and not attr.isupper() 134 | and "__{}".format(attr) not in names 135 | and not isinstance(getattr(attrs, attr, None), types.MethodType) 136 | ) 137 | 138 | 139 | def _create_typed_object_meta(get_fset): 140 | # type: (Callable[[str, str, Type[_T]], Callable[[_T], None]]) -> type 141 | """Create a metaclass for typed objects. 142 | 143 | Args: 144 | get_fset: A function that takes three parameters: the name of an 145 | attribute, the name of the private attribute that holds the 146 | property data, and a type. This function must an object method that 147 | accepts a value. 148 | 149 | Returns: 150 | A metaclass that reads annotations from a class definition and creates 151 | properties for annotated, public, non-constant, non-method attributes 152 | that will guarantee the type of the stored value matches the 153 | annotation. 154 | """ 155 | 156 | def _get_fget(attr, private_attr, type_): 157 | # type: (str, str, Type[_T]) -> Callable[[], Any] 158 | """Create a property getter method for an attribute. 159 | 160 | Args: 161 | attr: The name of the attribute that will be retrieved. 162 | private_attr: The name of the attribute that will store any data 163 | related to the attribute. 164 | type_: The annotated type defining what values can be stored in the 165 | attribute. 166 | 167 | Returns: 168 | A function that takes self and retrieves the private attribute from 169 | self. 170 | """ 171 | 172 | def _fget(self): 173 | # type: (...) -> Any 174 | """Get attribute from self without revealing the private name.""" 175 | try: 176 | return getattr(self, private_attr) 177 | except AttributeError: 178 | raise AttributeError( 179 | "'{}' object has no attribute '{}'".format( 180 | _get_type_name(type_), attr 181 | ) 182 | ) 183 | 184 | return _fget 185 | 186 | class _AnnotatedObjectMeta(type): 187 | """A metaclass that reads annotations from a class definition.""" 188 | 189 | def __new__( 190 | mcs, # type: Type[_AnnotatedObjectMeta] 191 | name, # type: str 192 | bases, # type: List[type] 193 | attrs, # type: Dict[str, Any] 194 | **kwargs # type: Dict[str, Any] 195 | ): 196 | # type: (...) -> type 197 | """Create class objs that replaces annotated attrs with properties. 198 | 199 | Args: 200 | mcs: The class object being created. 201 | name: The name of the class to create. 202 | bases: The list of all base classes for the new class. 203 | attrs: The list of all attributes for the new class from the 204 | definition. 205 | 206 | Returns: 207 | A new class instance with the expected base classes and 208 | attributes, but with annotated, public, non-constant, 209 | non-method attributes replaced by property objects that 210 | validate against the annotated type. 211 | """ 212 | annotations = attrs.get("__annotations__", {}) 213 | use_comment_type_hints = ( 214 | not annotations and attrs.get("__module__") != __name__ 215 | ) 216 | if use_comment_type_hints: 217 | frame_source = _get_class_frame_source(name) 218 | annotations = get_type_hints(*frame_source) 219 | names = list(attrs) + list(annotations) 220 | typed_attrs = {} 221 | for attr in names: 222 | typed_attrs[attr] = attrs.get(attr) 223 | if _is_propertyable(names, attrs, annotations, attr): 224 | private_attr = "__{}".format(attr) 225 | if attr in attrs: 226 | typed_attrs[private_attr] = attrs[attr] 227 | type_ = ( 228 | Optional[annotations[attr]] 229 | if not use_comment_type_hints 230 | and attr in attrs 231 | and attrs[attr] is None 232 | else annotations[attr] 233 | ) 234 | typed_attrs[attr] = property( 235 | _get_fget(attr, private_attr, type_), 236 | get_fset(attr, private_attr, type_), 237 | ) 238 | properties = [ 239 | attr 240 | for attr in annotations 241 | if _is_propertyable(names, attrs, annotations, attr) 242 | ] 243 | typed_attrs["_tp__typed_properties"] = properties 244 | typed_attrs["_tp__required_typed_properties"] = [ 245 | attr 246 | for attr in properties 247 | if ( 248 | attr not in attrs 249 | or attrs[attr] is None 250 | and use_comment_type_hints 251 | ) 252 | and NoneType not in getattr(annotations[attr], "__args__", ()) 253 | ] 254 | return super(_AnnotatedObjectMeta, mcs).__new__( # type: ignore 255 | mcs, name, bases, typed_attrs, **kwargs 256 | ) 257 | 258 | return _AnnotatedObjectMeta 259 | 260 | 261 | def _strict_object_meta_fset(_, private_attr, type_): 262 | # type: (str, str, Type[_T]) -> Callable[[_T], None] 263 | """Create a property setter method for the attribute. 264 | 265 | Args: 266 | _: The name of the attribute to set. Unused. 267 | private_attr: The name of the attribute that will store any data 268 | related to the attribute. 269 | type_: The annotated type defining what values can be stored in the 270 | attribute. 271 | 272 | Returns: 273 | A method that takes self and a value and stores that value on self 274 | in the private attribute iff the value is an instance of type_. 275 | """ 276 | 277 | def _fset(self, value): 278 | # type: (...) -> None 279 | """Set the value on self iff the value is an instance of type_. 280 | 281 | Args: 282 | value: The value to set. 283 | 284 | Raises: 285 | TypeError: Raised when the value is not an instance of type_. 286 | """ 287 | rtype = type_ 288 | if isinstance(type_, TypeVar): 289 | type_map = dict( 290 | zip(self.__parameters__, self.__orig_class__.__args__) 291 | ) 292 | rtype = type_map[type_] 293 | if not is_instance(value, rtype): 294 | raise TypeError( 295 | "Cannot assign type of {} to attribute of type {}.".format( 296 | _get_type_name(type(value)), _get_type_name(rtype) 297 | ) 298 | ) 299 | vars(self)[private_attr] = value 300 | 301 | return _fset 302 | 303 | 304 | _StrictObjectMeta = _create_typed_object_meta(_strict_object_meta_fset) 305 | 306 | 307 | def _object_meta_fset(_, private_attr, type_): 308 | # type: (str, str, Type[_T]) -> Callable[[_T], None] 309 | """Create a property setter method for the attribute. 310 | 311 | Args: 312 | _: The name of the attribute to set. Unused. 313 | private_attr: The name of the attribute that will store any data 314 | related to the attribute. 315 | type_: The annotated type defining what values can be stored in the 316 | attribute. 317 | 318 | Returns: 319 | A method that takes self and a value and stores that value on self 320 | in the private attribute if the value is not an instance of type_ 321 | and cannot be cast into type_. 322 | """ 323 | 324 | def _fset(self, value): 325 | # type: (...) -> None 326 | """Set the value on self and coerce it to type_ if necessary. 327 | 328 | Args: 329 | value: The value to set. 330 | 331 | Raises: 332 | TypeError: Raised when the value is not an instance of type_ 333 | and cannot be cast into a compatible object of type_. 334 | """ 335 | rtype = type_ 336 | if isinstance(type_, TypeVar): 337 | type_map = dict( 338 | zip(self.__parameters__, self.__orig_class__.__args__) 339 | ) 340 | rtype = type_map[type_] 341 | vars(self)[private_attr] = cast(rtype, value) 342 | 343 | return _fset 344 | 345 | 346 | _ObjectMeta = _create_typed_object_meta(_object_meta_fset) 347 | 348 | 349 | class _BaseAnnotatedObject(object): 350 | """A base class that looks for class attributes to create __init__.""" 351 | 352 | def __init__(self, *args, **kwargs): 353 | """Set all attributes according to their annotation status.""" 354 | super(_BaseAnnotatedObject, self).__init__() 355 | properties = self._tp__typed_properties 356 | required = self._tp__required_typed_properties 357 | positionals = zip(properties, args) 358 | for attr, value in positionals: 359 | if attr in kwargs: 360 | raise TypeError( 361 | "__init__() got multiple values for argument '{}'".format( 362 | attr 363 | ) 364 | ) 365 | kwargs[attr] = value 366 | missing = [attr for attr in required if attr not in kwargs] 367 | if missing: 368 | num_missing = len(missing) 369 | if num_missing > 1: 370 | args = ", ".join("'{}'".format(m) for m in missing[:-1]) 371 | if num_missing > 2: 372 | args += "," 373 | args += " and '{}'".format(missing[-1]) 374 | else: 375 | args = "'{}'".format(missing[0]) 376 | raise TypeError( 377 | "__init__() missing {} required argument{}: {}".format( 378 | num_missing, "s" if num_missing > 1 else "", args 379 | ) 380 | ) 381 | for attr, value in six.iteritems(kwargs): 382 | if attr in properties: 383 | setattr(self, attr, value) 384 | 385 | def __repr__(self): 386 | # type: () -> str 387 | """Return a Python readable representation of the class.""" 388 | return "{}({})".format( 389 | self.__class__.__name__, 390 | ", ".join( 391 | "{}={}".format(attr_name, repr(getattr(self, attr_name))) 392 | for attr_name in self._tp__typed_properties 393 | ), 394 | ) # type: ignore 395 | 396 | 397 | class _AnnotatedObjectComparisonMixin(object): 398 | """A mixin to add comparisons to classes made by _AnnotatedObjectMeta.""" 399 | 400 | def _tp__get_typed_properties(self): 401 | """Return a tuple of typed attrs that can be used for comparisons. 402 | 403 | Raises: 404 | NotImplementedError: Raised if this class was mixed into a class 405 | that was not created by _AnnotatedObjectMeta. 406 | """ 407 | try: 408 | return tuple(getattr(self, p) for p in self._tp__typed_properties) 409 | except AttributeError: 410 | raise NotImplementedError 411 | 412 | def __eq__(self, other): 413 | """Test if two objects of the same base class are equal. 414 | 415 | If the objects are not of the same class, Python will default to 416 | comparison-by-ID. 417 | 418 | Args: 419 | other: The object to compare for equality. 420 | 421 | Returns: 422 | True if the objects are equal; else False. 423 | """ 424 | if other.__class__ is not self.__class__: 425 | return NotImplemented 426 | return ( 427 | self._tp__get_typed_properties() 428 | == other._tp__get_typed_properties() 429 | ) 430 | 431 | def __ne__(self, other): 432 | """Test if two objects of the same class are not equal. 433 | 434 | If the objects are not of the same class, Python will default to 435 | comparison-by-ID. 436 | 437 | Args: 438 | other: The object to compare for non-equality. 439 | 440 | Returns: 441 | True if the objects are not equal; else False. 442 | """ 443 | if other.__class__ is not self.__class__: 444 | return NotImplemented 445 | return not self == other 446 | 447 | def __lt__(self, other): 448 | """Test if self is less than an object of the same class. 449 | 450 | Args: 451 | other: The object to compare against. 452 | 453 | Returns: 454 | True if self is less than other; else False. 455 | 456 | Raises: 457 | TypeError: Raised if the objects are not of the same class. 458 | """ 459 | if other.__class__ is not self.__class__: 460 | return NotImplemented 461 | return ( 462 | self._tp__get_typed_properties() 463 | < other._tp__get_typed_properties() 464 | ) 465 | 466 | def __le__(self, other): 467 | """Test if self is less than or equal an object of the same class. 468 | 469 | Args: 470 | other: The object to compare against. 471 | 472 | Returns: 473 | True if self is less than or equal other; else False. 474 | 475 | Raises: 476 | TypeError: Raised if the objects are not of the same class. 477 | """ 478 | if other.__class__ is not self.__class__: 479 | return NotImplemented 480 | return self == other or self < other 481 | 482 | def __gt__(self, other): 483 | """Test if self is greater than an object of the same class. 484 | 485 | Args: 486 | other: The object to compare against. 487 | 488 | Returns: 489 | True if self is greater than other; else False. 490 | 491 | Raises: 492 | TypeError: Raised if the objects are not of the same class. 493 | """ 494 | if other.__class__ is not self.__class__: 495 | return NotImplemented 496 | return not self <= other 497 | 498 | def __ge__(self, other): 499 | """Test if self is greater than or equal an object of the same class. 500 | 501 | Args: 502 | other: The object to compare against. 503 | 504 | Returns: 505 | True if self is greater than or equal to other; else False. 506 | 507 | Raises: 508 | TypeError: Raised if the objects are not of the same class. 509 | """ 510 | if other.__class__ is not self.__class__: 511 | return NotImplemented 512 | return not self < other 513 | 514 | def __hash__(self): 515 | """Generate a hash for the object based on the annotated attrs.""" 516 | return hash(self._tp__get_typed_properties()) 517 | 518 | 519 | @six.add_metaclass(_StrictObjectMeta) # type: ignore 520 | class BaseStrictObject(_BaseAnnotatedObject): 521 | """A base class to create instance attrs for annotated class attrs. 522 | 523 | For every class attribute that is annotated, public, and not constant in 524 | the subclasses, this base class will generate property objects for the 525 | the instances that will enforce the type of the value set. 526 | 527 | If the subclass does not define __init__, a default implementation will be 528 | generated that takes all of the annotated, public, non-constant attributes 529 | as parameters. If an annotated attribute is not defined, it will be 530 | required in __init__. 531 | 532 | >>> from typet import BaseStrictObject 533 | >>> class Point(BaseStrictObject): 534 | ... x: int 535 | ... y: int 536 | ... 537 | ... 538 | >>> p = Point(0, 0) 539 | >>> p.x 540 | 0 541 | >>> p.x = '0' 542 | Traceback (most recent call last): 543 | ... 544 | TypeError: Cannot assign value of type str to attribute of type int. 545 | """ 546 | 547 | 548 | class StrictObject(BaseStrictObject, _AnnotatedObjectComparisonMixin): 549 | """A base class to create instance attrs for annotated class attrs. 550 | 551 | For every class attribute that is annotated, public, and not constant in 552 | the subclasses, this base class will generate property objects for the 553 | the instances that will enforce the type of the value set. 554 | 555 | If the subclass does not define __init__, a default implementation will be 556 | generated that takes all of the annotated, public, non-constant attributes 557 | as parameters. If an annotated attribute is not defined, it will be 558 | required in __init__. 559 | 560 | >>> from typet import StrictObject 561 | >>> class Point(StrictObject): 562 | ... x: int 563 | ... y: int 564 | ... 565 | ... 566 | >>> p = Point(0, 0) 567 | >>> p.x 568 | 0 569 | >>> p.x = '0' 570 | Traceback (most recent call last): 571 | ... 572 | TypeError: Cannot assign value of type str to attribute of type int. 573 | >>> p2 = Point(2, 2) 574 | >>> p < p2 575 | True 576 | >>> p > p2 577 | False 578 | """ 579 | 580 | 581 | @six.add_metaclass(_ObjectMeta) # type: ignore 582 | class BaseObject(_BaseAnnotatedObject): 583 | """A base class to create instance attrs for annotated class attrs. 584 | 585 | For every class attribute that is annotated, public, and not constant in 586 | the subclasses, this base class will generate property objects for the 587 | the instances that will enforce the type of the value set by attempting to 588 | cast the given value to the set type. 589 | 590 | If the subclass does not define __init__, a default implementation will be 591 | generated that takes all of the annotated, public, non-constant attributes 592 | as parameters. If an annotated attribute is not defined, it will be 593 | required in __init__. 594 | 595 | Additionally, this class implements basic comparison operators and the hash 596 | function. 597 | 598 | >>> from typet import BaseObject 599 | >>> class Point(BaseObject): 600 | ... x: int 601 | ... y: int 602 | ... 603 | ... 604 | >>> p = Point(0, 0) 605 | >>> p.x 606 | 0 607 | >>> p.x = '5' 608 | >>> p.x 609 | 5 610 | >>> p.x = 'five' 611 | Traceback (most recent call last): 612 | ... 613 | TypeError: Cannot convert 'five' to int. 614 | """ 615 | 616 | 617 | class Object(BaseObject, _AnnotatedObjectComparisonMixin): 618 | """A base class to create instance attrs for annotated class attrs. 619 | 620 | For every class attribute that is annotated, public, and not constant in 621 | the subclasses, this base class will generate property objects for the 622 | the instances that will enforce the type of the value set by attempting to 623 | cast the given value to the set type. 624 | 625 | If the subclass does not define __init__, a default implementation will be 626 | generated that takes all of the annotated, public, non-constant attributes 627 | as parameters. If an annotated attribute is not defined, it will be 628 | required in __init__. 629 | 630 | Additionally, this class implements basic comparison operators and the hash 631 | function. 632 | 633 | >>> from typet import Object 634 | >>> class Point(Object): 635 | ... x: int 636 | ... y: int 637 | ... 638 | ... 639 | >>> p = Point(0, 0) 640 | >>> p.x 641 | 0 642 | >>> p.x = '5' 643 | >>> p.x 644 | 5 645 | >>> p.x = 'five' 646 | Traceback (most recent call last): 647 | ... 648 | TypeError: Cannot convert 'five' to int. 649 | >>> p2 = Point(2, 2) 650 | >>> p < p2 651 | True 652 | >>> p > p2 653 | False 654 | """ 655 | -------------------------------------------------------------------------------- /typet/path.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """A module containing types representing file and directory states. 3 | 4 | Classes: 5 | File: A type instance of Valid that validates that the value is a file. 6 | Dir: A type instance of Valid that validates that the value is a directory. 7 | Path: A type instance of Valid that expands a value to a path. 8 | """ 9 | 10 | from __future__ import unicode_literals 11 | 12 | import os.path 13 | 14 | from .validation import Valid 15 | 16 | try: 17 | import pathlib 18 | except ImportError: 19 | import pathlib2 as pathlib # type: ignore 20 | 21 | 22 | def _valid(name, type_, predicate): 23 | new_type = Valid[type_, predicate] 24 | setattr( 25 | new_type, "__class_repr__", "{}.{}".format(predicate.__module__, name) 26 | ) 27 | return new_type 28 | 29 | 30 | def is_dir(path): 31 | """Determine if a Path or string is a directory on the file system.""" 32 | try: 33 | return path.expanduser().absolute().is_dir() 34 | except AttributeError: 35 | return os.path.isdir(os.path.abspath(os.path.expanduser(str(path)))) 36 | 37 | 38 | def is_file(path): 39 | """Determine if a Path or string is a file on the file system.""" 40 | try: 41 | return path.expanduser().absolute().is_file() 42 | except AttributeError: 43 | return os.path.isfile(os.path.abspath(os.path.expanduser(str(path)))) 44 | 45 | 46 | def exists(path): 47 | """Determine if a Path or string is an existing path on the file system.""" 48 | try: 49 | return path.expanduser().absolute().exists() 50 | except AttributeError: 51 | return os.path.exists(os.path.abspath(os.path.expanduser(str(path)))) 52 | 53 | 54 | Dir = _valid("Dir", pathlib.Path, is_dir) 55 | File = _valid("File", pathlib.Path, is_file) 56 | ExistingPath = _valid("ExistingPath", pathlib.Path, exists) 57 | -------------------------------------------------------------------------------- /typet/types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """A module containing basic types. 3 | 4 | Classes: 5 | DefType: A union of function and method types. 6 | NoneType: An alias for type(None) 7 | """ 8 | 9 | from __future__ import absolute_import 10 | from __future__ import unicode_literals 11 | 12 | import types 13 | import typing 14 | 15 | 16 | __all__ = ("DefType", "NoneType") 17 | 18 | 19 | DefType = typing.Union[types.FunctionType, types.MethodType] 20 | NoneType = type(None) # pylint: disable=redefined-builtin 21 | -------------------------------------------------------------------------------- /typet/typing.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """A module for handling with typing and type hints. 3 | 4 | In addition to the functions below, it also exports everything that the typing 5 | and typing_extensions modules export. 6 | 7 | Functions: 8 | cast: Casts a value to a specific type. 9 | eval_type: Evaluates a type, or a string of the type. 10 | get_type_hints: Gets all type hints for an object, including comment type 11 | hints. 12 | is_instance: An implementation of isinstance that works with the type 13 | definitions from the typing library. 14 | """ 15 | # pragma pylint: disable=W0641,W0703 16 | 17 | from typing import ( 18 | _eval_type, 19 | _get_defaults, 20 | Any, 21 | ByteString, 22 | Iterable, 23 | Mapping, 24 | MutableSequence, 25 | Optional, 26 | TypeVar, 27 | ) 28 | import collections 29 | import functools 30 | import inspect 31 | import re 32 | import sys 33 | import tokenize 34 | import types 35 | import typing 36 | 37 | import six 38 | 39 | try: 40 | from typing import ForwardRef 41 | except ImportError: 42 | from typing import _ForwardRef as ForwardRef 43 | 44 | 45 | _get_type_hints = typing.get_type_hints 46 | 47 | _STRING_TYPES = six.string_types + (ByteString, bytes, bytearray) 48 | 49 | # Deque is not registered in some versions of the typing library. 50 | MutableSequence.register(collections.deque) 51 | 52 | 53 | def get_type_hints(obj, # type: Any 54 | globalns=None, # type: Optional[Dict[str, Any]] 55 | localns=None # type: Optional[Dict[str, Any]] 56 | ): 57 | # type: (...) -> Dict[str, Any] 58 | """Return all type hints for the function. 59 | 60 | This attempts to use typing.get_type_hints first, but if that returns None 61 | then it will attempt to reuse much of the logic from the Python 3 version 62 | of typing.get_type_hints; the Python 2 version does nothing. In addition to 63 | this logic, if no code annotations exist, it will attempt to extract 64 | comment type hints for Python 2/3 compatibility. 65 | 66 | Args: 67 | obj: The object to search for type hints. 68 | globalns: The currently known global namespace. 69 | localns: The currently known local namespace. 70 | 71 | Returns: 72 | A mapping of value names to type hints. 73 | """ 74 | hints = {} 75 | try: 76 | if not isinstance(obj, type): 77 | hints = _get_type_hints(obj, globalns, localns) or {} 78 | except TypeError: 79 | if not isinstance(obj, _STRING_TYPES): 80 | raise 81 | if not hints and not getattr(obj, '__no_type_check__', None): 82 | globalns, localns = _get_namespace(obj, globalns, localns) 83 | hints = _get_comment_type_hints(obj, globalns, localns) 84 | for name, value in six.iteritems(hints): 85 | if value is None: 86 | value = type(None) 87 | elif isinstance(value, _STRING_TYPES): 88 | value = ForwardRef(value) 89 | hints[name] = _eval_type(value, globalns, localns) 90 | return hints 91 | 92 | 93 | def _get_namespace(obj, # type: Any 94 | globalns, # type: Optional[Dict[str, Any]] 95 | localns # type: Optional[Dict[str, Any]] 96 | ): 97 | # type: (...) -> Tuple[Dict[str, Any], Dict[str, Any]] 98 | """Retrieve the global and local namespaces for an object. 99 | 100 | Args: 101 | obj: An object. 102 | globalns: The currently known global namespace. 103 | localns: The currently known local namespace. 104 | 105 | Returns: 106 | A tuple containing two dictionaries for the global and local namespaces 107 | to be used by eval. 108 | """ 109 | if globalns is None: 110 | globalns = getattr(obj, '__globals__', {}) 111 | if localns is None: 112 | localns = globalns 113 | elif localns is None: 114 | localns = globalns 115 | return globalns, localns 116 | 117 | 118 | def _get_type_comments(source): 119 | # type: (str) -> Generator[Tuple[str, str, Any], None, None] 120 | """Yield type hint comments from the source code. 121 | 122 | Args: 123 | source: The source code of the function to search for type hint 124 | comments. 125 | 126 | Yields: 127 | All type comments that come before the body of the function as 128 | (name, type) pairs, where the name is the name of the variable and 129 | type is the type hint. If a short-form type hint is reached, it is 130 | yielded as a single string containing the entire type hint. 131 | """ 132 | reader = six.StringIO(inspect.cleandoc(source)).readline 133 | name = last_token = None 134 | tokens = tokenize.generate_tokens(reader) 135 | is_func = source.startswith('def') 136 | indent_level = 0 137 | for token, value, _, _, _ in tokens: 138 | if is_func and token == tokenize.INDENT: 139 | return 140 | if token == tokenize.DEDENT: 141 | indent_level -= 1 142 | elif token == tokenize.NAME: 143 | if value in ('def', 'class'): 144 | indent_level += 1 145 | elif last_token != tokenize.OP: 146 | name = value 147 | elif token == tokenize.COMMENT and indent_level == 1: 148 | match = re.match(r'#\s*type:(.+)', value) 149 | if match: 150 | type_sig = match.group(1).strip() 151 | if '->' in type_sig and last_token == tokenize.NEWLINE: 152 | name, type_sig = type_sig.split('->', 1) 153 | yield name.strip(), type_sig.strip() 154 | elif name: 155 | yield name.strip(), type_sig.strip() 156 | name = None 157 | last_token = token 158 | 159 | 160 | def _get_comment_type_hints(obj, # type: Any 161 | globalns, # type: Dict[str, Any] 162 | localns # type: Dict[str, Any] 163 | ): 164 | # type: (...) -> Dict[str, Any] 165 | """Get a mapping of any names to type hints from type hint comments. 166 | 167 | Args: 168 | obj: The object to search for type hint comments. 169 | 170 | Returns: 171 | A dictionary mapping names to the type hints found in comments. 172 | """ 173 | if isinstance(obj, (types.FunctionType, types.MethodType)): 174 | return _get_func_type_hints(obj, globalns, localns) 175 | if isinstance(obj, type): 176 | return _get_class_type_hints(obj, globalns, localns) 177 | if isinstance(obj, types.ModuleType): 178 | try: 179 | source = inspect.getsource(obj) 180 | except (IOError, TypeError): 181 | return {} 182 | else: 183 | source = obj 184 | hints = {} 185 | for name, value in _get_type_comments(source): 186 | hints[name] = value 187 | return hints 188 | 189 | 190 | def _get_class_type_hints(type_, # type: Type 191 | globalns, # type: Dict[str, Any] 192 | localns # type: Dict[str, Any] 193 | ): 194 | # type: (...) -> Dict[str, Any] 195 | """Get a mapping of class attr names to type hints from type hint comments. 196 | 197 | Args: 198 | type_: The class object to search for type hint comments. 199 | 200 | Returns: 201 | A dictionary mapping the class attribute names to the type hints found 202 | for each class attribute in the type hint comments. 203 | """ 204 | hints = {} 205 | for base in reversed(type_.__mro__): 206 | if globalns is None: 207 | try: 208 | base_globals = sys.modules[base.__module__].__dict__ 209 | except KeyError: 210 | base_globals = globalns 211 | else: 212 | base_globals = globalns 213 | base_hints = vars(base).get('__annotations__', {}) 214 | if not base_hints: 215 | try: 216 | source = inspect.getsource(base) 217 | ns = localns if base is type_ else {} 218 | base_hints = _get_comment_type_hints(source, base_globals, ns) 219 | except (IOError, TypeError): 220 | pass 221 | hints.update(base_hints) 222 | return hints 223 | 224 | 225 | def _get_func_type_hints(func, # type: Callable[..., Any] 226 | globalns, # type: Dict[str, Any] 227 | localns # type: Dict[str, Any] 228 | ): 229 | # type: (...) -> Dict[str, Any] 230 | """Get a mapping of parameter names to type hints from type hint comments. 231 | 232 | Args: 233 | func: The function to search for type hint comments. 234 | 235 | Returns: 236 | A dictionary mapping the function parameters to the type hints found 237 | for each parameter in the type hint comments. 238 | """ 239 | try: 240 | source = inspect.getsource(func) 241 | except (IOError, TypeError): 242 | return {} 243 | hints = {} 244 | getargspec = getattr( 245 | inspect, 'get{}argspec'.format('full' if six.PY3 else '')) 246 | full_signature = getargspec(func) 247 | signature = list(full_signature[0]) + [s for s in full_signature[1:3] if s] 248 | for comment in _get_type_comments(source): 249 | name, value = comment 250 | if name in signature: 251 | hints[name] = value 252 | elif name.startswith('(') and name.endswith(')'): 253 | hints['return'] = value 254 | type_values = _parse_short_form(name, globalns, localns) 255 | if len(type_values) == len(signature) - 1: 256 | signature = signature[1:] 257 | if len(type_values) == len(signature): 258 | hints.update(zip(signature, type_values)) 259 | defaults = _get_func_defaults(func) 260 | for name, value in six.iteritems(hints): 261 | if name in defaults and defaults[name] is None: 262 | hints[name] = Optional[value] 263 | return hints 264 | 265 | 266 | def _get_func_defaults(func): 267 | # type: (Callable[..., Any]) -> Dict[str, Any] 268 | """Get the default values for the function parameters. 269 | 270 | Args: 271 | func: The function to inspect. 272 | 273 | Returns: 274 | A mapping of parameter names to default values. 275 | """ 276 | _func_like = functools.wraps(func)(lambda: None) 277 | _func_like.__defaults__ = getattr(func, '__defaults__', None) 278 | if hasattr(func, '__code__'): 279 | _func_like.__code__ = func.__code__ 280 | if not hasattr(_func_like, '__kwdefaults__'): 281 | _func_like.__kwdefaults__ = {} 282 | return _get_defaults(_func_like) 283 | 284 | 285 | def _parse_short_form(comment, globalns, localns): 286 | # type: (str, Dict[str, Any], Dict[str, Any]) -> Tuple[type, ...] 287 | """Return the hints from the comment. 288 | 289 | Parses the left-hand side of a type comment into a list of type objects. 290 | (e.g. everything to the left of "->"). 291 | 292 | Returns: 293 | A list of types evaluated from the type comment in the given global 294 | name space. 295 | """ 296 | if '(...)' in comment: 297 | return () 298 | comment = comment.replace('*', '') 299 | hints = eval(comment, globalns, localns) # pylint: disable=eval-used 300 | if not isinstance(hints, tuple): 301 | hints = (hints,) 302 | return hints 303 | 304 | 305 | def cast(tp, obj): 306 | # type: (Type[_T], Any) -> _T 307 | """Cast the value to the given type. 308 | 309 | Args: 310 | tp: The type the value is expected to be cast. 311 | obj: The value to cast. 312 | 313 | Returns: 314 | The cast value if it was possible to determine the type and cast it. 315 | """ 316 | if is_instance(obj, tp): 317 | return obj 318 | type_repr = repr(tp) 319 | obj_repr = repr(obj) 320 | if tp in _STRING_TYPES: 321 | obj = _cast_string(tp, obj) 322 | if is_instance(obj, tp): 323 | return obj 324 | if (hasattr(tp, '__origin__') and 325 | tp.__origin__ or hasattr(tp, '__args__') and tp.__args__): 326 | obj = _cast_iterables(tp, obj) 327 | for type_ in _get_cast_types(tp): 328 | try: 329 | args = getattr(type_, '__args__', None) 330 | constraints = getattr(type_, '__constraints__', None) 331 | if args or constraints: 332 | return cast(type_, obj) 333 | return type_(obj) 334 | except Exception as e: # NOQA 335 | pass 336 | six.raise_from( 337 | TypeError("Cannot convert {} to {}.".format(obj_repr, type_repr)), 338 | locals().get('e') 339 | ) 340 | 341 | 342 | def _get_cast_types(type_): 343 | # type: (Type) -> List[Union[type, Callable[..., Any]]] 344 | """Return all type callable type constraints for the given type. 345 | 346 | Args: 347 | type_: The type variable that may be callable or constrainted. 348 | 349 | Returns: 350 | A list of all callable type constraints for the type. 351 | """ 352 | cast_types = [type_] if callable( 353 | type_) and type_.__module__ != 'typing' else [] 354 | if (hasattr(type_, '__constraints__') and 355 | isinstance(type_.__constraints__, Iterable)): 356 | cast_types.extend(type_.__constraints__) 357 | if (hasattr(type_, '__args__') and 358 | isinstance(type_.__args__, Iterable) and 359 | not _is_subclass(type_, Mapping)): 360 | cast_types.extend(type_.__args__) 361 | if hasattr(type_, '_abc_registry'): 362 | cast_types.extend(sorted( # Give list and tuple precedence. 363 | type_._abc_registry, 364 | key=lambda k: k.__name__, 365 | reverse=True)) 366 | if hasattr(type_, '__extra__') and type_.__extra__: 367 | if isinstance(type_.__extra__, type): 368 | cast_types.append(type_.__extra__) 369 | if hasattr(type_.__extra__, '_abc_registry'): 370 | cast_types.extend(sorted( # Give list and tuple precedence. 371 | type_.__extra__._abc_registry, 372 | key=lambda k: k.__name__, 373 | reverse=True)) 374 | if hasattr(type_, '__origin__') and type_.__origin__: 375 | cast_types.append(type_.__origin__) 376 | try: 377 | type_name = vars(type_).get('_name') 378 | if type_name == 'MutableSequence': 379 | cast_types.insert(0, list) 380 | elif type_name == 'MutableSet': 381 | cast_types.insert(0, set) 382 | except TypeError: 383 | pass 384 | return cast_types 385 | 386 | 387 | def is_instance(obj, type_): 388 | # type: (Any, Type) -> bool 389 | """Determine if an object is an instance of a type. 390 | 391 | In addition to the built-in isinstance, this method will compare against 392 | Any and TypeVars. 393 | 394 | Args: 395 | obj: Any object. 396 | type_: The type to check the object instance against. 397 | 398 | Returns: 399 | True if the object is an instance of the type; otherwise, False. 400 | """ 401 | if type_ == Any or type_ is ByteString and isinstance( 402 | obj, (bytes, bytearray)): 403 | return True 404 | if isinstance(type_, type): 405 | if hasattr(type_, '__args__') and type_.__args__: 406 | generic_type = (type_.__origin__ if hasattr( 407 | type_, '__origin__') and type_.__origin__ else type_) 408 | if _is_subclass(type_, tuple) and Ellipsis not in type_.__args__: 409 | return (len(obj) == len(type_.__args__) and 410 | isinstance(obj, generic_type) and all( 411 | is_instance(val, typ) for typ, val in 412 | zip(type_.__args__, obj))) 413 | if _is_subclass(type_, Mapping): 414 | return isinstance(obj, generic_type) and all( 415 | is_instance(k, type_.__args__[0]) and 416 | is_instance(v, type_.__args__[1]) for 417 | k, v in six.iteritems(obj) 418 | ) 419 | if _is_subclass(type_, Iterable): 420 | return isinstance(obj, generic_type) and all( 421 | is_instance(v, type_.__args__[0]) for v in obj) 422 | elif isinstance(obj, type_): 423 | return True 424 | args = getattr(type_, '__args__', getattr(type_, '__constraints__', None)) 425 | return any(is_instance(obj, typ) for typ in args or ()) 426 | 427 | 428 | def _cast_iterables(type_, obj): 429 | # type: (Type, Any) -> Any 430 | """Cast items contained in the object if the object is a container. 431 | 432 | Args: 433 | type_: The type of the container. If the object is not a container, no 434 | casting is performed. 435 | obj: The container object. If the object is not a container, no casting 436 | is performed. 437 | 438 | Returns: 439 | An object that can be cast to the given type. This may be either the 440 | original object, or a generator that casts all items within the object 441 | if the object is a container. 442 | """ 443 | if not type_.__args__ or TypeVar in (type(t) for t in type_.__args__): 444 | return obj 445 | if _is_subclass(type_, tuple) and Ellipsis not in type_.__args__: 446 | if len(obj) == len(type_.__args__): 447 | return [cast(typ, val) for typ, val in zip(type_.__args__, obj)] 448 | raise TypeError( 449 | 'The number of elements [{}] does not match the type {}'.format( 450 | len(obj), repr(type_))) 451 | if _is_subclass(type_, Mapping): 452 | return { 453 | cast(type_.__args__[0], k): cast(type_.__args__[1], v) 454 | for k, v in six.iteritems(obj) 455 | } 456 | if _is_subclass(type_, Iterable): 457 | return [cast(type_.__args__[0], v) for v in obj] 458 | return obj 459 | 460 | 461 | def _cast_string(type_, obj): 462 | # type: (Type, Any) -> Any 463 | """Cast the object to a string type. 464 | 465 | If the type is a ByteString, but the object does not have a __bytes__ 466 | method, the object will first be converted to a string. 467 | 468 | Note: 469 | This does not guarantee that it will cast to a string, as some aspects 470 | are assumed to be handled by the calling function. Unless the object 471 | needs to be encoded or decoded, the object will be returned unmodified. 472 | 473 | Args: 474 | type_: The type to cast the object to if possible. 475 | obj: The object to cast. 476 | 477 | Returns: 478 | The object cast to a string type if necessary. This is only necessary 479 | if the requested type is a ByteString and the object does not have a 480 | __bytes__ method, or the object needs to be encoded or decoded. 481 | """ 482 | encoding = sys.stdin.encoding or sys.getdefaultencoding() 483 | if _is_subclass(type_, ByteString): 484 | if not hasattr(obj, '__bytes__'): 485 | obj = str(obj) 486 | if isinstance(obj, six.string_types): 487 | bytestr = obj.encode(encoding) 488 | if _is_subclass(type_, bytearray): 489 | return bytearray(bytestr) 490 | return bytestr 491 | if _is_subclass(type_, six.string_types) and isinstance(obj, ByteString): 492 | return obj.decode(encoding) 493 | return obj 494 | 495 | 496 | def _is_subclass(type_, class_or_tuple): 497 | # type: (Type, Union[Type, Tuple]) -> bool 498 | """Determine if the type is a subclass of the given class or classes. 499 | 500 | This takes __origin__ classes into consideration and does not raise. 501 | 502 | Args: 503 | type_: The type that may be a subclass. 504 | class_or_tuple: A type or a tuple containing multiple types of which 505 | type_ may be a subclass. 506 | 507 | Returns: 508 | A boolean indicating whether the given type is a subclass of the 509 | """ 510 | try: 511 | return issubclass(type_, class_or_tuple) 512 | except (TypeError, AttributeError): 513 | pass 514 | if hasattr(type_, '__origin__') and type_.__origin__: 515 | try: 516 | return issubclass(type_.__origin__, class_or_tuple) 517 | except (TypeError, AttributeError): 518 | pass 519 | return False 520 | 521 | 522 | def eval_type(type_, globalns=None, localns=None): 523 | """Evaluate the type. If the type is string, evaluate it with ForwardRef. 524 | 525 | Args: 526 | type_: The type to evaluate. 527 | globalns: The currently known global namespace. 528 | localns: The currently known local namespace. 529 | 530 | Returns: 531 | The evaluated type. 532 | """ 533 | globalns, localns = _get_namespace(type_, globalns, localns) 534 | if isinstance(type_, six.string_types): 535 | type_ = ForwardRef(type_) 536 | return _eval_type(type_, globalns, localns) 537 | -------------------------------------------------------------------------------- /typet/validation.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pragma pylint: disable=bad-mcs-method-argument,bad-mcs-classmethod-argument 3 | """A module for handling with typing and type hints. 4 | 5 | Classes: 6 | Bounded: A sliceable subclass of any class that raises a ValueError if the 7 | initialization value is out of bounds. 8 | Length: A sliceable subclass of any class that implements __len__ that 9 | raises a ValueError if the length of the initialization value is out of 10 | bounds. 11 | Valid: A sliceable type that validates any assigned value against a 12 | validation function. 13 | """ 14 | 15 | from __future__ import absolute_import 16 | from __future__ import unicode_literals 17 | 18 | from typing import ( # noqa: F401 pylint: disable=unused-import 19 | Any, 20 | Callable, 21 | Optional, 22 | Tuple, 23 | Type, 24 | TypeVar, 25 | Union, 26 | ) 27 | 28 | import six 29 | 30 | from .meta import Uninstantiable 31 | from .typing import eval_type 32 | 33 | 34 | _T = TypeVar("_T") 35 | 36 | if six.PY3: 37 | unicode = str # pylint: disable=redefined-builtin 38 | _STR_TYPE = unicode 39 | 40 | 41 | class _ValidationMeta(type): 42 | """A metaclass that returns handles custom type checks.""" 43 | 44 | __class_repr__ = None # type: Optional[str] 45 | 46 | def __repr__(cls): 47 | # type: () -> str 48 | """Return a custom string for the type repr if defined.""" 49 | if cls.__class_repr__: 50 | return cls.__class_repr__ 51 | return super(_ValidationMeta, cls).__repr__() 52 | 53 | def __instancecheck__(cls, other): 54 | # type: (Any) -> bool 55 | """Determine if an instance is of the sliced type and within bounds. 56 | 57 | Args: 58 | other: The instance to test. 59 | 60 | Returns: 61 | True if the object is both of the same type as sliced by the 62 | created class as well as within the bounds defined by the class. 63 | """ 64 | try: 65 | return bool( 66 | isinstance(other, cls.__type__) and cls(other) # type: ignore 67 | ) 68 | except ValueError: 69 | return False 70 | 71 | 72 | class _BoundedMeta(Uninstantiable): 73 | """A metaclass that adds slicing to a class that creates new classes.""" 74 | 75 | def __getitem__(cls, args): 76 | # type: (Union[Tuple[_T, Any], Tuple[_T, Any, Callable]]) -> type 77 | """Create a new subclass of a type bounded by the arguments. 78 | 79 | If a callable is passed as the third argument of the slice, it will be 80 | used as the comparison function for the boundaries. 81 | 82 | Args: 83 | args: A tuple with two or three parameters: a type, a slice 84 | representing the minimum and maximum lengths allowed for values 85 | of that type and, optionally, a function to use on values 86 | before comparing against the bounds. 87 | """ 88 | type_, bound, keyfunc = cls._get_args(args) 89 | keyfunc_name = cls._get_fullname(keyfunc) 90 | identity = cls._identity 91 | BaseClass, MetaClass = cls._get_bases(type_) 92 | instantiate = cls._instantiate 93 | 94 | @six.add_metaclass(MetaClass) # type: ignore 95 | class _BoundedSubclass(BaseClass): # type: ignore 96 | """A subclass of type_ or object, bounded by a slice.""" 97 | 98 | def __new__(cls, __value, *args, **kwargs): 99 | # type: (Type[_BoundedSubclass], Any, *Any, **Any) -> type 100 | """Return __value cast to _T. 101 | 102 | Any additional arguments are passed as-is to the constructor. 103 | 104 | Args: 105 | __value: A value that can be converted to type _T. 106 | args: Any additional positional arguments passed to the 107 | constructor. 108 | kwargs: Any additional keyword arguments passed to the 109 | constructor. 110 | """ 111 | instance = instantiate( 112 | BaseClass, type_, __value, *args, **kwargs 113 | ) 114 | cmp_val = keyfunc(instance) 115 | if bound.start is not None or bound.stop is not None: 116 | if bound.start is not None and cmp_val < bound.start: 117 | if keyfunc is not identity: 118 | raise ValueError( 119 | "The value of {}({}) [{}] is below the minimum" 120 | " allowed value of {}.".format( 121 | keyfunc_name, 122 | repr(__value), 123 | repr(cmp_val), 124 | bound.start, 125 | ) 126 | ) 127 | raise ValueError( 128 | "The value {} is below the minimum allowed value " 129 | "of {}.".format(repr(__value), bound.start) 130 | ) 131 | if bound.stop is not None and cmp_val > bound.stop: 132 | if keyfunc is not identity: 133 | raise ValueError( 134 | "The value of {}({}) [{}] is above the maximum" 135 | " allowed value of {}.".format( 136 | keyfunc_name, 137 | repr(__value), 138 | repr(cmp_val), 139 | bound.stop, 140 | ) 141 | ) 142 | raise ValueError( 143 | "The value {} is above the maximum allowed value " 144 | "of {}.".format(repr(__value), bound.stop) 145 | ) 146 | elif not cmp_val: 147 | raise ValueError( 148 | "{}({}) is False".format(keyfunc_name, repr(instance)) 149 | ) 150 | return instance 151 | 152 | _BoundedSubclass.__type__ = type_ 153 | _BoundedSubclass.__class_repr__ = cls._get_class_repr( 154 | type_, bound, keyfunc, keyfunc_name 155 | ) 156 | return _BoundedSubclass 157 | 158 | @staticmethod 159 | def _get_bases(type_): 160 | # type: (type) -> Tuple[type, type] 161 | """Get the base and meta classes to use in creating a subclass. 162 | 163 | Args: 164 | type_: The type to subclass. 165 | 166 | Returns: 167 | A tuple containing two values: a base class, and a metaclass. 168 | """ 169 | try: 170 | 171 | class _(type_): # type: ignore 172 | """Check if type_ is subclassable.""" 173 | 174 | BaseClass = type_ 175 | except TypeError: 176 | BaseClass = object 177 | 178 | class MetaClass(_ValidationMeta, BaseClass.__class__): # type: ignore 179 | """Use the type_ meta and include base validation functionality.""" 180 | 181 | return BaseClass, MetaClass 182 | 183 | @staticmethod 184 | def _instantiate(class_, type_, __value, *args, **kwargs): 185 | """Instantiate the object if possible. 186 | 187 | Args: 188 | class_: The class to instantiate. 189 | type_: The the class is uninstantiable, attempt to cast to a base 190 | type. 191 | __value: The value to return if the class and type are 192 | uninstantiable. 193 | *args: The positional arguments to pass to the class. 194 | **kwargs: The keyword arguments to pass to the class. 195 | 196 | Returns: 197 | The class or base type instantiated using the arguments. If it is 198 | not possible to instantiate either, returns __value. 199 | """ 200 | try: 201 | return class_(__value, *args, **kwargs) 202 | except TypeError: 203 | try: 204 | return type_(__value, *args, **kwargs) 205 | except Exception: # pylint: disable=broad-except 206 | return __value 207 | 208 | def _get_class_repr(cls, type_, bound, keyfunc, keyfunc_name): 209 | # type: (Any, slice, Callable, str) -> str 210 | """Return a class representation using the slice parameters. 211 | 212 | Args: 213 | type_: The type the class was sliced with. 214 | bound: The boundaries specified for the values of type_. 215 | keyfunc: The comparison function used to check the value 216 | boundaries. 217 | keyfunc_name: The name of keyfunc. 218 | 219 | Returns: 220 | A string representing the class. 221 | """ 222 | if keyfunc is not cls._default: 223 | return "{}.{}[{}, {}, {}]".format( 224 | cls.__module__, 225 | cls.__name__, 226 | cls._get_fullname(type_), 227 | cls._get_bound_repr(bound), 228 | keyfunc_name, 229 | ) 230 | return "{}.{}[{}, {}]".format( 231 | cls.__module__, 232 | cls.__name__, 233 | cls._get_fullname(type_), 234 | cls._get_bound_repr(bound), 235 | ) 236 | 237 | def _get_args(cls, args): 238 | # type: (tuple) -> Tuple[Any, slice, Callable] 239 | """Return the parameters necessary to check type boundaries. 240 | 241 | Args: 242 | args: A tuple with two or three elements: a type, a slice 243 | representing the minimum and maximum lengths allowed for values 244 | of that type and, optionally, a function to use on values 245 | before comparing against the bounds. 246 | 247 | Returns: 248 | A tuple with three elements: a type, a slice, and a function to 249 | apply to objects of the given type. If no function was specified, 250 | it returns the identity function. 251 | """ 252 | if not isinstance(args, tuple): 253 | raise TypeError( 254 | "{}[...] takes two or three arguments.".format(cls.__name__) 255 | ) 256 | if len(args) == 2: 257 | type_, bound = args 258 | keyfunc = cls._identity 259 | elif len(args) == 3: 260 | type_, bound, keyfunc = args 261 | else: 262 | raise TypeError( 263 | "Too many parameters given to {}[...]".format(cls.__name__) 264 | ) 265 | if not isinstance(bound, slice): 266 | bound = slice(bound) 267 | return eval_type(type_), bound, keyfunc 268 | 269 | @staticmethod 270 | def _get_bound_repr(bound): 271 | # type: (slice) -> str 272 | """Return a string representation of a boundary slice. 273 | 274 | Args: 275 | bound: A slice object. 276 | 277 | Returns: 278 | A string representing the slice. 279 | """ 280 | return "{}:{}".format(bound.start or "", bound.stop or "") 281 | 282 | @staticmethod 283 | def _identity(obj): 284 | # type: (Any) -> Any 285 | """Return the given object. 286 | 287 | Args: 288 | obj: An object. 289 | 290 | Returns: 291 | The given object. 292 | """ 293 | return obj 294 | 295 | _default = _identity # type: Callable[[Any], Any] 296 | 297 | @staticmethod 298 | def _get_fullname(obj): 299 | # type: (Any) -> str 300 | """Get the full name of an object including the module. 301 | 302 | Args: 303 | obj: An object. 304 | 305 | Returns: 306 | The full class name of the object. 307 | """ 308 | if not hasattr(obj, "__name__"): 309 | obj = obj.__class__ 310 | if obj.__module__ in ("builtins", "__builtin__"): 311 | return obj.__name__ 312 | return "{}.{}".format(obj.__module__, obj.__name__) 313 | 314 | 315 | @six.add_metaclass(_BoundedMeta) 316 | class Bounded(object): 317 | """A type that creates a bounded version of a type when sliced. 318 | 319 | Bounded can be sliced with two or three elements: a type, a slice 320 | representing the minimum and maximum lengths allowed for values of that 321 | type and, optionally, a function to use on values before comparing against 322 | the bounds. 323 | 324 | >>> Bounded[int, 5:10](7) 325 | 7 326 | >>> Bounded[int, 5:10](1) 327 | Traceback (most recent call last): 328 | ... 329 | ValueError: The value 1 is below the minimum allowed value of 5. 330 | >>> Bounded[int, 5:10](11) 331 | Traceback (most recent call last): 332 | ... 333 | ValueError: The value 11 is above the maximum allowed value of 10. 334 | >>> Bounded[str, 5:10, len]('abcde') 335 | 'abcde' 336 | """ 337 | 338 | 339 | class _LengthBoundedMeta(_BoundedMeta): 340 | """A metaclass that bounds a type with the len function.""" 341 | 342 | _default = len 343 | 344 | def _get_args(cls, args): 345 | # type: (tuple) -> Tuple[type, slice, Callable] 346 | """Return the parameters necessary to check type boundaries. 347 | 348 | Args: 349 | args: A tuple with two parameters: a type, and a slice representing 350 | the minimum and maximum lengths allowed for values of that 351 | type. 352 | 353 | Returns: 354 | A tuple with three parameters: a type, a slice, and the len 355 | function. 356 | """ 357 | if not isinstance(args, tuple) or not len(args) == 2: 358 | raise TypeError( 359 | "{}[...] takes exactly two arguments.".format(cls.__name__) 360 | ) 361 | return super(_LengthBoundedMeta, cls)._get_args(args + (len,)) 362 | 363 | 364 | @six.add_metaclass(_LengthBoundedMeta) 365 | class Length(object): 366 | """A type that creates a length bounded version of a type when sliced. 367 | 368 | Length can be sliced with two parameters: a type, and a slice representing 369 | the minimum and maximum lengths allowed for values of that type. 370 | 371 | >>> Length[str, 5:10]('abcde') 372 | 'abcde' 373 | >>> Length[str, 5:10]('abc') 374 | Traceback (most recent call last): 375 | ... 376 | ValueError: The value of len('abc') [3] is below the minimum ... 377 | >>> Length[str, 5:10]('abcdefghijk') 378 | Traceback (most recent call last): 379 | ... 380 | ValueError: The value of len('abcdefghijk') [11] is above the maximum ... 381 | """ 382 | 383 | 384 | class _ValidationBoundedMeta(_BoundedMeta): 385 | """A metaclass that binds a type to a validation method.""" 386 | 387 | def _get_args(cls, args): 388 | # type: (tuple) -> Tuple[type, slice, Callable] 389 | """Return the parameters necessary to check type boundaries. 390 | 391 | Args: 392 | args: A tuple with one or two parameters: A type to cast the 393 | value passed, and a predicate function to use for bounds 394 | checking. 395 | 396 | Returns: 397 | A tuple with three parameters: a type, a slice, and the predicate 398 | function. If no type was passed in args, the type defaults to Any. 399 | """ 400 | if isinstance(args, tuple): 401 | if not len(args) == 2: 402 | raise TypeError( 403 | "{}[...] takes one or two argument.".format(cls.__name__) 404 | ) 405 | return super(_ValidationBoundedMeta, cls)._get_args( 406 | (args[0], None, args[1]) 407 | ) 408 | return super(_ValidationBoundedMeta, cls)._get_args((Any, None, args)) 409 | 410 | def _get_class_repr(cls, type_, bound, keyfunc, keyfunc_name): 411 | # type: (Any, slice, Callable, str) -> str 412 | """Return a class representation using the slice parameters. 413 | 414 | Args: 415 | type_: The type the class was sliced with. 416 | bound: The boundaries specified for the values of type_. 417 | keyfunc: The comparison function used to check the value 418 | boundaries. 419 | keyfunc_name: The name of keyfunc. 420 | 421 | Returns: 422 | A string representing the class. 423 | """ 424 | if type_ is not Any: 425 | return "{}.{}[{}, {}]".format( 426 | cls.__module__, 427 | cls.__name__, 428 | cls._get_fullname(type_), 429 | keyfunc_name, 430 | ) 431 | return "{}.{}[{}]".format(cls.__module__, cls.__name__, keyfunc_name) 432 | 433 | 434 | @six.add_metaclass(_ValidationBoundedMeta) 435 | class Valid(object): 436 | """A type that creates a type that is validated against a function. 437 | 438 | Valid can be sliced with one or two parameters: an optional type to cast 439 | the given value to, and a validation method. 440 | """ 441 | 442 | 443 | class _StringMeta(_LengthBoundedMeta): 444 | """A metaclass that binds a string to a length bound.""" 445 | 446 | def __call__(cls, *args, **kwargs): 447 | """Instantiate a string object.""" 448 | return _STR_TYPE(*args, **kwargs) 449 | 450 | def __instancecheck__(self, other): 451 | # type: (Any) -> bool 452 | """Determine if an instance is of the string type. 453 | 454 | Args: 455 | other: The instance to test. 456 | 457 | Returns: 458 | True if the object is both of the same type as the String; 459 | otherwise, False, 460 | """ 461 | return isinstance(other, _STR_TYPE) 462 | 463 | def _get_args(cls, args): 464 | # type: (tuple) -> Tuple[type, slice, Callable] 465 | """Return the parameters necessary to check type boundaries. 466 | 467 | Args: 468 | args: A slice representing the minimum and maximum lengths allowed 469 | for values of that string. 470 | 471 | Returns: 472 | A tuple with three parameters: a type, a slice, and the len 473 | function. 474 | """ 475 | if isinstance(args, tuple): 476 | raise TypeError( 477 | "{}[...] takes exactly one argument.".format(cls.__name__) 478 | ) 479 | return super(_StringMeta, cls)._get_args((_STR_TYPE, args)) 480 | 481 | def _get_class_repr(cls, type_, bound, keyfunc, keyfunc_name): 482 | # type: (Any, slice, Callable, str) -> str 483 | """Return a class representation using the slice parameters. 484 | 485 | Args: 486 | type_: The type the class was sliced with. This will always be 487 | _STR_TYPE. 488 | bound: The boundaries specified for the values of type_. 489 | keyfunc: The comparison function used to check the value 490 | boundaries. This will always be builtins.len(). 491 | keyfunc_name: The name of keyfunc. This will always be 'len'. 492 | 493 | Returns: 494 | A string representing the class. 495 | """ 496 | return "{}.{}[{}]".format( 497 | cls.__module__, cls.__name__, cls._get_bound_repr(bound) 498 | ) 499 | 500 | 501 | @six.add_metaclass(_StringMeta) 502 | class String(object): 503 | """A type that creates a length bounded version of a string when sliced. 504 | 505 | String represents a specific string type. On Python 2, this is 'unicode' 506 | and on Python 3 this is 'str'. 507 | 508 | String can be sliced with one parameter: a slice representing the minimum 509 | and maximum lengths allowed for values of the string. 510 | 511 | If String is not sliced, it will act as a constructor for the string type. 512 | 513 | >>> String[5:10]('abcde') 514 | 'abcde' 515 | >>> String[5:10]('abc') 516 | Traceback (most recent call last): 517 | ... 518 | ValueError: The value of len('abc') [3] is below the minimum ... 519 | >>> String[5:10]('abcdefghijk') 520 | Traceback (most recent call last): 521 | ... 522 | ValueError: The value of len('abcdefghijk') [11] is above the maximum ... 523 | """ 524 | 525 | 526 | NonEmptyString = String[1:] 527 | --------------------------------------------------------------------------------