diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ed530795..360ac934 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,4 +45,5 @@ repos: - id: mypy files: ^(fmf) additional_dependencies: + - click - types-jsonschema diff --git a/fmf/__init__.py b/fmf/__init__.py index 12af8b89..5e574ba1 100644 --- a/fmf/__init__.py +++ b/fmf/__init__.py @@ -11,6 +11,7 @@ __version__ = importlib.metadata.version("fmf") __all__ = [ + "__version__", "Context", "Tree", "filter", diff --git a/fmf/__main__.py b/fmf/__main__.py new file mode 100644 index 00000000..4e28416e --- /dev/null +++ b/fmf/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/fmf/cli.py b/fmf/cli.py index 67e57470..575f848a 100644 --- a/fmf/cli.py +++ b/fmf/cli.py @@ -16,207 +16,217 @@ of available options. """ -import argparse -import os -import os.path -import shlex -import sys -from typing import Optional +import functools +from collections.abc import Iterator +from contextlib import contextmanager +from os import chdir, getcwd +from pathlib import Path +from typing import Any, Callable, Optional, TypeVar, Union, cast -import fmf -import fmf.utils as utils +import click +from click import Context +from click_option_group import optgroup -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -# Parser -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +from fmf import Tree, __version__ +from fmf.utils import (GeneralError, clean_cache_directory, color, + get_cache_directory, info, listed, log) +# import sys +# if sys.version_info < (3, 8): +# from typing_extensions import ParamSpec, Concatenate +# else: +# from typing import ParamSpec, Concatenate +# from typing_extensions import ParamSpec, Concatenate -class Parser: - """ Command line options parser """ - arguments: list[str] - - def __init__(self, arguments: Optional[list[str]] = None, path: Optional[str] = None): - """ Prepare the parser. """ - # Change current working directory (used for testing) - if path is not None: - os.chdir(path) - # Split command line if given as a string (used for testing) - if isinstance(arguments, str): - self.arguments = shlex.split(arguments) - # Otherwise use sys.argv - if arguments is None: - self.arguments = sys.argv - # Enable debugging output if requested - if "--debug" in self.arguments: - utils.log.setLevel(utils.LOG_DEBUG) - # Show current version and exit - if "--version" in self.arguments: - self.output = f"{fmf.__version__}" - print(self.output) - return - - # Handle subcommands (mapped to format_* methods) - self.parser = argparse.ArgumentParser( - usage="fmf command [options]\n" + __doc__) - self.parser.add_argument( - "--version", action="store_true", - help="print fmf version with commit hash and exit") - self.parser.add_argument('command', help='Command to run') - self.command = self.parser.parse_args(self.arguments[1:2]).command - if not hasattr(self, "command_" + self.command): - self.parser.print_help() - raise utils.GeneralError( - "Unrecognized command: '{0}'".format(self.command)) - # Initialize the rest and run the subcommand - self.output = "" - getattr(self, "command_" + self.command)() - - def options_select(self) -> None: - """ Select by name, filter """ - group = self.parser.add_argument_group("Select") - group.add_argument( - "--key", dest="keys", action="append", default=[], - help="Key content definition (required attributes)") - group.add_argument( - "--name", dest="names", action="append", default=[], - help="List objects with name matching regular expression") - group.add_argument( - "--source", dest="sources", action="append", default=[], - help="List objects defined in specified source files") - group.add_argument( - "--filter", dest="filters", action="append", default=[], - help="Apply advanced filter (see 'pydoc fmf.filter')") - group.add_argument( - "--condition", dest="conditions", action="append", default=[], - metavar="EXPR", - help="Use arbitrary Python expression for filtering") - group.add_argument( - "--whole", dest="whole", action="store_true", - help="Consider the whole tree (leaves only by default)") - - def options_formatting(self) -> None: - """ Formating options """ - group = self.parser.add_argument_group("Format") - group.add_argument( - "--format", dest="formatting", default=None, - help="Custom output format using the {} expansion") - group.add_argument( - "--value", dest="values", action="append", default=[], - help="Values for the custom formatting string") - - def options_utils(self) -> None: - """ Utilities """ - group = self.parser.add_argument_group("Utils") - group.add_argument( - "--path", action="append", dest="paths", - help="Path to the metadata tree (default: current directory)") - group.add_argument( - "--verbose", action="store_true", - help="Print information about parsed files to stderr") - group.add_argument( - "--debug", action="store_true", - help="Turn on debugging output, do not catch exceptions") - - def command_ls(self) -> None: - """ List names """ - self.parser = argparse.ArgumentParser( - description="List names of available objects") - self.options_select() - self.options_utils() - self.options = self.parser.parse_args(self.arguments[2:]) - self.show(brief=True) - - def command_clean(self) -> None: - """ Clean cache """ - self.parser = argparse.ArgumentParser( - description="Remove cache directory and its content") - self.clean() - - def command_show(self) -> None: - """ Show metadata """ - self.parser = argparse.ArgumentParser( - description="Show metadata of available objects") - self.options_select() - self.options_formatting() - self.options_utils() - self.options = self.parser.parse_args(self.arguments[2:]) - self.show(brief=False) - - def command_init(self) -> None: - """ Initialize tree """ - self.parser = argparse.ArgumentParser( - description="Initialize a new metadata tree") - self.options_utils() - self.options = self.parser.parse_args(self.arguments[2:]) - # For each path create an .fmf directory and version file - for path in self.options.paths or ["."]: - root = fmf.Tree.init(path) - print("Metadata tree '{0}' successfully initialized.".format(root)) - - def show(self, brief: bool = False) -> None: - """ Show metadata for each path given """ - output = [] - for path in self.options.paths or ["."]: - if self.options.verbose: - utils.info("Checking {0} for metadata.".format(path)) - tree = fmf.Tree(path) - for node in tree.prune( - self.options.whole, - self.options.keys, - self.options.names, - self.options.filters, - self.options.conditions, - self.options.sources): - if brief: - show = node.show(brief=True) - else: - show = node.show( - brief=False, - formatting=self.options.formatting, - values=self.options.values) - # List source files when in debug mode - if self.options.debug: - for source in node.sources: - show += utils.color("{0}\n".format(source), "blue") - if show is not None: - output.append(show) - - # Print output and summary - if brief or self.options.formatting: - joined = "".join(output) - else: - joined = "\n".join(output) - print(joined, end="") - if self.options.verbose: - utils.info("Found {0}.".format( - utils.listed(len(output), "object"))) - self.output = joined - - def clean(self) -> None: - """ Remove cache directory """ - try: - cache = utils.get_cache_directory(create=False) - utils.clean_cache_directory() - print("Cache directory '{0}' has been removed.".format(cache)) - except Exception as error: # pragma: no cover - utils.log.error( - "Unable to remove cache, exception was: {0}".format(error)) + +# Typing +F = TypeVar('F', bound=Callable[..., Any]) + + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# Common option groups +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +def _select_options(func: F) -> F: + """Select group options""" + + # Type is not yet supported for click option groups + # https://github.com/click-contrib/click-option-group/issues/49 + @optgroup.group('Select') + @optgroup.option('--key', 'keys', metavar='KEY', default=[], multiple=True, + help='Key content definition (required attributes)') + @optgroup.option("--name", 'names', metavar='NAME', default=[], multiple=True, + help="List objects with name matching regular expression") + @optgroup.option("--source", 'sources', metavar='SOURCE', default=[], multiple=True, + help="List objects defined in specified source files") + @optgroup.option("--filter", 'filters', metavar='FILTER', default=[], multiple=True, + help="Apply advanced filter (see 'pydoc fmf.filter')") + @optgroup.option("--condition", 'conditions', metavar="EXPR", default=[], multiple=True, + help="Use arbitrary Python expression for filtering") + @optgroup.option("--whole", is_flag=True, default=False, + help="Consider the whole tree (leaves only by default)") + @functools.wraps(func) + def wrapper(*args, **kwargs): + # Hack to group the options into one variable + select = { + opt: kwargs.pop(opt) + for opt in ('keys', 'names', 'sources', 'filters', 'conditions', 'whole') + } + return func(*args, select=select, **kwargs) + + # return wrapper + return cast(F, wrapper) + + +def _format_options(func: F) -> F: + """Format group options""" + + @optgroup.group('Format') + @optgroup.option("--format", "formatting", metavar="FORMAT", default=None, + help="Custom output format using the {} expansion") + @optgroup.option("--value", "values", metavar="VALUE", default=[], multiple=True, + help="Values for the custom formatting string") + @functools.wraps(func) + def wrapper(*args, **kwargs): + # Hack to group the options into one variable + format = { + opt: kwargs.pop(opt) + for opt in ('formatting', 'values') + } + return func(*args, format=format, **kwargs) + + return cast(F, wrapper) + + +def _utils_options(func: F) -> F: + """Utils group options""" + + @optgroup.group('Utils') + @optgroup.option("--path", "paths", metavar="PATH", multiple=True, + type=Path, default=["."], + show_default='current directory', + help="Path to the metadata tree") + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return cast(F, wrapper) # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Main # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +class CatchAllExceptions(click.Group): -def main(arguments: Optional[list[str]] = None, path: Optional[str] = None) -> str: - """ Parse options, do what is requested """ - parser = Parser(arguments, path) - return parser.output + def __call__(self, *args, **kwargs): + try: + return self.main(*args, **kwargs) + except Exception as err: + raise GeneralError("fmf cli command failed") from err + + +@click.group("fmf", cls=CatchAllExceptions) +@click.version_option(__version__, message="%(version)s") +@click.option("--verbose", is_flag=True, default=False, type=bool, + help="Print information about parsed files to stderr") +@click.option("--debug", "-d", count=True, default=0, type=int, + help="Provide debugging information. Repeat to see more details.") +@click.pass_context +def main(ctx: Context, debug: int, verbose: bool) -> None: + """This is command line interface for the Flexible Metadata Format.""" + ctx.ensure_object(dict) + log.setLevel(debug) + ctx.obj['verbose'] = verbose + ctx.obj['debug'] = debug -def cli_entry(): +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# Sub-commands +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +@main.command("ls") +@_select_options +@_utils_options +@click.pass_context +def ls(ctx: Context, paths: list[Path], select: dict[str, Any]) -> None: + """List names of available objects""" + _show(ctx, paths, select, brief=True) + + +@main.command("show") +@_select_options +@_format_options +@_utils_options +@click.pass_context +def show(ctx: Context, paths: list[Path], select: dict[str, Any], format: dict[str, Any]) -> None: + """List names of available objects""" + _show(ctx, paths, select, format_opts=format, brief=False) + + +@main.command("init") +@_utils_options +def init(paths: list[Path]) -> None: + """Initialize a new metadata tree""" + # For each path create an .fmf directory and version file + for p in paths: + root = Tree.init(str(p)) + click.echo(f"Metadata tree '{root}' successfully initialized.") + + +@main.command("clean") +def clean() -> None: + """ Remove cache directory and its content """ + try: + cache = get_cache_directory(create=False) + clean_cache_directory() + click.echo(f"Cache directory '{cache}' has been removed.") + except Exception as err: # pragma: no cover + raise GeneralError("Unable to remove cache") from err + + +def _show(ctx: Context, paths: list[Path], select_opts: dict[str, Any], + format_opts: Optional[dict[str, Any]] = None, + brief: bool = False) -> None: + """ Show metadata for each path given """ + output = [] + for p in paths: + if ctx.obj['verbose']: + info(f"Checking {p} for metadata.") + tree = Tree(str(p)) + for node in tree.prune(**select_opts): + if brief: + show_out = node.show(brief=True) + else: + assert format_opts is not None + show_out = node.show(brief=False, **format_opts) + # List source files when in debug mode + if ctx.obj['debug']: + for source in node.sources: + show_out += color(f"{source}\n", "blue") + if show_out is not None: + output.append(show_out) + + # Print output and summary + if brief or format_opts and format_opts['formatting']: + joined = "".join(output) + else: + joined = "\n".join(output) + click.echo(joined, nl=False) + if ctx.obj['verbose']: + info(f"Found {listed(len(output), 'object')}.") + + +@contextmanager +def cd(target: Union[str, Path]) -> Iterator[None]: + """ + Manage cd in a pushd/popd fashion. + + Usage: + + with cd(tmpdir): + do something in tmpdir + """ + curdir = getcwd() + chdir(target) try: - main() - except fmf.utils.GeneralError as error: - if "--debug" not in sys.argv: - fmf.utils.log.error(error) - raise + yield + finally: + chdir(curdir) diff --git a/pyproject.toml b/pyproject.toml index ed74a547..fa5af991 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,8 @@ dependencies = [ 'filelock', 'jsonschema', 'typing-extensions ; python_version<"3.10"', + 'click', + 'click-option-group', ] dynamic = ['version'] @@ -60,7 +62,7 @@ all = [ ] [project.scripts] -fmf = 'fmf.cli:cli_entry' +fmf = 'fmf.cli:main' [tool.hatch] version.source = 'vcs' diff --git a/tests/unit/test_base.py b/tests/unit/test_base.py index 4c64cfcf..1e9cd04f 100644 --- a/tests/unit/test_base.py +++ b/tests/unit/test_base.py @@ -3,14 +3,17 @@ import tempfile import threading import time +from pathlib import Path from shutil import rmtree import pytest +from click.testing import CliRunner from ruamel.yaml import YAML -import fmf.cli +import fmf import fmf.utils as utils from fmf.base import Tree +from fmf.cli import cd, main # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Constants @@ -186,42 +189,39 @@ def test_find_root(self): tree = Tree(os.path.join(EXAMPLES, "wget", "protocols")) assert tree.find("/download/test") - def test_yaml_syntax_errors(self): + def test_yaml_syntax_errors(self, tmp_path): """ Handle YAML syntax errors """ - path = tempfile.mkdtemp() - fmf.cli.main("fmf init", path) - with open(os.path.join(path, "main.fmf"), "w") as main: - main.write("missing\ncolon:") - with pytest.raises(utils.FileError): - fmf.Tree(path) - rmtree(path) - - def test_yaml_duplicate_keys(self): + with cd(tmp_path): + CliRunner().invoke(main, args=['init']) + with open("main.fmf", "w") as main_fmf: + main_fmf.write("missing\ncolon:") + with pytest.raises(utils.FileError): + fmf.Tree('.') + + def test_yaml_duplicate_keys(self, tmp_path): """ Handle YAML duplicate keys """ - path = tempfile.mkdtemp() - fmf.cli.main("fmf init", path) - - # Simple test - with open(os.path.join(path, "main.fmf"), "w") as main: - main.write("a: b\na: c\n") - with pytest.raises(utils.FileError): - fmf.Tree(path) - - # Add some hierarchy - subdir = os.path.join(path, "dir") - os.makedirs(subdir) - with open(os.path.join(subdir, "a.fmf"), "w") as new_file: - new_file.write("a: d\n") - with pytest.raises(utils.FileError): - fmf.Tree(path) - - # Remove duplicate key, check that inheritance doesn't - # raise an exception - with open(os.path.join(path, "main.fmf"), "w") as main: - main.write("a: b\n") - fmf.Tree(path) - - rmtree(path) + with cd(tmp_path): + CliRunner().invoke(main, args=['init']) + + # Simple test + with open("main.fmf", "w") as main_fmf: + main_fmf.write("a: b\na: c\n") + with pytest.raises(utils.FileError): + fmf.Tree('.') + + # Add some hierarchy + subdir = Path(".", "dir") + subdir.mkdir() + with open(subdir / "a.fmf", "w") as new_file: + new_file.write("a: d\n") + with pytest.raises(utils.FileError): + fmf.Tree('.') + + # Remove duplicate key, check that inheritance doesn't + # raise an exception + with open("main.fmf", "w") as main_fmf: + main_fmf.write("a: b\n") + fmf.Tree('.') def test_inaccessible_directories(self): """ Inaccessible directories should be silently ignored """ diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 96a6d2e3..74121dce 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1,15 +1,16 @@ import os -import sys -import tempfile +from pathlib import Path import pytest +from click.testing import CliRunner -import fmf.cli import fmf.utils as utils +from fmf.cli import cd, main # Prepare path to examples PATH = os.path.dirname(os.path.realpath(__file__)) WGET = PATH + "/../../examples/wget" +CONDITIONS = PATH + "/../../examples/conditions" class TestCommandLine: @@ -17,143 +18,195 @@ class TestCommandLine: def test_smoke(self): """ Smoke test """ - fmf.cli.main("fmf show", WGET) - fmf.cli.main("fmf show --debug", WGET) - fmf.cli.main("fmf show --verbose", WGET) - fmf.cli.main("fmf --version") + runner = CliRunner() + runner.invoke(main, args=['--version']) + with cd(WGET): + runner.invoke(main, args=['show']) + runner.invoke(main, args=['show', '--debug']) + runner.invoke(main, args=['show', '--verbose']) def test_missing_root(self): """ Missing root """ - with pytest.raises(utils.FileError): - fmf.cli.main("fmf show", "/") + with cd('/'): + result = CliRunner().invoke(main, args=['show']) + assert isinstance(result.exception, utils.FileError) def test_invalid_path(self): """ Missing root """ - with pytest.raises(utils.FileError): - fmf.cli.main("fmf show --path /some-non-existent-path") + result = CliRunner().invoke(main, args=['show', '--path', '/some-non-existent-path']) + assert isinstance(result.exception, utils.FileError) def test_wrong_command(self): """ Wrong command """ - with pytest.raises(utils.GeneralError): - fmf.cli.main("fmf wrongcommand") + result = CliRunner().invoke(main, args=['wrongcommand']) + assert result.exit_code == 2 + assert "Error: No such command" in result.output def test_output(self): """ There is some output """ - output = fmf.cli.main("fmf show", WGET) - assert "download" in output + with cd(WGET): + result = CliRunner().invoke(main, args=['show']) + assert "download" in result.output def test_recursion(self): """ Recursion """ - output = fmf.cli.main("fmf show --name recursion/deep", WGET) - assert "1000" in output + with cd(WGET): + result = CliRunner().invoke(main, args=['show', '--name', 'recursion/deep']) + assert "1000" in result.output def test_inheritance(self): """ Inheritance """ - output = fmf.cli.main("fmf show --name protocols/https", WGET) - assert "psplicha" in output - - def test_sys_argv(self): - """ Parsing sys.argv """ - backup = sys.argv - sys.argv = ['fmf', 'show', '--path', WGET, '--name', 'recursion/deep'] - output = fmf.cli.main() - assert "1000" in output - sys.argv = backup + with cd(WGET): + result = CliRunner().invoke(main, args=['show', '--name', 'protocols/https']) + assert "psplicha" in result.output + + # def test_sys_argv(self): + # """ Parsing sys.argv """ + # backup = sys.argv + # sys.argv = ['fmf', 'show', '--path', WGET, '--name', 'recursion/deep'] + # output = main() + # assert "1000" in output + # sys.argv = backup def test_missing_attribute(self): """ Missing attribute """ - output = fmf.cli.main("fmf show --filter x:y", WGET) - assert "wget" not in output + with cd(WGET): + result = CliRunner().invoke(main, args=['show', '--name', '--filter', 'x:y']) + assert "wget" not in result.output def test_filtering_by_source(self): """ By source """ - output = fmf.cli.main("fmf show --source protocols/ftp/main.fmf", WGET) - assert "/protocols/ftp" in output + with cd(WGET): + result = CliRunner().invoke(main, args=['show', '--source', 'protocols/ftp/main.fmf']) + assert "/protocols/ftp" in result.output def test_filtering(self): """ Filtering """ - output = fmf.cli.main( - "fmf show --filter tags:Tier1 --filter tags:TierSecurity", WGET) - assert "/download/test" in output - output = fmf.cli.main( - "fmf show --filter tags:Tier1 --filter tags:Wrong", WGET) - assert "wget" not in output - output = fmf.cli.main( - " fmf show --filter 'tags: Tier[A-Z].*'", WGET) - assert "/download/test" in output - assert "/recursion" not in output + runner = CliRunner() + with cd(WGET): + result = runner.invoke( + main, + args=[ + 'show', + '--filter', + 'tags:Tier1', + '--filter', + 'tags:TierSecurity']) + assert "/download/test" in result.output + result = runner.invoke( + main, + args=[ + 'show', + '--filter', + 'tags:Tier1', + '--filter', + 'tags:Wrong']) + assert "wget" not in result.output + result = runner.invoke( + main, + args=[ + 'show', + '--filter', + 'tags: Tier[A-Z].*', + '--filter', + 'tags:TierSecurity']) + assert "/download/test" in result.output + assert "/recursion" not in result.output def test_key_content(self): """ Key content """ - output = fmf.cli.main("fmf show --key depth") - assert "/recursion/deep" in output - assert "/download/test" not in output + with cd(WGET): + result = CliRunner().invoke(main, args=['show', '--key', 'depth']) + assert "/recursion/deep" in result.output + assert "/download/test" not in result.output def test_format_basic(self): """ Custom format (basic) """ - output = fmf.cli.main(WGET + "fmf show --format foo") - assert "wget" not in output - assert "foo" in output + result = CliRunner().invoke(main, args=['show', '--format', 'foo']) + assert "wget" not in result.output + assert "foo" in result.output def test_format_key(self): """ Custom format (find by key, check the name) """ - output = fmf.cli.main( - "fmf show --key depth --format {0} --value name", WGET) - assert "/recursion/deep" in output + with cd(WGET): + result = CliRunner().invoke( + main, + args=[ + 'show', + '--key', + 'depth', + '--format', + '{0}', + '--value', + 'name']) + assert "/recursion/deep" in result.output def test_format_functions(self): """ Custom format (using python functions) """ - output = fmf.cli.main( - "fmf show --key depth --format {0} --value os.path.basename(name)", - WGET) - assert "deep" in output - assert "/recursion" not in output + with cd(WGET): + result = CliRunner().invoke( + main, + args=[ + 'show', + '--key', + 'depth', + '--format', + '{0}', + '--value', + 'os.path.basename(name)']) + assert "deep" in result.output + assert "/recursion" not in result.output @pytest.mark.skipif(os.geteuid() == 0, reason="Running as root") - def test_init(self): + def test_init(self, tmp_path): """ Initialize metadata tree """ - path = tempfile.mkdtemp() - fmf.cli.main("fmf init", path) - fmf.cli.main("fmf show", path) - # Already exists - with pytest.raises(utils.FileError): - fmf.cli.main("fmf init", path) - version_path = os.path.join(path, ".fmf", "version") - with open(version_path) as version: - assert "1" in version.read() - # Permission denied - secret_path = os.path.join(path, 'denied') - os.makedirs(secret_path) - os.chmod(secret_path, 0o666) - with pytest.raises(utils.FileError): - fmf.cli.main('fmf init --path {}'.format(secret_path), path) - os.chmod(secret_path, 0o777) - # Invalid version - with open(version_path, "w") as version: - version.write("bad") - with pytest.raises(utils.FormatError): - fmf.cli.main("fmf ls", path) - # Missing version - os.remove(version_path) - with pytest.raises(utils.FormatError): - fmf.cli.main("fmf ls", path) + runner = CliRunner() + with cd(tmp_path): + runner.invoke(main, args=['init']) + runner.invoke(main, args=['show']) + # Already exists + result = runner.invoke(main, args=['init']) + assert isinstance(result.exception, utils.FileError) + version_path = Path('.', ".fmf", "version") + with open(version_path) as version: + assert "1" in version.read() + # Permission denied + secret_path = Path('.', 'denied') + secret_path.mkdir(0o666) + result = runner.invoke( + main, args=['init', '--path', str(secret_path.relative_to('.'))]) + assert isinstance(result.exception, utils.FileError) + secret_path.chmod(0o777) + # Invalid version + with open(version_path, "w") as version: + version.write("bad") + result = runner.invoke(main, args=['ls']) + assert isinstance(result.exception, utils.FormatError) + # Missing version + version_path.unlink() + result = runner.invoke(main, args=['ls']) + assert isinstance(result.exception, utils.FormatError) def test_conditions(self): """ Advanced filters via conditions """ - path = PATH + "/../../examples/conditions" - # Compare numbers - output = fmf.cli.main("fmf ls --condition 'float(release) >= 7'", path) - assert len(output.splitlines()) == 3 - output = fmf.cli.main("fmf ls --condition 'float(release) > 7'", path) - assert len(output.splitlines()) == 2 - # Access a dictionary key - output = fmf.cli.main( - "fmf ls --condition \"execute['how'] == 'dependency'\"", path) - assert output.strip() == "/top/rhel7" - # Wrong key means unsatisfied condition - output = fmf.cli.main( - "fmf ls --condition \"execute['wrong key'] == 0\"", path) - assert output == '' + runner = CliRunner() + with cd(CONDITIONS): + # Compare numbers + result = runner.invoke(main, args=['ls', '--condition', 'float(release) >= 7']) + assert len(result.output.splitlines()) == 3 + result = runner.invoke(main, args=['ls', '--condition', 'float(release) > 7']) + assert len(result.output.splitlines()) == 2 + # Access a dictionary key + result = runner.invoke( + main, + args=[ + 'ls', + '--condition', + 'execute["how"] == "dependency"']) + assert result.output.strip() == "/top/rhel7" + result = runner.invoke(main, args=['ls', '--condition', 'execute["wrong key"] == "0"']) + # Wrong key means unsatisfied condition + assert result.output == '' def test_clean(self, tmpdir, monkeypatch): """ Cache cleanup """ @@ -161,5 +214,5 @@ def test_clean(self, tmpdir, monkeypatch): monkeypatch.setattr('fmf.utils._CACHE_DIRECTORY', str(tmpdir)) testing_file = tmpdir.join("something") testing_file.write("content") - fmf.cli.main("fmf clean") + CliRunner().invoke(main, ['clean']) assert not os.path.isfile(str(testing_file)) diff --git a/tests/unit/test_smoke.py b/tests/unit/test_smoke.py index c41ce6a9..408bfce0 100644 --- a/tests/unit/test_smoke.py +++ b/tests/unit/test_smoke.py @@ -1,6 +1,8 @@ import os -import fmf.cli +from click.testing import CliRunner + +from fmf.cli import cd, main # Prepare path to examples PATH = os.path.dirname(os.path.realpath(__file__)) @@ -12,9 +14,11 @@ class TestSmoke: def test_smoke(self): """ Smoke test """ - fmf.cli.main("fmf ls", WGET) + with cd(WGET): + CliRunner().invoke(main, ['ls']) def test_output(self): """ There is some output """ - output = fmf.cli.main("fmf ls", WGET) - assert "download" in output + with cd(WGET): + result = CliRunner().invoke(main, ['ls']) + assert "download" in result.output