diff options
-rw-r--r-- | .travis.yml | 4 | ||||
-rw-r--r-- | CHANGES.rst | 113 | ||||
-rw-r--r-- | LICENSE | 21 | ||||
-rw-r--r-- | README.rst | 113 | ||||
-rw-r--r-- | docs/conf.py | 4 | ||||
-rw-r--r-- | docs/index.rst | 11 | ||||
-rw-r--r-- | ptr.py | 205 | ||||
-rw-r--r-- | setup.py | 22 | ||||
-rw-r--r-- | tests/test_ptr.py | 157 |
9 files changed, 629 insertions, 21 deletions
diff --git a/.travis.yml b/.travis.yml index 91ba39a..03d4e22 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,7 @@ language: python python: - 2.7 - 3.6 +- nightly install: - pip install tox "setuptools>=28.2" script: @@ -18,6 +19,7 @@ deploy: all_branches: true python: 3.6 user: jaraco - # supply password with `travis encrypt --add deploy.password` distributions: dists skip_upload_docs: true + password: + secure: lZfYQx0ZrCf2FJ+348etKWfzTySB3BZYGd0ce5RFHN2BppcdkONyJfTs4rgdrFEn/WtOaKV3SkJYR09xvlr+4kbLibg7fXhueqZt0ZkhRBnoDE4SxCjICyFCmisG6O3zkrVosizch70/0MqseNanhgXOPhd5llCfQHIqLsa145BG4hM5kxAHPO3Rz2/HCObOTPe4HKj93RAK7lPIMZVN6omcWoG6ZB0QqK+i3LTUtmJ3gE6q/iHk3VF9cJs8xtn3hdo++Lhrboa2NIqf6fl8oxR1C24Wh8vBQ69uTNjmVnDFYMulTs5475jjgDBXPPgVz3CAqYAy/PI+NPw59ebK8MzlaMRK/h/xSrdhxW6K3WbBL71Dn2UGuejXHFC3IuCI832xwkuEupOcGLWz4r2uBnhbgXF63vZ2gYPqrCGHxvDpbtllTVyEeebP8BnFzZttxSn9rbhTP1O9Dn/9tRko8WskyXIR+/2JOA9KP5uT47yeHoFBBUS0GB1XdkGhBrJfQx2LhfHuI6bpzosMiMfJrVNZEg8k0I9XA0uwxDgZrdqKGMKBR6233MXiJg8NyUPRInCRGth4rufcq6kn+wsT9RvuSt9XOhGiYLwChjvHexUXrLGBgupu0Y80Oqcp3YkDG3WHUX++Z2zJSQovrb4BMdKS6Y4usQ8Df5SklIpMCf0= diff --git a/CHANGES.rst b/CHANGES.rst index e69de29..21132ef 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -0,0 +1,113 @@ +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. @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2011-2016 Jason R. Coombs + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. @@ -1,12 +1,15 @@ -.. 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/pypi/dm/skeleton.svg +.. image:: https://img.shields.io/pypi/dm/pytest-runner.svg -.. image:: https://img.shields.io/travis/jaraco/skeleton/master.svg - :target: http://travis-ci.org/jaraco/skeleton +.. image:: https://img.shields.io/travis/pytest-dev/pytest-runner/master.svg + :target: http://travis-ci.org/pytest-dev/pytest-runner + +Setup scripts can use pytest-runner to add setup.py test support for pytest +runner. License @@ -15,3 +18,101 @@ License License is indicated in the project metadata (typically one or more of the Trove classifiers). For more details, see `this explanation <https://github.com/jaraco/skeleton/issues/1>`_. + +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 8bc8298..5d50361 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,6 +27,10 @@ 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,205 @@ +""" +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 + + +@_contextlib.contextmanager +def null(): + yield + + +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 = [] + + 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) + ) + + @staticmethod + def _install_dists_compat(dist): + """ + Copy of install_dists from setuptools 27.3.0. + """ + ir_d = dist.fetch_build_eggs(dist.install_requires or []) + tr_d = dist.fetch_build_eggs(dist.tests_require or []) + return _itertools.chain(ir_d, tr_d) + + def install_dists(self, dist): + """ + Extend install_dists to include extras support + """ + i_d = getattr(orig.test, 'install_dists', self._install_dists_compat) + return _itertools.chain(i_d(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) + + @staticmethod + def paths_on_pythonpath(paths): + """ + Backward compatibility for paths_on_pythonpath; + Returns a null context if paths_on_pythonpath is + not implemented in orig.test. + Note that this also means that the paths iterable + is never consumed, which incidentally means that + the None values from dist.fetch_build_eggs in + older Setuptools will be disregarded. + """ + try: + return orig.test.paths_on_pythonpath(paths) + except AttributeError: + return null() + + def _super_run(self): + dist = CustomizedDist() + for attr in 'allow_hosts index_url'.split(): + setattr(dist, attr, getattr(self, attr)) + for attr in '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): + self.with_project_on_sys_path(self.run_tests) + + def run(self): + """ + Override run to ensure requirements are available in this session (but + don't install them anywhere). + """ + self._super_run() + if self.result_code: + raise SystemExit(self.result_code) + return self.result_code + + @property + def _argv(self): + return ['pytest'] + self.addopts + + def run_tests(self): + """ + Invoke pytest, replacing argv. + """ + with _save_argv(_sys.argv[:1] + self.addopts): + self.result_code = __import__('pytest').main() @@ -9,8 +9,8 @@ import setuptools with io.open('README.rst', encoding='utf-8') as readme: long_description = readme.read() -name = 'skeleton' -description = '' +name = 'pytest-runner' +description = 'Invoke py.test as distutils command with dependency resolution' nspkg_technique = 'native' """ Does this package use "native" namespace packages or @@ -24,20 +24,24 @@ params = dict( author_email="jaraco@jaraco.com", description=description or name, long_description=long_description, - url="https://github.com/jaraco/" + name, - packages=setuptools.find_packages(), - include_package_data=True, + url="https://github.com/pytest-dev/" + name, namespace_packages=( name.split('.')[:-1] if nspkg_technique == 'managed' else [] ), - python_requires='>=2.7', + py_modules=['ptr'], + python_requires='>=2.6', install_requires=[ ], extras_require={ 'testing': [ 'pytest>=2.8', 'pytest-sugar', + 'pytest-virtualenv', + ], + 'testing:python_version=="2.6"': [ + # undeclared dependency of pytest-virtualenv + 'importlib', ], 'docs': [ 'sphinx', @@ -52,10 +56,16 @@ params = dict( "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", + "Framework :: Pytest", ], entry_points={ + 'distutils.commands': [ + 'ptr = ptr:PyTest', + 'pytest = ptr:PyTest', + ], }, ) if __name__ == '__main__': diff --git a/tests/test_ptr.py b/tests/test_ptr.py new file mode 100644 index 0000000..aeb0f8d --- /dev/null +++ b/tests/test_ptr.py @@ -0,0 +1,157 @@ +from __future__ import unicode_literals + +import contextlib +import io +import os +import sys +import tarfile +import textwrap +import time + +import pytest + + +def DALS(s): + "dedent and left-strip" + return textwrap.dedent(s).lstrip() + + +def _tarfile_open_ex(*args, **kwargs): + """ + Extend result as a context manager. + """ + return contextlib.closing(tarfile.open(*args, **kwargs)) + + +if sys.version_info[:2] < (2, 7) or (3, 0) <= sys.version_info[:2] < (3, 2): + tarfile_open = _tarfile_open_ex +else: + tarfile_open = tarfile.open + + +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() + + +@pytest.mark.parametrize('setuptools_req, test_args', ( + ('setuptools==27.2.0', ''), + ('setuptools==27.2.0', '--extras'), + ('setuptools==27.3.0', ''), + ('setuptools==27.3.0', '--extras'), + ('setuptools==32.3.1', ''), + ('setuptools==32.3.1', '--extras'), + ('setuptools==36.3.0', ''), + ('setuptools==36.3.0', '--extras'), + ('setuptools' , ''), + ('setuptools' , '--extras'), +)) +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)) + # 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', + 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)) + 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) |