diff --git a/HISTORY b/HISTORY index 41d3a37..a571ee4 100644 --- a/HISTORY +++ b/HISTORY @@ -1,6 +1,6 @@ Changelog ========= -* Next Release +* 0.0.1 - - Create something amazing + - Support removal of distribution directories. diff --git a/README.rst b/README.rst index b2324c3..9bf3777 100644 --- a/README.rst +++ b/README.rst @@ -25,8 +25,50 @@ keeping targets such as *clean*, *dist-clean*, and *maintainer-clean*. This extension is inspired by the same desire for a clean working environment. -Ok... Where? ------------- +Installation +~~~~~~~~~~~~ +The ``setuptools`` package contains a number of interesting ways in which +it can be extended. If you develop Python packages, then you can include +extension packages using the ``setup_requires`` and ``cmdclass`` keyword +parameters to the ``setup`` function call. This is a little more +difficult than it should be since the ``setupext`` package needs to be +imported into *setup.py* so that it can be passed as a keyword parameter +**before** it is downloaded. The easiest way to do this is to catch the +``ImportError`` that happens if it is not already downloaded:: + + import setuptools + try: + from setupext import janitor + CleanCommand = janitor.CleanCommand + except ImportError: + CleanCommand = None + + cmd_classes = {} + if CleanCommand is not None: + cmd_classes['clean'] = CleanCommand + + setup( + # normal parameters + setup_requires=['setupext.janitor'], + cmdclass=cmd_classes, + ) + +You can use a different approach if you are simply a developer that wants +to have this functionality available for your own use, then you can install +it into your working environment. This package installs itself into the +environment as a `distutils extension`_ so that it is available to any +*setup.py* script as if by magic. + +Usage +~~~~~ +Once the extension is installed, the ``clean`` command will accept a +few new command line parameters. + +``setup.py clean --dist`` + Removes directories that the various *dist* commands produce. + +Where can I get this extension from? +------------------------------------ +---------------+-----------------------------------------------------+ | Source | https://github.com/dave-shawley/setupext-janitor | +---------------+-----------------------------------------------------+ @@ -39,7 +81,10 @@ Ok... Where? | Issues | https://github.com/dave-shawley/setupext-janitor | +---------------+-----------------------------------------------------+ +.. _distutils extension: https://pythonhosted.org/setuptools/setuptools.html + #extending-and-reusing-setuptools .. _setuptools: https://pythonhosted.org/setuptools/ + .. |Version| image:: https://badge.fury.io/py/setupext-janitor.svg :target: https://badge.fury.io/ .. |Downloads| image:: https://pypip.in/d/setupext-janitor/badge.svg? @@ -48,4 +93,3 @@ Ok... Where? :target: https://travis-ci.org/dave-shawley/setupext-janitor .. |License| image:: https://pypip.in/license/dave-shawley/badge.svg? :target: https://setupext-dave-shawley.readthedocs.org/ - diff --git a/dev-requirements.txt b/dev-requirements.txt index 8b3a884..827cb92 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,4 +1,5 @@ -r test-requirements.txt +detox>=0.9,<1 flake8>=2.2,<3 pyflakes>=0.8,<1 sphinx>=1.2,<2 diff --git a/setup.py b/setup.py index 2373c0c..74e3447 100755 --- a/setup.py +++ b/setup.py @@ -13,6 +13,8 @@ test_requirements = ['nose>1.3,<2'] if sys.version_info < (3, ): test_requirements.append('mock>1.0,<2') +if sys.version_info < (2, 7): + test_requirements.append('unittest2') setuptools.setup( @@ -39,6 +41,7 @@ ], entry_points={ 'distutils.commands': [ + 'clean = setupext.janitor:CleanCommand', ], }, ) diff --git a/setupext/janitor/__init__.py b/setupext/janitor/__init__.py index 2e4dd40..f946931 100644 --- a/setupext/janitor/__init__.py +++ b/setupext/janitor/__init__.py @@ -1,2 +1,88 @@ -version_info = (0, 0, 0) +from distutils import log +from distutils.command.clean import clean as _CleanCommand +import shutil + + +version_info = (0, 0, 1) __version__ = '.'.join(str(v) for v in version_info) + + +class CleanCommand(_CleanCommand): + """ + Extend the clean command to do additional house keeping. + + The traditional distutils clean command removes the by-products of + compiling extension code. This class extends it to remove the + similar by-products generated by producing a Python distribution. + Most notably, it will remove .egg/.egg-info directories, the + generated distribution, those pesky *__pycache__* directories, + and even the virtual environment that it is running in. + + The level of cleanliness is controlled by command-line options as + you might expect. The targets that are removed are influenced by + the commands that created them. For example, if you set a custom + distribution directory using the ``--dist-dir`` option or the + matching snippet in *setup.cfg*, then this extension will honor + that setting. It even goes as far as to detect the virtual + environment directory based on environment variables. + + This all sounds a little dangerous... there is little to worry + about though. This command only removes what it is configured to + remove which is nothing by default. It also honors the + ``--dry-run`` global option so that there should be no question + what it is going to remove. + + """ + + # See _set_options for `user_options` + + def __init__(self, *args, **kwargs): + _CleanCommand.__init__(self, *args, **kwargs) + self.dist = None + + def initialize_options(self): + _CleanCommand.initialize_options(self) + self.dist = False + + def run(self): + _CleanCommand.run(self) + if not self.dist: + return + + dist_dirs = set() + for cmd_name in self.distribution.commands: + if 'dist' in cmd_name: + command = self.distribution.get_command_obj(cmd_name) + command.ensure_finalized() + if getattr(command, 'dist_dir', None): + dist_dirs.add(command.dist_dir) + + for dir_name in dist_dirs: + self.announce('removing {0}'.format(dir_name), level=log.DEBUG) + shutil.rmtree(dir_name, ignore_errors=True) + + +def _set_options(): + """ + Set the options for CleanCommand. + + There are a number of reasons that this has to be done in an + external function instead of inline in the class. First of all, + the setuptools machinery really wants the options to be defined + in a class attribute - otherwise, the help command doesn't work + so we need a class attribute. However, we are extending an + existing command and do not want to "monkey patch" over it so + we need to define a *new* class attribute with the same name + that contains a copy of the base class value. This could be + accomplished using some magic in ``__new__`` but I would much + rather set the class attribute externally... it's just cleaner. + + """ + CleanCommand.user_options = _CleanCommand.user_options[:] + CleanCommand.user_options.extend([ + ('dist', 'd', 'remove distribution directory'), + ]) + CleanCommand.boolean_options = _CleanCommand.boolean_options[:] + CleanCommand.boolean_options.append('dist') + +_set_options() diff --git a/tests.py b/tests.py index e69de29..68371d2 100644 --- a/tests.py +++ b/tests.py @@ -0,0 +1,130 @@ +from distutils import core, dist +from distutils.command import clean +import atexit +import os.path +import shutil +import sys +import tempfile + +if sys.version_info >= (2, 7): + import unittest +else: # noinspection PyPackageRequirements,PyUnresolvedReferences + import unittest2 as unittest + +from setupext import janitor + + +def run_setup(*command_line): + """ + Run the setup command with `command_line`. + + :param command_line: the command line arguments to pass + as the simulated command line + + This function runs :func:`distutils.core.setup` after it + configures an environment that mimics passing the specified + command line arguments. The ``distutils`` internals are + replaced with a :class:`~distutils.dist.Distribution` + instance that will only execute the clean command. Other + commands can be passed freely to simulate command line usage + patterns. + + """ + class FakeDistribution(dist.Distribution): + + def __init__(self, *args, **kwargs): + """Enable verbose output to make tests easier to debug.""" + dist.Distribution.__init__(self, *args, **kwargs) + self.verbose = 3 + + def run_command(self, command): + """Only run the clean command.""" + if command == 'clean': + dist.Distribution.run_command(self, command) + + def parse_config_files(self, filenames=None): + """Skip processing of configuration files.""" + pass + + core.setup( + distclass=FakeDistribution, + script_name='testsetup.py', + script_args=command_line, + cmdclass={'clean': janitor.CleanCommand}, + ) + + +class CommandOptionTests(unittest.TestCase): + + def test_that_distutils_options_are_present(self): + defined_options = set(t[0] for t in janitor.CleanCommand.user_options) + superclass_options = set(t[0] for t in clean.clean.user_options) + self.assertTrue(defined_options.issuperset(superclass_options)) + + def test_that_janitor_user_options_are_not_clean_options(self): + self.assertIsNot( + janitor.CleanCommand.user_options, clean.clean.user_options) + + def test_that_janitor_defines_dist_command(self): + self.assertIn( + ('dist', 'd', 'remove distribution directory'), + janitor.CleanCommand.user_options) + + +class DirectoryCleanupTests(unittest.TestCase): + temp_dir = tempfile.mkdtemp() + + @classmethod + def setUpClass(cls): + super(DirectoryCleanupTests, cls).setUpClass() + atexit.register(shutil.rmtree, cls.temp_dir) + + @classmethod + def create_directory(cls, dir_name): + return tempfile.mkdtemp(dir=cls.temp_dir, prefix=dir_name) + + def assert_path_does_not_exist(self, full_path): + if os.path.exists(full_path): + raise AssertionError('{0} should not exist'.format(full_path)) + + def assert_path_exists(self, full_path): + if not os.path.exists(full_path): + raise AssertionError('{0} should exist'.format(full_path)) + + def test_that_dist_directory_is_removed_for_sdist(self): + dist_dir = self.create_directory('dist-dir') + run_setup( + 'sdist', '--dist-dir={0}'.format(dist_dir), + 'clean', '--dist', + ) + self.assert_path_does_not_exist(dist_dir) + + def test_that_dist_directory_is_removed_for_bdist_dumb(self): + dist_dir = self.create_directory('dist-dir') + run_setup( + 'bdist_dumb', '--dist-dir={0}'.format(dist_dir), + 'clean', '--dist', + ) + self.assert_path_does_not_exist(dist_dir) + + def test_that_multiple_dist_directories_with_be_removed(self): + sdist_dir = self.create_directory('sdist-dir') + bdist_dir = self.create_directory('bdist_dumb') + run_setup( + 'sdist', '--dist-dir={0}'.format(sdist_dir), + 'bdist_dumb', '--dist-dir={0}'.format(bdist_dir), + 'clean', '--dist', + ) + self.assert_path_does_not_exist(sdist_dir) + self.assert_path_does_not_exist(bdist_dir) + + def test_that_directories_are_not_removed_without_parameter(self): + sdist_dir = self.create_directory('sdist-dir') + bdist_dir = self.create_directory('bdist_dumb') + run_setup( + 'sdist', '--dist-dir={0}'.format(sdist_dir), + 'bdist_dumb', '--dist-dir={0}'.format(bdist_dir), + 'clean', + ) + self.assert_path_exists(sdist_dir) + self.assert_path_exists(bdist_dir) diff --git a/tox.ini b/tox.ini index b16cf6c..4f47dfd 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,17 @@ [tox] -envlist = py27,py33,py34 +envlist = py26,py27,py33,py34,pypy3 toxworkdir = {toxinidir}/build/tox [testenv] -deps = nose +deps = -rtest-requirements.txt commands = {envbindir}/nosetests [testenv:py27] deps = {[testenv]deps} mock + +[testenv:py26] +deps = + {[testenv:py27]deps} + unittest2