Skip to content

Commit

Permalink
Merge pull request #9 from dangle/commentable
Browse files Browse the repository at this point in the history
Add support for comment type hints using frame inspection.
  • Loading branch information
dangle committed Nov 20, 2017
2 parents 58d93cf + 5a9e944 commit 254d73f
Show file tree
Hide file tree
Showing 5 changed files with 89 additions and 29 deletions.
11 changes: 5 additions & 6 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ typet

*Types that make validating types in Python easy.*

``typet`` works best with Python 3.6 or later. Prior to 3.6, validation types are
supported, but the object types cannot be supported until typingplus_ supports
class type comments.
``typet`` works best with Python 3.6 or later. Prior to 3.6, object types must
use comment type hint syntax.


Installation
Expand Down Expand Up @@ -136,10 +135,10 @@ bounds and contains an optional attribute.
Person('Jimothy', 23, 'Figure Skating') # Okay, and sets hobby
Future Usage for Python 2.7 to 3.5
----------------------------------
Python 2.7 to 3.5
-----------------

In the future, ``typet`` will support class type comments for annotations.
``typet`` supports class type comments for annotations.

.. code-block:: python
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
'PEP 483', 'PEP 484', 'PEP 526'],
license='MIT',
packages=find_packages(exclude=['tests', 'docs']),
install_requires=['typingplus >= 2, < 3'],
install_requires=['typingplus >= 2.1, < 3'],
setup_requires=[
'pytest-runner',
'setuptools_scm',
Expand Down
9 changes: 9 additions & 0 deletions tests/py36/test_typed_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ class X(typet.Object):
assert x.x == '5'


def test_object_comments():
"""Simple test to verify basic Object functionality with comment hints."""
class X(typet.Object):
x = None # type: str
x = X(5)
assert isinstance(x.x, str)
assert x.x == '5'


def test_object_failure():
"""Simple test to verify basic Object failure functionality."""
class X(typet.Object):
Expand Down
2 changes: 1 addition & 1 deletion tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def test_path_types(request):
def _reset_home():
os.environ['HOME'] = home

request.add_finalizer(_reset_home)
request.addfinalizer(_reset_home)
except KeyError:
pass
os.environ['HOME'] = '/home/bob'
Expand Down
94 changes: 73 additions & 21 deletions typet/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
# pragma pylint: disable=bad-mcs-classmethod-argument
# pragma pylint: disable=bad-mcs-classmethod-argument,too-many-lines
"""A module for handling with typing and type hints.
Classes:
Expand Down Expand Up @@ -29,13 +29,17 @@
from __future__ import absolute_import
from __future__ import unicode_literals

import inspect
import os.path
import re
import tokenize
import types

import six
from typingplus import ( # noqa: F401 pylint: disable=unused-import
_ForwardRef,
cast,
get_type_hints,
is_instance,
Any,
Callable,
Expand Down Expand Up @@ -490,6 +494,63 @@ def _get_type_name(type_):
return name.rsplit('.', 1)[-1] or str(type_)


def _get_class_frame_source(class_name):
# type: (str) -> Optional[str]
"""Return the source code for a class by checking the frame stack.
This is necessary because it is not possible to get the source of a class
being created by a metaclass directly.
Args:
class_name: The class to look for on the stack.
Returns:
The source code for the requested class if the class was found and the
source was accessible.
"""
for frame_info in inspect.stack():
with open(frame_info.filename) as fp:
src = ''.join(
fp.readlines()[frame_info.lineno - 1:])
if re.search(r'class\s+{}'.format(class_name), src):
reader = six.StringIO(src).readline
tokens = tokenize.generate_tokens(reader)
source_tokens = []
indent_level = 0
base_indent_level = 0
has_base_level = False
for token, value, _, _, _ in tokens:
source_tokens.append((token, value))
if token == tokenize.INDENT:
indent_level += 1
elif token == tokenize.DEDENT:
indent_level -= 1
if has_base_level and indent_level <= base_indent_level:
return tokenize.untokenize(source_tokens)
elif not has_base_level:
has_base_level = True
base_indent_level = indent_level


def _is_propertyable(names, attrs, annotations, attr):
"""Determine if an attribute can be replaced with a property.
Args:
names: The complete list of all attribute names for the class.
attrs: The attribute dict returned by __prepare__.
annotations: A mapping of all defined annotations for the class.
attr: The attribute to test.
Returns:
True if the attribute can be replaced with a property; else False.
"""
return (attr in annotations and
not attr.startswith('_') and
not attr.isupper() and
'__{}'.format(attr) not in names and
not isinstance(getattr(attrs, attr, None), types.MethodType))


def _create_typed_object_meta(get_fset):
"""Create a metaclass for typed objects.
Expand All @@ -505,24 +566,6 @@ def _create_typed_object_meta(get_fset):
that will guarantee the type of the stored value matches the
annotation.
"""
def _is_propertyable(names, attrs, annotations, attr):
"""Determine if an attribute can be replaced with a property.
Args:
names: The complete list of all attribute names for the class.
attrs: The attribute dict returned by __prepare__.
annotations: A mapping of all defined annotations for the class.
attr: The attribute to test.
Returns:
True if the attribute can be replaced with a property; else False.
"""
return (attr in annotations and
not attr.startswith('_') and
not attr.isupper() and
'__{}'.format(attr) not in names and
not isinstance(getattr(attrs, attr, None), types.MethodType))

def _get_fget(attr, private_attr, type_):
"""Create a property getter method for an attribute.
Expand Down Expand Up @@ -568,6 +611,11 @@ def __new__(cls, name, bases, attrs):
validate against the annotated type.
"""
annotations = attrs.get('__annotations__', {})
use_comment_type_hints = (
not annotations and attrs.get('__module__') != __name__)
if use_comment_type_hints:
source = _get_class_frame_source(name)
annotations = get_type_hints(source)
names = list(attrs) + list(annotations)
typed_attrs = {}
for attr in names:
Expand All @@ -577,8 +625,12 @@ def __new__(cls, name, bases, attrs):
if attr in attrs:
typed_attrs[private_attr] = attrs[attr]
type_ = (
Optional[annotations[attr]] if attr in attrs and
attrs[attr] is None else annotations[attr])
Optional[annotations[attr]] if
not use_comment_type_hints and
attr in attrs and
attrs[attr] is None
else annotations[attr]
)
typed_attrs[attr] = property(
_get_fget(attr, private_attr, type_),
get_fset(attr, private_attr, type_)
Expand Down

0 comments on commit 254d73f

Please sign in to comment.