diff --git a/.git_archival.txt b/.git_archival.txt new file mode 100644 index 00000000..7c510094 --- /dev/null +++ b/.git_archival.txt @@ -0,0 +1,3 @@ +node: $Format:%H$ +node-date: $Format:%cI$ +describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..00a7b00c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.git_archival.txt export-subst diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4ca4e41..747edf2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: release +name: 🚀 Release on: release: @@ -11,18 +11,21 @@ on: jobs: release: + name: 🚀 Release runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/fmf + permissions: + id-token: write # For pypi-publish steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 if: ${{ github.event_name == 'release' }} - - uses: actions/checkout@v2 - if: ${{ github.event_name == 'workflow_dispatch' }} + - uses: actions/checkout@v4 with: ref: ${{ github.event.inputs.ref }} - - name: Create dist - run: make wheel + if: ${{ github.event_name == 'workflow_dispatch' }} + - name: Build package + run: pipx hatch build - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.packit.yaml b/.packit.yaml index cf35ac09..f59aedd8 100644 --- a/.packit.yaml +++ b/.packit.yaml @@ -5,15 +5,18 @@ synced_files: upstream_package_name: fmf downstream_package_name: fmf +# Epel9 fails to build with dynamic version. Need to create archive with PKG-INFO +# F37 works with setuptools_scm 7.0 actions: create-archive: - - make tarball + - "hatch build -t sdist" + - "sh -c 'echo dist/fmf-*.tar.gz'" get-current-version: - - make version + - "hatch version" srpm_build_deps: - - make - - python3-docutils + - hatch + - python3-hatch-vcs jobs: - job: copr_build diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index b4ac02bd..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include fmf.spec diff --git a/Makefile b/Makefile index 9ee405b3..0c50731a 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,10 @@ # Prepare variables TMP = $(CURDIR)/tmp -VERSION = $(shell grep ^Version fmf.spec | sed 's/.* //') -COMMIT = $(shell git rev-parse --short HEAD) -REPLACE_VERSION = "s/running from the source/$(VERSION) ($(COMMIT))/" +VERSION = $(hatch version) PACKAGE = fmf-$(VERSION) FILES = LICENSE README.rst \ - Makefile fmf.spec setup.py \ - examples fmf bin tests + Makefile fmf.spec pyproject.toml \ + examples fmf tests # Define special targets all: docs packages @@ -19,35 +17,25 @@ tmp: # Run the test suite, optionally with coverage test: tmp - pytest tests/unit -c tests/unit/pytest.ini + hatch run test:unit smoke: tmp - pytest tests/unit/test_smoke.py -c tests/unit/pytest.ini + hatch run test:smoke coverage: tmp - coverage run --source=fmf,bin -m py.test -c tests/unit/pytest.ini tests - coverage report - coverage annotate + hatch run cov:cov # Build documentation, prepare man page docs: man - cd docs && make html -man: source + hatch run docs:html +man: cp docs/header.txt $(TMP)/man.rst tail -n+7 README.rst >> $(TMP)/man.rst rst2man $(TMP)/man.rst > $(TMP)/$(PACKAGE)/fmf.1 # RPM packaging -source: clean tmp - mkdir -p $(TMP)/SOURCES - mkdir -p $(TMP)/$(PACKAGE) - cp -a $(FILES) $(TMP)/$(PACKAGE) - sed -i $(REPLACE_VERSION) $(TMP)/$(PACKAGE)/fmf/__init__.py -tarball: source man - cd $(TMP) && tar cfz SOURCES/$(PACKAGE).tar.gz $(PACKAGE) - @echo ./tmp/SOURCES/$(PACKAGE).tar.gz -version: - @echo "$(VERSION)" +tarball: man + hatch build -t sdist rpm: tarball rpmbuild --define '_topdir $(TMP)' -bb fmf.spec srpm: tarball @@ -57,10 +45,9 @@ packages: rpm srpm # Python packaging wheel: - python setup.py bdist_wheel - python3 setup.py bdist_wheel + hatch build upload: - twine upload dist/*.whl + hatch publish # Vim tags and cleanup diff --git a/bin/fmf b/bin/fmf deleted file mode 100755 index 0b8a45df..00000000 --- a/bin/fmf +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/python -# coding: utf-8 - -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -# -# fmf - Flexible Metadata Format -# Author: Petr Šplíchal -# -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -# -# Copyright (c) 2018 Red Hat, Inc. -# -# This program is free software: you can redistribute it and/or -# modify it under the terms of the GNU General Public License as -# published by the Free Software Foundation, either version 2 of -# the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be -# useful, but WITHOUT ANY WARRANTY; without even the implied -# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR -# PURPOSE. See the GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see http://www.gnu.org/licenses/. -# -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -import sys - -import fmf.base -import fmf.cli -import fmf.utils - -try: - fmf.cli.main() -except fmf.utils.GeneralError as error: - if "--debug" in sys.argv: - raise - fmf.utils.log.error(error) - raise SystemExit(1) diff --git a/fmf.spec b/fmf.spec index ae8b0cb1..f0b9c78f 100644 --- a/fmf.spec +++ b/fmf.spec @@ -1,16 +1,19 @@ -Name: fmf -Version: 1.4.1 -Release: 1%{?dist} +Name: fmf +Version: 0.0.0 +Release: 1%{?dist} -Summary: Flexible Metadata Format -License: GPLv2+ -BuildArch: noarch +Summary: Flexible Metadata Format +License: GPL-2.0-or-later +BuildArch: noarch -URL: https://github.com/psss/fmf -Source0: https://github.com/psss/fmf/releases/download/%{version}/fmf-%{version}.tar.gz +URL: https://github.com/teemtee/fmf +Source: %{pypi_source fmf} # Main fmf package requires the Python module -Requires: python%{python3_pkgversion}-%{name} == %{version}-%{release} +BuildRequires: python3-devel +BuildRequires: python3dist(docutils) +BuildRequires: git-core +Requires: python3-fmf == %{version}-%{release} %description The fmf Python module and command line tool implement a flexible @@ -20,22 +23,12 @@ with support for inheritance and elasticity it provides an efficient way to organize data into well-sized text documents. This package contains the command line tool. -%?python_enable_dependency_generator - -%package -n python%{python3_pkgversion}-%{name} +%package -n python3-fmf Summary: %{summary} -BuildRequires: python%{python3_pkgversion}-devel -BuildRequires: python%{python3_pkgversion}-setuptools -BuildRequires: python%{python3_pkgversion}-pytest -BuildRequires: python%{python3_pkgversion}-ruamel-yaml -BuildRequires: python%{python3_pkgversion}-filelock -BuildRequires: python%{python3_pkgversion}-jsonschema -BuildRequires: git-core -%{?python_provide:%python_provide python%{python3_pkgversion}-%{name}} Requires: git-core -%description -n python%{python3_pkgversion}-%{name} +%description -n python3-fmf The fmf Python module and command line tool implement a flexible format for defining metadata in plain text files which can be stored close to the source code. Thanks to hierarchical structure @@ -45,24 +38,31 @@ This package contains the Python 3 module. %prep -%autosetup +%autosetup -p1 -n fmf-%{version} + + +%generate_buildrequires +%pyproject_buildrequires -x tests %{?epel:-w} %build -%py3_build +%pyproject_wheel +cp docs/header.txt man.rst +tail -n+7 README.rst >> man.rst +rst2man man.rst > fmf.1 %install -%py3_install +%pyproject_install +%pyproject_save_files fmf + mkdir -p %{buildroot}%{_mandir}/man1 install -pm 644 fmf.1* %{buildroot}%{_mandir}/man1 %check -%{__python3} -m pytest -vv -c tests/unit/pytest.ini -m 'not web' - +%pytest -vv -m 'not web' -%{!?_licensedir:%global license %%doc} %files %{_mandir}/man1/* @@ -70,10 +70,12 @@ install -pm 644 fmf.1* %{buildroot}%{_mandir}/man1 %doc README.rst examples %license LICENSE -%files -n python%{python3_pkgversion}-%{name} -%{python3_sitelib}/%{name}/ -%{python3_sitelib}/%{name}-*.egg-info +%files -n python3-fmf -f %{pyproject_files} +# Epel9 does not tag the license file in pyproject_files as a license. Manually install it in this case +%if 0%{?el9} %license LICENSE +%endif +%doc README.rst %changelog diff --git a/fmf/__init__.py b/fmf/__init__.py index bdca5f5d..5e574ba1 100644 --- a/fmf/__init__.py +++ b/fmf/__init__.py @@ -1,14 +1,18 @@ """ Flexible Metadata Format """ -# Version is replaced before building the package -__version__ = 'running from the source' +from __future__ import annotations + +import importlib.metadata + +from fmf.base import Tree +from fmf.context import Context +from fmf.utils import filter + +__version__ = importlib.metadata.version("fmf") __all__ = [ + "__version__", "Context", "Tree", "filter", ] - -from fmf.base import Tree -from fmf.context import Context -from fmf.utils import 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 7fd1804d..49442ec8 100644 --- a/fmf/cli.py +++ b/fmf/cli.py @@ -16,196 +16,189 @@ of available options. """ -import argparse -import os -import os.path -import shlex -import sys +import functools +from pathlib import Path + +import click +from click_option_group import optgroup import fmf import fmf.utils as utils # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -# Parser +# Common option groups # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -class Parser: - """ Command line options parser """ - - def __init__(self, arguments=None, path=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): - """ 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): - """ 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): - """ 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): - """ 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): - """ Clean cache """ - self.parser = argparse.ArgumentParser( - description="Remove cache directory and its content") - self.clean() - - def command_show(self): - """ 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): - """ 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=False): - """ 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): - """ 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)) +def _select_options(func): + """Select group options""" + + @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 + + +def _format_options(func): + """Formating 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 wrapper + + +def _utils_options(func): + """Utilities 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 wrapper # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Main # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +class CatchAllExceptions(click.Group): + def __call__(self, *args, **kwargs): + # TODO: This actually has no effect + try: + return self.main(*args, **kwargs) + except fmf.utils.GeneralError as error: + # TODO: Better handling of --debug + if "--debug" not in kwargs: + fmf.utils.log.error(error) + raise + + +@click.group("fmf", cls=CatchAllExceptions) +@click.version_option(fmf.__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, debug, verbose) -> None: + """This is command line interface for the Flexible Metadata Format.""" + ctx.ensure_object(dict) + if debug: + utils.log.setLevel(debug) + ctx.obj["verbose"] = verbose + ctx.obj["debug"] = debug -def main(arguments=None, path=None): - """ Parse options, do what is requested """ - parser = Parser(arguments, path) - return parser.output + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# Sub-commands +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +@main.command("ls") +@_select_options +@_utils_options +@click.pass_context +def ls(ctx, paths, select) -> None: + """List names of available objects""" + _show(ctx, paths, select, brief=True) + + +@main.command("clean") +def clean() -> None: + """Remove cache directory and its content""" + _clean() + + +@main.command("show") +@_select_options +@_format_options +@_utils_options +@click.pass_context +def show(ctx, paths, select, format) -> None: + """Show metadata of available objects""" + _show(ctx, paths, select, format_opts=format, brief=False) + + +@main.command("init") +@_utils_options +def init(paths) -> None: + """Initialize a new metadata tree""" + # For each path create an .fmf directory and version file + for path in paths: + root = fmf.Tree.init(path) + click.echo("Metadata tree '{0}' successfully initialized.".format(root)) + + +def _show(ctx, paths, select_opts, format_opts=None, brief=False): + """ Show metadata for each path given """ + output = [] + for path in paths: + if ctx.obj["verbose"]: + utils.info("Checking {0} for metadata.".format(path)) + tree = fmf.Tree(path) + for node in tree.prune(**select_opts): + if brief: + show = node.show(brief=True) + else: + assert format_opts is not None + show = node.show(brief=False, **format_opts) + # List source files when in debug mode + if ctx.obj["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 format_opts and format_opts["formatting"]: + joined = "".join(output) + else: + joined = "\n".join(output) + click.echo(joined, nl=False) + if ctx.obj["verbose"]: + utils.info("Found {0}.".format( + utils.listed(len(output), "object"))) + + +def _clean(): + """Remove cache directory""" + try: + cache = utils.get_cache_directory(create=False) + utils.clean_cache_directory() + click.echo("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)) diff --git a/fmf/utils.py b/fmf/utils.py index 4e70fb61..aa926cbb 100644 --- a/fmf/utils.py +++ b/fmf/utils.py @@ -1,5 +1,6 @@ """ Logging, config, constants & utilities """ +import contextlib import copy import logging import os @@ -191,6 +192,24 @@ def info(message, newline=True): sys.stderr.write(message + ("\n" if newline else "")) +@contextlib.contextmanager +def cd(target): + """ + Manage cd in a pushd/popd fashion. + + Usage: + + with cd(tmpdir): + do something in tmpdir + """ + curdir = os.getcwd() + os.chdir(target) + try: + yield + finally: + os.chdir(curdir) + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Filtering # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..0dbf8e23 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,107 @@ +[build-system] +requires = ['hatchling', 'hatch-vcs'] +build-backend = 'hatchling.build' + +[project] +name = 'fmf' +authors = [ + { name = 'Petr Splichal', email = 'psplicha@redhat.com' }, +] +maintainers = [ + { name = 'Petr Splichal', email = 'psplicha@redhat.com' }, +] +description = 'Flexible Metadata Format' +readme = 'README.rst' +license = 'GPL-2.0-or-later' +license-files = { paths = ['LICENSE'] } +requires-python = '>=3.9' +classifiers = [ + 'Natural Language :: English', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Topic :: Utilities', +] +keywords = [ + 'metadata', + 'testing', +] +dependencies = [ + 'ruamel.yaml', + 'filelock', + 'jsonschema', + 'click', + 'click-option-group', +] +dynamic = ['version'] + +[project.urls] +Homepage = 'https://github.com/teemtee/fmf' +Documentation = 'https://fmf.readthedocs.io' + +[project.optional-dependencies] +# Needed for tests inside rpm build. Not being pacakged in rpm +tests = [ + 'pytest', +] +# Needed for readthedocs and man page build. Not being packaged in rpm. +docs = [ + 'sphinx', + 'sphinx_rtd_theme', +] + +[project.scripts] +fmf = 'fmf.cli:main' + +[tool.hatch] +version.source = 'vcs' + +[tool.hatch.envs.default] +platforms = ["linux"] + +[tool.hatch.envs.dev] +description = "Development environment" +dependencies = [ + "pytest-cov" +] +features = ["tests"] + +[tool.hatch.envs.dev.scripts] +type = ["mypy {args:tmt}"] +check = ["lint", "type"] + +unit = "pytest -vvv -ra --showlocals tests/unit" +smoke = "pytest -vvv -ra --showlocals tests/unit/test_cli.py" + +cov = [ + "coverage run --source=fmf -m pytest -vvv -ra --showlocals tests", + "coverage report", + "coverage annotate", + ] + +[tool.hatch.envs.dev-not-editable] +template = "dev" +description = "Same as 'dev', but not using editable install" +dev-mode = false + +[tool.hatch.envs.test] +template = "dev" +description = "Run scripts with multiple Python versions" + +[[tool.hatch.envs.test.matrix]] +python = ["3.9", "3.11", "3.12"] + +[tool.hatch.envs.docs] +features = ["docs"] + +[tool.hatch.envs.docs.scripts] +html = "sphinx-build -b html {root}/docs {root}/docs/_build {args}" + +[tool.pytest.ini_options] +markers = [ + "web: tests which need to access the web", +] +testpaths = [ + 'tests', +] diff --git a/setup.py b/setup.py deleted file mode 100755 index da308297..00000000 --- a/setup.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python - -import re -from io import open - -from setuptools import setup - -# Parse version from the spec file -with open('fmf.spec', encoding='utf-8') as specfile: - lines = "\n".join(line.rstrip() for line in specfile) - version = re.search('Version: (.+)', lines).group(1).rstrip() - -# acceptable version schema: major.minor[.patch][sub] -__version__ = version -__pkg__ = 'fmf' -__pkgdir__ = {} -__pkgs__ = ['fmf'] -__provides__ = ['fmf'] -__desc__ = 'Flexible Metadata Format' -__scripts__ = ['bin/fmf'] - -# Prepare install requires and extra requires -install_requires = [ - 'ruamel.yaml', - 'filelock', - 'jsonschema', - ] -extras_require = { - 'docs': ['sphinx==7.2.4', 'sphinx-rtd-theme==1.3.0'], - 'tests': ['pytest', 'python-coveralls', 'pre-commit'], - } -extras_require['all'] = [ - dependency - for extra in extras_require.values() - for dependency in extra] - -pip_src = 'https://pypi.python.org/packages/source' -__deplinks__ = [] - -# README is in the parent directory -readme = 'README.rst' -with open(readme, encoding='utf-8') as _file: - readme = _file.read() - -github = 'https://github.com/psss/fmf' -download_url = '{0}/archive/master.zip'.format(github) - -default_setup = dict( - url=github, - license='GPLv2', - author='Petr Splichal', - author_email='psplicha@redhat.com', - maintainer='Petr Splichal', - maintainer_email='psplicha@redhat.com', - download_url=download_url, - long_description=readme, - data_files=[], - classifiers=[ - 'License :: OSI Approved :: GNU General Public License v2 (GPLv2)', - 'Natural Language :: English', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Topic :: Utilities', - ], - keywords=['metadata', 'testing'], - dependency_links=__deplinks__, - description=__desc__, - install_requires=install_requires, - extras_require=extras_require, - name=__pkg__, - package_dir=__pkgdir__, - packages=__pkgs__, - provides=__provides__, - scripts=__scripts__, - version=__version__, - ) - -setup(**default_setup) diff --git a/tests/unit/pytest.ini b/tests/unit/pytest.ini deleted file mode 100644 index cafe5e5d..00000000 --- a/tests/unit/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -markers = - web: tests which need to access the web diff --git a/tests/unit/test_base.py b/tests/unit/test_base.py index ac2d3f85..acae8307 100644 --- a/tests/unit/test_base.py +++ b/tests/unit/test_base.py @@ -6,6 +6,7 @@ from shutil import rmtree import pytest +from click.testing import CliRunner from ruamel.yaml import YAML import fmf.cli @@ -228,42 +229,42 @@ 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 utils.cd(tmp_path): + CliRunner().invoke(fmf.cli.main, "init") + with (tmp_path / "main.fmf").open("w") as main: + main.write("missing\ncolon:") + with pytest.raises(utils.FileError): + fmf.Tree(".") + rmtree(tmp_path) + + 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 utils.cd(tmp_path): + CliRunner().invoke(fmf.cli.main, "init") + + # Simple test + with (tmp_path / "main.fmf").open("w") as main: + main.write("a: b\na: c\n") + with pytest.raises(utils.FileError): + fmf.Tree(".") + + # Add some hierarchy + subdir = tmp_path / "dir" + subdir.mkdir() + with (subdir / "a.fmf").open("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 (tmp_path / "main.fmf").open("w") as main: + main.write("a: b\n") + fmf.Tree(".") + + rmtree(tmp_path) 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..1e8a7af9 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1,8 +1,7 @@ import os -import sys -import tempfile import pytest +from click.testing import CliRunner import fmf.cli import fmf.utils as utils @@ -17,143 +16,165 @@ 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() + with utils.cd(WGET): + runner.invoke(fmf.cli.main, "show") + runner.invoke(fmf.cli.main, "show --debug") + runner.invoke(fmf.cli.main, "show --verbose") + runner.invoke(fmf.cli.main, "--version") def test_missing_root(self): """ Missing root """ - with pytest.raises(utils.FileError): - fmf.cli.main("fmf show", "/") + with utils.cd("/"): + with pytest.raises(utils.RootError): + CliRunner().invoke(fmf.cli.main, "show", catch_exceptions=False) def test_invalid_path(self): """ Missing root """ with pytest.raises(utils.FileError): - fmf.cli.main("fmf show --path /some-non-existent-path") + CliRunner().invoke( + fmf.cli.main, + "show --path /some-non-existent-path", + catch_exceptions=False) def test_wrong_command(self): """ Wrong command """ - with pytest.raises(utils.GeneralError): - fmf.cli.main("fmf wrongcommand") + result = CliRunner().invoke(fmf.cli.main, "wrongcommand", catch_exceptions=False) + assert result.exit_code == 2 + assert "No such command 'wrongcommand'" in result.stdout def test_output(self): """ There is some output """ - output = fmf.cli.main("fmf show", WGET) - assert "download" in output + with utils.cd(WGET): + result = CliRunner().invoke(fmf.cli.main, "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 utils.cd(WGET): + result = CliRunner().invoke(fmf.cli.main, "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 utils.cd(WGET): + result = CliRunner().invoke(fmf.cli.main, "show --name protocols/https") + assert "psplicha" in result.output def test_missing_attribute(self): """ Missing attribute """ - output = fmf.cli.main("fmf show --filter x:y", WGET) - assert "wget" not in output + with utils.cd(WGET): + result = CliRunner().invoke(fmf.cli.main, "show --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 utils.cd(WGET): + result = CliRunner().invoke(fmf.cli.main, "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 utils.cd(WGET): + result = runner.invoke( + fmf.cli.main, + "show --filter tags:Tier1 --filter tags:TierSecurity") + assert "/download/test" in result.output + result = runner.invoke( + fmf.cli.main, + "show --filter tags:Tier1 --filter tags:Wrong") + assert "wget" not in result.output + result = runner.invoke( + fmf.cli.main, + "show --filter 'tags: Tier[A-Z].*'") + 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 utils.cd(WGET): + result = CliRunner().invoke(fmf.cli.main, "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 + with utils.cd(WGET): + result = CliRunner().invoke(fmf.cli.main, "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 utils.cd(WGET): + result = CliRunner().invoke( + fmf.cli.main, + "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 utils.cd(WGET): + result = CliRunner().invoke( + fmf.cli.main, + "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 utils.cd(tmp_path): + runner.invoke(fmf.cli.main, "init") + runner.invoke(fmf.cli.main, "show") + # Already exists + with pytest.raises(utils.FileError): + runner.invoke(fmf.cli.main, "init", catch_exceptions=False) + version_path = tmp_path / ".fmf" / "version" + with version_path.open() as version: + assert "1" in version.read() + # Permission denied + secret_path = tmp_path / "denied" + secret_path.mkdir(0o666) + with pytest.raises(utils.FileError): + runner.invoke( + fmf.cli.main, + "init --path {}".format(secret_path), + catch_exceptions=False) + secret_path.chmod(0o777) + # Invalid version + with version_path.open("w") as version: + version.write("bad") + with pytest.raises(utils.FormatError): + runner.invoke(fmf.cli.main, "ls", catch_exceptions=False) + # Missing version + version_path.unlink() + with pytest.raises(utils.FormatError): + runner.invoke(fmf.cli.main, "ls", catch_exceptions=False) 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 utils.cd(path): + # Compare numbers + result = runner.invoke(fmf.cli.main, "ls --condition 'float(release) >= 7'") + assert len(result.output.splitlines()) == 3 + result = runner.invoke(fmf.cli.main, "ls --condition 'float(release) > 7'") + assert len(result.output.splitlines()) == 2 + # Access a dictionary key + result = runner.invoke( + fmf.cli.main, + "ls --condition \"execute['how'] == 'dependency'\"") + assert result.output.strip() == "/top/rhel7" + # Wrong key means unsatisfied condition + result = runner.invoke( + fmf.cli.main, + "ls --condition \"execute['wrong key'] == 0\"") + assert result.output == '' def test_clean(self, tmpdir, monkeypatch): """ Cache cleanup """ @@ -161,5 +182,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(fmf.cli.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..04b56a7c 100644 --- a/tests/unit/test_smoke.py +++ b/tests/unit/test_smoke.py @@ -1,6 +1,9 @@ import os -import fmf.cli +from click.testing import CliRunner + +import fmf.utils as utils +from fmf.cli import main # Prepare path to examples PATH = os.path.dirname(os.path.realpath(__file__)) @@ -12,9 +15,11 @@ class TestSmoke: def test_smoke(self): """ Smoke test """ - fmf.cli.main("fmf ls", WGET) + with utils.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 utils.cd(WGET): + result = CliRunner().invoke(main, ['ls']) + assert "download" in result.output