Skip to content

Commit

Permalink
Merge pull request #1270 from Zac-HD/public-hints
Browse files Browse the repository at this point in the history
Add type hints to our public API  (PEPs 484 & 561)
  • Loading branch information
Zac-HD committed Jun 26, 2018
2 parents 1b71a2e + 03e1dd1 commit 8691d81
Show file tree
Hide file tree
Showing 8 changed files with 181 additions and 27 deletions.
1 change: 0 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ jobs:
- env: TASK=lint-ruby
- env: TASK=doctest
- env: TASK=check-format
- env: TASK=check-type-hints
- env: TASK=check-requirements
- env: TASK=check-rust-tests

Expand Down
9 changes: 9 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
RELEASE_TYPE: minor

This release adds :PEP:`484` type hints to Hypothesis on a provisional
basis, using the comment-based syntax for Python 2 compatibility. You
can :ref:`read more about our type hints here <our-type-hints>`.

It *also* adds the ``py.typed`` marker specified in :PEP:`561`.
After you ``pip install hypothesis``, :pypi:`mypy` 0.590 or later
will therefore type-check your use of our public interface!
59 changes: 59 additions & 0 deletions hypothesis-python/docs/details.rst
Original file line number Diff line number Diff line change
Expand Up @@ -543,3 +543,62 @@ changes between Python 3.5.0 and 3.6.1, including at minor versions. These
are all supported on a best-effort basis, but you may encounter problems with
an old version of the module. Please report them to us, and consider
updating to a newer version of Python as a workaround.
.. _our-type-hints:
------------------------------
Type Annotations in Hypothesis
------------------------------
If you install Hypothesis and use :pypi:`mypy` 0.590+, or another
:PEP:`561`-compatible tool, the type checker should automatically pick
up our type hints.
.. note::
Hypothesis' type hints may make breaking changes between minor releases.
Upstream tools and conventions about type hints remain in flux - for
example the :mod:`python:typing` module itself is provisional, and Mypy
has not yet reached version 1.0 - and we plan to support the latest
version of this ecosystem, as well as older versions where practical.
We may also find more precise ways to describe the type of various
interfaces, or change their type and runtime behaviour togther in a way
which is otherwise backwards-compatible. We often omit type hints for
deprecated features or arguments, as an additional form of warning.
There are known issues inferring the type of examples generated by
:func:`~hypothesis.strategies.deferred`, :func:`~hypothesis.strategies.recursive`,
:func:`~hypothesis.strategies.one_of`, :func:`~hypothesis.strategies.dictionaries`,
and :func:`~hypothesis.strategies.fixed_dictionaries`.
We will fix these, and require correspondingly newer versions of Mypy for type
hinting, as the ecosystem improves.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Writing downstream type hints
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Projects that :doc:`provide Hypothesis strategies <strategies>` and use
type hints may wish to annotate their strategies too. This *is* a
supported use-case, again on a best-effort provisional basis. For example:
.. code:: python
def foo_strategy() -> SearchStrategy[Foo]: ...
:class:`hypothesis.strategies.SearchStrategy` is the type of all strategy
objects. It is a generic type, and covariant in the type of the examples
it creates. For example:
- ``integers()`` is of type ``SearchStrategy[int]``.
- ``lists(integers())`` is of type ``SearchStrategy[List[int]]``.
- ``SearchStrategy[Dog]`` is a subtype of ``SearchStrategy[Animal]``
if ``Dog`` is a subtype of ``Animal`` (as seems likely).
.. warning::
:class:`~hypothesis.strategies.SearchStrategy` **should only be used
in type hints.** Please do not inherit from, compare to, or otherwise
use it in any way outside of type hints. The only supported way to
construct objects of this type is to use the functions provided by the
:mod:`hypothesis.strategies` module!
2 changes: 1 addition & 1 deletion hypothesis-python/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def local_file(name):
author_email='david@drmaciver.com',
packages=setuptools.find_packages(SOURCE),
package_dir={'': SOURCE},
# package_data={'': ['py.typed']}, # un-comment to release type hints
package_data={'hypothesis': ['py.typed']},
url=(
'https://github.com/HypothesisWorks/hypothesis/'
'tree/master/hypothesis-python'
Expand Down
36 changes: 25 additions & 11 deletions hypothesis-python/src/hypothesis/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,13 @@
from random import Random # noqa
from typing import Any, Dict, Union, Sequence, Callable, Pattern # noqa
from typing import TypeVar, Tuple, List, Set, FrozenSet, overload # noqa
from typing import Type, Mapping, Text, AnyStr, Optional # noqa
from typing import Type, Text, AnyStr, Optional # noqa

from hypothesis.utils.conventions import InferType # noqa
from hypothesis.searchstrategy.strategies import T, Ex # noqa
K, V = TypeVar['K'], TypeVar['V']
# See https://github.com/python/mypy/issues/3186 - numbers.Real is wrong!
Real = Union[int, float, Fraction, Decimal]
ExtendFunc = Callable[[SearchStrategy[Union[T, Ex]]], SearchStrategy[T]]
else:
def overload(f):
return f
Expand Down Expand Up @@ -260,10 +259,23 @@ def none():
return just(None)


def one_of(
*args # type: Union[SearchStrategy[Ex], Sequence[SearchStrategy[Ex]]]
):
# type: (...) -> SearchStrategy[Ex]
@overload
def one_of(args):
# type: (Sequence[SearchStrategy[Any]]) -> SearchStrategy[Any]
pass # pragma: no cover


@overload
def one_of(*args):
# type: (SearchStrategy[Any]) -> SearchStrategy[Any]
pass # pragma: no cover


def one_of(*args):
# Mypy workaround alert: Any is too loose above; the return paramater
# should be the union of the input parameters. Unfortunately, Mypy <=0.600
# raises errors due to incompatible inputs instead. See #1270 for links.
# v0.610 doesn't error; it gets inference wrong for 2+ arguments instead.
"""Return a strategy which generates values from any of the argument
strategies.
Expand Down Expand Up @@ -704,7 +716,7 @@ def iterables(elements=None, min_size=None, average_size=None, max_size=None,

@defines_strategy
def fixed_dictionaries(
mapping # type: Mapping[T, SearchStrategy[Ex]]
mapping # type: Dict[T, SearchStrategy[Ex]]
):
# type: (...) -> SearchStrategy[Dict[T, Ex]]
"""Generates a dictionary of the same type as mapping with a fixed set of
Expand All @@ -729,12 +741,14 @@ def fixed_dictionaries(
def dictionaries(
keys, # type: SearchStrategy[Ex]
values, # type: SearchStrategy[T]
dict_class=dict, # type: Type[Mapping]
dict_class=dict, # type: type
min_size=None, # type: int
average_size=None, # type: int
max_size=None, # type: int
):
# type: (...) -> SearchStrategy[Mapping[Ex, T]]
# type: (...) -> SearchStrategy[Dict[Ex, T]]
# Describing the exact dict_class to Mypy drops the key and value types,
# so we report Dict[K, V] instead of Mapping[Any, Any] for now. Sorry!
"""Generates dictionaries of type dict_class with keys drawn from the keys
argument and values drawn from the values argument.
Expand Down Expand Up @@ -875,7 +889,7 @@ def characters(
@cacheable
@defines_strategy_with_reusable_values
def text(
alphabet=None, # type: Union[Text, SearchStrategy[Text]]
alphabet=None, # type: Union[Sequence[Text], SearchStrategy[Text]]
min_size=None, # type: int
average_size=None, # type: int
max_size=None # type: int
Expand Down Expand Up @@ -1440,7 +1454,7 @@ def fraction_to_decimal(val):

def recursive(
base, # type: SearchStrategy[Ex]
extend, # type: ExtendFunc
extend, # type: Callable[[SearchStrategy[Any]], SearchStrategy[T]]
max_leaves=100, # type: int
):
# type: (...) -> SearchStrategy[Union[T, Ex]]
Expand Down
22 changes: 13 additions & 9 deletions hypothesis-python/tests/cover/test_regex.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,10 @@ def test_can_generate(pattern, encode):


@pytest.mark.parametrize('pattern', [
re.compile(u'a', re.IGNORECASE),
u'(?i)a',
re.compile(u'[ab]', re.IGNORECASE),
u'(?i)[ab]',
re.compile(u'\\Aa\\Z', re.IGNORECASE),
u'(?i)\\Aa\\Z',
re.compile(u'\\A[ab]\\Z', re.IGNORECASE),
u'(?i)\\A[ab]\\Z',
])
def test_literals_with_ignorecase(pattern):
strategy = st.from_regex(pattern)
Expand All @@ -155,15 +155,19 @@ def test_not_literal_with_ignorecase(pattern):


def test_any_doesnt_generate_newline():
assert_all_examples(st.from_regex(u'.'), lambda s: s != u'\n')
assert_all_examples(st.from_regex(u'\\A.\\Z'), lambda s: s != u'\n')


@pytest.mark.parametrize('pattern', [re.compile(u'.', re.DOTALL), u'(?s).'])
@pytest.mark.parametrize('pattern', [
re.compile(u'\\A.\\Z', re.DOTALL), u'(?s)\\A.\\Z'
])
def test_any_with_dotall_generate_newline(pattern):
find_any(st.from_regex(pattern), lambda s: s == u'\n')


@pytest.mark.parametrize('pattern', [re.compile(b'.', re.DOTALL), b'(?s).'])
@pytest.mark.parametrize('pattern', [
re.compile(b'\\A.\\Z', re.DOTALL), b'(?s)\\A.\\Z'
])
def test_any_with_dotall_generate_newline_binary(pattern):
find_any(st.from_regex(pattern), lambda s: s == b'\n')

Expand Down Expand Up @@ -214,7 +218,7 @@ def test_end_with_terminator_does_not_pad():


def test_end():
strategy = st.from_regex(u'abc$')
strategy = st.from_regex(u'\\Aabc$')

find_any(strategy, lambda s: s == u'abc')
find_any(strategy, lambda s: s == u'abc\n')
Expand Down Expand Up @@ -303,7 +307,7 @@ def test_group_backref_may_not_be_present(s):
@pytest.mark.skipif(sys.version_info[:2] < (3, 6),
reason='requires Python 3.6')
def test_subpattern_flags():
strategy = st.from_regex(u'(?i)a(?-i:b)')
strategy = st.from_regex(u'(?i)\\Aa(?-i:b)\\Z')

# "a" is case insensitive
find_any(strategy, lambda s: s[0] == u'a')
Expand Down
5 changes: 0 additions & 5 deletions tooling/src/hypothesistooling/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,6 @@ def lint():
)


@task(if_changed=hp.PYTHON_SRC)
def check_type_hints():
pip_tool('mypy', hp.PYTHON_SRC)


HEAD = tools.hash_for_name('HEAD')
MASTER = tools.hash_for_name('origin/master')

Expand Down
74 changes: 74 additions & 0 deletions whole-repo-tests/test_type_hints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# coding=utf-8
#
# This file is part of Hypothesis, which may be found at
# https://github.com/HypothesisWorks/hypothesis-python
#
# Most of this work is copyright (C) 2013-2018 David R. MacIver
# (david@drmaciver.com), but it contains contributions by others. See
# CONTRIBUTING.rst for a full list of people who may hold copyright, and
# consult the git log if you need to determine who owns an individual
# contribution.
#
# This Source Code Form is subject to the terms of the Mozilla Public License,
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
# obtain one at http://mozilla.org/MPL/2.0/.
#
# END HEADER

from __future__ import division, print_function, absolute_import

import os
import subprocess

import pytest

from hypothesistooling.scripts import pip_tool, tool_path
from hypothesistooling.projects.hypothesispython import PYTHON_SRC


def test_mypy_passes_on_hypothesis():
pip_tool('mypy', PYTHON_SRC)


def get_mypy_analysed_type(fname, val):
out = subprocess.Popen(
[tool_path('mypy'), fname],
stdout=subprocess.PIPE, encoding='utf-8', universal_newlines=True,
# We set the MYPYPATH explicitly, because PEP561 discovery wasn't
# working in CI as of mypy==0.600 - hopefully a temporary workaround.
env=dict(os.environ, MYPYPATH=PYTHON_SRC),
).stdout.read()
assert len(out.splitlines()) == 1
# See https://mypy.readthedocs.io/en/latest/common_issues.html#reveal-type
# The shell output for `reveal_type([1, 2, 3])` looks like a literal:
# file.py:2: error: Revealed type is 'builtins.list[builtins.int*]'
typ = out.split('error: Revealed type is ')[1].strip().strip("'")
qualname = 'hypothesis.searchstrategy.strategies.SearchStrategy'
assert typ.startswith(qualname)
return typ[len(qualname) + 1:-1].replace('builtins.', '').replace('*', '')


@pytest.mark.parametrize('val,expect', [
('integers()', 'int'),
('text()', 'str'),
('integers().map(str)', 'str'),
('booleans().filter(bool)', 'bool'),
('lists(none())', 'list[None]'),
('dictionaries(integers(), datetimes())', 'dict[int, datetime.datetime]'),
# Ex`-1 stands for recursion in the whole type, i.e. Ex`0 == Union[...]
('recursive(integers(), lists)', 'Union[list[Ex`-1], int]'),
# See https://github.com/python/mypy/issues/5269 - fix the hints on
# `one_of` and document the minimum Mypy version when the issue is fixed.
('one_of(integers(), text())', 'Any'),
])
def test_revealed_types(tmpdir, val, expect):
"""Check that Mypy picks up the expected `X` in SearchStrategy[`X`]."""
f = tmpdir.join(expect + '.py')
f.write(
'from hypothesis.strategies import *\n'
's = {}\n'
'reveal_type(s)\n'
.format(val)
)
got = get_mypy_analysed_type(str(f.realpath()), val)
assert got == expect

0 comments on commit 8691d81

Please sign in to comment.