diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..73c06e6 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,16 @@ +[run] +branch = True +omit = setup.py, */migrations/*, */conftest.py + +[report] +# Regexes for lines to exclude from consideration +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + NOCOV + + # Don't complain if non-runnable code isn't run: + if __name__ == .__main__.: + +[html] +directory = .htmlcov diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d980ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__ +*.pyc +/.cache +/.coverage +/.tox +/build +/dist +/docs/_build diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..395240b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: python + +python: + - "2.7" + - "3.3" + - "3.4" + - "3.5" + +install: + - pip install -r requirements-test.txt + - pip install coveralls + +script: py.test --flake8 --cov djclick + +after_success: coveralls diff --git a/AUTHORS.rst b/AUTHORS.rst new file mode 100644 index 0000000..22b369b --- /dev/null +++ b/AUTHORS.rst @@ -0,0 +1,5 @@ +==================== +Project contributors +==================== + + * Jonathan Stoppani diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..d985631 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,41 @@ +======================= +Contribution guidelines +======================= + + +Running tests +============= + +Use ``tox``:: + + pip install tox + tox + + +Creating a release +================== + +* Checkout the ``master`` branch. +* Pull the latest changes from ``origin``. +* Make sure ``check-manifest`` is happy. +* Increment the version number. +* Set the correct title for the release in ``HISTORY.rst``. +* If needed update the ``AUTHORS.rst`` file with new contributors. +* Commit everything and make sure the working tree is clean. +* Push everything to github and make sure the tests pass on Travis:: + + git push origin master + +* Build and upload the release:: + + ./setup.py publish + +* Tag the release:: + + git tag -a "v$(python setup.py --version)" -m "$(python setup.py --name) release version $(python setup.py --version)" + +* Push everything to github:: + + git push --tags origin master + +* Add the title for the next release to `HISTORY.rst` diff --git a/HISTORY.rst b/HISTORY.rst new file mode 100644 index 0000000..2028112 --- /dev/null +++ b/HISTORY.rst @@ -0,0 +1,9 @@ +======= +History +======= + + +0.1.0 – Unreleased +================== + +* Initial release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e0c4c5f --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2015 Jonathan Stoppani + +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. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..fe0aedc --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,15 @@ +include AUTHORS.rst +include CONTRIBUTING.rst +include HISTORY.rst +include LICENSE +include README.rst + +include requirements-dev.txt +include requirements-test.txt +include requirements.txt + +exclude .coveragerc +exclude .travis.yml +exclude tox.ini + +prune docs diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..70800cb --- /dev/null +++ b/README.rst @@ -0,0 +1,55 @@ +============ +Django Click +============ + +.. image:: https://img.shields.io/travis/GaretJax/django-click.svg + :target: https://travis-ci.org/GaretJax/django-click + +.. image:: https://img.shields.io/pypi/v/django-click.svg + :target: https://pypi.python.org/pypi/django-click + +.. image:: https://img.shields.io/pypi/dm/django-click.svg + :target: https://pypi.python.org/pypi/django-click + +.. image:: https://img.shields.io/coveralls/GaretJax/django-click/master.svg + :target: https://coveralls.io/r/GaretJax/django-click?branch=master + +.. image:: https://img.shields.io/badge/docs-latest-brightgreen.svg + :target: http://django-click.readthedocs.org/en/latest/ + +.. image:: https://img.shields.io/pypi/l/django-click.svg + :target: https://github.com/GaretJax/django-click/blob/develop/LICENSE + +.. image:: https://img.shields.io/requires/github/GaretJax/django-click.svg + :target: https://requires.io/github/GaretJax/django-click/requirements/?branch=master + +.. .. image:: https://img.shields.io/codeclimate/github/GaretJax/django-click.svg +.. :target: https://codeclimate.com/github/GaretJax/django-click + +django-click is a library to easily write django management commands using the +click command line library. + +* Free software: MIT license +* Documentation: http://django-click.rtfd.org + + +Installation +============ + +:: + + pip install django-click + + +Example +======= + +Create a command module as you would usually do, but instead of creating a +class, just put a djclick command into it:: + + import djclick as click + + @click.command() + @click.argument('name') + def command(name): + click.secho('Hello, {}'.format(name), fg='red') diff --git a/djclick/__init__.py b/djclick/__init__.py new file mode 100644 index 0000000..59a6c25 --- /dev/null +++ b/djclick/__init__.py @@ -0,0 +1,14 @@ +""" +Support click in Django management commands. +""" + +import click +from click import * # NOQA +from .adapter import CommandRegistrator + + +__version__ = '0.1.0' +__url__ = 'https://github.com/GaretJax/django-click' +__all__ = click.__all__ + +command = CommandRegistrator diff --git a/djclick/adapter.py b/djclick/adapter.py new file mode 100644 index 0000000..6b87568 --- /dev/null +++ b/djclick/adapter.py @@ -0,0 +1,150 @@ +import sys + +import six + +import click + +from django import get_version + + +class ParserAdapter(object): + def parse_args(self, args): + return (self, None) + + +class CommandAdapter(click.Command): + use_argparse = False + + def run_from_argv(self, argv): + """ + Called when run from the command line. + """ + return self.main(args=argv[2:]) + + def create_parser(self, progname, subcommand): + """ + Called when run through `call_command`. + """ + return ParserAdapter() + + def map_names(self): + for param in self.params: + for opt in param.opts: + yield opt.lstrip('--').replace('-', '_'), param.name + + def execute(self, *args, **kwargs): + """ + Called when run through `call_command`. `args` are passed through, + while `kwargs` is the __dict__ of the return value of + `self.create_parser('', name)` updated with the kwargs passed to + `call_command`. + """ + # Remove internal Django command handling machinery + kwargs.pop('skip_checks') + + with self.make_context('', list(args)) as ctx: + # Rename kwargs to to the appropriate destination argument name + opt_mapping = dict(self.map_names()) + arg_options = {opt_mapping.get(key, key): value + for key, value in six.iteritems(kwargs)} + + # Update the context with the passed (renamed) kwargs + ctx.params.update(arg_options) + + # Invoke the command + self.invoke(ctx) + + +def register_on_context(ctx, param, value): + setattr(ctx, param.name, value) + return value + + +def suppress_colors(ctx, param, value): + if value: + ctx.color = False + return value + + +class CommandRegistrator(object): + common_options = [ + click.option( + '-v', '--verbosity', + expose_value=False, + callback=register_on_context, + type=click.Choice(str(s) for s in range(4)), + help=('Verbosity level; 0=minimal output, 1=normal ''output, ' + '2=verbose output, 3=very verbose output.'), + ), + click.option( + '--settings', + metavar='SETTINGS', + expose_value=False, + help=('The Python path to a settings module, e.g. ' + '"myproject.settings.main". If this is not provided, the ' + 'DJANGO_SETTINGS_MODULE environment variable will be used.'), + ), + click.option( + '--pythonpath', + metavar='PYTHONPATH', + expose_value=False, + help=('A directory to add to the Python path, e.g. ' + '"/home/djangoprojects/myproject".'), + ), + click.option( + '--traceback', + is_flag=True, + expose_value=False, + callback=register_on_context, + help='Raise on CommandError exceptions.', + ), + click.option( + '--no-color', + is_flag=True, + expose_value=False, + callback=suppress_colors, + help='Do not colorize the command output.', + ), + ] + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.version = self.kwargs.pop('version', get_version()) + + context_settings = kwargs.setdefault('context_settings', {}) + context_settings['help_option_names'] = ['-h', '--help'] + + def get_params(self, name): + def show_help(ctx, param, value): + if value and not ctx.resilient_parsing: + ctx.info_name += ' ' + name + click.echo(ctx.get_help(), color=ctx.color) + ctx.exit() + + return [ + click.version_option(version=self.version, message='%(version)s'), + click.option('-h', '--help', is_flag=True, is_eager=True, + expose_value=False, callback=show_help, + help='Show this message and exit.',), + ] + self.common_options + + def __call__(self, func): + module = sys.modules[func.__module__] + + # Get the command name as Django expects it + self.name = func.__module__.rsplit('.', 1)[-1] + + # Build the click command + decorators = [ + click.command(name=self.name, cls=CommandAdapter, **self.kwargs), + ] + self.get_params(self.name) + + for decorator in reversed(decorators): + func = decorator(func) + + # Django expects the command to be callable (it instantiates the class + # pointed at by the `Command` module-level property)... + # ...let's make it happy. + module.Command = lambda: func + + return func diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..1542b12 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,22 @@ +-r requirements-test.txt + +# Automation +Fabric +livereload + +# Packaging +wheel +check-manifest + +# Code linting +flake8 +mccabe +pep8 +flake8-todo +pep8-naming +pyflakes + +# Documentation +Sphinx +sphinx-autobuild +sphinx_rtd_theme diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..5f52239 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,5 @@ +-r requirements.txt + +pytest +pytest-cov +pytest-flake8 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dde8185 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +six>=1.9.0 diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..8a99e6d --- /dev/null +++ b/setup.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python + +import io +import os +import re +import sys + +from setuptools import setup, find_packages + + +PACKAGE = 'djclick' +PACKAGE_NAME = 'django-click' + + +if sys.argv[-1] == 'publish': + os.system('python setup.py sdist bdist_wheel upload') + sys.exit() + + +class Setup(object): + @staticmethod + def read(fname, fail_silently=False): + """ + Read the content of the given file. The path is evaluated from the + directory containing this file. + """ + try: + filepath = os.path.join(os.path.dirname(__file__), fname) + with io.open(filepath, 'rt', encoding='utf8') as f: + return f.read() + except: + if not fail_silently: + raise + return '' + + @staticmethod + def requirements(fname): + """ + Create a list of requirements from the output of the pip freeze command + saved in a text file. + """ + packages = Setup.read(fname, fail_silently=True).split('\n') + packages = (p.strip() for p in packages) + packages = (p for p in packages if p and not p.startswith('#')) + packages = (p for p in packages if p and not p.startswith('https://')) + return list(packages) + + @staticmethod + def get_files(*bases): + """ + List all files in a data directory. + """ + for base in bases: + basedir, _ = base.split('.', 1) + base = os.path.join(os.path.dirname(__file__), *base.split('.')) + + rem = len(os.path.dirname(base)) + len(basedir) + 2 + + for root, dirs, files in os.walk(base): + for name in files: + yield os.path.join(basedir, root, name)[rem:] + + @staticmethod + def version(): + data = Setup.read(os.path.join(PACKAGE, '__init__.py')) + version = (re.search(u"__version__\s*=\s*u?'([^']+)'", data) + .group(1).strip()) + return version + + @staticmethod + def url(): + data = Setup.read(os.path.join(PACKAGE, '__init__.py')) + version = (re.search(u"__url__\s*=\s*u?'([^']+)'", data) + .group(1).strip()) + return version + + @staticmethod + def longdesc(): + return Setup.read('README.rst') + '\n\n' + Setup.read('HISTORY.rst') + + @staticmethod + def test_links(): + # Test if hardlinks work. This is a workaround until + # http://bugs.python.org/issue8876 is solved + if hasattr(os, 'link'): + tempfile = __file__ + '.tmp' + try: + os.link(__file__, tempfile) + except OSError as e: + if e.errno == 1: # Operation not permitted + del os.link + else: + raise + finally: + if os.path.exists(tempfile): + os.remove(tempfile) + + +Setup.test_links() + +setup(name=PACKAGE_NAME, + version=Setup.version(), + author='Jonathan Stoppani', + author_email='jonathan@stoppani.name', + include_package_data=True, + zip_safe=False, + url=Setup.url(), + license='MIT', + packages=find_packages(), + package_dir={PACKAGE: PACKAGE}, + description='Helpers for dealing with application settings', + install_requires=Setup.requirements('requirements.txt'), + long_description=Setup.longdesc(), + entry_points=Setup.read('entry-points.ini', True), + classifiers=[ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + ]) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..24f3f6b --- /dev/null +++ b/tox.ini @@ -0,0 +1,12 @@ +[tox] +envlist = {py27,py34,pypy19}-{dj14,dj15,dj16,dj17,dj18} + +[testenv] +deps = + -rrequirements-test.txt + dj14: django==1.4 + dj15: django==1.5 + dj16: django==1.6 + dj17: django==1.7 + dj18: django==1.8 +commands = py.test -rxs -s --flake8 --cov-report html --cov djclick djclick