diff options
-rw-r--r-- | .travis.yml | 2 | ||||
-rw-r--r-- | CHANGES.rst | 153 | ||||
-rw-r--r-- | README.rst | 119 | ||||
-rw-r--r-- | docs/conf.py | 5 | ||||
-rw-r--r-- | docs/index.rst | 11 | ||||
-rw-r--r-- | ptr.py | 189 | ||||
-rw-r--r-- | setup.cfg | 12 | ||||
-rw-r--r-- | tests/test_ptr.py | 160 |
8 files changed, 630 insertions, 21 deletions
diff --git a/.travis.yml b/.travis.yml index 8fc8932..88e69ef 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,7 +17,7 @@ jobs: env: - TWINE_USERNAME=jaraco # TWINE_PASSWORD - - secure: ... + - secure: Ud9UIW92Gdg2eZpuA9UwC0YjanRaD9CTpKXoLT9Cjp5C2rufwiZd8K/LXK6P6x/nVyLJ8+7HR+TQTReoO2c6kt2vVkA9i3KE2N/bD9PXQo80NwMWepFkRBJh28M9x3fGeQTYRegx3cUWVh4gaa9ZFZBwSjnRZvTtvQh1WhEvHWFzBkb/8CkifmQPmdEYT4D0JBskBAtC295G6p3FukcSdzKlDljY1G4m2ZDqUepYC0R0mr5vDz9JxbJ4axxUvvy7hVd5vrI6xshUc1lRYGocfTcc0IBCq8kw2nzG+KK06axBGFMqCioT4ohUFWB1sLj8pbwpc0uCLtjIBE9OnFZFt52ZKuJcJFAZwiQ3Mxnpg+WC39mrD+jAyIOBDIp/5+2JDCPGyD2i8RuBJrt1CzMqno+Y0K5H2g3XD7E+pclFgyj21JhqUzRAvYE9gbA6561PyFoi+JEFbk5eiNYRdWjp/7XJJSNWB3tDC6hbEAIGQLY2ZcZjnURKN92AC7361negFgwEaaCRUeHx3g7k/wrR/16Po3kFFb/vzQ4ygMxH0lVzzQ0M81121zl4fJPzs/klnBVcf4YOph8Gm6md3ARJu7CKDV2bt2Qr/TObroYFe/flAWZ+KM5DTFqd1dediYrrdVMDuvwSUn1kDjpRnrcI/v0MeT1Vx5v2yekS8iucwCY= - TOX_TESTENV_PASSENV="TWINE_USERNAME TWINE_PASSWORD" script: tox -e release diff --git a/CHANGES.rst b/CHANGES.rst index e69de29..3fa11b9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -0,0 +1,153 @@ +4.2 +=== + +* #40: Remove declared dependency and instead assert it at + run time. + +4.1 +=== + +* #40: Declare dependency on Setuptools in package metadata. + +4.0 +=== + +* Drop support for Setuptools before Setuptools 27.3.0. + +3.0.1 +===== + +* #38: Fixed AttributeError when running with ``--dry-run``. + ``PyTest.run()`` no longer stores nor returns the result code. + Based on the commit message for `840ff4c < + https://github.com/pytest-dev/pytest-runner/commit/840ff4c2bf6c752d9770f0dd8d64a841060cf9bc>`_, + nothing has ever relied on that value. + +3.0 +=== + +* Dropped support for Python 2.6 and 3.1. + +2.12.2 +====== + +* #33: Packaging refresh. + +2.12.1 +====== + +* #32: Fix support for ``dependency_links``. + +2.12 +==== + +* #30: Rework support for ``--allow-hosts`` and + ``--index-url``, removing dependence on + ``setuptools.Distribution``'s private member. + Additionally corrects logic in marker evaluation + along with unit tests! + +2.11.1 +====== + +* #28: Fix logic in marker evaluation. + +2.11 +==== + +* #27: Improved wording in the README around configuration + for the distutils command and pytest proper. + +2.10.1 +====== + +* #21: Avoid mutating dictionary keys during iteration. + +2.10 +==== + +* #20: Leverage technique in `setuptools 794 + <https://github.com/pypa/setuptools/issues/794>`_ + to populate PYTHONPATH during test runs such that + Python subprocesses will have a dependency context + comparable to the test runner. + +2.9 +=== + +* Added Trove Classifier indicating this package is part + of the pytest framework. + +2.8 +=== + +* #16: Added a license file, required for membership to + pytest-dev. +* Releases are now made automatically by pushing a + tagged release that passes tests on Python 3.5. + +2.7 +=== + +* Moved hosting to Github. + +2.6 +=== + +* Add support for un-named, environment-specific extras. + +2.5.1 +===== + +* Restore Python 2.6 compatibility. + +2.5 +=== + +* Moved hosting to `pytest-dev + <https://bitbucket.org/pytest-dev/pytest-runner>`_. + +2.4 +=== + +* Added `documentation <https://pythonhosted.org/pytest-runner>`_. +* Use setuptools_scm for version management and file discovery. +* Updated internal packaging technique. README is now included + in the package metadata. + +2.3 +=== + +* Use hgdistver for version management and file discovery. + +2.2 +=== + +* Honor ``.eggs`` directory for transient downloads as introduced in Setuptools + 7.0. + +2.1 +=== + +* The preferred invocation is now the 'pytest' command. + +2.0 +=== + +* Removed support for the alternate usage. The recommended usage (as a + distutils command) is now the only supported usage. +* Removed support for the --junitxml parameter to the ptr command. Clients + should pass the same parameter (and all other py.test arguments) to py.test + via the --addopts parameter. + +1.1 +=== + +* Added support for --addopts to pass any arguments through to py.test. +* Deprecated support for --junitxml. Use --addopts instead. --junitxml will be + removed in 2.0. + +1.0 +=== + +Initial implementation. @@ -1,13 +1,114 @@ -.. image:: https://img.shields.io/pypi/v/skeleton.svg - :target: https://pypi.org/project/skeleton +.. image:: https://img.shields.io/pypi/v/pytest-runner.svg + :target: https://pypi.org/project/pytest-runner -.. image:: https://img.shields.io/pypi/pyversions/skeleton.svg +.. image:: https://img.shields.io/pypi/pyversions/pytest-runner.svg -.. image:: https://img.shields.io/travis/jaraco/skeleton/master.svg - :target: https://travis-ci.org/jaraco/skeleton +.. image:: https://img.shields.io/travis/pytest-dev/pytest-runner/master.svg + :target: https://travis-ci.org/pytest-dev/pytest-runner -.. .. image:: https://img.shields.io/appveyor/ci/jaraco/skeleton/master.svg -.. :target: https://ci.appveyor.com/project/jaraco/skeleton/branch/master +.. .. image:: https://img.shields.io/appveyor/ci/pytest-dev/pytest-runner/master.svg +.. :target: https://ci.appveyor.com/project/pytest-dev/pytest-runner/branch/master -.. .. image:: https://readthedocs.org/projects/skeleton/badge/?version=latest -.. :target: https://skeleton.readthedocs.io/en/latest/?badge=latest +.. .. image:: https://readthedocs.org/projects/pytest-runner/badge/?version=latest +.. :target: https://pytest-runner.readthedocs.io/en/latest/?badge=latest + +Setup scripts can use pytest-runner to add setup.py test support for pytest +runner. + +Usage +===== + +- Add 'pytest-runner' to your 'setup_requires'. Pin to '>=2.0,<3dev' (or + similar) to avoid pulling in incompatible versions. +- Include 'pytest' and any other testing requirements to 'tests_require'. +- Invoke tests with ``setup.py pytest``. +- Pass ``--index-url`` to have test requirements downloaded from an alternate + index URL (unnecessary if specified for easy_install in setup.cfg). +- Pass additional py.test command-line options using ``--addopts``. +- Set permanent options for the ``python setup.py pytest`` command (like ``index-url``) + in the ``[pytest]`` section of ``setup.cfg``. +- Set permanent options for the ``py.test`` run (like ``addopts`` or ``pep8ignore``) in the ``[pytest]`` + section of ``pytest.ini`` or ``tox.ini`` or put them in the ``[tool:pytest]`` + section of ``setup.cfg``. See `pytest issue 567 + <https://github.com/pytest-dev/pytest/issues/567>`_. +- Optionally, set ``test=pytest`` in the ``[aliases]`` section of ``setup.cfg`` + to cause ``python setup.py test`` to invoke pytest. + +Example +======= + +The most simple usage looks like this in setup.py:: + + setup( + setup_requires=[ + 'pytest-runner', + ], + tests_require=[ + 'pytest', + ], + ) + +Additional dependencies require to run the tests (e.g. mock or pytest +plugins) may be added to tests_require and will be downloaded and +required by the session before invoking pytest. + +Follow `this search on github +<https://github.com/search?utf8=%E2%9C%93&q=filename%3Asetup.py+pytest-runner&type=Code&ref=searchresults>`_ +for examples of real-world usage. + +Standalone Example +================== + +This technique is deprecated - if you have standalone scripts +you wish to invoke with dependencies, `use rwt +<https://pypi.org/project/rwt>`_. + +Although ``pytest-runner`` is typically used to add pytest test +runner support to maintained packages, ``pytest-runner`` may +also be used to create standalone tests. Consider `this example +failure <https://gist.github.com/jaraco/d979a558bc0bf2194c23>`_, +reported in `jsonpickle #117 +<https://github.com/jsonpickle/jsonpickle/issues/117>`_ +or `this MongoDB test +<https://gist.github.com/jaraco/0b9e482f5c0a1300dc9a>`_ +demonstrating a technique that works even when dependencies +are required in the test. + +Either example file may be cloned or downloaded and simply run on +any system with Python and Setuptools. It will download the +specified dependencies and run the tests. Afterward, the the +cloned directory can be removed and with it all trace of +invoking the test. No other dependencies are needed and no +system configuration is altered. + +Then, anyone trying to replicate the failure can do so easily +and with all the power of pytest (rewritten assertions, +rich comparisons, interactive debugging, extensibility through +plugins, etc). + +As a result, the communication barrier for describing and +replicating failures is made almost trivially low. + +Considerations +============== + +Conditional Requirement +----------------------- + +Because it uses Setuptools setup_requires, pytest-runner will install itself +on every invocation of setup.py. In some cases, this causes delays for +invocations of setup.py that will never invoke pytest-runner. To help avoid +this contingency, consider requiring pytest-runner only when pytest +is invoked:: + + needs_pytest = {'pytest', 'test', 'ptr'}.intersection(sys.argv) + pytest_runner = ['pytest-runner'] if needs_pytest else [] + + # ... + + setup( + #... + setup_requires=[ + #... (other setup requirements) + ] + pytest_runner, + ) diff --git a/docs/conf.py b/docs/conf.py index 49a855f..84b53da 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,6 +21,11 @@ link_files = { pattern=r"PEP[- ](?P<pep_number>\d+)", url="https://www.python.org/dev/peps/pep-{pep_number:0>4}/", ), + dict( + pattern=r'Setuptools #(?P<setuptools_issue>\d+)', + url='https://github.com/pypa/setuptools' + '/issues/{setuptools_issue}/', + ), ], ) } diff --git a/docs/index.rst b/docs/index.rst index d14131b..ae93273 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,17 +1,12 @@ -Welcome to skeleton documentation! -======================================== +Welcome to pytest-runner documentation! +======================================= .. toctree:: :maxdepth: 1 history - -.. automodule:: skeleton - :members: - :undoc-members: - :show-inheritance: - +.. include:: ../README.rst Indices and tables ================== @@ -0,0 +1,189 @@ +""" +Implementation +""" + +import os as _os +import shlex as _shlex +import contextlib as _contextlib +import sys as _sys +import operator as _operator +import itertools as _itertools + +try: + # ensure that map has the same meaning on Python 2 + from future_builtins import map +except ImportError: + pass + +import pkg_resources +import setuptools.command.test as orig +from setuptools import Distribution + + +@_contextlib.contextmanager +def _save_argv(repl=None): + saved = _sys.argv[:] + if repl is not None: + _sys.argv[:] = repl + try: + yield saved + finally: + _sys.argv[:] = saved + + +class CustomizedDist(Distribution): + + allow_hosts = None + index_url = None + + def fetch_build_egg(self, req): + """ Specialized version of Distribution.fetch_build_egg + that respects respects allow_hosts and index_url. """ + from setuptools.command.easy_install import easy_install + dist = Distribution({'script_args': ['easy_install']}) + dist.parse_config_files() + opts = dist.get_option_dict('easy_install') + keep = ( + 'find_links', 'site_dirs', 'index_url', 'optimize', + 'site_dirs', 'allow_hosts' + ) + for key in list(opts): + if key not in keep: + del opts[key] # don't use any other settings + if self.dependency_links: + links = self.dependency_links[:] + if 'find_links' in opts: + links = opts['find_links'][1].split() + links + opts['find_links'] = ('setup', links) + if self.allow_hosts: + opts['allow_hosts'] = ('test', self.allow_hosts) + if self.index_url: + opts['index_url'] = ('test', self.index_url) + install_dir_func = getattr(self, 'get_egg_cache_dir', _os.getcwd) + install_dir = install_dir_func() + cmd = easy_install( + dist, args=["x"], install_dir=install_dir, + exclude_scripts=True, + always_copy=False, build_directory=None, editable=False, + upgrade=False, multi_version=True, no_report=True, user=False + ) + cmd.ensure_finalized() + return cmd.easy_install(req) + + +class PyTest(orig.test): + """ + >>> import setuptools + >>> dist = setuptools.Distribution() + >>> cmd = PyTest(dist) + """ + + user_options = [ + ('extras', None, "Install (all) setuptools extras when running tests"), + ('index-url=', None, "Specify an index url from which to retrieve " + "dependencies"), + ('allow-hosts=', None, "Whitelist of comma-separated hosts to allow " + "when retrieving dependencies"), + ('addopts=', None, "Additional options to be passed verbatim to the " + "pytest runner") + ] + + def initialize_options(self): + self.extras = False + self.index_url = None + self.allow_hosts = None + self.addopts = [] + self.ensure_setuptools_version() + + @staticmethod + def ensure_setuptools_version(): + """ + Due to the fact that pytest-runner is often required (via + setup-requires directive) by toolchains that never invoke + it (i.e. they're only installing the package, not testing it), + instead of declaring the dependency in the package + metadata, assert the requirement at run time. + """ + pkg_resources.require('setuptools>=27.3') + + def finalize_options(self): + if self.addopts: + self.addopts = _shlex.split(self.addopts) + + @staticmethod + def marker_passes(marker): + """ + Given an environment marker, return True if the marker is valid + and matches this environment. + """ + return ( + not marker + or not pkg_resources.invalid_marker(marker) + and pkg_resources.evaluate_marker(marker) + ) + + def install_dists(self, dist): + """ + Extend install_dists to include extras support + """ + return _itertools.chain( + orig.test.install_dists(dist), + self.install_extra_dists(dist), + ) + + def install_extra_dists(self, dist): + """ + Install extras that are indicated by markers or + install all extras if '--extras' is indicated. + """ + extras_require = dist.extras_require or {} + + spec_extras = ( + (spec.partition(':'), reqs) + for spec, reqs in extras_require.items() + ) + matching_extras = ( + reqs + for (name, sep, marker), reqs in spec_extras + # include unnamed extras or all if self.extras indicated + if (not name or self.extras) + # never include extras that fail to pass marker eval + and self.marker_passes(marker) + ) + results = list(map(dist.fetch_build_eggs, matching_extras)) + return _itertools.chain.from_iterable(results) + + def run(self): + """ + Override run to ensure requirements are available in this session (but + don't install them anywhere). + """ + dist = CustomizedDist() + for attr in 'allow_hosts index_url'.split(): + setattr(dist, attr, getattr(self, attr)) + for attr in ( + 'dependency_links install_requires ' + 'tests_require extras_require ' + ).split(): + setattr(dist, attr, getattr(self.distribution, attr)) + installed_dists = self.install_dists(dist) + if self.dry_run: + self.announce('skipping tests (dry run)') + return + paths = map(_operator.attrgetter('location'), installed_dists) + with self.paths_on_pythonpath(paths): + with self.project_on_sys_path(): + return self.run_tests() + + @property + def _argv(self): + return ['pytest'] + self.addopts + + def run_tests(self): + """ + Invoke pytest, replacing argv. Return result code. + """ + with _save_argv(_sys.argv[:1] + self.addopts): + result_code = __import__('pytest').main() + if result_code: + raise SystemExit(result_code) @@ -3,24 +3,26 @@ universal = 1 [metadata] license_file = LICENSE -name = skeleton +name = pytest-runner author = Jason R. Coombs author_email = jaraco@jaraco.com -description = skeleton +description = Invoke py.test as distutils command with dependency resolution long_description = file:README.rst -url = https://github.com/jaraco/skeleton +url = https://github.com/pytest-dev/pytest-runner/ classifiers = Development Status :: 5 - Production/Stable Intended Audience :: Developers License :: OSI Approved :: MIT License Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 + Framework :: Pytest [options] packages = find: include_package_data = true python_requires = >=2.7 install_requires = + # setuptools 27.3 is required at run time setup_requires = setuptools_scm >= 1.15.0 [options.extras_require] @@ -31,6 +33,7 @@ testing = pytest-flake8 # local + pytest-virtualenv docs = # upstream @@ -41,3 +44,6 @@ docs = # local [options.entry_points] +distutils.commands = + ptr = ptr:PyTest + pytest = ptr:PyTest diff --git a/tests/test_ptr.py b/tests/test_ptr.py new file mode 100644 index 0000000..ce5b2e5 --- /dev/null +++ b/tests/test_ptr.py @@ -0,0 +1,160 @@ +from __future__ import unicode_literals + +import io +import os +import shutil +import sys +import tarfile +import textwrap +import time +import itertools + +import pytest + + +def DALS(s): + "dedent and left-strip" + return textwrap.dedent(s).lstrip() + + +def make_sdist(dist_path, files): + """ + Create a simple sdist tarball at dist_path, containing the files + listed in ``files`` as ``(filename, content)`` tuples. + """ + + with tarfile.open(dist_path, 'w:gz') as dist: + for filename, content in files: + file_bytes = io.BytesIO(content.encode('utf-8')) + file_info = tarfile.TarInfo(name=filename) + file_info.size = len(file_bytes.getvalue()) + file_info.mtime = int(time.time()) + dist.addfile(file_info, fileobj=file_bytes) + + +@pytest.fixture +def venv(virtualenv): + yield virtualenv + # Workaround virtualenv not cleaning itself as it should... + virtualenv.delete = True + virtualenv.teardown() + + +setuptools_reqs = [ + 'setuptools', + 'setuptools==27.3.0', + 'setuptools==32.3.1', + 'setuptools==36.3.0', +] if sys.version_info < (3, 7) else [ + 'setuptools', + 'setuptools==38.4.1', +] +args_variants = ['', '--extras'] + + +@pytest.mark.parametrize( + 'setuptools_req, test_args', + itertools.product(setuptools_reqs, args_variants), +) +def test_egg_fetcher(venv, setuptools_req, test_args): + test_args = test_args.split() + # Install pytest & pytest-runner. + venv.run('python setup.py develop', cwd=os.getcwd()) + venv.run('pip install pytest') + # Install setuptools version. + venv.run('pip install -U'.split() + [setuptools_req]) + # For debugging purposes. + venv.run('pip freeze --all') + # Prepare fake index. + index_dir = (venv.workspace / 'index').mkdir() + for n in range(5): + dist_name = 'barbazquux' + str(n + 1) + dist_version = '0.1' + dist_sdist = '%s-%s.tar.gz' % (dist_name, dist_version) + dist_dir = (index_dir / dist_name).mkdir() + make_sdist(dist_dir / dist_sdist, ( + ('setup.py', textwrap.dedent( + ''' + from setuptools import setup + setup( + name={dist_name!r}, + version={dist_version!r}, + py_modules=[{dist_name!r}], + ) + ''' + ).format(dist_name=dist_name, dist_version=dist_version)), + (dist_name + '.py', ''), + )) + with (dist_dir / 'index.html').open('w') as fp: + fp.write(DALS( + ''' + <!DOCTYPE html><html><body> + <a href="{dist_sdist}" rel="internal">{dist_sdist}</a><br/> + </body></html> + ''' + ).format(dist_sdist=dist_sdist)) + # Move barbazquux1 out of the index. + shutil.move(index_dir / 'barbazquux1', venv.workspace) + barbazquux1_link = ( + 'file://' + str(venv.workspace.abspath()) + + '/barbazquux1/barbazquux1-0.1.tar.gz' + + '#egg=barbazquux1-0.1' + ) + # Prepare fake project. + project_dir = (venv.workspace / 'project-0.1').mkdir() + with open(project_dir / 'setup.py', 'w') as fp: + fp.write(DALS( + ''' + from setuptools import setup + setup( + name='project', + version='0.1', + dependency_links = [ + {barbazquux1_link!r}, + ], + setup_requires=[ + 'pytest-runner', + ], + install_requires=[ + 'barbazquux1', + ], + tests_require=[ + 'pytest', + 'barbazquux2', + ], + extras_require={{ + ':"{sys_platform}" in sys_platform': 'barbazquux3', + ':"barbazquux" in sys_platform': 'barbazquux4', + 'extra': 'barbazquux5', + }} + ) + ''').format(sys_platform=sys.platform, + barbazquux1_link=barbazquux1_link)) + with open(project_dir / 'setup.cfg', 'w') as fp: + fp.write(DALS( + ''' + [easy_install] + index_url = . + ''')) + with open(project_dir / 'test_stuff.py', 'w') as fp: + fp.write(DALS( + ''' + import pytest + + def test_stuff(): + import barbazquux1 + import barbazquux2 + import barbazquux3 + with pytest.raises(ImportError): + import barbazquux4 + if {importable_barbazquux5}: + import barbazquux5 + else: + with pytest.raises(ImportError): + import barbazquux5 + ''').format(importable_barbazquux5=('--extras' in test_args))) + # Run fake project tests. + cmd = 'python setup.py pytest'.split() + cmd += ['--index-url=' + index_dir.abspath()] + cmd += test_args + venv.run(cmd, cwd=project_dir) |