diff --git a/src/reuse/__main__.py b/src/reuse/__main__.py index 952a87b7..f5c3e260 100644 --- a/src/reuse/__main__.py +++ b/src/reuse/__main__.py @@ -4,9 +4,8 @@ """Entry module for reuse.""" -import sys - if __name__ == "__main__": - from ._main import main + from .cli.main import main - sys.exit(main()) + # pylint: disable=no-value-for-parameter + main() diff --git a/src/reuse/cli/__init__.py b/src/reuse/cli/__init__.py index c0a7798a..2138eeb2 100644 --- a/src/reuse/cli/__init__.py +++ b/src/reuse/cli/__init__.py @@ -2,6 +2,8 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +"""All command-line functionality.""" + from . import ( annotate, convert_dep5, diff --git a/src/reuse/cli/annotate.py b/src/reuse/cli/annotate.py index 25f92cf8..f32fc9b3 100644 --- a/src/reuse/cli/annotate.py +++ b/src/reuse/cli/annotate.py @@ -44,7 +44,7 @@ ) from ..i18n import _ from ..project import Project -from .common import ClickObj, MutexOption, spdx_identifier +from .common import ClickObj, MutexOption, requires_project, spdx_identifier from .main import main _LOGGER = logging.getLogger(__name__) @@ -284,6 +284,7 @@ def get_reuse_info( ) +@requires_project @main.command(name="annotate", help=_HELP) @click.option( "--copyright", @@ -323,7 +324,7 @@ def get_reuse_info( "--style", "-s", cls=MutexOption, - mutually_exclusive=["skip_unrecognised"], # FIXME: test + mutually_exclusive=["skip_unrecognised"], type=click.Choice(list(NAME_STYLE_MAP)), help=_("Comment style to use."), ) @@ -359,8 +360,6 @@ def get_reuse_info( @click.option( "--single-line", cls=MutexOption, - # FIXME: This results in an ugly error message that shows 'multi_line' - # instead of '--multi-line'. mutually_exclusive=_LINE_MUTEX, is_flag=True, help=_("Force single-line comment style."), @@ -407,7 +406,6 @@ def get_reuse_info( @click.option( "--skip-unrecognized", "skip_unrecognised", - # FIXME: test if mutex is applied. is_flag=True, hidden=True, ) @@ -444,6 +442,7 @@ def annotate( skip_existing: bool, paths: Sequence[Path], ) -> None: + # pylint: disable=too-many-arguments,too-many-locals,missing-function-docstring project = cast(Project, obj.project) test_mandatory_option_required(copyrights, licenses, contributors) diff --git a/src/reuse/cli/common.py b/src/reuse/cli/common.py index 7840dd1f..88189bfc 100644 --- a/src/reuse/cli/common.py +++ b/src/reuse/cli/common.py @@ -5,7 +5,7 @@ """Utilities that are common to multiple CLI commands.""" from dataclasses import dataclass -from typing import Any, Mapping, Optional +from typing import Any, Callable, Mapping, Optional, TypeVar import click from boolean.boolean import Expression, ParseError @@ -15,6 +15,16 @@ from ..i18n import _ from ..project import Project +F = TypeVar("F", bound=Callable) + + +def requires_project(f: F) -> F: + """A decorator to mark subcommands that require a :class:`Project` object. + Make sure to apply this decorator _first_. + """ + setattr(f, "requires_project", True) + return f + @dataclass(frozen=True) class ClickObj: diff --git a/src/reuse/cli/convert_dep5.py b/src/reuse/cli/convert_dep5.py index 99d4911a..f213bc33 100644 --- a/src/reuse/cli/convert_dep5.py +++ b/src/reuse/cli/convert_dep5.py @@ -13,7 +13,7 @@ from ..global_licensing import ReuseDep5 from ..i18n import _ from ..project import Project -from .common import ClickObj +from .common import ClickObj, requires_project from .main import main _HELP = _( @@ -23,9 +23,11 @@ ) +@requires_project @main.command(name="convert-dep5", help=_HELP) @click.pass_obj def convert_dep5(obj: ClickObj) -> None: + # pylint: disable=missing-function-docstring project = cast(Project, obj.project) if not (project.root / ".reuse/dep5").exists(): raise click.UsageError(_("no '.reuse/dep5' file")) diff --git a/src/reuse/cli/download.py b/src/reuse/cli/download.py index d3a31873..a1d57b91 100644 --- a/src/reuse/cli/download.py +++ b/src/reuse/cli/download.py @@ -20,7 +20,7 @@ from ..project import Project from ..report import ProjectReport from ..types import StrPath -from .common import ClickObj, MutexOption +from .common import ClickObj, MutexOption, requires_project from .main import main _LOGGER = logging.getLogger(__name__) @@ -113,6 +113,7 @@ def _successfully_downloaded(destination: StrPath) -> None: ) +@requires_project @main.command(name="download", help=_HELP) @click.option( "--all", @@ -152,7 +153,7 @@ def download( output: Optional[Path], source: Optional[Path], ) -> None: - + # pylint: disable=missing-function-docstring if all_ and license_: raise click.UsageError( _( diff --git a/src/reuse/cli/lint.py b/src/reuse/cli/lint.py index 11d3305b..f4545081 100644 --- a/src/reuse/cli/lint.py +++ b/src/reuse/cli/lint.py @@ -18,7 +18,7 @@ from ..lint import format_json, format_lines, format_plain from ..project import Project from ..report import ProjectReport -from .common import ClickObj, MutexOption +from .common import ClickObj, MutexOption, requires_project from .main import main _OUTPUT_MUTEX = ["quiet", "json", "plain", "lines"] @@ -61,6 +61,7 @@ ) +@requires_project @main.command(name="lint", help=_HELP) @click.option( "--quiet", @@ -98,6 +99,7 @@ def lint( obj: ClickObj, quiet: bool, json: bool, plain: bool, lines: bool ) -> None: + # pylint: disable=missing-function-docstring report = ProjectReport.generate( cast(Project, obj.project), do_checksum=False, diff --git a/src/reuse/cli/lint_file.py b/src/reuse/cli/lint_file.py index 0b42d9db..7de7d83e 100644 --- a/src/reuse/cli/lint_file.py +++ b/src/reuse/cli/lint_file.py @@ -17,7 +17,7 @@ from ..lint import format_lines_subset from ..project import Project from ..report import ProjectSubsetReport -from .common import ClickObj, MutexOption +from .common import ClickObj, MutexOption, requires_project from .main import main _OUTPUT_MUTEX = ["quiet", "lines"] @@ -29,6 +29,7 @@ ) +@requires_project @main.command(name="lint-file", help=_HELP) @click.option( "--quiet", @@ -56,6 +57,7 @@ def lint_file( obj: ClickObj, quiet: bool, lines: bool, files: Collection[Path] ) -> None: + # pylint: disable=missing-function-docstring project = cast(Project, obj.project) subset_files = {Path(file_) for file_ in files} for file_ in subset_files: diff --git a/src/reuse/cli/main.py b/src/reuse/cli/main.py index aebe161b..b2e2a955 100644 --- a/src/reuse/cli/main.py +++ b/src/reuse/cli/main.py @@ -74,7 +74,8 @@ + "\n\n" + _("Support the FSFE's work:") + "\n\n" - # FIXME: Indent this. + # Indent next paragraph. + + " " + _( "Donations are critical to our strength and autonomy. They enable us" " to continue working for Free Software wherever necessary. Please" @@ -133,6 +134,7 @@ def main( no_multiprocessing: bool, root: Optional[Path], ) -> None: + # pylint: disable=missing-function-docstring,too-many-arguments setup_logging(level=logging.DEBUG if debug else logging.WARNING) # Very stupid workaround to not print a DEP5 deprecation warning in the @@ -143,29 +145,31 @@ def main( if not suppress_deprecation: warnings.filterwarnings("default", module="reuse") - # FIXME: Not needed for all subcommands. - if root is None: - root = find_root() - if root is None: - root = Path.cwd() - - # FIXME: Not needed for all subcommands. - try: - project = Project.from_directory(root) - # FileNotFoundError and NotADirectoryError don't need to be caught because - # argparse already made sure of these things. - except GlobalLicensingParseError as error: - raise click.UsageError( - _( - "'{path}' could not be parsed. We received the following error" - " message: {message}" - ).format(path=error.source, message=str(error)) - ) from error - - except (GlobalLicensingConflict, OSError) as error: - raise click.UsageError(str(error)) from error - project.include_submodules = include_submodules - project.include_meson_subprojects = include_meson_subprojects + project: Optional[Project] = None + if ctx.invoked_subcommand: + cmd = main.get_command(ctx, ctx.invoked_subcommand) + if getattr(cmd, "requires_project", False): + if root is None: + root = find_root() + if root is None: + root = Path.cwd() + + try: + project = Project.from_directory(root) + # FileNotFoundError and NotADirectoryError don't need to be caught + # because argparse already made sure of these things. + except GlobalLicensingParseError as error: + raise click.UsageError( + _( + "'{path}' could not be parsed. We received the" + " following error message: {message}" + ).format(path=error.source, message=str(error)) + ) from error + + except (GlobalLicensingConflict, OSError) as error: + raise click.UsageError(str(error)) from error + project.include_submodules = include_submodules + project.include_meson_subprojects = include_meson_subprojects ctx.obj = ClickObj( no_multiprocessing=no_multiprocessing, diff --git a/src/reuse/cli/spdx.py b/src/reuse/cli/spdx.py index 25a9a8e0..115dd25d 100644 --- a/src/reuse/cli/spdx.py +++ b/src/reuse/cli/spdx.py @@ -7,16 +7,16 @@ import contextlib import logging -from typing import Optional, cast import sys +from typing import Optional, cast import click -from ..project import Project from .. import _IGNORE_SPDX_PATTERNS -from ..report import ProjectReport from ..i18n import _ -from .common import ClickObj +from ..project import Project +from ..report import ProjectReport +from .common import ClickObj, requires_project from .main import main _LOGGER = logging.getLogger(__name__) @@ -24,6 +24,7 @@ _HELP = _("Generate an SPDX bill of materials.") +@requires_project @main.command(name="spdx", help=_HELP) @click.option( "--output", @@ -68,6 +69,8 @@ def spdx( creator_person: Optional[str], creator_organization: Optional[str], ) -> None: + # pylint: disable=missing-function-docstring + # The SPDX spec mandates that a creator must be specified when a license # conclusion is made, so here we enforce that. More context: # https://github.com/fsfe/reuse-tool/issues/586#issuecomment-1310425706 diff --git a/src/reuse/cli/supported_licenses.py b/src/reuse/cli/supported_licenses.py index 1bd82eb1..65c8df96 100644 --- a/src/reuse/cli/supported_licenses.py +++ b/src/reuse/cli/supported_licenses.py @@ -17,6 +17,7 @@ @main.command(name="supported-licenses", help=_HELP) def supported_licenses() -> None: + # pylint: disable=missing-function-docstring licenses = _load_license_list(_LICENSES)[1] for license_id, license_info in licenses.items(): diff --git a/src/reuse/report.py b/src/reuse/report.py index f3e5eeaa..dd2a0355 100644 --- a/src/reuse/report.py +++ b/src/reuse/report.py @@ -143,8 +143,8 @@ def _generate_file_reports( def _process_error(error: Exception, path: StrPath) -> None: # Facilitate better debugging by being able to quit the program. - if isinstance(error, bdb.BdbQuit): - raise bdb.BdbQuit() from error + if isinstance(error, (bdb.BdbQuit, KeyboardInterrupt)): + raise error if isinstance(error, (OSError, UnicodeError)): _LOGGER.error( _("Could not read '{path}'").format(path=path), diff --git a/tests/test_cli_annotate.py b/tests/test_cli_annotate.py index 331b53cd..f7b5f892 100644 --- a/tests/test_cli_annotate.py +++ b/tests/test_cli_annotate.py @@ -621,7 +621,12 @@ def test_skip_unrecognised(self, fake_repository, skip_unrecognised): assert result.exit_code == 0 assert "Skipped unrecognised file 'foo.foo'" in result.output - def test_skip_unrecognised_and_style_mutex(self, fake_repository): + @pytest.mark.parametrize( + "skip_unrecognised", ["--skip-unrecognised", "--skip-unrecognized"] + ) + def test_skip_unrecognised_and_style_mutex( + self, fake_repository, skip_unrecognised + ): """--skip-unrecognised and --style are mutually exclusive.""" simple_file = fake_repository / "foo.foo" simple_file.write_text("pass") @@ -635,7 +640,7 @@ def test_skip_unrecognised_and_style_mutex(self, fake_repository): "--copyright", "Jane Doe", "--style=c", - "--skip-unrecognised", + skip_unrecognised, "foo.foo", ], ) diff --git a/tests/test_cli_convert_dep5.py b/tests/test_cli_convert_dep5.py index 5b8c32ba..679fc351 100644 --- a/tests/test_cli_convert_dep5.py +++ b/tests/test_cli_convert_dep5.py @@ -11,6 +11,8 @@ from reuse._util import cleandoc_nl from reuse.cli.main import main +# pylint: disable=unused-argument + class TestConvertDep5: """Tests for convert-dep5.""" diff --git a/tests/test_cli_spdx.py b/tests/test_cli_spdx.py index 613a548a..b9bb466c 100644 --- a/tests/test_cli_spdx.py +++ b/tests/test_cli_spdx.py @@ -15,6 +15,8 @@ from reuse.cli.main import main +# pylint: disable=unused-argument + class TestSpdx: """Tests for spdx."""