diff --git a/flit/__init__.py b/flit/__init__.py index 64e63e3a..f5b6e023 100644 --- a/flit/__init__.py +++ b/flit/__init__.py @@ -5,23 +5,13 @@ import sys from . import common +from .subcommand import register from .log import enable_colourful_output __version__ = '1.3' log = logging.getLogger(__name__) -def add_shared_install_options(parser): - parser.add_argument('--user', action='store_true', default=None, - help="Do a user-local install (default if site.ENABLE_USER_SITE is True)" - ) - parser.add_argument('--env', action='store_false', dest='user', - help="Install into sys.prefix (default if site.ENABLE_USER_SITE is False, i.e. in virtualenvs)" - ) - parser.add_argument('--python', default=sys.executable, - help="Target Python executable, if different from the one running flit" - ) - def main(argv=None): ap = argparse.ArgumentParser() ap.add_argument('-f', '--ini-file', type=pathlib.Path, default='pyproject.toml') @@ -31,53 +21,13 @@ def main(argv=None): ) ap.add_argument('--debug', action='store_true', help=argparse.SUPPRESS) ap.add_argument('--logo', action='store_true', help=argparse.SUPPRESS) - subparsers = ap.add_subparsers(title='subcommands', dest='subcmd') - - parser_build = subparsers.add_parser('build', - help="Build wheel and sdist", - ) - - parser_build.add_argument('--format', action='append', - help="Select a format to build. Options: 'wheel', 'sdist'" - ) - - parser_publish = subparsers.add_parser('publish', - help="Upload wheel and sdist", - ) - - parser_publish.add_argument('--format', action='append', - help="Select a format to publish. Options: 'wheel', 'sdist'" - ) - parser_install = subparsers.add_parser('install', - help="Install the package", - ) - parser_install.add_argument('-s', '--symlink', action='store_true', - help="Symlink the module/package into site packages instead of copying it" - ) - parser_install.add_argument('--pth-file', action='store_true', - help="Add .pth file for the module/package to site packages instead of copying it" - ) - add_shared_install_options(parser_install) - parser_install.add_argument('--deps', choices=['all', 'production', 'develop', 'none'], default='all', - help="Which set of dependencies to install. If --deps=develop, the extras dev, doc, and test are installed" - ) - parser_install.add_argument('--extras', default=(), type=lambda l: l.split(',') if l else (), - help="Install the dependencies of these (comma separated) extras additionally to the ones implied by --deps. " - "--extras=all can be useful in combination with --deps=production, --deps=none precludes using --extras" - ) - - parser_installfrom = subparsers.add_parser('installfrom', - help="Download and install a package using flit from source" - ) - parser_installfrom.add_argument('location', - help="A URL to download, or a shorthand like github:takluyver/flit" - ) - add_shared_install_options(parser_installfrom) - - parser_init = subparsers.add_parser('init', - help="Prepare pyproject.toml for a new package" - ) + subparsers = ap.add_subparsers(title='subcommands', dest='subcmd') + register(subparsers, 'build') + register(subparsers, 'publish') + register(subparsers, 'install') + register(subparsers, 'installfrom') + register(subparsers, 'info') args = ap.parse_args(argv) @@ -104,30 +54,9 @@ def main(argv=None): print(clogo.format(version=__version__)) sys.exit(0) - if args.subcmd == 'build': - from .build import main - try: - main(args.ini_file, formats=set(args.format or [])) - except(common.NoDocstringError, common.VCSError, common.NoVersionError) as e: - sys.exit(e.args[0]) - elif args.subcmd == 'publish': - from .upload import main - main(args.ini_file, args.repository, formats=set(args.format or [])) - - elif args.subcmd == 'install': - from .install import Installer - try: - Installer(args.ini_file, user=args.user, python=args.python, - symlink=args.symlink, deps=args.deps, extras=args.extras, - pth=args.pth_file).install() - except (common.NoDocstringError, common.NoVersionError) as e: - sys.exit(e.args[0]) - elif args.subcmd == 'installfrom': - from .installfrom import installfrom - sys.exit(installfrom(args.location, user=args.user, python=args.python)) - elif args.subcmd == 'init': - from .init import TerminalIniter - TerminalIniter().initialise() + if args.subcmd: + exitcode = args.subcmd_entrypoint(args) + sys.exit(exitcode) else: ap.print_help() sys.exit(1) diff --git a/flit/subcommand/__init__.py b/flit/subcommand/__init__.py new file mode 100644 index 00000000..9a638e83 --- /dev/null +++ b/flit/subcommand/__init__.py @@ -0,0 +1,38 @@ +""" +The "subcommand" package contains implementations for CLI commands in flit. + +Subcommands must: + +* Contain an implementation in ``flit/subcommand/.py`` +* Be registered using ``flit.subcommand.register`` before parsing the arguments + +Each subcommand module must contain the following names: + +* NAME: the name of the command which is exposed in the CLI args (this is what + the user types to trigger that subcommand). +* HELP: A short 1-line help which is displayed when the user runs + ``flit --help`` +* setup: A callable which sets up the subparsers. As a single argument it gets + a reference to the sub-parser. No return required. +* run: A callable which gets executed when the subcommand is selected by the + end-user. As a single argument it gets a reference to the root + argument-parser. It should return an integer representing the exit-code of + the application. +""" +from importlib import import_module + + +def register(main_parser, module_name): + """ + This registers a new subcommand with the main argument parser. + + :param main_parser: A reference to the main argument parser instance. + :param module_name: The base-name of the subcommand module. If a module is + added as ``flit/subcommand/foo.py``, this should be ``foo``. This value + is used to dynamically import the subcommend so it must be a valid + module name. + """ + subcmd = import_module('flit.subcommand.%s' % module_name) + parser = main_parser.add_parser(subcmd.NAME,help=subcmd.HELP) + parser.set_defaults(subcmd_entrypoint=subcmd.run) + subcmd.setup(parser) diff --git a/flit/subcommand/build.py b/flit/subcommand/build.py new file mode 100644 index 00000000..b063cbb3 --- /dev/null +++ b/flit/subcommand/build.py @@ -0,0 +1,25 @@ +""" +This module contains the implementation for the "build" subcommand. +""" +import sys + +from .. import common +from ..build import main + +NAME = 'build' +HELP = "Build wheel and sdist" + + +def setup(parser): + parser.add_argument( + '--format', action='append', + help="Select a format to build. Options: 'wheel', 'sdist'" + ) + + +def run(args): + try: + main(args.ini_file, formats=set(args.format or [])) + except(common.NoDocstringError, common.VCSError, common.NoVersionError) as e: + return e.args[0] + return 0 diff --git a/flit/subcommand/info.py b/flit/subcommand/info.py new file mode 100644 index 00000000..1b842992 --- /dev/null +++ b/flit/subcommand/info.py @@ -0,0 +1,27 @@ +""" +This module contains the implementation for the "info" subcommand +""" + +import sys + +from .. import inifile +from ..common import Module, make_metadata + +NAME = 'info' +HELP = "Retrieve metadata information from the project" + + +def setup(parser): + parser.add_argument( + '--version', default=False, action='store_true', dest='show_version', + help="Print the version number of the project to stdout" + ) + + +def run(args): + ini_info = inifile.read_pkg_ini(args.ini_file) + module = Module(ini_info['module'], args.ini_file.parent) + metadata = make_metadata(module, ini_info) + output = metadata.version + print(output) + return 0 diff --git a/flit/subcommand/init.py b/flit/subcommand/init.py new file mode 100644 index 00000000..42b7662c --- /dev/null +++ b/flit/subcommand/init.py @@ -0,0 +1,17 @@ +""" +This module contains the implementation for the "init" subcommand +""" + +from ..init import TerminalIniter + +NAME = 'init' +HELP = "Prepare pyproject.toml for a new package" + + +def setup(parser): + pass + + +def run(args): + TerminalIniter().initialise() + return 0 diff --git a/flit/subcommand/install.py b/flit/subcommand/install.py new file mode 100644 index 00000000..7890b168 --- /dev/null +++ b/flit/subcommand/install.py @@ -0,0 +1,52 @@ +""" +This module contains the implementation for the "install" subcommand + +This module also contains a definition of ``add_shared_install_options`` which +can be used to set up additional arguments for an "install-type" subcommand. +""" + +import sys +from ..install import Installer +from .. import common + +NAME = 'install' +HELP = "Install the package" + + +def add_shared_install_options(parser): + parser.add_argument('--user', action='store_true', default=None, + help="Do a user-local install (default if site.ENABLE_USER_SITE is True)" + ) + parser.add_argument('--env', action='store_false', dest='user', + help="Install into sys.prefix (default if site.ENABLE_USER_SITE is False, i.e. in virtualenvs)" + ) + parser.add_argument('--python', default=sys.executable, + help="Target Python executable, if different from the one running flit" + ) + + +def setup(parser): + parser.add_argument('-s', '--symlink', action='store_true', + help="Symlink the module/package into site packages instead of copying it" + ) + parser.add_argument('--pth-file', action='store_true', + help="Add .pth file for the module/package to site packages instead of copying it" + ) + parser.add_argument('--deps', choices=['all', 'production', 'develop', 'none'], default='all', + help="Which set of dependencies to install. If --deps=develop, the extras dev, doc, and test are installed" + ) + parser.add_argument('--extras', default=(), type=lambda l: l.split(',') if l else (), + help="Install the dependencies of these (comma separated) extras additionally to the ones implied by --deps. " + "--extras=all can be useful in combination with --deps=production, --deps=none precludes using --extras" + ) + add_shared_install_options(parser) + + +def run(args): + try: + Installer(args.ini_file, user=args.user, python=args.python, + symlink=args.symlink, deps=args.deps, extras=args.extras, + pth=args.pth_file).install() + except (common.NoDocstringError, common.NoVersionError) as e: + return e.args[0] + return 0 diff --git a/flit/subcommand/installfrom.py b/flit/subcommand/installfrom.py new file mode 100644 index 00000000..4b0a167a --- /dev/null +++ b/flit/subcommand/installfrom.py @@ -0,0 +1,24 @@ +""" +This module contains the implementation of the "installfrom" subcommand. +""" + +import sys + +from ..installfrom import installfrom +from .install import add_shared_install_options + +NAME = 'installfrom' +HELP = "Download and install a package using flit from source" + + +def setup(parser): + parser.add_argument( + 'location', + help="A URL to download, or a shorthand like github:takluyver/flit" + ) + add_shared_install_options(parser) + + +def run(args): + returncode = installfrom(args.location, user=args.user, python=args.python) + return returncode diff --git a/flit/subcommand/publish.py b/flit/subcommand/publish.py new file mode 100644 index 00000000..e92c97f5 --- /dev/null +++ b/flit/subcommand/publish.py @@ -0,0 +1,18 @@ +""" +This module contains the implementation of the "installfrom" subcommand. +""" +from ..upload import main + +NAME = 'publish' +HELP = "Upload wheel and sdist" + + +def setup(parser): + parser.add_argument('--format', action='append', + help="Select a format to publish. Options: 'wheel', 'sdist'" + ) + + +def run(args): + main(args.ini_file, args.repository, formats=set(args.format or [])) + return 0 diff --git a/tests/test_command.py b/tests/test_command.py index 1cec17da..127116e7 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -1,5 +1,8 @@ +from flit.common import VCSError, NoVersionError, NoDocstringError from subprocess import Popen, PIPE, STDOUT +from unittest.mock import patch, MagicMock import sys +import pytest def test_flit_help(): p = Popen([sys.executable, '-m', 'flit', '--help'], stdout=PIPE, stderr=STDOUT) @@ -11,3 +14,126 @@ def test_flit_usage(): out, _ = p.communicate() assert 'Build wheel' in out.decode('utf-8', 'replace') assert p.poll() == 1 + +def test_flit_version(): + import flit + version = flit.__version__ + + p = Popen([sys.executable, '-m', 'flit', 'info', '--version'], + stdout=PIPE, stderr=PIPE) + out, _ = p.communicate() + assert out.decode('utf-8', 'replace').strip() == version + + +def test_flit_init(): + from flit.subcommand import init + with patch('flit.subcommand.init.TerminalIniter') as ptch: + exitcode = init.run(None) + ptch().initialise.assert_called_with() + assert exitcode == 0 + + +def test_flit_build(): + from flit.subcommand import build + mock_args = MagicMock(ini_file='foo', format='bar') + with patch('flit.subcommand.build.main') as ptch: + exitcode = build.run(mock_args) + ptch.assert_called_with('foo', formats=set('bar')) + assert exitcode == 0 + + +@pytest.mark.parametrize('error_instance', [ + NoDocstringError('whoops'), + VCSError('whoops', 'dirname'), + NoVersionError('whoops'), +]) +def test_flit_build_error(error_instance): + from flit.subcommand import build + mock_args = MagicMock(ini_file='foo', format='bar') + with patch('flit.subcommand.build.main') as ptch: + ptch.side_effect = error_instance + exitcode = build.run(mock_args) + assert exitcode != 0 + + +def test_flit_install(): + from flit.subcommand import install + # Set up simple sentinels. We don't care about the type. We only want to + # make sure the correct argument-parser values are passed to the correct + # constructor args. + mock_args = MagicMock( + ini_file='inifile', + user='user', + python='python', + symlink='symlink', + deps='deps', + extras='extras', + pth_file='pth', + ) + with patch('flit.subcommand.install.Installer') as ptch: + exitcode = install.run(mock_args) + ptch.assert_called_with( + 'inifile', + user='user', + python='python', + symlink='symlink', + deps='deps', + extras='extras', + pth='pth' + ) + ptch().install.assert_called_with() + assert exitcode == 0 + + +@pytest.mark.parametrize('error_instance', [ + NoDocstringError('whoops'), + NoVersionError('whoops') +]) +def test_flit_install_error(error_instance): + from flit.subcommand import install + mock_args = MagicMock(ini_file='foo', format='bar') + with patch('flit.subcommand.install.Installer') as ptch: + ptch.side_effect = error_instance + exitcode = install.run(mock_args) + assert exitcode != 0 + + +def test_flit_installfrom(): + from flit.subcommand import installfrom + # Set up simple sentinels. We don't care about the type. We only want to + # make sure the correct argument-parser values are passed to the correct + # constructor args. + mock_args = MagicMock( + location='location', + user='user', + python='python', + ) + with patch('flit.subcommand.installfrom.installfrom') as ptch: + ptch.return_value = 0 + exitcode = installfrom.run(mock_args) + ptch.assert_called_with( + 'location', + user='user', + python='python', + ) + assert exitcode == 0 + + +def test_flit_publish(): + from flit.subcommand import publish + # Set up simple sentinels. We don't care about the type. We only want to + # make sure the correct argument-parser values are passed to the correct + # constructor args. + mock_args = MagicMock( + ini_file='ini_file', + repository='repository', + format='format', + ) + with patch('flit.subcommand.publish.main') as ptch: + exitcode = publish.run(mock_args) + ptch.assert_called_with( + 'ini_file', + 'repository', + formats=set('format') + ) + assert exitcode == 0