From 59948791d15578b034531937117c7c78e9137dfd Mon Sep 17 00:00:00 2001 From: Hervé Beraud Date: Jan 16 2019 13:57:31 +0000 Subject: Refactor the build system and package generator - Modernize the packaging by using PBR and setuptools; - Introducing metadata inside setup.cfg; - Using tox; - Unittest on python 3.5, 3.6, 3.7; - Remove module version; - Remove toml file (pyproject.toml); - Using PEP 516 (Build system abstraction for pip/conda etc) instead of PEP 518 (Specifying Minimum Build System Requirements for Python Projects); - Introducing requirements files Co-authored-by: Hobbestigrou --- diff --git a/.gitignore b/.gitignore index 9de2f25..e219718 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ __pycache__/ /dist/ /.eggs/ /*.egg-info +Pipfile # Test artifacts. /.coverage diff --git a/README b/README deleted file mode 100644 index 2b870b6..0000000 --- a/README +++ /dev/null @@ -1,67 +0,0 @@ -############# -python-daemon -############# - -Library to implement a well-behaved Unix daemon process -####################################################### - -This library implements the well-behaved daemon specification of -:pep:`3143`, “Standard daemon process library”. - -A well-behaved Unix daemon process is tricky to get right, but the -required steps are much the same for every daemon program. A -`DaemonContext` instance holds the behaviour and configured process -environment for the program; use the instance as a context manager to -enter a daemon state. - -Simple example of usage:: - - import daemon - - from spam import do_main_program - - with daemon.DaemonContext(): - do_main_program() - -Customisation of the steps to become a daemon is available by setting -options on the `DaemonContext` instance; see the documentation for -that class for each option. - - -Copying -======= - -This work, ‘python-daemon’, is free software: you may copy, modify, -and/or distribute this work under certain conditions; see the relevant -files for specific grant of license. No warranty expressed or implied. - -* Parts of this work are licensed to you under the terms of the GNU - General Public License as published by the Free Software Foundation; - version 3 of that license or any later version. - See the file ‘LICENSE.GPL-3’ for details. - -* Parts of this work are licensed to you under the terms of the Apache - License, version 2.0 as published by the Apache Software Foundation. - See the file ‘LICENSE.ASF-2’ for details. - - -.. - This document is written using `reStructuredText`_ markup, and can - be rendered with `Docutils`_ to other formats. - - .. _Docutils: http://docutils.sourceforge.net/ - .. _reStructuredText: http://docutils.sourceforge.net/rst.html - -.. - This is free software: you may copy, modify, and/or distribute this work - under the terms of the Apache License version 2.0 as published by the - Apache Software Foundation. - No warranty expressed or implied. See the file ‘LICENSE.ASF-2’ for details. - -.. - Local variables: - coding: utf-8 - mode: text - mode: rst - End: - vim: fileencoding=utf-8 filetype=rst : diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..2b870b6 --- /dev/null +++ b/README.rst @@ -0,0 +1,67 @@ +############# +python-daemon +############# + +Library to implement a well-behaved Unix daemon process +####################################################### + +This library implements the well-behaved daemon specification of +:pep:`3143`, “Standard daemon process library”. + +A well-behaved Unix daemon process is tricky to get right, but the +required steps are much the same for every daemon program. A +`DaemonContext` instance holds the behaviour and configured process +environment for the program; use the instance as a context manager to +enter a daemon state. + +Simple example of usage:: + + import daemon + + from spam import do_main_program + + with daemon.DaemonContext(): + do_main_program() + +Customisation of the steps to become a daemon is available by setting +options on the `DaemonContext` instance; see the documentation for +that class for each option. + + +Copying +======= + +This work, ‘python-daemon’, is free software: you may copy, modify, +and/or distribute this work under certain conditions; see the relevant +files for specific grant of license. No warranty expressed or implied. + +* Parts of this work are licensed to you under the terms of the GNU + General Public License as published by the Free Software Foundation; + version 3 of that license or any later version. + See the file ‘LICENSE.GPL-3’ for details. + +* Parts of this work are licensed to you under the terms of the Apache + License, version 2.0 as published by the Apache Software Foundation. + See the file ‘LICENSE.ASF-2’ for details. + + +.. + This document is written using `reStructuredText`_ markup, and can + be rendered with `Docutils`_ to other formats. + + .. _Docutils: http://docutils.sourceforge.net/ + .. _reStructuredText: http://docutils.sourceforge.net/rst.html + +.. + This is free software: you may copy, modify, and/or distribute this work + under the terms of the Apache License version 2.0 as published by the + Apache Software Foundation. + No warranty expressed or implied. See the file ‘LICENSE.ASF-2’ for details. + +.. + Local variables: + coding: utf-8 + mode: text + mode: rst + End: + vim: fileencoding=utf-8 filetype=rst : diff --git a/doc/hacking.txt b/doc/hacking.txt index 8f73b12..97e71d8 100644 --- a/doc/hacking.txt +++ b/doc/hacking.txt @@ -192,6 +192,14 @@ changes to existing code, will only be considered for inclusion in the development tree when accompanied by corresponding additions or changes to the unit tests. +To launch unit test using the following commands:: + + $ tox + +You can also check specific environment by using:: + + $ tox -e py37 + Test-driven development ----------------------- diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index b396dd7..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,16 +0,0 @@ -# pyproject.toml -# Build system requirements for Python code in this code base. -# Documentation: . - -[build-system] - -# Minimum requirements for the build system. -requires = ["setuptools", "wheel", "docutils"] - - -# Local-variables: -# coding: utf-8 -# mode: conf -# mode: toml -# End: -# vim: fileencoding=utf-8 filetype=toml : diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..64ed2a0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +lockfile>=0.10 diff --git a/setup.cfg b/setup.cfg index 1b14719..c5af71a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,42 @@ # setup.cfg # Python Distutils configuration options for this distribution. +[metadata] +name = python-daemon +home-page = https://pagure.io/python-daemon/ +summary = Library to implement a well-behaved Unix daemon process +description-file = + README.rst +author = Ben Finney +author-email = ben+python@benfinney.id.au +license = Apache License 2.0 +classifier = + Development Status :: 5 - Production/Stable + Intended Audience :: Developers + License :: OSI Approved :: Apache Software License + Operating System :: POSIX + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Topic :: Software Development :: Libraries :: Python Modules +keywords = + daemon + fork + unix + +[files] +packages = + daemon + +[extras] +devel= + pbr + tox + +[pbr] + skip_authors = 1 + skip_changelog = 1 [aliases] distribute = register sdist bdist_wheel upload @@ -13,10 +50,3 @@ universal = true # Sign distributions, and upload the signing public key? sign = true - - -# Local variables: -# coding: utf-8 -# mode: conf -# End: -# vim: fileencoding=utf-8 filetype=conf : diff --git a/setup.py b/setup.py index b5bae5f..c6404f2 100644 --- a/setup.py +++ b/setup.py @@ -9,99 +9,27 @@ """ Distribution setup for ‘python-daemon’ library. """ -from __future__ import (absolute_import, unicode_literals) import os.path -import pydoc -import sys import unittest -from setuptools import (setup, find_packages) +from setuptools import setup -import version - -fromlist_expects_type = str -if sys.version_info < (3, 0): - fromlist_expects_type = bytes - - -main_module_name = 'daemon' -main_module_fromlist = list(map(fromlist_expects_type, [ - '_metadata'])) -main_module = __import__( - main_module_name, - level=0, fromlist=main_module_fromlist) -metadata = main_module._metadata - -(synopsis, long_description) = pydoc.splitdoc(pydoc.getdoc(main_module)) - - def test_suite(): """ Make the test suite for this code base. """ loader = unittest.TestLoader() suite = loader.discover(os.path.curdir, pattern='test_*.py') return suite - -setup_kwargs = dict( - distclass=version.ChangelogAwareDistribution, - name=metadata.distribution_name, - packages=find_packages(exclude=["test"]), - cmdclass={ - "write_version_info": version.WriteVersionInfoCommand, - "egg_info": version.EggInfoCommand, - "build": version.BuildCommand, - }, - - # Setuptools metadata. - zip_safe=False, - setup_requires=[ - "docutils", - ], - test_suite="setup.test_suite", - tests_require=[ - "unittest2 >=0.5.1", - "testtools", - "testscenarios >=0.4", - "mock >=1.3", - "docutils", - ], - install_requires=[ - "setuptools", - "lockfile >=0.10", - ], - # PyPI metadata. - author=metadata.author_name, - author_email=metadata.author_email, - description=synopsis, - license=metadata.license, - keywords="daemon fork unix".split(), - url=metadata.url, - long_description=long_description, - classifiers=[ - # Reference: - "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: Apache Software License", - "Operating System :: POSIX", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Intended Audience :: Developers", - "Topic :: Software Development :: Libraries :: Python Modules", - ], - ) +setup( + setup_requires=["pbr"], + pbr=True, + test_suite='setup.test_suite' +) -# Docutils is only required for building, but Setuptools can't distinguish -# dependencies properly. -# See . -setup_kwargs['install_requires'].append("docutils") - -if __name__ == '__main__': - setup(**setup_kwargs) - - # Copyright © 2008–2018 Ben Finney # # This is free software: you may copy, modify, and/or distribute this work diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..30394da --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,5 @@ +unittest2>=0.5.1 +testtools +testscenarios>=0.4 +mock>=1.3 +docutils diff --git a/test_version.py b/test_version.py deleted file mode 100644 index a0a0862..0000000 --- a/test_version.py +++ /dev/null @@ -1,1486 +0,0 @@ -# -*- coding: utf-8 -*- -# -# test_version.py -# Part of ‘python-daemon’, an implementation of PEP 3143. -# -# This is free software, and you are welcome to redistribute it under -# certain conditions; see the end of this file for copyright -# information, grant of license, and disclaimer of warranty. - -""" Unit test for ‘version’ packaging module. """ - -from __future__ import (absolute_import, unicode_literals) - -import collections -import distutils.cmd -import distutils.dist -import distutils.errors -import distutils.fancy_getopt -import errno -import functools -import io -import json -import os -import os.path -import tempfile -import textwrap - -import docutils -import docutils.nodes -import docutils.writers -import mock -import setuptools -import setuptools.command -import testscenarios -import testtools - -import version - - -version.ensure_class_bases_begin_with( - version.__dict__, str('VersionInfoWriter'), docutils.writers.Writer) -version.ensure_class_bases_begin_with( - version.__dict__, str('VersionInfoTranslator'), - docutils.nodes.SparseNodeVisitor) - -__metaclass__ = type - - -def make_test_classes_for_ensure_class_bases_begin_with(): - """ Make test classes for use with ‘ensure_class_bases_begin_with’. - - :return: Mapping {`name`: `type`} of the custom types created. - - """ - - class quux_metaclass(type): - def __new__(metaclass, name, bases, namespace): - return super(quux_metaclass, metaclass).__new__( - metaclass, name, bases, namespace) - - class Foo(object): - __metaclass__ = type - - class Bar(object): - pass - - class FooInheritingBar(Bar): - __metaclass__ = type - - class FooWithCustomMetaclass(object): - __metaclass__ = quux_metaclass - - result = dict( - (name, value) for (name, value) in locals().items() - if isinstance(value, type)) - - return result - - -class ensure_class_bases_begin_with_TestCase( - testscenarios.WithScenarios, testtools.TestCase): - """ Test cases for ‘ensure_class_bases_begin_with’ function. """ - - test_classes = make_test_classes_for_ensure_class_bases_begin_with() - - scenarios = [ - ('simple', { - 'test_class': test_classes['Foo'], - 'base_class': test_classes['Bar'], - }), - ('custom metaclass', { - 'test_class': test_classes['FooWithCustomMetaclass'], - 'base_class': test_classes['Bar'], - 'expected_metaclass': test_classes['quux_metaclass'], - }), - ] - - def setUp(self): - """ Set up test fixtures. """ - super(ensure_class_bases_begin_with_TestCase, self).setUp() - - self.class_name = self.test_class.__name__ - self.test_module_namespace = {self.class_name: self.test_class} - - if not hasattr(self, 'expected_metaclass'): - self.expected_metaclass = type - - patcher_metaclass = mock.patch.object( - self.test_class, '__metaclass__') - patcher_metaclass.start() - self.addCleanup(patcher_metaclass.stop) - - self.fake_new_class = type(object) - self.test_class.__metaclass__.return_value = ( - self.fake_new_class) - - def test_module_namespace_contains_new_class(self): - """ Specified module namespace should have new class. """ - version.ensure_class_bases_begin_with( - self.test_module_namespace, self.class_name, self.base_class) - self.assertIn(self.fake_new_class, self.test_module_namespace.values()) - - def test_calls_metaclass_with_expected_class_name(self): - """ Should call the metaclass with the expected class name. """ - version.ensure_class_bases_begin_with( - self.test_module_namespace, self.class_name, self.base_class) - expected_class_name = self.class_name - self.test_class.__metaclass__.assert_called_with( - expected_class_name, mock.ANY, mock.ANY) - - def test_calls_metaclass_with_expected_bases(self): - """ Should call the metaclass with the expected bases. """ - version.ensure_class_bases_begin_with( - self.test_module_namespace, self.class_name, self.base_class) - expected_bases = tuple( - [self.base_class] - + list(self.test_class.__bases__)) - self.test_class.__metaclass__.assert_called_with( - mock.ANY, expected_bases, mock.ANY) - - def test_calls_metaclass_with_expected_namespace(self): - """ Should call the metaclass with the expected class namespace. """ - version.ensure_class_bases_begin_with( - self.test_module_namespace, self.class_name, self.base_class) - expected_namespace = self.test_class.__dict__.copy() - del expected_namespace['__dict__'] - self.test_class.__metaclass__.assert_called_with( - mock.ANY, mock.ANY, expected_namespace) - - -class ensure_class_bases_begin_with_AlreadyHasBase_TestCase( - testscenarios.WithScenarios, testtools.TestCase): - """ Test cases for ‘ensure_class_bases_begin_with’ function. - - These test cases test the conditions where the class's base is - already the specified base class. - - """ - - test_classes = make_test_classes_for_ensure_class_bases_begin_with() - - scenarios = [ - ('already Bar subclass', { - 'test_class': test_classes['FooInheritingBar'], - 'base_class': test_classes['Bar'], - }), - ] - - def setUp(self): - """ Set up test fixtures. """ - super( - ensure_class_bases_begin_with_AlreadyHasBase_TestCase, - self).setUp() - - self.class_name = self.test_class.__name__ - self.test_module_namespace = {self.class_name: self.test_class} - - patcher_metaclass = mock.patch.object( - self.test_class, '__metaclass__') - patcher_metaclass.start() - self.addCleanup(patcher_metaclass.stop) - - def test_metaclass_not_called(self): - """ Should not call metaclass to create a new type. """ - version.ensure_class_bases_begin_with( - self.test_module_namespace, self.class_name, self.base_class) - self.assertFalse(self.test_class.__metaclass__.called) - - -class VersionInfoWriter_TestCase(testtools.TestCase): - """ Test cases for ‘VersionInfoWriter’ class. """ - - def setUp(self): - """ Set up test fixtures. """ - super(VersionInfoWriter_TestCase, self).setUp() - - self.test_instance = version.VersionInfoWriter() - - def test_declares_version_info_support(self): - """ Should declare support for ‘version_info’. """ - instance = self.test_instance - expected_support = "version_info" - result = instance.supports(expected_support) - self.assertTrue(result) - - -class VersionInfoWriter_translate_TestCase(testtools.TestCase): - """ Test cases for ‘VersionInfoWriter.translate’ method. """ - - def setUp(self): - """ Set up test fixtures. """ - super(VersionInfoWriter_translate_TestCase, self).setUp() - - patcher_translator = mock.patch.object( - version, 'VersionInfoTranslator') - self.mock_class_translator = patcher_translator.start() - self.addCleanup(patcher_translator.stop) - self.mock_translator = self.mock_class_translator.return_value - - self.test_instance = version.VersionInfoWriter() - patcher_document = mock.patch.object( - self.test_instance, 'document') - patcher_document.start() - self.addCleanup(patcher_document.stop) - - def test_creates_translator_with_document(self): - """ Should create a translator with the writer's document. """ - instance = self.test_instance - expected_document = self.test_instance.document - instance.translate() - self.mock_class_translator.assert_called_with(expected_document) - - def test_calls_document_walkabout_with_translator(self): - """ Should call document.walkabout with the translator. """ - instance = self.test_instance - instance.translate() - instance.document.walkabout.assert_called_with(self.mock_translator) - - def test_output_from_translator_astext(self): - """ Should have output from translator.astext(). """ - instance = self.test_instance - instance.translate() - expected_output = self.mock_translator.astext.return_value - self.assertEqual(expected_output, instance.output) - - -class parse_person_field_TestCase( - testscenarios.WithScenarios, testtools.TestCase): - """ Test cases for ‘get_latest_version’ function. """ - - scenarios = [ - ('simple', { - 'test_person': "Foo Bar ", - 'expected_result': ("Foo Bar", "foo.bar@example.com"), - }), - ('empty', { - 'test_person': "", - 'expected_result': (None, None), - }), - ('none', { - 'test_person': None, - 'expected_error': TypeError, - }), - ('no email', { - 'test_person': "Foo Bar", - 'expected_result': ("Foo Bar", None), - }), - ] - - def test_returns_expected_result(self): - """ Should return expected result. """ - if hasattr(self, 'expected_error'): - self.assertRaises( - self.expected_error, - version.parse_person_field, self.test_person) - else: - result = version.parse_person_field(self.test_person) - self.assertEqual(self.expected_result, result) - - -class NoOpContextManager: - """ A context manager with no effect. """ - - def __enter__(self): pass - - def __exit__(self, exc_type, exc_value, traceback): pass - - -class ChangeLogEntry_BaseTestCase( - testscenarios.WithScenarios, testtools.TestCase): - """ Base class for ‘ChangeLogEntry’ test case classes. """ - - def expected_error_context(self): - """ Make a context manager to expect the nominated error. """ - context = NoOpContextManager() - if hasattr(self, 'expected_error'): - context = testtools.ExpectedException(self.expected_error) - return context - - -class ChangeLogEntry_TestCase(ChangeLogEntry_BaseTestCase): - """ Test cases for ‘ChangeLogEntry’ class. """ - - def setUp(self): - """ Set up test fixtures. """ - super(ChangeLogEntry_TestCase, self).setUp() - - self.test_instance = version.ChangeLogEntry() - - def test_instantiate(self): - """ New instance of ‘ChangeLogEntry’ should be created. """ - self.assertIsInstance( - self.test_instance, version.ChangeLogEntry) - - def test_minimum_zero_arguments(self): - """ Initialiser should not require any arguments. """ - instance = version.ChangeLogEntry() - self.assertIsNot(instance, None) - - -class ChangeLogEntry_release_date_TestCase(ChangeLogEntry_BaseTestCase): - """ Test cases for ‘ChangeLogEntry.release_date’ attribute. """ - - scenarios = [ - ('default', { - 'test_args': {}, - 'expected_release_date': - version.ChangeLogEntry.default_release_date, - }), - ('unknown token', { - 'test_args': {'release_date': "UNKNOWN"}, - 'expected_release_date': "UNKNOWN", - }), - ('future token', { - 'test_args': {'release_date': "FUTURE"}, - 'expected_release_date': "FUTURE", - }), - ('2001-01-01', { - 'test_args': {'release_date': "2001-01-01"}, - 'expected_release_date': "2001-01-01", - }), - ('bogus', { - 'test_args': {'release_date': "b0gUs"}, - 'expected_error': ValueError, - }), - ] - - def test_has_expected_release_date(self): - """ Should have default `release_date` attribute. """ - with self.expected_error_context(): - instance = version.ChangeLogEntry(**self.test_args) - if hasattr(self, 'expected_release_date'): - self.assertEqual(self.expected_release_date, instance.release_date) - - -class ChangeLogEntry_version_TestCase(ChangeLogEntry_BaseTestCase): - """ Test cases for ‘ChangeLogEntry.version’ attribute. """ - - scenarios = [ - ('default', { - 'test_args': {}, - 'expected_version': - version.ChangeLogEntry.default_version, - }), - ('unknown token', { - 'test_args': {'version': "UNKNOWN"}, - 'expected_version': "UNKNOWN", - }), - ('next token', { - 'test_args': {'version': "NEXT"}, - 'expected_version': "NEXT", - }), - ('0.0', { - 'test_args': {'version': "0.0"}, - 'expected_version': "0.0", - }), - ('1.2.3', { - 'test_args': {'version': "1.2.3"}, - 'expected_version': "1.2.3", - }), - ('1.23.456', { - 'test_args': {'version': "1.23.456"}, - 'expected_version': "1.23.456", - }), - ('1.23.456a5', { - 'test_args': {'version': "1.23.456a5"}, - 'expected_version': "1.23.456a5", - }), - ('123.456.789', { - 'test_args': {'version': "123.456.789"}, - 'expected_version': "123.456.789", - }), - ('non-number', { - 'test_args': {'version': "b0gUs"}, - 'expected_error': ValueError, - }), - ('negative', { - 'test_args': {'version': "-1.0"}, - 'expected_error': ValueError, - }), - ('non-number parts', { - 'test_args': {'version': "1.b0gUs.0"}, - 'expected_error': ValueError, - }), - ('too many parts', { - 'test_args': {'version': "1.2.3.4.5"}, - 'expected_error': ValueError, - }), - ] - - def test_has_expected_version(self): - """ Should have default `version` attribute. """ - with self.expected_error_context(): - instance = version.ChangeLogEntry(**self.test_args) - if hasattr(self, 'expected_version'): - self.assertEqual(self.expected_version, instance.version) - - -class ChangeLogEntry_maintainer_TestCase(ChangeLogEntry_BaseTestCase): - """ Test cases for ‘ChangeLogEntry.maintainer’ attribute. """ - - scenarios = [ - ('default', { - 'test_args': {}, - 'expected_maintainer': None, - }), - ('person', { - 'test_args': {'maintainer': "Foo Bar "}, - 'expected_maintainer': "Foo Bar ", - }), - ('bogus', { - 'test_args': {'maintainer': "b0gUs"}, - 'expected_error': ValueError, - }), - ] - - def test_has_expected_maintainer(self): - """ Should have default `maintainer` attribute. """ - with self.expected_error_context(): - instance = version.ChangeLogEntry(**self.test_args) - if hasattr(self, 'expected_maintainer'): - self.assertEqual(self.expected_maintainer, instance.maintainer) - - -class ChangeLogEntry_body_TestCase(ChangeLogEntry_BaseTestCase): - """ Test cases for ‘ChangeLogEntry.body’ attribute. """ - - scenarios = [ - ('default', { - 'test_args': {}, - 'expected_body': None, - }), - ('simple', { - 'test_args': {'body': "Foo bar baz."}, - 'expected_body': "Foo bar baz.", - }), - ] - - def test_has_expected_body(self): - """ Should have default `body` attribute. """ - instance = version.ChangeLogEntry(**self.test_args) - self.assertEqual(self.expected_body, instance.body) - - -class ChangeLogEntry_as_version_info_entry_TestCase( - ChangeLogEntry_BaseTestCase): - """ Test cases for ‘ChangeLogEntry.as_version_info_entry’ attribute. """ - - scenarios = [ - ('default', { - 'test_args': {}, - 'expected_result': collections.OrderedDict([ - ( - 'release_date', - version.ChangeLogEntry.default_release_date), - ('version', version.ChangeLogEntry.default_version), - ('maintainer', None), - ('body', None), - ]), - }), - ] - - def setUp(self): - """ Set up test fixtures. """ - super(ChangeLogEntry_as_version_info_entry_TestCase, self).setUp() - - self.test_instance = version.ChangeLogEntry(**self.test_args) - - def test_returns_result(self): - """ Should return expected result. """ - result = self.test_instance.as_version_info_entry() - self.assertEqual(self.expected_result, result) - - -def make_mock_field_node(field_name, field_body): - """ Make a mock Docutils field node for tests. """ - - mock_field_node = mock.MagicMock( - name='field', spec=docutils.nodes.field) - - mock_field_name_node = mock.MagicMock( - name='field_name', spec=docutils.nodes.field_name) - mock_field_name_node.parent = mock_field_node - mock_field_name_node.children = [field_name] - - mock_field_body_node = mock.MagicMock( - name='field_body', spec=docutils.nodes.field_body) - mock_field_body_node.parent = mock_field_node - mock_field_body_node.children = [field_body] - - mock_field_node.children = [mock_field_name_node, mock_field_body_node] - - def fake_func_first_child_matching_class(node_class): - result = None - node_class_name = node_class.__name__ - for (index, node) in enumerate(mock_field_node.children): - if node._mock_name == node_class_name: - result = index - break - return result - - mock_field_node.first_child_matching_class.side_effect = ( - fake_func_first_child_matching_class) - - return mock_field_node - - -class JsonEqual(testtools.matchers.Matcher): - """ A matcher to compare the value of JSON streams. """ - - def __init__(self, expected): - self.expected_value = expected - - def match(self, content): - """ Assert the JSON `content` matches the `expected_content`. """ - result = None - actual_value = json.loads(content.decode('utf-8')) - if actual_value != self.expected_value: - result = JsonValueMismatch(self.expected_value, actual_value) - return result - - -class JsonValueMismatch(testtools.matchers.Mismatch): - """ The specified JSON stream does not evaluate to the expected value. """ - - def __init__(self, expected, actual): - self.expected_value = expected - self.actual_value = actual - - def describe(self): - """ Emit a text description of this mismatch. """ - expected_json_text = json.dumps(self.expected_value, indent=4) - actual_json_text = json.dumps(self.actual_value, indent=4) - text = ( - "\n" - "reference: {expected}\n" - "actual: {actual}\n").format( - expected=expected_json_text, actual=actual_json_text) - return text - - -class changelog_to_version_info_collection_TestCase( - testscenarios.WithScenarios, testtools.TestCase): - """ Test cases for ‘changelog_to_version_info_collection’ function. """ - - scenarios = [ - ('single entry', { - 'test_input': textwrap.dedent("""\ - Version 1.0 - =========== - - :Released: 2009-01-01 - :Maintainer: Foo Bar - - * Lorem ipsum dolor sit amet. - """), - 'expected_version_info': [ - { - 'release_date': "2009-01-01", - 'version': "1.0", - 'maintainer': "Foo Bar ", - 'body': "* Lorem ipsum dolor sit amet.\n", - }, - ], - }), - ('multiple entries', { - 'test_input': textwrap.dedent("""\ - Version 1.0 - =========== - - :Released: 2009-01-01 - :Maintainer: Foo Bar - - * Lorem ipsum dolor sit amet. - - - Version 0.8 - =========== - - :Released: 2004-01-01 - :Maintainer: Foo Bar - - * Donec venenatis nisl aliquam ipsum. - - - Version 0.7.2 - ============= - - :Released: 2001-01-01 - :Maintainer: Foo Bar - - * Pellentesque elementum mollis finibus. - """), - 'expected_version_info': [ - { - 'release_date': "2009-01-01", - 'version': "1.0", - 'maintainer': "Foo Bar ", - 'body': "* Lorem ipsum dolor sit amet.\n", - }, - { - 'release_date': "2004-01-01", - 'version': "0.8", - 'maintainer': "Foo Bar ", - 'body': "* Donec venenatis nisl aliquam ipsum.\n", - }, - { - 'release_date': "2001-01-01", - 'version': "0.7.2", - 'maintainer': "Foo Bar ", - 'body': "* Pellentesque elementum mollis finibus.\n", - }, - ], - }), - ('trailing comment', { - 'test_input': textwrap.dedent("""\ - Version NEXT - ============ - - :Released: FUTURE - :Maintainer: - - * Lorem ipsum dolor sit amet. - - .. - Vivamus aliquam felis rutrum rutrum dictum. - """), - 'expected_version_info': [ - { - 'release_date': "FUTURE", - 'version': "NEXT", - 'maintainer': "", - 'body': "* Lorem ipsum dolor sit amet.\n", - }, - ], - }), - ('inline comment', { - 'test_input': textwrap.dedent("""\ - Version NEXT - ============ - - :Released: FUTURE - :Maintainer: - - .. - Vivamus aliquam felis rutrum rutrum dictum. - - * Lorem ipsum dolor sit amet. - """), - 'expected_version_info': [ - { - 'release_date': "FUTURE", - 'version': "NEXT", - 'maintainer': "", - 'body': "* Lorem ipsum dolor sit amet.\n", - }, - ], - }), - ('unreleased entry', { - 'test_input': textwrap.dedent("""\ - Version NEXT - ============ - - :Released: FUTURE - :Maintainer: - - * Lorem ipsum dolor sit amet. - - - Version 0.8 - =========== - - :Released: 2001-01-01 - :Maintainer: Foo Bar - - * Donec venenatis nisl aliquam ipsum. - """), - 'expected_version_info': [ - { - 'release_date': "FUTURE", - 'version': "NEXT", - 'maintainer': "", - 'body': "* Lorem ipsum dolor sit amet.\n", - }, - { - 'release_date': "2001-01-01", - 'version': "0.8", - 'maintainer': "Foo Bar ", - 'body': "* Donec venenatis nisl aliquam ipsum.\n", - }, - ], - }), - ('no section', { - 'test_input': textwrap.dedent("""\ - :Released: 2009-01-01 - :Maintainer: Foo Bar - - * Lorem ipsum dolor sit amet. - """), - 'expected_error': version.InvalidFormatError, - }), - ('subsection', { - 'test_input': textwrap.dedent("""\ - Version 1.0 - =========== - - :Released: 2009-01-01 - :Maintainer: Foo Bar - - * Lorem ipsum dolor sit amet. - - Ut ultricies fermentum quam - --------------------------- - - * In commodo magna facilisis in. - """), - 'expected_error': version.InvalidFormatError, - 'subsection': True, - }), - ('unknown field', { - 'test_input': textwrap.dedent("""\ - Version 1.0 - =========== - - :Released: 2009-01-01 - :Maintainer: Foo Bar - :Favourite: Spam - - * Lorem ipsum dolor sit amet. - """), - 'expected_error': version.InvalidFormatError, - }), - ('invalid version word', { - 'test_input': textwrap.dedent("""\ - BoGuS 1.0 - ========= - - :Released: 2009-01-01 - :Maintainer: Foo Bar - - * Lorem ipsum dolor sit amet. - """), - 'expected_error': version.InvalidFormatError, - }), - ('invalid section title', { - 'test_input': textwrap.dedent("""\ - Lorem Ipsum 1.0 - =============== - - :Released: 2009-01-01 - :Maintainer: Foo Bar - - * Lorem ipsum dolor sit amet. - """), - 'expected_error': version.InvalidFormatError, - }), - ] - - def expected_error_context(self): - """ Make a context manager to expect the nominated error. """ - context = NoOpContextManager() - if hasattr(self, 'expected_error'): - context = testtools.ExpectedException(self.expected_error) - return context - - def test_returns_expected_version_info(self): - """ Should return expected version info mapping. """ - infile = io.StringIO(self.test_input) - with self.expected_error_context(): - result = version.changelog_to_version_info_collection(infile) - if hasattr(self, 'expected_version_info'): - self.assertThat(result, JsonEqual(self.expected_version_info)) - - -try: - FileNotFoundError - PermissionError -except NameError: - # Python 2 uses OSError. - FileNotFoundError = functools.partial(IOError, errno.ENOENT) - PermissionError = functools.partial(IOError, errno.EPERM) - -fake_version_info = { - 'release_date': "2001-01-01", 'version': "2.0", - 'maintainer': None, 'body': None, - } - -@mock.patch.object( - version, "get_latest_version", return_value=fake_version_info) -class generate_version_info_from_changelog_TestCase( - testscenarios.WithScenarios, testtools.TestCase): - """ Test cases for ‘generate_version_info_from_changelog’ function. """ - - fake_open_side_effects = { - 'success': ( - lambda *args, **kwargs: io.StringIO()), - 'file not found': FileNotFoundError(), - 'permission denied': PermissionError(), - } - - scenarios = [ - ('simple', { - 'open_scenario': 'success', - 'fake_versions_json': json.dumps([fake_version_info]), - 'expected_result': fake_version_info, - }), - ('file not found', { - 'open_scenario': 'file not found', - 'expected_result': {}, - }), - ('permission denied', { - 'open_scenario': 'permission denied', - 'expected_result': {}, - }), - ] - - def setUp(self): - """ Set up test fixtures. """ - super(generate_version_info_from_changelog_TestCase, self).setUp() - - self.fake_changelog_file_path = tempfile.mktemp() - - def fake_open(filespec, *args, **kwargs): - if filespec == self.fake_changelog_file_path: - side_effect = self.fake_open_side_effects[self.open_scenario] - if callable(side_effect): - result = side_effect() - else: - raise side_effect - else: - result = io.StringIO() - return result - - func_patcher_io_open = mock.patch.object( - io, "open") - func_patcher_io_open.start() - self.addCleanup(func_patcher_io_open.stop) - io.open.side_effect = fake_open - - self.file_encoding = "utf-8" - - func_patcher_changelog_to_version_info_collection = mock.patch.object( - version, "changelog_to_version_info_collection") - func_patcher_changelog_to_version_info_collection.start() - self.addCleanup(func_patcher_changelog_to_version_info_collection.stop) - if hasattr(self, 'fake_versions_json'): - version.changelog_to_version_info_collection.return_value = ( - self.fake_versions_json.encode(self.file_encoding)) - - def test_returns_empty_collection_on_read_error( - self, - mock_func_get_latest_version): - """ Should return empty collection on error reading changelog. """ - test_error = PermissionError("Not for you") - version.changelog_to_version_info_collection.side_effect = test_error - result = version.generate_version_info_from_changelog( - self.fake_changelog_file_path) - expected_result = {} - self.assertDictEqual(expected_result, result) - - def test_opens_file_with_expected_encoding( - self, - mock_func_get_latest_version): - """ Should open changelog file in text mode with expected encoding. """ - version.generate_version_info_from_changelog( - self.fake_changelog_file_path) - expected_file_path = self.fake_changelog_file_path - expected_open_mode = 'rt' - expected_encoding = self.file_encoding - (open_args_positional, open_args_kwargs) = io.open.call_args - (open_args_filespec, open_args_mode) = open_args_positional[:2] - open_args_encoding = open_args_kwargs['encoding'] - self.assertEqual(expected_file_path, open_args_filespec) - self.assertEqual(expected_open_mode, open_args_mode) - self.assertEqual(expected_encoding, open_args_encoding) - - def test_returns_expected_result( - self, - mock_func_get_latest_version): - """ Should return expected result. """ - result = version.generate_version_info_from_changelog( - self.fake_changelog_file_path) - self.assertEqual(self.expected_result, result) - - -DefaultNoneDict = functools.partial(collections.defaultdict, lambda: None) - -class get_latest_version_TestCase( - testscenarios.WithScenarios, testtools.TestCase): - """ Test cases for ‘get_latest_version’ function. """ - - scenarios = [ - ('simple', { - 'test_versions': [ - DefaultNoneDict({'release_date': "LATEST"}), - ], - 'expected_result': version.ChangeLogEntry.make_ordered_dict( - DefaultNoneDict({'release_date': "LATEST"})), - }), - ('no versions', { - 'test_versions': [], - 'expected_result': collections.OrderedDict(), - }), - ('ordered versions', { - 'test_versions': [ - DefaultNoneDict({'release_date': "1"}), - DefaultNoneDict({'release_date': "2"}), - DefaultNoneDict({'release_date': "LATEST"}), - ], - 'expected_result': version.ChangeLogEntry.make_ordered_dict( - DefaultNoneDict({'release_date': "LATEST"})), - }), - ('un-ordered versions', { - 'test_versions': [ - DefaultNoneDict({'release_date': "2"}), - DefaultNoneDict({'release_date': "LATEST"}), - DefaultNoneDict({'release_date': "1"}), - ], - 'expected_result': version.ChangeLogEntry.make_ordered_dict( - DefaultNoneDict({'release_date': "LATEST"})), - }), - ] - - def test_returns_expected_result(self): - """ Should return expected result. """ - result = version.get_latest_version(self.test_versions) - self.assertDictEqual(self.expected_result, result) - - -@mock.patch.object(json, "dumps", side_effect=json.dumps) -class serialise_version_info_from_mapping_TestCase( - testscenarios.WithScenarios, testtools.TestCase): - """ Test cases for ‘get_latest_version’ function. """ - - scenarios = [ - ('simple', { - 'test_version_info': {'foo': "spam"}, - }), - ] - - for (name, scenario) in scenarios: - scenario['fake_json_dump'] = json.dumps(scenario['test_version_info']) - scenario['expected_value'] = scenario['test_version_info'] - - def test_passes_specified_object(self, mock_func_json_dumps): - """ Should pass the specified object to `json.dumps`. """ - version.serialise_version_info_from_mapping( - self.test_version_info) - mock_func_json_dumps.assert_called_with( - self.test_version_info, indent=mock.ANY) - - def test_returns_expected_result(self, mock_func_json_dumps): - """ Should return expected result. """ - mock_func_json_dumps.return_value = self.fake_json_dump - result = version.serialise_version_info_from_mapping( - self.test_version_info) - value = json.loads(result) - self.assertEqual(self.expected_value, value) - - -DistributionMetadata_defaults = { - name: None - for name in list(collections.OrderedDict.fromkeys( - distutils.dist.DistributionMetadata._METHOD_BASENAMES))} -FakeDistributionMetadata = collections.namedtuple( - 'FakeDistributionMetadata', DistributionMetadata_defaults.keys()) - -Distribution_defaults = { - 'metadata': None, - 'version': None, - 'release_date': None, - 'maintainer': None, - 'maintainer_email': None, - } -FakeDistribution = collections.namedtuple( - 'FakeDistribution', Distribution_defaults.keys()) - -def make_fake_distribution( - fields_override=None, metadata_fields_override=None): - metadata_fields = DistributionMetadata_defaults.copy() - if metadata_fields_override is not None: - metadata_fields.update(metadata_fields_override) - metadata = FakeDistributionMetadata(**metadata_fields) - - fields = Distribution_defaults.copy() - fields['metadata'] = metadata - if fields_override is not None: - fields.update(fields_override) - distribution = FakeDistribution(**fields) - - return distribution - - -class get_changelog_path_TestCase( - testscenarios.WithScenarios, testtools.TestCase): - """ Test cases for ‘get_changelog_path’ function. """ - - default_path = "" - default_script_filename = "setup.py" - - scenarios = [ - ('simple', {}), - ('unusual script name', { - 'script_filename': "lorem_ipsum", - }), - ('relative script path', { - 'script_directory': "dolor/sit/amet", - }), - ('absolute script path', { - 'script_directory': "/dolor/sit/amet", - }), - ('specify filename', { - 'changelog_filename': "adipiscing", - }), - ] - - def setUp(self): - """ Set up test fixtures. """ - super(get_changelog_path_TestCase, self).setUp() - - test_distribution = distutils.dist.Distribution() - self.test_distribution = mock.MagicMock(test_distribution) - - if not hasattr(self, 'script_directory'): - self.script_directory = self.default_path - if not hasattr(self, 'script_filename'): - self.script_filename = self.default_script_filename - - self.test_distribution.packages = None - self.test_distribution.package_dir = {'': self.script_directory} - self.test_distribution.script_name = self.script_filename - - changelog_filename = version.changelog_filename - if hasattr(self, 'changelog_filename'): - changelog_filename = self.changelog_filename - - self.expected_result = os.path.join( - self.script_directory, changelog_filename) - - def test_returns_expected_result(self): - """ Should return expected result. """ - args = { - 'distribution': self.test_distribution, - } - if hasattr(self, 'changelog_filename'): - args.update({'filename': self.changelog_filename}) - result = version.get_changelog_path(**args) - self.assertEqual(self.expected_result, result) - - -class WriteVersionInfoCommand_BaseTestCase( - testscenarios.WithScenarios, testtools.TestCase): - """ Base class for ‘WriteVersionInfoCommand’ test case classes. """ - - def setUp(self): - """ Set up test fixtures. """ - super(WriteVersionInfoCommand_BaseTestCase, self).setUp() - - fake_distribution_name = self.getUniqueString() - - self.test_distribution = distutils.dist.Distribution() - self.test_distribution.metadata.name = fake_distribution_name - - -class WriteVersionInfoCommand_TestCase(WriteVersionInfoCommand_BaseTestCase): - """ Test cases for ‘WriteVersionInfoCommand’ class. """ - - def test_subclass_of_distutils_command(self): - """ Should be a subclass of ‘distutils.cmd.Command’. """ - instance = version.WriteVersionInfoCommand(self.test_distribution) - self.assertIsInstance(instance, distutils.cmd.Command) - - -class WriteVersionInfoCommand_user_options_TestCase( - WriteVersionInfoCommand_BaseTestCase): - """ Test cases for ‘WriteVersionInfoCommand.user_options’ attribute. """ - - def setUp(self): - """ Set up test fixtures. """ - super(WriteVersionInfoCommand_user_options_TestCase, self).setUp() - - self.test_instance = version.WriteVersionInfoCommand( - self.test_distribution) - self.commandline_parser = distutils.fancy_getopt.FancyGetopt( - self.test_instance.user_options) - - def test_parses_correctly_as_fancy_getopt(self): - """ Should parse correctly in ‘FancyGetopt’. """ - self.assertIsInstance( - self.commandline_parser, distutils.fancy_getopt.FancyGetopt) - - def test_includes_base_class_user_options(self): - """ Should include base class's user_options. """ - base_command = setuptools.command.egg_info.egg_info - expected_user_options = base_command.user_options - self.assertThat( - set(expected_user_options), - IsSubset(set(self.test_instance.user_options))) - - def test_has_option_changelog_path(self): - """ Should have a ‘changelog-path’ option. """ - expected_option_name = "changelog-path=" - result = self.commandline_parser.has_option(expected_option_name) - self.assertTrue(result) - - def test_has_option_outfile_path(self): - """ Should have a ‘outfile-path’ option. """ - expected_option_name = "outfile-path=" - result = self.commandline_parser.has_option(expected_option_name) - self.assertTrue(result) - - -class WriteVersionInfoCommand_initialize_options_TestCase( - WriteVersionInfoCommand_BaseTestCase): - """ Test cases for ‘WriteVersionInfoCommand.initialize_options’ method. """ - - def setUp(self): - """ Set up test fixtures. """ - super( - WriteVersionInfoCommand_initialize_options_TestCase, self - ).setUp() - - patcher_func_egg_info_initialize_options = mock.patch.object( - setuptools.command.egg_info.egg_info, "initialize_options") - patcher_func_egg_info_initialize_options.start() - self.addCleanup(patcher_func_egg_info_initialize_options.stop) - - def test_calls_base_class_method(self): - """ Should call base class's ‘initialize_options’ method. """ - version.WriteVersionInfoCommand(self.test_distribution) - base_command_class = setuptools.command.egg_info.egg_info - base_command_class.initialize_options.assert_called_with() - - def test_sets_changelog_path_to_none(self): - """ Should set ‘changelog_path’ attribute to ``None``. """ - instance = version.WriteVersionInfoCommand(self.test_distribution) - self.assertIs(instance.changelog_path, None) - - def test_sets_outfile_path_to_none(self): - """ Should set ‘outfile_path’ attribute to ``None``. """ - instance = version.WriteVersionInfoCommand(self.test_distribution) - self.assertIs(instance.outfile_path, None) - - -class WriteVersionInfoCommand_finalize_options_TestCase( - WriteVersionInfoCommand_BaseTestCase): - """ Test cases for ‘WriteVersionInfoCommand.finalize_options’ method. """ - - def setUp(self): - """ Set up test fixtures. """ - super(WriteVersionInfoCommand_finalize_options_TestCase, self).setUp() - - self.test_instance = version.WriteVersionInfoCommand( - self.test_distribution) - - patcher_func_egg_info_finalize_options = mock.patch.object( - setuptools.command.egg_info.egg_info, "finalize_options") - patcher_func_egg_info_finalize_options.start() - self.addCleanup(patcher_func_egg_info_finalize_options.stop) - - self.fake_script_dir = self.getUniqueString() - self.test_distribution.script_name = os.path.join( - self.fake_script_dir, self.getUniqueString()) - - self.fake_egg_dir = self.getUniqueString() - self.test_instance.egg_info = self.fake_egg_dir - - patcher_func_get_changelog_path = mock.patch.object( - version, "get_changelog_path") - patcher_func_get_changelog_path.start() - self.addCleanup(patcher_func_get_changelog_path.stop) - - self.fake_changelog_path = self.getUniqueString() - version.get_changelog_path.return_value = self.fake_changelog_path - - def test_calls_base_class_method(self): - """ Should call base class's ‘finalize_options’ method. """ - base_command_class = setuptools.command.egg_info.egg_info - self.test_instance.finalize_options() - base_command_class.finalize_options.assert_called_with() - - def test_sets_force_to_none(self): - """ Should set ‘force’ attribute to ``None``. """ - self.test_instance.finalize_options() - self.assertIs(self.test_instance.force, None) - - def test_sets_changelog_path_using_get_changelog_path(self): - """ Should set ‘changelog_path’ attribute if it was ``None``. """ - self.test_instance.changelog_path = None - self.test_instance.finalize_options() - expected_changelog_path = self.fake_changelog_path - self.assertEqual( - expected_changelog_path, self.test_instance.changelog_path) - - def test_leaves_changelog_path_if_already_set(self): - """ Should leave ‘changelog_path’ attribute set. """ - prior_changelog_path = self.getUniqueString() - self.test_instance.changelog_path = prior_changelog_path - self.test_instance.finalize_options() - expected_changelog_path = prior_changelog_path - self.assertEqual( - expected_changelog_path, self.test_instance.changelog_path) - - def test_sets_outfile_path_to_default(self): - """ Should set ‘outfile_path’ attribute to default value. """ - fake_version_info_filename = self.getUniqueString() - with mock.patch.object( - version, "version_info_filename", - new=fake_version_info_filename): - self.test_instance.finalize_options() - expected_outfile_path = os.path.join( - self.fake_egg_dir, fake_version_info_filename) - self.assertEqual( - expected_outfile_path, self.test_instance.outfile_path) - - def test_leaves_outfile_path_if_already_set(self): - """ Should leave ‘outfile_path’ attribute set. """ - prior_outfile_path = self.getUniqueString() - self.test_instance.outfile_path = prior_outfile_path - self.test_instance.finalize_options() - expected_outfile_path = prior_outfile_path - self.assertEqual( - expected_outfile_path, self.test_instance.outfile_path) - - -class has_changelog_TestCase( - testscenarios.WithScenarios, testtools.TestCase): - """ Test cases for ‘has_changelog’ function. """ - - fake_os_path_exists_side_effects = { - 'true': (lambda path: True), - 'false': (lambda path: False), - } - - scenarios = [ - ('no changelog path', { - 'changelog_path': None, - 'expected_result': False, - }), - ('changelog exists', { - 'os_path_exists_scenario': 'true', - 'expected_result': True, - }), - ('changelog not found', { - 'os_path_exists_scenario': 'false', - 'expected_result': False, - }), - ] - - def setUp(self): - """ Set up test fixtures. """ - super(has_changelog_TestCase, self).setUp() - - self.test_distribution = distutils.dist.Distribution() - self.test_command = version.EggInfoCommand( - self.test_distribution) - - patcher_func_get_changelog_path = mock.patch.object( - version, "get_changelog_path") - patcher_func_get_changelog_path.start() - self.addCleanup(patcher_func_get_changelog_path.stop) - - self.fake_changelog_file_path = self.getUniqueString() - if hasattr(self, 'changelog_path'): - self.fake_changelog_file_path = self.changelog_path - version.get_changelog_path.return_value = self.fake_changelog_file_path - self.fake_changelog_file = io.StringIO() - - def fake_os_path_exists(path): - if path == self.fake_changelog_file_path: - side_effect = self.fake_os_path_exists_side_effects[ - self.os_path_exists_scenario] - if callable(side_effect): - result = side_effect(path) - else: - raise side_effect - else: - result = False - return result - - func_patcher_os_path_exists = mock.patch.object( - os.path, "exists") - func_patcher_os_path_exists.start() - self.addCleanup(func_patcher_os_path_exists.stop) - os.path.exists.side_effect = fake_os_path_exists - - def test_gets_changelog_path_from_distribution(self): - """ Should call ‘get_changelog_path’ with distribution. """ - version.has_changelog(self.test_command) - version.get_changelog_path.assert_called_with( - self.test_distribution) - - def test_returns_expected_result(self): - """ Should be a subclass of ‘distutils.cmd.Command’. """ - result = version.has_changelog(self.test_command) - self.assertEqual(self.expected_result, result) - - -class WriteVersionInfoCommand_run_TestCase( - WriteVersionInfoCommand_BaseTestCase): - """ Test cases for ‘WriteVersionInfoCommand.run’ method. """ - - def setUp(self): - """ Set up test fixtures. """ - super(WriteVersionInfoCommand_run_TestCase, self).setUp() - - self.test_instance = version.WriteVersionInfoCommand( - self.test_distribution) - - self.fake_changelog_path = self.set_changelog_path(self.test_instance) - self.fake_outfile_path = self.set_outfile_path(self.test_instance) - - self.patch_version_info() - self.patch_egg_info_write_file() - - def set_changelog_path(self, instance): - """ Set the changelog path for the test instance `instance`. """ - self.test_instance.changelog_path = self.getUniqueString() - return self.test_instance.changelog_path - - def set_outfile_path(self, instance): - """ Set the outfile path for the test instance `instance`. """ - self.test_instance.outfile_path = self.getUniqueString() - return self.test_instance.outfile_path - - def patch_version_info(self): - """ Patch the generation of version info. """ - self.fake_version_info = self.getUniqueString() - func_patcher = mock.patch.object( - version, 'generate_version_info_from_changelog', - return_value=self.fake_version_info) - self.mock_func_generate_version_info = func_patcher.start() - self.addCleanup(func_patcher.stop) - - self.fake_version_info_serialised = self.getUniqueString() - func_patcher = mock.patch.object( - version, 'serialise_version_info_from_mapping', - return_value=self.fake_version_info_serialised) - self.mock_func_serialise_version_info = func_patcher.start() - self.addCleanup(func_patcher.stop) - - def patch_egg_info_write_file(self): - """ Patch the command `write_file` method for this test case. """ - func_patcher = mock.patch.object( - version.WriteVersionInfoCommand, 'write_file') - self.mock_func_egg_info_write_file = func_patcher.start() - self.addCleanup(func_patcher.stop) - - def test_returns_none(self): - """ Should return ``None``. """ - result = self.test_instance.run() - self.assertIs(result, None) - - def test_generates_version_info_from_changelog(self): - """ Should generate version info from specified changelog. """ - self.test_instance.run() - expected_changelog_path = self.test_instance.changelog_path - self.mock_func_generate_version_info.assert_called_with( - expected_changelog_path) - - def test_serialises_version_info_from_mapping(self): - """ Should serialise version info from specified mapping. """ - self.test_instance.run() - expected_version_info = self.fake_version_info - self.mock_func_serialise_version_info.assert_called_with( - expected_version_info) - - def test_writes_file_using_command_context(self): - """ Should write the metadata file using the command context. """ - self.test_instance.run() - expected_content = self.fake_version_info_serialised - self.mock_func_egg_info_write_file.assert_called_with( - "version info", self.fake_outfile_path, expected_content) - - -IsSubset = testtools.matchers.MatchesPredicateWithParams( - set.issubset, "{0} should be a subset of {1}") - -class EggInfoCommand_TestCase(testtools.TestCase): - """ Test cases for ‘EggInfoCommand’ class. """ - - def setUp(self): - """ Set up test fixtures. """ - super(EggInfoCommand_TestCase, self).setUp() - - self.test_distribution = distutils.dist.Distribution() - self.test_instance = version.EggInfoCommand(self.test_distribution) - - def test_subclass_of_setuptools_egg_info(self): - """ Should be a subclass of Setuptools ‘egg_info’. """ - self.assertIsInstance( - self.test_instance, setuptools.command.egg_info.egg_info) - - def test_sub_commands_include_base_class_sub_commands(self): - """ Should include base class's sub-commands in this sub_commands. """ - base_command = setuptools.command.egg_info.egg_info - expected_sub_commands = base_command.sub_commands - self.assertThat( - set(expected_sub_commands), - IsSubset(set(self.test_instance.sub_commands))) - - def test_sub_commands_includes_write_version_info_command(self): - """ Should include sub-command named ‘write_version_info’. """ - commands_by_name = dict(self.test_instance.sub_commands) - expected_predicate = version.has_changelog - expected_item = ('write_version_info', expected_predicate) - self.assertIn(expected_item, commands_by_name.items()) - - -@mock.patch.object(setuptools.command.egg_info.egg_info, "run") -class EggInfoCommand_run_TestCase(testtools.TestCase): - """ Test cases for ‘EggInfoCommand.run’ method. """ - - def setUp(self): - """ Set up test fixtures. """ - super(EggInfoCommand_run_TestCase, self).setUp() - - self.test_distribution = distutils.dist.Distribution() - self.test_instance = version.EggInfoCommand(self.test_distribution) - - base_command = setuptools.command.egg_info.egg_info - patcher_func_egg_info_get_sub_commands = mock.patch.object( - base_command, "get_sub_commands") - patcher_func_egg_info_get_sub_commands.start() - self.addCleanup(patcher_func_egg_info_get_sub_commands.stop) - - patcher_func_egg_info_run_command = mock.patch.object( - base_command, "run_command") - patcher_func_egg_info_run_command.start() - self.addCleanup(patcher_func_egg_info_run_command.stop) - - self.fake_sub_commands = ["spam", "eggs", "beans"] - base_command.get_sub_commands.return_value = self.fake_sub_commands - - def test_returns_none(self, mock_func_egg_info_run): - """ Should return ``None``. """ - result = self.test_instance.run() - self.assertIs(result, None) - - def test_runs_each_command_in_sub_commands( - self, mock_func_egg_info_run): - """ Should run each command in ‘self.get_sub_commands()’. """ - base_command = setuptools.command.egg_info.egg_info - self.test_instance.run() - expected_calls = [mock.call(name) for name in self.fake_sub_commands] - base_command.run_command.assert_has_calls(expected_calls) - - def test_calls_base_class_run(self, mock_func_egg_info_run): - """ Should call base class's ‘run’ method. """ - self.test_instance.run() - mock_func_egg_info_run.assert_called_with() - - -# Copyright © 2008–2018 Ben Finney -# -# This is free software: you may copy, modify, and/or distribute this work -# under the terms of the GNU General Public License as published by the -# Free Software Foundation; version 3 of that license or any later version. -# No warranty expressed or implied. See the file ‘LICENSE.GPL-3’ for details. - - -# Local variables: -# coding: utf-8 -# mode: python -# End: -# vim: fileencoding=utf-8 filetype=python : diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..00001ca --- /dev/null +++ b/tox.ini @@ -0,0 +1,13 @@ +# tox (https://tox.readthedocs.io/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +envlist = py35, py36, py37, pep8, docs + +[testenv] +deps = -r{toxinidir}/test-requirements.txt + +commands = + python -m unittest discover diff --git a/version.py b/version.py deleted file mode 100644 index 7d48d4a..0000000 --- a/version.py +++ /dev/null @@ -1,698 +0,0 @@ -# -*- coding: utf-8 -*- - -# version.py -# Part of ‘python-daemon’, an implementation of PEP 3143. -# -# This is free software, and you are welcome to redistribute it under -# certain conditions; see the end of this file for copyright -# information, grant of license, and disclaimer of warranty. - -""" Version information unified for human- and machine-readable formats. - - The project ‘ChangeLog’ file is a reStructuredText document, with - each section describing a version of the project. The document is - intended to be readable as-is by end users. - - This module handles transformation from the ‘ChangeLog’ to a - mapping of version information, serialised as JSON. It also - provides functionality for Distutils to use this information. - - Requires: - - * Docutils - * JSON - - """ - -from __future__ import (absolute_import, unicode_literals) - -import collections -import datetime -import distutils -import distutils.cmd -import distutils.command.build -import distutils.command.build_py -import distutils.dist -import distutils.errors -import distutils.version -import functools -import io -import json -import os -import re -import sys -import textwrap - -import setuptools -import setuptools.command.egg_info - -try: - # Python 2 has both ‘str’ (bytes) and ‘unicode’ (text). - basestring = basestring - unicode = unicode -except NameError: - # Python 3 names the Unicode data type ‘str’. - basestring = str - unicode = str - -__metaclass__ = type - - -def ensure_class_bases_begin_with(namespace, class_name, base_class): - """ Ensure the named class's bases start with the base class. - - :param namespace: The namespace containing the class name. - :param class_name: The name of the class to alter. - :param base_class: The type to be the first base class for the - newly created type. - :return: ``None``. - - This function is a hack to circumvent a circular dependency: - using classes from a module which is not installed at the time - this module is imported. - - Call this function after ensuring `base_class` is available, - before using the class named by `class_name`. - - """ - existing_class = namespace[class_name] - assert isinstance(existing_class, type) - - bases = list(existing_class.__bases__) - if base_class is bases[0]: - # Already bound to a type with the right bases. - return - bases.insert(0, base_class) - - new_class_namespace = existing_class.__dict__.copy() - # Type creation will assign the correct ‘__dict__’ attribute. - del new_class_namespace['__dict__'] - - metaclass = existing_class.__metaclass__ - new_class = metaclass(class_name, tuple(bases), new_class_namespace) - - namespace[class_name] = new_class - - -class VersionInfoWriter(object): - """ Docutils writer to produce a version info JSON data stream. """ - - # This class needs its base class to be a class from `docutils`. - # But that would create a circular dependency: Setuptools cannot - # ensure `docutils` is available before importing this module. - # - # Use `ensure_class_bases_begin_with` after importing `docutils`, to - # re-bind the `VersionInfoWriter` name to a new type that inherits - # from `docutils.writers.Writer`. - - __metaclass__ = type - - supported = ['version_info'] - """ Formats this writer supports. """ - - def __init__(self): - super(VersionInfoWriter, self).__init__() - self.translator_class = VersionInfoTranslator - - def translate(self): - visitor = self.translator_class(self.document) - self.document.walkabout(visitor) - self.output = visitor.astext() - - -rfc822_person_regex = re.compile( - "^(?P[^<]+) <(?P[^>]+)>$") - -ParsedPerson = collections.namedtuple('ParsedPerson', ['name', 'email']) - -def parse_person_field(value): - """ Parse a person field into name and email address. - - :param value: The text value specifying a person. - :return: A 2-tuple (name, email) for the person's details. - - If the `value` does not match a standard person with email - address, the `email` item is ``None``. - - """ - result = ParsedPerson(None, None) - - match = rfc822_person_regex.match(value) - if len(value): - if match is not None: - result = ParsedPerson( - name=match.group('name'), - email=match.group('email')) - else: - result = ParsedPerson(name=value, email=None) - - return result - - -class ChangeLogEntry: - """ An individual entry from the ‘ChangeLog’ document. """ - - __metaclass__ = type - - field_names = [ - 'release_date', - 'version', - 'maintainer', - 'body', - ] - - date_format = "%Y-%m-%d" - default_version = "UNKNOWN" - default_release_date = "UNKNOWN" - - def __init__( - self, - release_date=default_release_date, version=default_version, - maintainer=None, body=None): - self.validate_release_date(release_date) - self.release_date = release_date - - self.validate_version(version) - self.version = version - - self.validate_maintainer(maintainer) - self.maintainer = maintainer - self.body = body - - @classmethod - def validate_release_date(cls, value): - """ Validate the `release_date` value. - - :param value: The prospective `release_date` value. - :return: ``None`` if the value is valid. - :raises ValueError: If the value is invalid. - - """ - if value in ["UNKNOWN", "FUTURE"]: - # A valid non-date value. - return None - - # Raises `ValueError` if parse fails. - datetime.datetime.strptime(value, ChangeLogEntry.date_format) - - @classmethod - def validate_version(cls, value): - """ Validate the `version` value. - - :param vaue: The prospective `version` value. - :return: ``None`` if the value is valid. - :raises ValueError: If the value is invalid. - - """ - if value in ["UNKNOWN", "NEXT"]: - # A valid non-version value. - return None - - match = distutils.version.StrictVersion.version_re.match(value) - if match is None: - raise ValueError( - "not a valid version string {value!r}".format( - value=value)) - - @classmethod - def validate_maintainer(cls, value): - """ Validate the `maintainer` value. - - :param value: The prospective `maintainer` value. - :return: ``None`` if the value is valid. - :raises ValueError: If the value is invalid. - - """ - valid = False - - if value is None: - valid = True - elif rfc822_person_regex.search(value): - valid = True - - if not valid: - raise ValueError( - "not a valid person specification {value!r}".format( - value=value)) - else: - return None - - @classmethod - def make_ordered_dict(cls, fields): - """ Make an ordered dict of the fields. """ - result = collections.OrderedDict( - (name, fields[name]) - for name in cls.field_names) - return result - - def as_version_info_entry(self): - """ Format the changelog entry as a version info entry. """ - fields = vars(self) - entry = self.make_ordered_dict(fields) - - return entry - - -class InvalidFormatError(ValueError): - """ Raised when the document is not a valid ‘ChangeLog’ document. """ - - def __init__(self, node, message=None): - self.node = node - self.message = message - - def __str__(self): - text = "{message}: {source} line {line:d}".format( - message=( - getattr(self, 'message', "(no message)")), - source=( - getattr(self.node, 'source', "(source unknown)")), - line=( - getattr(self.node, 'line', "(unknown)")), - ) - - return text - - -class VersionInfoTranslator(object): - """ Translator from document nodes to a version info stream. """ - - # This class needs its base class to be a class from `docutils`. - # But that would create a circular dependency: Setuptools cannot - # ensure `docutils` is available before importing this module. - # - # Use `ensure_class_bases_begin_with` after importing `docutils`, - # to re-bind the `VersionInfoTranslator` name to a new type that - # inherits from `docutils.nodes.SparseNodeVisitor`. - - __metaclass__ = type - - wrap_width = 78 - bullet_text = "* " - - attr_convert_funcs_by_attr_name = { - 'released': ('release_date', unicode), - 'version': ('version', unicode), - 'maintainer': ('maintainer', unicode), - } - - def __init__(self, document): - super(VersionInfoTranslator, self).__init__(document) - self.settings = document.settings - self.current_field_name = None - self.content = [] - self.indent_width = 0 - self.initial_indent = "" - self.subsequent_indent = "" - self.current_entry = None - - # Docutils is not available when this class is defined. - # Get the `docutils` module dynamically. - self._docutils = sys.modules['docutils'] - - def astext(self): - """ Return the translated document as text. """ - text = json.dumps(self.content, indent=4) - return text - - def append_to_current_entry(self, text): - if self.current_entry is not None: - if self.current_entry.body is not None: - self.current_entry.body += text - - def visit_Text(self, node): - raw_text = node.astext() - text = textwrap.fill( - raw_text, - width=self.wrap_width, - initial_indent=self.initial_indent, - subsequent_indent=self.subsequent_indent) - self.append_to_current_entry(text) - - def depart_Text(self, node): - pass - - def visit_comment(self, node): - raise self._docutils.nodes.SkipNode - - def visit_field_body(self, node): - field_list_node = node.parent.parent - if not isinstance(field_list_node, self._docutils.nodes.field_list): - raise InvalidFormatError( - node, - "Unexpected field within {node!r}".format( - node=field_list_node)) - if not isinstance( - field_list_node.parent, self._docutils.nodes.section): - # Field list is not in a section. - raise self._docutils.nodes.SkipNode - if self.current_field_name not in self.attr_convert_funcs_by_attr_name: - raise InvalidFormatError( - node, - "Unexpected field name {name!r}".format( - name=self.current_field_name)) - (attr_name, convert_func) = self.attr_convert_funcs_by_attr_name[ - self.current_field_name] - attr_value = convert_func(node.astext()) - setattr(self.current_entry, attr_name, attr_value) - - def depart_field_body(self, node): - pass - - def visit_field_list(self, node): - pass - - def depart_field_list(self, node): - self.current_field_name = None - self.current_entry.body = "" - - def visit_field_name(self, node): - field_name = node.astext() - self.current_field_name = field_name.lower() - field_list_node = node.parent - if not isinstance( - field_list_node.parent, self._docutils.nodes.section): - # Field list is not in a section. - raise self._docutils.nodes.SkipNode - if not isinstance( - field_list_node.parent.parent, self._docutils.nodes.Root): - # The section is not top-level. - raise self._docutils.nodes.SkipNode - if field_name.lower() not in ["released", "maintainer"]: - raise InvalidFormatError( - node, - "Unexpected field name {name!r}".format(name=field_name)) - - def depart_field_name(self, node): - pass - - def visit_bullet_list(self, node): - self.current_context = [] - - def depart_bullet_list(self, node): - self.current_entry.changes = self.current_context - self.current_context = None - - def adjust_indent_width(self, delta): - self.indent_width += delta - self.subsequent_indent = " " * self.indent_width - self.initial_indent = self.subsequent_indent - - def visit_list_item(self, node): - indent_delta = +len(self.bullet_text) - self.adjust_indent_width(indent_delta) - self.initial_indent = self.subsequent_indent[:-indent_delta] - self.append_to_current_entry(self.initial_indent + self.bullet_text) - - def depart_list_item(self, node): - indent_delta = +len(self.bullet_text) - self.adjust_indent_width(-indent_delta) - self.append_to_current_entry("\n") - - def visit_section(self, node): - if not isinstance(node.parent, self._docutils.nodes.Root): - raise InvalidFormatError( - node, "Subsections not implemented for this writer") - self.current_entry = ChangeLogEntry() - - def depart_section(self, node): - self.content.append( - self.current_entry.as_version_info_entry()) - self.current_entry = None - - _expected_title_word_length = len("Version FOO".split(" ")) - - def depart_title(self, node): - title_text = node.astext() - words = title_text.split(" ") - version = None - if len(words) != self._expected_title_word_length: - raise InvalidFormatError( - node, - "Unexpected title text {text!r}".format(text=title_text)) - if words[0].lower() not in ["version"]: - raise InvalidFormatError( - node, - "Unexpected title text {text!r}".format(text=title_text)) - version = words[-1] - self.current_entry.version = version - - -def changelog_to_version_info_collection(infile): - """ Render the ‘ChangeLog’ document to a version info collection. - - :param infile: A file-like object containing the changelog. - :return: The serialised JSON data of the version info collection. - - """ - - # Docutils is not available when Setuptools needs this module, so - # delay the imports to this function instead. - import docutils.core - import docutils.nodes - import docutils.writers - - ensure_class_bases_begin_with( - globals(), str('VersionInfoWriter'), docutils.writers.Writer) - ensure_class_bases_begin_with( - globals(), str('VersionInfoTranslator'), - docutils.nodes.SparseNodeVisitor) - - writer = VersionInfoWriter() - settings_overrides = { - 'doctitle_xform': False, - } - version_info_json = docutils.core.publish_string( - infile.read(), writer=writer, - settings_overrides=settings_overrides) - - return version_info_json - - -try: - lru_cache = functools.lru_cache -except AttributeError: - # Python < 3.2 does not have the `functools.lru_cache` function. - # Not essential, so replace it with a no-op. - lru_cache = lambda maxsize=None, typed=False: lambda func: func - - -@lru_cache(maxsize=128) -def generate_version_info_from_changelog(infile_path): - """ Get the version info for the latest version in the changelog. - - :param infile_path: Filesystem path to the input changelog file. - :return: The generated version info mapping; or ``None`` if the - file cannot be read. - - The document is explicitly opened as UTF-8 encoded text. - - """ - version_info = collections.OrderedDict() - - versions_all_json = None - try: - with io.open(infile_path, 'rt', encoding="utf-8") as infile: - versions_all_json = changelog_to_version_info_collection(infile) - except EnvironmentError: - # If we can't read the input file, leave the collection empty. - pass - - if versions_all_json is not None: - versions_all = json.loads(versions_all_json.decode('utf-8')) - # The changelog will have the latest entry first. - version_info = versions_all[0] - - return version_info - - -def get_latest_version(versions): - """ Get the latest version from a collection of changelog entries. - - :param versions: A collection of mappings for changelog entries. - :return: An ordered mapping of fields for the latest version, - if `versions` is non-empty; otherwise, an empty mapping. - - """ - version_info = collections.OrderedDict() - - versions_by_release_date = { - item['release_date']: item - for item in versions} - if versions_by_release_date: - latest_release_date = max(versions_by_release_date.keys()) - version_info = ChangeLogEntry.make_ordered_dict( - versions_by_release_date[latest_release_date]) - - return version_info - - -def serialise_version_info_from_mapping(version_info): - """ Generate the version info serialised data. - - :param version_info: Mapping of version info items. - :return: The version info serialised to JSON. - - """ - content = json.dumps(version_info, indent=4) - - return content - - -changelog_filename = "ChangeLog" - -def get_changelog_path(distribution, filename=changelog_filename): - """ Get the changelog file path for the distribution. - - :param distribution: The distutils.dist.Distribution instance. - :param filename: The base filename of the changelog document. - :return: Filesystem path of the changelog document, or ``None`` - if not discoverable. - - """ - build_py_command = distutils.command.build_py.build_py(distribution) - build_py_command.finalize_options() - setup_dirname = build_py_command.get_package_dir("") - filepath = os.path.join(setup_dirname, filename) - - return filepath - - -def has_changelog(command): - """ Return ``True`` iff the distribution's changelog file exists. """ - result = False - - changelog_path = get_changelog_path(command.distribution) - if changelog_path is not None: - if os.path.exists(changelog_path): - result = True - - return result - - -class BuildCommand(distutils.command.build.build, object): - """ Custom ‘build’ command for this distribution. """ - - sub_commands = ( - distutils.command.build.build.sub_commands + [ - ('write_version_info', has_changelog), - ]) - - -class EggInfoCommand(setuptools.command.egg_info.egg_info, object): - """ Custom ‘egg_info’ command for this distribution. """ - - sub_commands = ([ - ('write_version_info', has_changelog), - ] + setuptools.command.egg_info.egg_info.sub_commands) - - def run(self): - """ Execute this command. """ - super(EggInfoCommand, self).run() - - for command_name in self.get_sub_commands(): - self.run_command(command_name) - - -version_info_filename = "version_info.json" - -class WriteVersionInfoCommand(setuptools.command.egg_info.egg_info, object): - """ Setuptools command to serialise version info metadata. """ - - user_options = ([ - ("changelog-path=", None, - "Filesystem path to the changelog document."), - ("outfile-path=", None, - "Filesystem path to the version info file."), - ] + setuptools.command.egg_info.egg_info.user_options) - - def initialize_options(self): - """ Initialise command options to defaults. """ - super(WriteVersionInfoCommand, self).initialize_options() - self.changelog_path = None - self.outfile_path = None - - def finalize_options(self): - """ Finalise command options before execution. """ - self.set_undefined_options( - 'build', - ('force', 'force')) - - super(WriteVersionInfoCommand, self).finalize_options() - - if self.changelog_path is None: - self.changelog_path = get_changelog_path(self.distribution) - - if self.outfile_path is None: - egg_dir = self.egg_info - self.outfile_path = os.path.join(egg_dir, version_info_filename) - - def run(self): - """ Execute this command. """ - version_info = generate_version_info_from_changelog( - self.changelog_path) - content = serialise_version_info_from_mapping(version_info) - self.write_file("version info", self.outfile_path, content) - - -class ChangelogAwareDistribution(distutils.dist.Distribution, object): - """ A distribution of Python code for installation. - - This class gets the following attributes instead from the - ‘ChangeLog’ document: - - * version - * maintainer - * maintainer_email - - """ - - __metaclass__ = type - - def __init__(self, *args, **kwargs): - super(ChangelogAwareDistribution, self).__init__(*args, **kwargs) - - if self.script_name is None: - self.script_name = sys.argv[1] - - # Undo the per-instance delegation for these methods. - del ( - self.get_version, - self.get_maintainer, - self.get_maintainer_email, - ) - - @lru_cache(maxsize=128) - def get_version_info(self): - changelog_path = get_changelog_path(self) - version_info = generate_version_info_from_changelog(changelog_path) - return version_info - - def get_version(self): - version_info = self.get_version_info() - version_string = version_info['version'] - return version_string - - def get_maintainer(self): - version_info = self.get_version_info() - person = parse_person_field(version_info['maintainer']) - return person.name - - def get_maintainer_email(self): - version_info = self.get_version_info() - person = parse_person_field(version_info['maintainer']) - return person.email - - -# Copyright © 2008–2018 Ben Finney -# -# This is free software: you may copy, modify, and/or distribute this work -# under the terms of the GNU General Public License as published by the -# Free Software Foundation; version 3 of that license or any later version. -# No warranty expressed or implied. See the file ‘LICENSE.GPL-3’ for details. - - -# Local variables: -# coding: utf-8 -# mode: python -# End: -# vim: fileencoding=utf-8 filetype=python :