From c1d90d82980901f8da7bd17546e897bdb7a4a345 Mon Sep 17 00:00:00 2001 From: Michel Albert Date: Wed, 1 May 2019 11:50:07 +0200 Subject: [PATCH 1/4] Add "flit info --version" command References #262 --- flit/__init__.py | 13 +++++++++++++ flit/info.py | 20 ++++++++++++++++++++ tests/test_command.py | 9 +++++++++ 3 files changed, 42 insertions(+) create mode 100644 flit/info.py diff --git a/flit/__init__.py b/flit/__init__.py index 64e63e3a..1bf84ee3 100644 --- a/flit/__init__.py +++ b/flit/__init__.py @@ -79,6 +79,14 @@ def main(argv=None): help="Prepare pyproject.toml for a new package" ) + parser_info = subparsers.add_parser('info', + help="Retrieve metadata information from the project", + ) + parser_info.add_argument( + '--version', default=False, action='store_true', dest='show_version', + help="Print the version number of the project to stdout" + ) + args = ap.parse_args(argv) cf = args.ini_file @@ -99,6 +107,11 @@ def main(argv=None): log.debug("Parsed arguments %r", args) + if args.subcmd == 'info' and args.show_version: + from .info import get_version + print(get_version(args.ini_file)) + sys.exit(0) + if args.logo: from .logo import clogo print(clogo.format(version=__version__)) diff --git a/flit/info.py b/flit/info.py new file mode 100644 index 00000000..642dab67 --- /dev/null +++ b/flit/info.py @@ -0,0 +1,20 @@ +""" +This module contains code for the "info" subcommand +""" + + +def get_version(ini_path): + # type: (str) -> str + """ + This will return the package version as a string. + + :param ini_path: The filename of the main config-file + (flit.ini/pyproject.toml) + """ + from . import inifile + from .common import Module, make_metadata + ini_info = inifile.read_pkg_ini(ini_path) + module = Module(ini_info['module'], ini_path.parent) + metadata = make_metadata(module, ini_info) + output = metadata.version + return output diff --git a/tests/test_command.py b/tests/test_command.py index 1cec17da..804154ec 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -11,3 +11,12 @@ 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=STDOUT) + out, _ = p.communicate() + assert out.decode('utf-8', 'replace').strip() == version From b4fed3d174397f6b2c7d9af1817dc7aacd859412 Mon Sep 17 00:00:00 2001 From: Michel Albert Date: Wed, 1 May 2019 13:09:59 +0200 Subject: [PATCH 2/4] Refactor subcommands This change yanks out each subcommand from `flit/__init__.py` and moves them to an isolated file in `flit/subcommand`. Additionally, `flit.subcommand.register` has been implemented to dynamically load these subcommands and make them available via argparse. See `flit/subcommand/__init__.py` for details. This could have been implemented as classes/subclasses which would avoid import caching and aid testing, but, in my opinion the structure with modules is simpler. --- flit/__init__.py | 104 ++++----------------------------- flit/info.py | 20 ------- flit/subcommand/__init__.py | 38 ++++++++++++ flit/subcommand/build.py | 25 ++++++++ flit/subcommand/info.py | 27 +++++++++ flit/subcommand/init.py | 17 ++++++ flit/subcommand/install.py | 52 +++++++++++++++++ flit/subcommand/installfrom.py | 24 ++++++++ flit/subcommand/publish.py | 18 ++++++ 9 files changed, 211 insertions(+), 114 deletions(-) delete mode 100644 flit/info.py create mode 100644 flit/subcommand/__init__.py create mode 100644 flit/subcommand/build.py create mode 100644 flit/subcommand/info.py create mode 100644 flit/subcommand/init.py create mode 100644 flit/subcommand/install.py create mode 100644 flit/subcommand/installfrom.py create mode 100644 flit/subcommand/publish.py diff --git a/flit/__init__.py b/flit/__init__.py index 1bf84ee3..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,61 +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" - ) - - parser_info = subparsers.add_parser('info', - help="Retrieve metadata information from the project", - ) - parser_info.add_argument( - '--version', default=False, action='store_true', dest='show_version', - help="Print the version number of the project to stdout" - ) + 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) @@ -107,40 +49,14 @@ def main(argv=None): log.debug("Parsed arguments %r", args) - if args.subcmd == 'info' and args.show_version: - from .info import get_version - print(get_version(args.ini_file)) - sys.exit(0) - if args.logo: from .logo import clogo 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/info.py b/flit/info.py deleted file mode 100644 index 642dab67..00000000 --- a/flit/info.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -This module contains code for the "info" subcommand -""" - - -def get_version(ini_path): - # type: (str) -> str - """ - This will return the package version as a string. - - :param ini_path: The filename of the main config-file - (flit.ini/pyproject.toml) - """ - from . import inifile - from .common import Module, make_metadata - ini_info = inifile.read_pkg_ini(ini_path) - module = Module(ini_info['module'], ini_path.parent) - metadata = make_metadata(module, ini_info) - output = metadata.version - return output 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..5de561ff --- /dev/null +++ b/flit/subcommand/installfrom.py @@ -0,0 +1,24 @@ +""" +This module contains the implementation of the "installfrom" subcommand. +""" + +import sys + +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): + from ..installfrom import installfrom + 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 From eda5758a8d0276219eaae8800abd0bfde8e5b476 Mon Sep 17 00:00:00 2001 From: Michel Albert Date: Wed, 1 May 2019 13:32:18 +0200 Subject: [PATCH 3/4] Fix unit-test for "flit info --version" The "stderr" stream was redirected to stdout which made the test not representative of the intended use-case and also caused issues on travis --- tests/test_command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_command.py b/tests/test_command.py index 804154ec..bf6a0c29 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -17,6 +17,6 @@ def test_flit_version(): version = flit.__version__ p = Popen([sys.executable, '-m', 'flit', 'info', '--version'], - stdout=PIPE, stderr=STDOUT) + stdout=PIPE, stderr=PIPE) out, _ = p.communicate() assert out.decode('utf-8', 'replace').strip() == version From e7cd73d08b82f4d17b811c0ec682bbfaa400ad61 Mon Sep 17 00:00:00 2001 From: Michel Albert Date: Wed, 1 May 2019 14:12:27 +0200 Subject: [PATCH 4/4] Improve coverage on latest changes --- flit/subcommand/installfrom.py | 2 +- tests/test_command.py | 117 +++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/flit/subcommand/installfrom.py b/flit/subcommand/installfrom.py index 5de561ff..4b0a167a 100644 --- a/flit/subcommand/installfrom.py +++ b/flit/subcommand/installfrom.py @@ -4,6 +4,7 @@ import sys +from ..installfrom import installfrom from .install import add_shared_install_options NAME = 'installfrom' @@ -19,6 +20,5 @@ def setup(parser): def run(args): - from ..installfrom import installfrom returncode = installfrom(args.location, user=args.user, python=args.python) return returncode diff --git a/tests/test_command.py b/tests/test_command.py index bf6a0c29..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) @@ -20,3 +23,117 @@ def test_flit_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