diff --git a/.github/workflows/gettext.yaml b/.github/workflows/gettext.yaml index c86d6e81b..b2d9c0c0c 100644 --- a/.github/workflows/gettext.yaml +++ b/.github/workflows/gettext.yaml @@ -25,6 +25,9 @@ jobs: token: ${{ secrets.FSFE_SYSTEM_TOKEN }} - name: Install gettext and wlc run: sudo apt-get install -y gettext wlc + # We mostly install reuse to install the click dependency. + - name: Install reuse + run: poetry install --no-interaction --only main - name: Lock Weblate run: | wlc --url https://hosted.weblate.org/api/ --key ${{secrets.WEBLATE_KEY }} lock fsfe/reuse-tool @@ -34,7 +37,7 @@ jobs: - name: Pull Weblate translations run: git pull origin main - name: Create .pot file - run: make create-pot + run: poetry run make create-pot # Normally, POT-Creation-Date changes in two locations. Check if the diff # includes more than just those two lines. - name: Check if sufficient lines were changed diff --git a/.gitignore b/.gitignore index 880aee8b9..466122efe 100644 --- a/.gitignore +++ b/.gitignore @@ -147,7 +147,6 @@ dmypy.json # End of https://www.gitignore.io/api/linux,python -po/reuse.pot *.mo docs/api/ docs/history.md diff --git a/Makefile b/Makefile index 8626dcd1f..7edcd64f8 100644 --- a/Makefile +++ b/Makefile @@ -67,8 +67,8 @@ dist: clean-build clean-pyc clean-docs ## builds source and wheel package .PHONY: create-pot create-pot: ## generate .pot file xgettext --add-comments --from-code=utf-8 --output=po/reuse.pot src/reuse/**.py - xgettext --add-comments --output=po/argparse.pot /usr/lib*/python3*/argparse.py - msgcat --output=po/reuse.pot po/reuse.pot po/argparse.pot + xgettext --add-comments --output=po/click.pot "${VIRTUAL_ENV}"/lib/python*/*-packages/click/**.py + msgcat --output=po/reuse.pot po/reuse.pot po/click.pot for name in po/*.po; do \ msgmerge --output=$${name} $${name} po/reuse.pot; \ done diff --git a/README.md b/README.md index 64ba00c1d..c772e9b34 100644 --- a/README.md +++ b/README.md @@ -270,13 +270,18 @@ repos: ### Shell completion -You can generate a shell completion script with `reuse --print-completion bash`. -Replace 'bash' as needed. You must place the printed text in a file dictated by -your shell to benefit from completions. +In order to enable shell completion, you need to generate the shell completion +script. You do this with `_REUSE_COMPLETE=bash_source reuse`. Replace `bash` +with `zsh` or `fish` as needed, or any other shells supported by the +Python`click` library. You can then source the output in your shell rc file, +like so (e.g.`~/.bashrc`): -This functionality depends on `shtab`, which is an optional dependency. To -benefit from this feature, install reuse with the `completion` extra, like -`pipx install reuse[completion]`. +```bash +eval "$(_REUSE__COMPLETE=bash_source reuse)" +``` + +Alternatively, you can place the generated completion script in +`${XDG_DATA_HOME}/bash-completion/completions/reuse`. ## Maintainers diff --git a/changelog.d/added/completion.md b/changelog.d/added/completion.md new file mode 100644 index 000000000..3bd05e0d7 --- /dev/null +++ b/changelog.d/added/completion.md @@ -0,0 +1 @@ +- Added shell completion via `click`. (#1084) diff --git a/changelog.d/added/shtab.md b/changelog.d/added/shtab.md deleted file mode 100644 index b3543469a..000000000 --- a/changelog.d/added/shtab.md +++ /dev/null @@ -1 +0,0 @@ -- Added `--print-completion` using a new `shtab` optional dependency. (#1076) diff --git a/changelog.d/changed/click.md b/changelog.d/changed/click.md new file mode 100644 index 000000000..bedc5e5a7 --- /dev/null +++ b/changelog.d/changed/click.md @@ -0,0 +1,17 @@ +- Switched from `argparse` to `click` for handling the CLI. The CLI should still + handle the same, with identical options and arguments, but some stuff changed + under the hood. (#1084) + + Find here a small list of differences: + + - `-h` is no longer shorthand for `--help`. + - `--version` now outputs "reuse, version X.Y.Z", followed by a licensing + blurb on different paragraphs. + - Some options are made explicitly mutually exclusive, such as `annotate`'s + `--skip-unrecognised` and `--style`, and `download`'s `--output` and + `--all`. + - Subcommands which take a list of things (files, license) as arguments, such + as `annotate`, `lint-file`, or `download`, now also allow zero arguments. + This will do nothing, but can be useful in scripting. + - `annotate` and `lint-file` now also take directories as arguments. This will + do nothing, but can be useful in scripting. diff --git a/docs/conf.py b/docs/conf.py index 18bcd02cf..d7c79c716 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -62,7 +62,7 @@ apidoc_module_dir = str(ROOT_DIR / "src/reuse") # apidoc_output_dir = "api" -# apidoc_excluded_paths = [] +apidoc_excluded_paths = ["cli"] apidoc_separate_modules = True apidoc_toc_file = False apidoc_extra_args = ["--maxdepth", "2"] diff --git a/docs/man/reuse-annotate.rst b/docs/man/reuse-annotate.rst index ae01e0af2..9a1795cb6 100644 --- a/docs/man/reuse-annotate.rst +++ b/docs/man/reuse-annotate.rst @@ -46,22 +46,22 @@ Mandatory options ----------------- At least *one* among the following options is required. They contain the -information which the tool will add to the file(s). +information which the tool will add to the file(s). You can repeat these +options. .. option:: -c, --copyright COPYRIGHT A copyright holder. This does not contain the year or the copyright prefix. See :option:`--year` and :option:`--copyright-prefix` for the year and prefix. - This option can be repeated. .. option:: -l, --license LICENSE - An SPDX license identifier. This option can be repeated. + An SPDX license identifier. .. option:: --contributor CONTRIBUTOR A name of a contributor. The contributor will be added via the - ``SPDX-FileContributor:`` tag. This option can be repeated. + ``SPDX-FileContributor:`` tag. Other options ------------- @@ -143,7 +143,7 @@ Other options Instead of aborting when a file extension does not have an associated comment style, skip those files. -.. option:: -h, --help +.. option:: --help Display help and exit. diff --git a/docs/man/reuse-convert-dep5.rst b/docs/man/reuse-convert-dep5.rst index 427344c2b..0be35935b 100644 --- a/docs/man/reuse-convert-dep5.rst +++ b/docs/man/reuse-convert-dep5.rst @@ -21,7 +21,7 @@ functionally equivalent ``REUSE.toml`` file in the root of the project. The Options ------- -.. option:: -h, --help +.. option:: --help Display help and exit. diff --git a/docs/man/reuse-download.rst b/docs/man/reuse-download.rst index 4c79f0fa3..88adc424d 100644 --- a/docs/man/reuse-download.rst +++ b/docs/man/reuse-download.rst @@ -36,11 +36,11 @@ Options If downloading a single file, output it to a specific file instead of putting it in a detected ``LICENSES/`` directory. -.. option:: --source SOURCE +.. option:: --source PATH Specify a source from which to copy custom ``LicenseRef-`` files. This can be a directory containing such file, or a path to the file itself. -.. option:: -h, --help +.. option:: --help Display help and exit. diff --git a/docs/man/reuse-lint-file.rst b/docs/man/reuse-lint-file.rst index 314cc46da..8e9487cb3 100644 --- a/docs/man/reuse-lint-file.rst +++ b/docs/man/reuse-lint-file.rst @@ -45,6 +45,6 @@ Options Output one line per error, prefixed by the file path. This option is the default. -.. option:: -h, --help +.. option:: --help Display help and exit. diff --git a/docs/man/reuse-lint.rst b/docs/man/reuse-lint.rst index 6c0cf6d77..03c66c2dd 100644 --- a/docs/man/reuse-lint.rst +++ b/docs/man/reuse-lint.rst @@ -97,6 +97,6 @@ Options Output one line per error, prefixed by the file path. -.. option:: -h, --help +.. option:: --help Display help and exit. diff --git a/docs/man/reuse-spdx.rst b/docs/man/reuse-spdx.rst index 46515dced..e6d5fb4e4 100644 --- a/docs/man/reuse-spdx.rst +++ b/docs/man/reuse-spdx.rst @@ -41,6 +41,6 @@ Options Name of the creator (organization) of the bill of materials. -.. option:: -h, --help +.. option:: --help Display help and exit. diff --git a/docs/man/reuse-supported-licenses.rst b/docs/man/reuse-supported-licenses.rst index 8dbb87596..902d171f3 100644 --- a/docs/man/reuse-supported-licenses.rst +++ b/docs/man/reuse-supported-licenses.rst @@ -25,6 +25,6 @@ full name of the license, and an URL to the license. Options ------- -.. option:: -h, --help +.. option:: --help Display help and exit. diff --git a/docs/man/reuse.rst b/docs/man/reuse.rst index 121b66144..e1b448c21 100644 --- a/docs/man/reuse.rst +++ b/docs/man/reuse.rst @@ -78,18 +78,7 @@ Options current working directory's VCS repository, or to the current working directory. -.. option:: -s, --print-completion SHELL - - Print a static shell completion script for the given shell and exit. You must - place the printed text in a file dictated by your shell before the completions - will function. For Bash, this file is - ``${XDG_DATA_HOME}/bash-completion/reuse``. - - This option depends on ``shtab``, which is an optional dependency of - :program:`reuse`. The supported shells depend on your installed version of - ``shtab``. - -.. option:: -h, --help +.. option:: --help Display help and exit. If no command is provided, this option is implied. @@ -112,6 +101,9 @@ Commands :manpage:`reuse-lint(1)` Verify whether a project is compliant with the REUSE Specification. +:manpage:`reuse-lint-file(1)` + Verify whether individual files are compliant with the REUSE Specification. + :manpage:`reuse-spdx(1)` Generate SPDX bill of materials. diff --git a/poetry.lock b/poetry.lock index baa868b63..5403c5225 100644 --- a/poetry.lock +++ b/poetry.lock @@ -317,7 +317,7 @@ files = [ name = "click" version = "8.1.7" description = "Composable command line interface toolkit" -category = "dev" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -332,7 +332,7 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "dev" +category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -1421,21 +1421,6 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] -[[package]] -name = "shtab" -version = "1.7.1" -description = "Automagic shell tab completion for Python CLI applications" -category = "main" -optional = true -python-versions = ">=3.7" -files = [ - {file = "shtab-1.7.1-py3-none-any.whl", hash = "sha256:32d3d2ff9022d4c77a62492b6ec875527883891e33c6b479ba4d41a51e259983"}, - {file = "shtab-1.7.1.tar.gz", hash = "sha256:4e4bcb02eeb82ec45920a5d0add92eac9c9b63b2804c9196c1f1fdc2d039243c"}, -] - -[package.extras] -dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout"] - [[package]] name = "six" version = "1.16.0" @@ -1850,10 +1835,7 @@ enabler = ["pytest-enabler (>=2.2)"] test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] -[extras] -completion = ["shtab"] - [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "de64d8500ffb6b578394daaa489960517c59cdb9836c53102ac6510fd279280c" +content-hash = "bbda89d5e0d59bc1261b9a4f5076fd97a88757ff80958825302ac8faa8988cef" diff --git a/pyproject.toml b/pyproject.toml index 420c0a0ab..dc91d3f95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,10 +62,7 @@ python-debian = ">=0.1.34,!=0.1.45,!=0.1.46,!=0.1.47" # Python 3.10. tomlkit = ">=0.8" attrs = ">=21.3" -shtab = { version = ">=1.4.0", optional = true } - -[tool.poetry.extras] -completion = ["shtab"] +click = ">=8.0" [tool.poetry.group.test.dependencies] pytest = ">=6.0.0" @@ -98,7 +95,7 @@ pyls-isort = "*" python-lsp-black = "*" [tool.poetry.scripts] -reuse = 'reuse._main:main' +reuse = "reuse.cli.main:main" [tool.poetry.build] generate-setup-file = false diff --git a/src/reuse/__main__.py b/src/reuse/__main__.py index 952a87b7d..f5c3e2606 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/_annotate.py b/src/reuse/_annotate.py index 40daec635..e914f5c27 100644 --- a/src/reuse/_annotate.py +++ b/src/reuse/_annotate.py @@ -15,75 +15,34 @@ """Functions for the CLI portion of manipulating headers.""" -import datetime import logging -import os import sys -from argparse import SUPPRESS, ArgumentParser, Namespace -from gettext import gettext as _ -from pathlib import Path -from typing import IO, Iterable, Optional, Type, cast +from typing import IO, Optional, Type, cast -from binaryornot.check import is_binary from jinja2 import Environment, FileSystemLoader, Template from jinja2.exceptions import TemplateNotFound from . import ReuseInfo from ._util import ( - _COPYRIGHT_PREFIXES, - PathType, - StrPath, - _determine_license_path, _determine_license_suffix_path, - _get_comment_style, - _has_style, - _is_uncommentable, contains_reuse_info, detect_line_endings, - make_copyright_line, - spdx_identifier, ) from .comment import ( NAME_STYLE_MAP, CommentCreateError, CommentStyle, EmptyCommentStyle, + get_comment_style, ) from .header import MissingReuseInfo, add_new_header, find_and_replace_header +from .i18n import _ from .project import Project +from .types import StrPath _LOGGER = logging.getLogger(__name__) -def verify_paths_line_handling( - args: Namespace, - paths: Iterable[Path], -) -> None: - """This function aborts the parser when --single-line or --multi-line is - used, but the file type does not support that type of comment style. - """ - for path in paths: - style = NAME_STYLE_MAP.get(args.style) - if style is None: - style = _get_comment_style(path) - if style is None: - continue - if args.single_line and not style.can_handle_single(): - args.parser.error( - _( - "'{path}' does not support single-line comments, please" - " do not use --single-line" - ).format(path=path) - ) - if args.multi_line and not style.can_handle_multi(): - args.parser.error( - _( - "'{path}' does not support multi-line comments, please" - " do not use --multi-line" - ).format(path=path) - ) - - def find_template(project: Project, name: str) -> Template: """Find a template given a name. @@ -130,7 +89,7 @@ def add_header_to_file( cast(str, style) ) if comment_style is None: - comment_style = _get_comment_style(path) + comment_style = get_comment_style(path) if comment_style is None: if skip_unrecognised: out.write(_("Skipped unrecognised file '{path}'").format(path=path)) @@ -211,343 +170,3 @@ def add_header_to_file( out.write("\n") return result - - -def style_and_unrecognised_warning(args: Namespace) -> None: - """Log a warning if --style is used together with --skip-unrecognised.""" - if args.style is not None and args.skip_unrecognised: - _LOGGER.warning( - _( - "--skip-unrecognised has no effect when used together with" - " --style" - ) - ) - - -def test_mandatory_option_required(args: Namespace) -> None: - """Raise a parser error if one of the mandatory options is not provided.""" - if not any((args.contributor, args.copyright, args.license)): - args.parser.error( - _("option --contributor, --copyright or --license is required") - ) - - -def all_paths(args: Namespace, project: Project) -> set[Path]: - """Return a set of all provided paths, converted into .license paths if they - exist. If recursive is enabled, all files belonging to *project* are also - added. - """ - if args.recursive: - paths: set[Path] = set() - all_files = [path.resolve() for path in project.all_files()] - for path in args.path: - if path.is_file(): - paths.add(path) - else: - paths |= { - child - for child in all_files - if path.resolve() in child.parents - } - else: - paths = args.path - return {_determine_license_path(path) for path in paths} - - -def get_template( - args: Namespace, project: Project -) -> tuple[Optional[Template], bool]: - """If a template is specified on the CLI, find and return it, including - whether it is a 'commented' template. - - If no template is specified, just return None. - """ - template: Optional[Template] = None - commented = False - if args.template: - try: - template = cast(Template, find_template(project, args.template)) - except TemplateNotFound: - args.parser.error( - _("template {template} could not be found").format( - template=args.template - ) - ) - # This code is never reached, but mypy is not aware that - # parser.error quits the program. - raise - - if ".commented" in Path(cast(str, template.name)).suffixes: - commented = True - return template, commented - - -def get_year(args: Namespace) -> Optional[str]: - """Get the year. Normally it is today's year. If --year is specified once, - get that one. If it is specified twice (or more), return the range between - the two. - - If --exclude-year is specified, return None. - """ - year = None - if not args.exclude_year: - if args.year and len(args.year) > 1: - year = f"{min(args.year)} - {max(args.year)}" - elif args.year: - year = args.year[0] - else: - year = str(datetime.date.today().year) - return year - - -def get_reuse_info(args: Namespace, year: Optional[str]) -> ReuseInfo: - """Create a ReuseInfo object from --license, --copyright, and - --contributor. - """ - expressions = set(args.license) if args.license is not None else set() - copyright_prefix = ( - args.copyright_prefix if args.copyright_prefix is not None else "spdx" - ) - copyright_lines = ( - { - make_copyright_line( - item, year=year, copyright_prefix=copyright_prefix - ) - for item in args.copyright - } - if args.copyright is not None - else set() - ) - contributors = ( - set(args.contributor) if args.contributor is not None else set() - ) - - return ReuseInfo( - spdx_expressions=expressions, - copyright_lines=copyright_lines, - contributor_lines=contributors, - ) - - -def verify_write_access( - paths: Iterable[StrPath], parser: ArgumentParser -) -> None: - """Raise a parser.error if one of the paths is not writable.""" - not_writeable = [ - str(path) for path in paths if not os.access(path, os.W_OK) - ] - if not_writeable: - parser.error( - _("can't write to '{}'").format("', '".join(not_writeable)) - ) - - -def verify_paths_comment_style(args: Namespace, paths: Iterable[Path]) -> None: - """Exit if --style, --force-dot-license, --fallback-dot-license, - or --skip-unrecognised is not enabled and one of the paths has an - unrecognised style. - """ - if ( - not args.style - and not args.fallback_dot_license - and not args.skip_unrecognised - and not args.force_dot_license - ): - unrecognised_files = [] - - for path in paths: - if not _has_style(path): - unrecognised_files.append(path) - - if unrecognised_files: - args.parser.error( - "{}\n\n{}".format( - _( - "The following files do not have a recognised file" - " extension. Please use --style, --force-dot-license," - " --fallback-dot-license, or --skip-unrecognised:" - ), - "\n".join(str(path) for path in unrecognised_files), - ) - ) - - -def add_arguments(parser: ArgumentParser) -> None: - """Add arguments to parser.""" - parser.add_argument( - "--copyright", - "-c", - action="append", - type=str, - help=_("copyright statement, repeatable"), - ) - parser.add_argument( - "--license", - "-l", - action="append", - type=spdx_identifier, - help=_("SPDX Identifier, repeatable"), - ) - parser.add_argument( - "--contributor", - action="append", - type=str, - help=_("file contributor, repeatable"), - ) - year_mutex_group = parser.add_mutually_exclusive_group() - year_mutex_group.add_argument( - "--year", - "-y", - action="append", - type=str, - help=_("year of copyright statement, optional"), - ) - parser.add_argument( - "--style", - "-s", - action="store", - type=str, - choices=list(NAME_STYLE_MAP), - help=_("comment style to use, optional"), - ) - parser.add_argument( - "--copyright-prefix", - action="store", - choices=list(_COPYRIGHT_PREFIXES.keys()), - help=_("copyright prefix to use, optional"), - ) - parser.add_argument( - "--copyright-style", - action="store", - dest="copyright_prefix", - help=SUPPRESS, - ) - parser.add_argument( - "--template", - "-t", - action="store", - type=str, - help=_("name of template to use, optional"), - ) - year_mutex_group.add_argument( - "--exclude-year", - action="store_true", - help=_("do not include year in statement"), - ) - parser.add_argument( - "--merge-copyrights", - action="store_true", - help=_("merge copyright lines if copyright statements are identical"), - ) - line_mutex_group = parser.add_mutually_exclusive_group() - line_mutex_group.add_argument( - "--single-line", - action="store_true", - help=_("force single-line comment style, optional"), - ) - line_mutex_group.add_argument( - "--multi-line", - action="store_true", - help=_("force multi-line comment style, optional"), - ) - parser.add_argument( - "--recursive", - "-r", - action="store_true", - help=_( - "add headers to all files under specified directories recursively" - ), - ) - parser.add_argument( - "--no-replace", - action="store_true", - help=_( - "do not replace the first header in the file; just add a new one" - ), - ) - style_mutex_group = parser.add_mutually_exclusive_group() - style_mutex_group.add_argument( - "--force-dot-license", - action="store_true", - help=_( - "always write a .license file instead of a header inside the file" - ), - ) - style_mutex_group.add_argument( - "--fallback-dot-license", - action="store_true", - help=_( - "write a .license file to files with unrecognised comment styles" - ), - ) - style_mutex_group.add_argument( - "--skip-unrecognised", - action="store_true", - help=_("skip files with unrecognised comment styles"), - ) - style_mutex_group.add_argument( - "--skip-unrecognized", - dest="skip_unrecognised", - action="store_true", - help=SUPPRESS, - ) - parser.add_argument( - "--skip-existing", - action="store_true", - help=_("skip files that already contain REUSE information"), - ) - parser.add_argument("path", action="store", nargs="+", type=PathType("r")) - - -def run(args: Namespace, project: Project, out: IO[str] = sys.stdout) -> int: - """Add headers to files.""" - test_mandatory_option_required(args) - - style_and_unrecognised_warning(args) - - paths = all_paths(args, project) - - verify_paths_comment_style(args, paths) - - if not args.force_dot_license: - verify_write_access(paths, args.parser) - - # Verify line handling and comment styles before proceeding - verify_paths_line_handling(args, paths) - - template, commented = get_template(args, project) - - year = get_year(args) - - reuse_info = get_reuse_info(args, year) - - result = 0 - for path in paths: - binary = is_binary(str(path)) - if binary or _is_uncommentable(path) or args.force_dot_license: - new_path = _determine_license_suffix_path(path) - if binary: - _LOGGER.info( - _( - "'{path}' is a binary, therefore using '{new_path}'" - " for the header" - ).format(path=path, new_path=new_path) - ) - path = Path(new_path) - path.touch() - result += add_header_to_file( - path=path, - reuse_info=reuse_info, - template=template, - template_is_commented=commented, - style=args.style, - force_multi=args.multi_line, - skip_existing=args.skip_existing, - skip_unrecognised=args.skip_unrecognised, - fallback_dot_license=args.fallback_dot_license, - merge_copyrights=args.merge_copyrights, - replace=not args.no_replace, - out=out, - ) - - return min(result, 1) diff --git a/src/reuse/_format.py b/src/reuse/_format.py deleted file mode 100644 index afc8bc3d8..000000000 --- a/src/reuse/_format.py +++ /dev/null @@ -1,50 +0,0 @@ -# SPDX-FileCopyrightText: 2018 Free Software Foundation Europe e.V. -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Formatting functions primarily for the CLI.""" - -from textwrap import fill, indent -from typing import Iterator - -WIDTH = 78 -INDENT = 2 - - -def fill_paragraph(text: str, width: int = WIDTH, indent_width: int = 0) -> str: - """Wrap a single paragraph.""" - return indent( - fill(text.strip(), width=width - indent_width), indent_width * " " - ) - - -def fill_all(text: str, width: int = WIDTH, indent_width: int = 0) -> str: - """Wrap all paragraphs.""" - return "\n\n".join( - fill_paragraph(paragraph, width=width, indent_width=indent_width) - for paragraph in split_into_paragraphs(text) - ) - - -def split_into_paragraphs(text: str) -> Iterator[str]: - """Yield all paragraphs in a text. A paragraph is a piece of text - surrounded by empty lines. - """ - lines = text.splitlines() - paragraph = "" - - for line in lines: - if not line: - if paragraph: - yield paragraph - paragraph = "" - else: - continue - else: - if paragraph: - padding = " " - else: - padding = "" - paragraph = f"{paragraph}{padding}{line}" - if paragraph: - yield paragraph diff --git a/src/reuse/_lint_file.py b/src/reuse/_lint_file.py deleted file mode 100644 index 2c8d53eb3..000000000 --- a/src/reuse/_lint_file.py +++ /dev/null @@ -1,63 +0,0 @@ -# SPDX-FileCopyrightText: 2024 Kerry McAdams -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Linting specific files happens here. The linting here is nothing more than -reading the reports and printing some conclusions. -""" - -import sys -from argparse import ArgumentParser, Namespace -from gettext import gettext as _ -from pathlib import Path -from typing import IO - -from ._util import PathType -from .lint import format_lines_subset -from .project import Project -from .report import ProjectSubsetReport - - -def add_arguments(parser: ArgumentParser) -> None: - """Add arguments to parser.""" - mutex_group = parser.add_mutually_exclusive_group() - mutex_group.add_argument( - "-q", "--quiet", action="store_true", help=_("prevents output") - ) - mutex_group.add_argument( - "-l", - "--lines", - action="store_true", - help=_("formats output as errors per line (default)"), - ) - parser.add_argument( - "files", - action="store", - nargs="*", - type=PathType("r"), - help=_("files to lint"), - ) - - -def run(args: Namespace, project: Project, out: IO[str] = sys.stdout) -> int: - """List all non-compliant files from specified file list.""" - subset_files = {Path(file_) for file_ in args.files} - for file_ in subset_files: - if not file_.resolve().is_relative_to(project.root.resolve()): - args.parser.error( - _("'{file}' is not inside of '{root}'").format( - file=file_, root=project.root - ) - ) - report = ProjectSubsetReport.generate( - project, - subset_files, - multiprocessing=not args.no_multiprocessing, - ) - - if args.quiet: - pass - else: - out.write(format_lines_subset(report)) - - return 0 if report.is_compliant else 1 diff --git a/src/reuse/_main.py b/src/reuse/_main.py deleted file mode 100644 index 3acfd1cab..000000000 --- a/src/reuse/_main.py +++ /dev/null @@ -1,327 +0,0 @@ -# SPDX-FileCopyrightText: 2017 Free Software Foundation Europe e.V. -# SPDX-FileCopyrightText: 2022 Florian Snow -# SPDX-FileCopyrightText: 2024 Carmen Bianca BAKKER -# SPDX-FileCopyrightText: © 2020 Liferay, Inc. -# SPDX-FileCopyrightText: 2024 Kerry McAdams -# SPDX-FileCopyrightText: 2024 Emil Velikov -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Entry functions for reuse.""" - -import argparse -import contextlib -import logging -import os -import sys -import warnings -from gettext import gettext as _ -from pathlib import Path -from types import ModuleType -from typing import IO, Callable, Optional, Type, cast - -from . import ( - __REUSE_version__, - __version__, - _annotate, - _lint_file, - convert_dep5, - download, - lint, - spdx, - supported_licenses, -) -from ._format import INDENT, fill_all, fill_paragraph -from ._util import PathType, setup_logging -from .global_licensing import GlobalLicensingParseError -from .project import GlobalLicensingConflict, Project -from .vcs import find_root - -shtab: Optional[ModuleType] = None -with contextlib.suppress(ImportError): - import shtab # type: ignore[no-redef,import-not-found] - -_LOGGER = logging.getLogger(__name__) - -_DESCRIPTION_LINES = [ - _( - "reuse is a tool for compliance with the REUSE" - " recommendations. See for more" - " information, and for the online" - " documentation." - ), - _( - "This version of reuse is compatible with version {} of the REUSE" - " Specification." - ).format(__REUSE_version__), - _("Support the FSFE's work:"), -] - -_INDENTED_LINE = _( - "Donations are critical to our strength and autonomy. They enable us to" - " continue working for Free Software wherever necessary. Please consider" - " making a donation at ." -) - -_DESCRIPTION_TEXT = ( - fill_all("\n\n".join(_DESCRIPTION_LINES)) - + "\n\n" - + fill_paragraph(_INDENTED_LINE, indent_width=INDENT) -) - -_EPILOG_TEXT = "" - - -def parser() -> argparse.ArgumentParser: - """Create the parser and return it.""" - # pylint: disable=redefined-outer-name - parser = argparse.ArgumentParser( - formatter_class=argparse.RawTextHelpFormatter, - description=_DESCRIPTION_TEXT, - epilog=_EPILOG_TEXT, - ) - parser.add_argument( - "--debug", action="store_true", help=_("enable debug statements") - ) - parser.add_argument( - "--suppress-deprecation", - action="store_true", - help=_("hide deprecation warnings"), - ) - parser.add_argument( - "--include-submodules", - action="store_true", - help=_("do not skip over Git submodules"), - ) - parser.add_argument( - "--include-meson-subprojects", - action="store_true", - help=_("do not skip over Meson subprojects"), - ) - parser.add_argument( - "--no-multiprocessing", - action="store_true", - help=_("do not use multiprocessing"), - ) - parser.add_argument( - "--root", - action="store", - metavar="PATH", - type=PathType("r", force_directory=True), - help=_("define root of project"), - ) - if shtab: - # This is magic. Running `reuse -s bash` now prints bash completions. - shtab.add_argument_to(parser, ["-s", "--print-completion"]) - parser.add_argument( - "--version", - action="store_true", - help=_("show program's version number and exit"), - ) - parser.set_defaults(func=lambda *args: parser.print_help()) - - subparsers = parser.add_subparsers(title=_("subcommands")) - - add_command( - subparsers, - "annotate", - _annotate.add_arguments, - _annotate.run, - help=_("add copyright and licensing into the header of files"), - description=fill_all( - _( - "Add copyright and licensing into the header of one or more" - " files.\n" - "\n" - "By using --copyright and --license, you can specify which" - " copyright holders and licenses to add to the headers of the" - " given files.\n" - "\n" - "By using --contributor, you can specify people or entity that" - " contributed but are not copyright holder of the given" - " files." - ) - ), - ) - - add_command( - subparsers, - "download", - download.add_arguments, - download.run, - help=_("download a license and place it in the LICENSES/ directory"), - description=fill_all( - _("Download a license and place it in the LICENSES/ directory.") - ), - ) - - add_command( - subparsers, - "lint", - lint.add_arguments, - lint.run, - help=_("list all non-compliant files"), - description=fill_all( - _( - "Lint the project directory for compliance with" - " version {reuse_version} of the REUSE Specification. You can" - " find the latest version of the specification at" - " .\n" - "\n" - "Specifically, the following criteria are checked:\n" - "\n" - "- Are there any bad (unrecognised, not compliant with SPDX)" - " licenses in the project?\n" - "\n" - "- Are there any deprecated licenses in the project?\n" - "\n" - "- Are there any license files in the LICENSES/ directory" - " without file extension?\n" - "\n" - "- Are any licenses referred to inside of the project, but" - " not included in the LICENSES/ directory?\n" - "\n" - "- Are any licenses included in the LICENSES/ directory that" - " are not used inside of the project?\n" - "\n" - "- Are there any read errors?\n" - "\n" - "- Do all files have valid copyright and licensing" - " information?" - ).format(reuse_version=__REUSE_version__) - ), - ) - - add_command( - subparsers, - "lint-file", - _lint_file.add_arguments, - _lint_file.run, - description=fill_all( - _( - "Lint individual files. The specified files are checked for" - " the presence of copyright and licensing information, and" - " whether the found licenses are included in the LICENSES/" - " directory." - ) - ), - help=_("list non-compliant files from specified list of files"), - ) - - add_command( - subparsers, - "spdx", - spdx.add_arguments, - spdx.run, - description=fill_all( - _("Generate an SPDX bill of materials in RDF format.") - ), - help=_("print the project's bill of materials in SPDX format"), - ) - - add_command( - subparsers, - "supported-licenses", - supported_licenses.add_arguments, - supported_licenses.run, - description=fill_all( - _("List all non-deprecated SPDX licenses from the official list.") - ), - help=_("list all supported SPDX licenses"), - aliases=["supported-licences"], - ) - - add_command( - subparsers, - "convert-dep5", - convert_dep5.add_arguments, - convert_dep5.run, - description=fill_all( - _( - "Convert .reuse/dep5 into a REUSE.toml file in your project" - " root. The generated file is semantically identical. The" - " .reuse/dep5 file is subsequently deleted." - ) - ), - help=_("convert .reuse/dep5 to REUSE.toml"), - ) - - return parser - - -def add_command( # pylint: disable=too-many-arguments,redefined-builtin - subparsers: argparse._SubParsersAction, - name: str, - add_arguments_func: Callable[[argparse.ArgumentParser], None], - run_func: Callable[[argparse.Namespace, Project, IO[str]], int], - formatter_class: Optional[Type[argparse.HelpFormatter]] = None, - description: Optional[str] = None, - help: Optional[str] = None, - aliases: Optional[list[str]] = None, -) -> None: - """Add a subparser for a command.""" - if formatter_class is None: - formatter_class = argparse.RawTextHelpFormatter - subparser = subparsers.add_parser( - name, - formatter_class=formatter_class, - description=description, - help=help, - aliases=aliases or [], - ) - add_arguments_func(subparser) - subparser.set_defaults(func=run_func) - subparser.set_defaults(parser=subparser) - - -def main(args: Optional[list[str]] = None, out: IO[str] = sys.stdout) -> int: - """Main entry function.""" - if args is None: - args = cast(list[str], sys.argv[1:]) - - main_parser = parser() - parsed_args = main_parser.parse_args(args) - - setup_logging(level=logging.DEBUG if parsed_args.debug else logging.WARNING) - # Show all warnings raised by ourselves. - if not parsed_args.suppress_deprecation: - warnings.filterwarnings("default", module="reuse") - - if parsed_args.version: - out.write(f"reuse {__version__}\n") - return 0 - - # Very stupid workaround to not print a DEP5 deprecation warning in the - # middle of conversion to REUSE.toml. - if args and args[0] == "convert-dep5": - os.environ["_SUPPRESS_DEP5_WARNING"] = "1" - - root = parsed_args.root - 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: - main_parser.error( - _( - "'{path}' could not be parsed. We received the following error" - " message: {message}" - ).format(path=error.source, message=str(error)) - ) - except GlobalLicensingConflict as error: - main_parser.error(str(error)) - except OSError as error: - main_parser.error(str(error)) - - project.include_submodules = parsed_args.include_submodules - project.include_meson_subprojects = parsed_args.include_meson_subprojects - - return parsed_args.func(parsed_args, project, out) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/reuse/_util.py b/src/reuse/_util.py index c906dd64e..04af137d5 100644 --- a/src/reuse/_util.py +++ b/src/reuse/_util.py @@ -19,32 +19,20 @@ import re import shutil import subprocess -import sys -from argparse import ArgumentTypeError from collections import Counter -from difflib import SequenceMatcher -from gettext import gettext as _ from hashlib import sha1 from inspect import cleandoc from itertools import chain -from os import PathLike from pathlib import Path -from typing import IO, Any, BinaryIO, Iterator, Optional, Type, Union, cast +from typing import IO, Any, BinaryIO, Iterator, Optional, Union -from boolean.boolean import Expression, ParseError +from boolean.boolean import ParseError from license_expression import ExpressionError, Licensing from . import ReuseInfo, SourceType -from ._licenses import ALL_NON_DEPRECATED_MAP -from .comment import ( - EXTENSION_COMMENT_STYLE_MAP_LOWERCASE, - FILENAME_COMMENT_STYLE_MAP_LOWERCASE, - CommentStyle, - UncommentableCommentStyle, - _all_style_classes, -) - -StrPath = Union[str, PathLike[str]] +from .comment import _all_style_classes # TODO: This import is not ideal here. +from .i18n import _ +from .types import StrPath GIT_EXE = shutil.which("git") HG_EXE = shutil.which("hg") @@ -259,28 +247,6 @@ def _contains_snippet(binary_file: BinaryIO) -> bool: return False -def _get_comment_style(path: StrPath) -> Optional[Type[CommentStyle]]: - """Return value of CommentStyle detected for *path* or None.""" - path = Path(path) - style = FILENAME_COMMENT_STYLE_MAP_LOWERCASE.get(path.name.lower()) - if style is None: - style = cast( - Optional[Type[CommentStyle]], - EXTENSION_COMMENT_STYLE_MAP_LOWERCASE.get(path.suffix.lower()), - ) - return style - - -def _is_uncommentable(path: Path) -> bool: - """*path*'s extension has the UncommentableCommentStyle.""" - return _get_comment_style(path) == UncommentableCommentStyle - - -def _has_style(path: Path) -> bool: - """*path*'s extension has a CommentStyle.""" - return _get_comment_style(path) is not None - - def merge_copyright_lines(copyright_lines: set[str]) -> set[str]: """Parse all copyright lines and merge identical statements making years into a range. @@ -524,117 +490,6 @@ def _checksum(path: StrPath) -> str: return file_sha1.hexdigest() -class PathType: - """Factory for creating Paths""" - - def __init__( - self, - mode: str = "r", - force_file: bool = False, - force_directory: bool = False, - ): - if mode in ("r", "r+", "w"): - self._mode = mode - else: - raise ValueError(f"mode='{mode}' is not valid") - self._force_file = force_file - self._force_directory = force_directory - if self._force_file and self._force_directory: - raise ValueError( - "'force_file' and 'force_directory' cannot both be True" - ) - - def _check_read(self, path: Path) -> None: - if path.exists() and os.access(path, os.R_OK): - if self._force_file and not path.is_file(): - raise ArgumentTypeError(_("'{}' is not a file").format(path)) - if self._force_directory and not path.is_dir(): - raise ArgumentTypeError( - _("'{}' is not a directory").format(path) - ) - return - raise ArgumentTypeError(_("can't open '{}'").format(path)) - - def _check_write(self, path: Path) -> None: - if path.is_dir(): - raise ArgumentTypeError( - _("can't write to directory '{}'").format(path) - ) - if path.exists() and os.access(path, os.W_OK): - return - if not path.exists() and os.access(path.parent, os.W_OK): - return - raise ArgumentTypeError(_("can't write to '{}'").format(path)) - - def __call__(self, string: str) -> Path: - path = Path(string) - - try: - if self._mode in ("r", "r+"): - self._check_read(path) - if self._mode in ("w", "r+"): - self._check_write(path) - return path - except OSError as error: - raise ArgumentTypeError( - _("can't read or write '{}'").format(path) - ) from error - - -def spdx_identifier(text: str) -> Expression: - """argparse factory for creating SPDX expressions.""" - try: - return _LICENSING.parse(text) - except (ExpressionError, ParseError) as error: - raise ArgumentTypeError( - _("'{}' is not a valid SPDX expression, aborting").format(text) - ) from error - - -def similar_spdx_identifiers(identifier: str) -> list[str]: - """Given an incorrect SPDX identifier, return a list of similar ones.""" - suggestions: list[str] = [] - if identifier in ALL_NON_DEPRECATED_MAP: - return suggestions - - for valid_identifier in ALL_NON_DEPRECATED_MAP: - distance = SequenceMatcher( - a=identifier.lower(), b=valid_identifier[: len(identifier)].lower() - ).ratio() - if distance > 0.75: - suggestions.append(valid_identifier) - suggestions = sorted(suggestions) - - return suggestions - - -def print_incorrect_spdx_identifier( - identifier: str, out: IO[str] = sys.stdout -) -> None: - """Print out that *identifier* is not valid, and follow up with some - suggestions. - """ - out.write( - _("'{}' is not a valid SPDX License Identifier.").format(identifier) - ) - out.write("\n") - - suggestions = similar_spdx_identifiers(identifier) - if suggestions: - out.write("\n") - out.write(_("Did you mean:")) - out.write("\n") - for suggestion in suggestions: - out.write(f"* {suggestion}\n") - out.write("\n") - out.write( - _( - "See for a list of valid " - "SPDX License Identifiers." - ) - ) - - def detect_line_endings(text: str) -> str: """Return one of '\n', '\r' or '\r\n' depending on the line endings used in *text*. Return os.linesep if there are no line endings. diff --git a/src/reuse/cli/__init__.py b/src/reuse/cli/__init__.py new file mode 100644 index 000000000..2138eeb27 --- /dev/null +++ b/src/reuse/cli/__init__.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2024 Free Software Foundation Europe e.V. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""All command-line functionality.""" + +from . import ( + annotate, + convert_dep5, + download, + lint, + lint_file, + main, + spdx, + supported_licenses, +) diff --git a/src/reuse/cli/annotate.py b/src/reuse/cli/annotate.py new file mode 100644 index 000000000..301593a47 --- /dev/null +++ b/src/reuse/cli/annotate.py @@ -0,0 +1,496 @@ +# SPDX-FileCopyrightText: 2019 Free Software Foundation Europe e.V. +# SPDX-FileCopyrightText: 2019 Kirill Elagin +# SPDX-FileCopyrightText: 2019 Stefan Bakker +# SPDX-FileCopyrightText: 2020 Dmitry Bogatov +# SPDX-FileCopyrightText: 2021 Alliander N.V. +# SPDX-FileCopyrightText: 2021 Alvar Penning +# SPDX-FileCopyrightText: 2021 Robin Vobruba +# SPDX-FileCopyrightText: 2022 Carmen Bianca Bakker +# SPDX-FileCopyrightText: 2022 Florian Snow +# SPDX-FileCopyrightText: 2022 Yaman Qalieh +# SPDX-FileCopyrightText: 2024 Rivos Inc. +# SPDX-FileCopyrightText: © 2020 Liferay, Inc. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Click code for annotate subcommand.""" + +import datetime +import logging +import sys +from pathlib import Path +from typing import Any, Collection, Iterable, Optional, Sequence, Type, cast + +import click +from binaryornot.check import is_binary +from boolean.boolean import Expression +from jinja2 import Environment, FileSystemLoader, Template +from jinja2.exceptions import TemplateNotFound + +from .. import ReuseInfo +from .._annotate import add_header_to_file +from .._util import ( + _COPYRIGHT_PREFIXES, + _determine_license_path, + _determine_license_suffix_path, + make_copyright_line, +) +from ..comment import ( + NAME_STYLE_MAP, + CommentStyle, + get_comment_style, + has_style, + is_uncommentable, +) +from ..i18n import _ +from ..project import Project +from .common import ClickObj, MutexOption, requires_project, spdx_identifier +from .main import main + +_LOGGER = logging.getLogger(__name__) + + +def test_mandatory_option_required( + copyright_: Any, + license_: Any, + contributor: Any, +) -> None: + """Raise a parser error if one of the mandatory options is not provided.""" + if not any((copyright_, license_, contributor)): + raise click.UsageError( + _( + "Option '--copyright', '--license', or '--contributor' is" + " required." + ) + ) + + +def all_paths( + paths: Collection[Path], + recursive: bool, + project: Project, +) -> list[Path]: + """Return a set of all provided paths, converted into .license paths if they + exist. If *recursive* is enabled, all files belonging to *project* that are + recursive children of *paths* are also added. + + Directories are filtered out. + """ + if recursive: + result: set[Path] = set() + all_files = [path.resolve() for path in project.all_files()] + for path in paths: + if path.is_file(): + result.add(path) + else: + result |= { + child + for child in all_files + if path.resolve() in child.parents + } + else: + result = set(paths) + return [_determine_license_path(path) for path in result if path.is_file()] + + +def verify_paths_comment_style( + style: Any, + fallback_dot_license: Any, + skip_unrecognised: Any, + force_dot_license: Any, + paths: Iterable[Path], +) -> None: + """Exit if --style, --force-dot-license, --fallback-dot-license, + or --skip-unrecognised is not enabled and one of the paths has an + unrecognised style. + """ + if ( + not style + and not fallback_dot_license + and not skip_unrecognised + and not force_dot_license + ): + unrecognised_files: set[Path] = set() + + for path in paths: + if not has_style(path): + unrecognised_files.add(path) + + if unrecognised_files: + raise click.UsageError( + "{}\n\n{}".format( + _( + "The following files do not have a recognised file" + " extension. Please use '--style'," + " '--force-dot-license', '--fallback-dot-license', or" + " '--skip-unrecognised':" + ), + "\n".join(str(path) for path in unrecognised_files), + ) + ) + + +def verify_paths_line_handling( + single_line: bool, + multi_line: bool, + forced_style: Optional[str], + paths: Iterable[Path], +) -> None: + """This function aborts the parser when --single-line or --multi-line is + used, but the file type does not support that type of comment style. + """ + for path in paths: + style: Optional[Type[CommentStyle]] = None + if forced_style is not None: + style = NAME_STYLE_MAP.get(forced_style) + if style is None: + style = get_comment_style(path) + # This shouldn't happen because of prior tests, so let's not bother with + # this case. + if style is None: + continue + # TODO: list all non-functional paths + if single_line and not style.can_handle_single(): + raise click.UsageError( + _( + "'{path}' does not support single-line comments, please" + " do not use '--single-line'." + ).format(path=path.as_posix()) + ) + if multi_line and not style.can_handle_multi(): + raise click.UsageError( + _( + "'{path}' does not support multi-line comments, please" + " do not use '--multi-line'." + ).format(path=path.as_posix()) + ) + + +def find_template(project: Project, name: str) -> Template: + """Find a template given a name. + + Raises: + TemplateNotFound: if template could not be found. + """ + template_dir = project.root / ".reuse/templates" + env = Environment( + loader=FileSystemLoader(str(template_dir)), trim_blocks=True + ) + + names = [name] + if not name.endswith(".jinja2"): + names.append(f"{name}.jinja2") + if not name.endswith(".commented.jinja2"): + names.append(f"{name}.commented.jinja2") + + for item in names: + try: + return env.get_template(item) + except TemplateNotFound: + pass + raise TemplateNotFound(name) + + +def get_template( + template_str: Optional[str], project: Project +) -> tuple[Optional[Template], bool]: + """If a template is specified on the CLI, find and return it, including + whether it is a 'commented' template. + + If no template is specified, just return None. + """ + template: Optional[Template] = None + commented = False + if template_str: + try: + template = cast(Template, find_template(project, template_str)) + except TemplateNotFound as error: + raise click.UsageError( + _("Template '{template}' could not be found.").format( + template=template_str + ) + ) from error + + if ".commented" in Path(cast(str, template.name)).suffixes: + commented = True + return template, commented + + +def get_year(years: Sequence[str], exclude_year: bool) -> Optional[str]: + """Get the year. Normally it is today's year. If --year is specified once, + get that one. If it is specified twice (or more), return the range between + the two. + + If --exclude-year is specified, return None. + """ + year = None + if not exclude_year: + if years: + if len(years) > 1: + year = f"{min(years)} - {max(years)}" + else: + year = years[0] + else: + year = str(datetime.date.today().year) + return year + + +def get_reuse_info( + copyrights: Collection[str], + licenses: Collection[Expression], + contributors: Collection[str], + copyright_prefix: Optional[str], + year: Optional[str], +) -> ReuseInfo: + """Create a ReuseInfo object from --license, --copyright, and + --contributor. + """ + copyright_prefix = ( + copyright_prefix if copyright_prefix is not None else "spdx" + ) + copyright_lines = { + make_copyright_line(item, year=year, copyright_prefix=copyright_prefix) + for item in copyrights + } + + return ReuseInfo( + spdx_expressions=set(licenses), + copyright_lines=copyright_lines, + contributor_lines=set(contributors), + ) + + +_YEAR_MUTEX = ["years", "exclude_year"] +_LINE_MUTEX = ["single_line", "multi_line"] +_STYLE_MUTEX = [ + "force_dot_license", + "fallback_dot_license", + "skip_unrecognised", +] + +_HELP = ( + _("Add copyright and licensing into the headers of files.") + + "\n\n" + + _( + "By using --copyright and --license, you can specify which" + " copyright holders and licenses to add to the headers of the" + " given files." + ) + + "\n\n" + + _( + "By using --contributor, you can specify people or entity that" + " contributed but are not copyright holder of the given" + " files." + ) +) + + +@requires_project +@main.command(name="annotate", help=_HELP) +@click.option( + "--copyright", + "-c", + "copyrights", + metavar=_("COPYRIGHT"), + type=str, + multiple=True, + help=_("Copyright statement, repeatable."), +) +@click.option( + "--license", + "-l", + "licenses", + metavar=_("SPDX_IDENTIFIER"), + type=spdx_identifier, + multiple=True, + help=_("SPDX License Identifier, repeatable."), +) +@click.option( + "--contributor", + "contributors", + metavar=_("CONTRIBUTOR"), + type=str, + multiple=True, + help=_("File contributor, repeatable."), +) +@click.option( + "--year", + "-y", + "years", + metavar=_("YEAR"), + cls=MutexOption, + mutually_exclusive=_YEAR_MUTEX, + # TODO: This multiple behaviour is kind of word. Let's redo it. + multiple=True, + type=str, + help=_("Year of copyright statement."), +) +@click.option( + "--style", + "-s", + cls=MutexOption, + mutually_exclusive=["skip_unrecognised"], + type=click.Choice(list(NAME_STYLE_MAP)), + help=_("Comment style to use."), +) +@click.option( + "--copyright-prefix", + type=click.Choice(list(_COPYRIGHT_PREFIXES)), + help=_("Copyright prefix to use."), +) +@click.option( + "--copyright-style", + "copyright_prefix", + hidden=True, +) +@click.option( + "--template", + "-t", + "template_str", + metavar=_("TEMPLATE"), + type=str, + help=_("Name of template to use."), +) +@click.option( + "--exclude-year", + cls=MutexOption, + mutually_exclusive=_YEAR_MUTEX, + is_flag=True, + help=_("Do not include year in copyright statement."), +) +@click.option( + "--merge-copyrights", + is_flag=True, + help=_("Merge copyright lines if copyright statements are identical."), +) +@click.option( + "--single-line", + cls=MutexOption, + mutually_exclusive=_LINE_MUTEX, + is_flag=True, + help=_("Force single-line comment style."), +) +@click.option( + "--multi-line", + cls=MutexOption, + mutually_exclusive=_LINE_MUTEX, + is_flag=True, + help=_("Force multi-line comment style."), +) +@click.option( + "--recursive", + "-r", + is_flag=True, + help=_("Add headers to all files under specified directories recursively."), +) +@click.option( + "--no-replace", + is_flag=True, + help=_("Do not replace the first header in the file; just add a new one."), +) +@click.option( + "--force-dot-license", + cls=MutexOption, + mutually_exclusive=_STYLE_MUTEX, + is_flag=True, + help=_("Always write a .license file instead of a header inside the file."), +) +@click.option( + "--fallback-dot-license", + cls=MutexOption, + mutually_exclusive=_STYLE_MUTEX, + is_flag=True, + help=_("Write a .license file to files with unrecognised comment styles."), +) +@click.option( + "--skip-unrecognised", + cls=MutexOption, + mutually_exclusive=_STYLE_MUTEX, + is_flag=True, + help=_("Skip files with unrecognised comment styles."), +) +@click.option( + "--skip-unrecognized", + "skip_unrecognised", + is_flag=True, + hidden=True, +) +@click.option( + "--skip-existing", + is_flag=True, + help=_("Skip files that already contain REUSE information."), +) +@click.argument( + "paths", + metavar=_("PATH"), + type=click.Path(exists=True, writable=True, path_type=Path), + nargs=-1, +) +@click.pass_obj +def annotate( + obj: ClickObj, + copyrights: Sequence[str], + licenses: Sequence[Expression], + contributors: Sequence[str], + years: Sequence[str], + style: Optional[str], + copyright_prefix: Optional[str], + template_str: Optional[str], + exclude_year: bool, + merge_copyrights: bool, + single_line: bool, + multi_line: bool, + recursive: bool, + no_replace: bool, + force_dot_license: bool, + fallback_dot_license: bool, + skip_unrecognised: bool, + 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) + paths = all_paths(paths, recursive, project) + verify_paths_comment_style( + style, fallback_dot_license, skip_unrecognised, force_dot_license, paths + ) + # Verify line handling and comment styles before proceeding. + verify_paths_line_handling(single_line, multi_line, style, paths) + template, commented = get_template(template_str, project) + year = get_year(years, exclude_year) + reuse_info = get_reuse_info( + copyrights, licenses, contributors, copyright_prefix, year + ) + + result = 0 + for path in paths: + binary = is_binary(str(path)) + if binary or is_uncommentable(path) or force_dot_license: + new_path = _determine_license_suffix_path(path) + if binary: + _LOGGER.info( + _( + "'{path}' is a binary, therefore using '{new_path}'" + " for the header" + ).format(path=path, new_path=new_path) + ) + path = Path(new_path) + path.touch() + result += add_header_to_file( + path=path, + reuse_info=reuse_info, + template=template, + template_is_commented=commented, + style=style, + force_multi=multi_line, + skip_existing=skip_existing, + skip_unrecognised=skip_unrecognised, + fallback_dot_license=fallback_dot_license, + merge_copyrights=merge_copyrights, + replace=not no_replace, + out=sys.stdout, + ) + + sys.exit(min(result, 1)) diff --git a/src/reuse/cli/common.py b/src/reuse/cli/common.py new file mode 100644 index 000000000..88189bfcf --- /dev/null +++ b/src/reuse/cli/common.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2024 Free Software Foundation Europe e.V. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Utilities that are common to multiple CLI commands.""" + +from dataclasses import dataclass +from typing import Any, Callable, Mapping, Optional, TypeVar + +import click +from boolean.boolean import Expression, ParseError +from license_expression import ExpressionError + +from .._util import _LICENSING +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: + """A dataclass holding necessary context and options.""" + + no_multiprocessing: bool + project: Optional[Project] + + +class MutexOption(click.Option): + """Enable declaring mutually exclusive options.""" + + def __init__(self, *args: Any, **kwargs: Any): + self.mutually_exclusive: set[str] = set( + kwargs.pop("mutually_exclusive", []) + ) + super().__init__(*args, **kwargs) + # If self is in mutex, remove it. + self.mutually_exclusive -= {self.name} + + @staticmethod + def _get_long_name(ctx: click.Context, name: str) -> str: + """Given the option name, get the long name of the option. + + For example, 'output' return '--output'. + """ + param = next( + (param for param in ctx.command.params if param.name == name) + ) + return param.opts[0] + + def handle_parse_result( + self, ctx: click.Context, opts: Mapping[str, Any], args: list[str] + ) -> tuple[Any, list[str]]: + if self.mutually_exclusive.intersection(opts) and self.name in opts: + raise click.UsageError( + _("'{name}' is mutually exclusive with: {opts}").format( + name=self._get_long_name(ctx, str(self.name)), + opts=", ".join( + f"'{self._get_long_name(ctx, opt)}'" + for opt in self.mutually_exclusive + ), + ) + ) + return super().handle_parse_result(ctx, opts, args) + + +def spdx_identifier(text: str) -> Expression: + """factory for creating SPDX expressions.""" + try: + return _LICENSING.parse(text) + except (ExpressionError, ParseError) as error: + raise click.UsageError( + _("'{}' is not a valid SPDX expression.").format(text) + ) from error diff --git a/src/reuse/cli/convert_dep5.py b/src/reuse/cli/convert_dep5.py new file mode 100644 index 000000000..c7bec0263 --- /dev/null +++ b/src/reuse/cli/convert_dep5.py @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2024 Carmen Bianca BAKKER +# SPDX-FileCopyrightText: 2024 Free Software Foundation Europe e.V. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Click code for convert-dep5 subcommand.""" + +from typing import cast + +import click + +from ..convert_dep5 import toml_from_dep5 +from ..global_licensing import ReuseDep5 +from ..i18n import _ +from ..project import Project +from .common import ClickObj, requires_project +from .main import main + +_HELP = _( + "Convert .reuse/dep5 into a REUSE.toml file. The generated file is placed" + " in the project root and is semantically identical. The .reuse/dep5 file" + " is subsequently deleted." +) + + +@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.")) + + text = toml_from_dep5( + cast(ReuseDep5, project.global_licensing).dep5_copyright + ) + (project.root / "REUSE.toml").write_text(text) + (project.root / ".reuse/dep5").unlink() diff --git a/src/reuse/cli/download.py b/src/reuse/cli/download.py new file mode 100644 index 000000000..55d3998a9 --- /dev/null +++ b/src/reuse/cli/download.py @@ -0,0 +1,197 @@ +# SPDX-FileCopyrightText: 2019 Free Software Foundation Europe e.V. +# SPDX-FileCopyrightText: 2023 Nico Rikken +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Click code for download subcommand.""" + +import logging +import sys +from difflib import SequenceMatcher +from pathlib import Path +from typing import IO, Collection, Optional, cast +from urllib.error import URLError + +import click + +from .._licenses import ALL_NON_DEPRECATED_MAP +from ..download import _path_to_license_file, put_license_in_file +from ..i18n import _ +from ..project import Project +from ..report import ProjectReport +from ..types import StrPath +from .common import ClickObj, MutexOption, requires_project +from .main import main + +_LOGGER = logging.getLogger(__name__) + + +def _similar_spdx_identifiers(identifier: str) -> list[str]: + """Given an incorrect SPDX identifier, return a list of similar ones.""" + suggestions: list[str] = [] + if identifier in ALL_NON_DEPRECATED_MAP: + return suggestions + + for valid_identifier in ALL_NON_DEPRECATED_MAP: + distance = SequenceMatcher( + a=identifier.lower(), b=valid_identifier[: len(identifier)].lower() + ).ratio() + if distance > 0.75: + suggestions.append(valid_identifier) + suggestions = sorted(suggestions) + + return suggestions + + +def _print_incorrect_spdx_identifier( + identifier: str, out: IO[str] = sys.stdout +) -> None: + """Print out that *identifier* is not valid, and follow up with some + suggestions. + """ + out.write( + _("'{}' is not a valid SPDX License Identifier.").format(identifier) + ) + out.write("\n") + + suggestions = _similar_spdx_identifiers(identifier) + if suggestions: + out.write("\n") + out.write(_("Did you mean:")) + out.write("\n") + for suggestion in suggestions: + out.write(f"* {suggestion}\n") + out.write("\n") + out.write( + _( + "See for a list of valid " + "SPDX License Identifiers." + ) + ) + out.write("\n") + + +def _already_exists(path: StrPath) -> None: + click.echo( + _("Error: {spdx_identifier} already exists.").format( + spdx_identifier=path + ) + ) + + +def _not_found(path: StrPath) -> None: + click.echo(_("Error: {path} does not exist.").format(path=path)) + + +def _could_not_download(identifier: str) -> None: + click.echo(_("Error: Failed to download license.")) + click.echo("") + if identifier not in ALL_NON_DEPRECATED_MAP: + _print_incorrect_spdx_identifier(identifier, out=sys.stdout) + else: + click.echo(_("Is your internet connection working?")) + + +def _successfully_downloaded(destination: StrPath) -> None: + click.echo( + _("Successfully downloaded {spdx_identifier}.").format( + spdx_identifier=destination + ) + ) + + +_ALL_MUTEX = ["all_", "output"] + + +_HELP = ( + _("Download a license and place it in the LICENSES/ directory.") + + "\n\n" + + _( + "LICENSE must be a valid SPDX License Identifier. You may specify" + " LICENSE multiple times to download multiple licenses." + ) +) + + +@requires_project +@main.command(name="download", help=_HELP) +@click.option( + "--all", + "all_", + cls=MutexOption, + mutually_exclusive=_ALL_MUTEX, + is_flag=True, + help=_("Download all missing licenses detected in the project."), +) +@click.option( + "--output", + "-o", + cls=MutexOption, + mutually_exclusive=_ALL_MUTEX, + type=click.Path(dir_okay=False, writable=True, path_type=Path), + help=_("Path to download to."), +) +@click.option( + "--source", + type=click.Path(exists=True, readable=True, path_type=Path), + help=_( + "Source from which to copy custom LicenseRef- licenses, either" + " a directory that contains the file or the file itself." + ), +) +@click.argument( + "license_", + metavar=_("LICENSE"), + type=str, + nargs=-1, +) +@click.pass_obj +def download( + obj: ClickObj, + license_: Collection[str], + all_: bool, + output: Optional[Path], + source: Optional[Path], +) -> None: + # pylint: disable=missing-function-docstring + if all_ and license_: + raise click.UsageError( + _( + "The 'LICENSE' argument and '--all' option are mutually" + " exclusive." + ) + ) + + licenses: Collection[str] = license_ # type: ignore + + if all_: + # TODO: This is fairly inefficient, but gets the job done. + report = ProjectReport.generate( + cast(Project, obj.project), do_checksum=False + ) + licenses = report.missing_licenses.keys() + + if len(licenses) > 1 and output: + raise click.UsageError( + _("Cannot use '--output' with more than one license.") + ) + + return_code = 0 + for lic in licenses: + destination: Path = output # type: ignore + if destination is None: + destination = _path_to_license_file(lic, obj.project) + try: + put_license_in_file(lic, destination=destination, source=source) + except URLError: + _could_not_download(lic) + return_code = 1 + except FileExistsError as err: + _already_exists(err.filename) + return_code = 1 + except FileNotFoundError as err: + _not_found(err.filename) + return_code = 1 + else: + _successfully_downloaded(destination) + sys.exit(return_code) diff --git a/src/reuse/cli/lint.py b/src/reuse/cli/lint.py new file mode 100644 index 000000000..0509ce0df --- /dev/null +++ b/src/reuse/cli/lint.py @@ -0,0 +1,119 @@ +# SPDX-FileCopyrightText: 2017 Free Software Foundation Europe e.V. +# SPDX-FileCopyrightText: 2022 Florian Snow +# SPDX-FileCopyrightText: 2023 DB Systel GmbH +# SPDX-FileCopyrightText: 2024 Nico Rikken +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# pylint: disable=unused-argument + +"""Click code for lint subcommand.""" + +import sys +from typing import cast + +import click + +from .. import __REUSE_version__ +from ..i18n import _ +from ..lint import format_json, format_lines, format_plain +from ..project import Project +from ..report import ProjectReport +from .common import ClickObj, MutexOption, requires_project +from .main import main + +_OUTPUT_MUTEX = ["quiet", "json", "plain", "lines"] + +_HELP = ( + _( + "Lint the project directory for REUSE compliance. This version of the" + " tool checks against version {reuse_version} of the REUSE" + " Specification. You can find the latest version of the specification" + " at ." + ).format(reuse_version=__REUSE_version__) + + "\n\n" + + _("Specifically, the following criteria are checked:") + + "\n\n" + + _( + "- Are there any bad (unrecognised, not compliant with SPDX)" + " licenses in the project?" + ) + + "\n" + + _("- Are there any deprecated licenses in the project?") + + "\n" + + _( + "- Are there any license files in the LICENSES/ directory" + " without file extension?" + ) + + "\n" + + _( + "- Are any licenses referred to inside of the project, but" + " not included in the LICENSES/ directory?" + ) + + "\n" + + _( + "- Are any licenses included in the LICENSES/ directory that" + " are not used inside of the project?" + ) + + "\n" + + _("- Are there any read errors?") + + "\n" + + _("- Do all files have valid copyright and licensing information?") +) + + +@requires_project +@main.command(name="lint", help=_HELP) +@click.option( + "--quiet", + "-q", + cls=MutexOption, + mutually_exclusive=_OUTPUT_MUTEX, + is_flag=True, + help=_("Prevent output."), +) +@click.option( + "--json", + "-j", + cls=MutexOption, + mutually_exclusive=_OUTPUT_MUTEX, + is_flag=True, + help=_("Format output as JSON."), +) +@click.option( + "--plain", + "-p", + cls=MutexOption, + mutually_exclusive=_OUTPUT_MUTEX, + is_flag=True, + help=_("Format output as plain text. (default)"), +) +@click.option( + "--lines", + "-l", + cls=MutexOption, + mutually_exclusive=_OUTPUT_MUTEX, + is_flag=True, + help=_("Format output as errors per line."), +) +@click.pass_obj +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, + multiprocessing=not obj.no_multiprocessing, + ) + + if quiet: + pass + elif json: + click.echo(format_json(report), nl=False) + elif lines: + click.echo(format_lines(report), nl=False) + else: + click.echo(format_plain(report), nl=False) + + sys.exit(0 if report.is_compliant else 1) diff --git a/src/reuse/cli/lint_file.py b/src/reuse/cli/lint_file.py new file mode 100644 index 000000000..b6e8bd81a --- /dev/null +++ b/src/reuse/cli/lint_file.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2024 Kerry McAdams +# SPDX-FileCopyrightText: 2024 Free Software Foundation Europe e.V. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Click code for lint-file subcommand.""" + +# pylint: disable=unused-argument + +import sys +from pathlib import Path +from typing import Collection, cast + +import click + +from ..i18n import _ +from ..lint import format_lines_subset +from ..project import Project +from ..report import ProjectSubsetReport +from .common import ClickObj, MutexOption, requires_project +from .main import main + +_OUTPUT_MUTEX = ["quiet", "lines"] + +_HELP = _( + "Lint individual files for REUSE compliance. The specified FILEs are" + " checked for the presence of copyright and licensing information, and" + " whether the found licenses are included in the LICENSES/ directory." +) + + +@requires_project +@main.command(name="lint-file", help=_HELP) +@click.option( + "--quiet", + "-q", + cls=MutexOption, + mutually_exclusive=_OUTPUT_MUTEX, + is_flag=True, + help=_("Prevent output."), +) +@click.option( + "--lines", + "-l", + cls=MutexOption, + mutually_exclusive=_OUTPUT_MUTEX, + is_flag=True, + help=_("Format output as errors per line. (default)"), +) +@click.argument( + "files", + metavar=_("FILE"), + type=click.Path(exists=True, path_type=Path), + nargs=-1, +) +@click.pass_obj +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: + if not file_.resolve().is_relative_to(project.root.resolve()): + raise click.UsageError( + _("'{file}' is not inside of '{root}'.").format( + file=file_, root=project.root + ) + ) + report = ProjectSubsetReport.generate( + project, + subset_files, + multiprocessing=not obj.no_multiprocessing, + ) + + if quiet: + pass + else: + click.echo(format_lines_subset(report), nl=False) + + sys.exit(0 if report.is_compliant else 1) diff --git a/src/reuse/cli/main.py b/src/reuse/cli/main.py new file mode 100644 index 000000000..e85248906 --- /dev/null +++ b/src/reuse/cli/main.py @@ -0,0 +1,178 @@ +# SPDX-FileCopyrightText: 2017 Free Software Foundation Europe e.V. +# SPDX-FileCopyrightText: 2022 Florian Snow +# SPDX-FileCopyrightText: 2024 Carmen Bianca BAKKER +# SPDX-FileCopyrightText: © 2020 Liferay, Inc. +# SPDX-FileCopyrightText: 2024 Kerry McAdams +# SPDX-FileCopyrightText: 2024 Emil Velikov +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Entry function for reuse.""" + +import gettext +import logging +import os +import warnings +from pathlib import Path +from typing import Optional + +import click +from click.formatting import wrap_text + +from .. import __REUSE_version__ +from .._util import setup_logging +from ..global_licensing import GlobalLicensingParseError +from ..i18n import _ +from ..project import GlobalLicensingConflict, Project +from ..vcs import find_root +from .common import ClickObj + +_PACKAGE_PATH = os.path.dirname(__file__) +_LOCALE_DIR = os.path.join(_PACKAGE_PATH, "locale") +if gettext.find("reuse", localedir=_LOCALE_DIR): + gettext.bindtextdomain("reuse", _LOCALE_DIR) + # This is needed to make Click recognise our translations. Our own + # translations use the class-based API. + gettext.textdomain("reuse") + + +_VERSION_TEXT = ( + _("%(prog)s, version %(version)s") + + "\n\n" + + _( + "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 3 of the License, or" + " (at your option) any later version." + ) + + "\n\n" + + _( + "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." + ) + + "\n\n" + + _( + "You should have received a copy of the GNU General Public License" + " along with this program. If not, see" + " ." + ) +) + +_HELP = ( + _( + "reuse is a tool for compliance with the REUSE" + " recommendations. See for more" + " information, and for the online" + " documentation." + ) + + "\n\n" + + _( + "This version of reuse is compatible with version {} of the REUSE" + " Specification." + ).format(__REUSE_version__) + + "\n\n" + + _("Support the FSFE's work:") + + "\n\n" + # Indent next paragraph. + + " " + + _( + "Donations are critical to our strength and autonomy. They enable us" + " to continue working for Free Software wherever necessary. Please" + " consider making a donation at ." + ) +) + + +@click.group(name="reuse", help=_HELP) +@click.option( + "--debug", + is_flag=True, + help=_("Enable debug statements."), +) +@click.option( + "--suppress-deprecation", + is_flag=True, + help=_("Hide deprecation warnings."), +) +@click.option( + "--include-submodules", + is_flag=True, + help=_("Do not skip over Git submodules."), +) +@click.option( + "--include-meson-subprojects", + is_flag=True, + help=_("Do not skip over Meson subprojects."), +) +@click.option( + "--no-multiprocessing", + is_flag=True, + help=_("Do not use multiprocessing."), +) +@click.option( + "--root", + type=click.Path( + exists=True, + file_okay=False, + path_type=Path, + ), + default=None, + help=_("Define root of project."), +) +@click.version_option( + package_name="reuse", + message=wrap_text(_VERSION_TEXT, preserve_paragraphs=True), +) +@click.pass_context +def main( + ctx: click.Context, + debug: bool, + suppress_deprecation: bool, + include_submodules: bool, + include_meson_subprojects: bool, + 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 + # middle of ccompileonversion to REUSE.toml. + if ctx.invoked_subcommand == "convert-dep5": + os.environ["_SUPPRESS_DEP5_WARNING"] = "1" + + if not suppress_deprecation: + warnings.filterwarnings("default", module="reuse") + + 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, + project=project, + ) diff --git a/src/reuse/cli/spdx.py b/src/reuse/cli/spdx.py new file mode 100644 index 000000000..9c69477bb --- /dev/null +++ b/src/reuse/cli/spdx.py @@ -0,0 +1,122 @@ +# SPDX-FileCopyrightText: 2017 Free Software Foundation Europe e.V. +# SPDX-FileCopyrightText: 2022 Pietro Albini +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Click code for spdx subcommand.""" + +import contextlib +import logging +import sys +from typing import Optional, cast + +import click + +from .. import _IGNORE_SPDX_PATTERNS +from ..i18n import _ +from ..project import Project +from ..report import ProjectReport +from .common import ClickObj, requires_project +from .main import main + +_LOGGER = logging.getLogger(__name__) + +_HELP = _("Generate an SPDX bill of materials.") + + +@requires_project +@main.command(name="spdx", help=_HELP) +@click.option( + "--output", + "-o", + type=click.File("w", encoding="utf-8", lazy=True), + # Default is stdout. + default=None, + help=_("File to write to."), +) +@click.option( + "--add-license-concluded", + is_flag=True, + help=_( + "Populate the LicenseConcluded field; note that reuse cannot guarantee" + " that the field is accurate." + ), +) +@click.option( + "--add-licence-concluded", + "add_license_concluded", + hidden=True, +) +@click.option( + "--creator-person", + type=str, + help=_("Name of the person signing off on the SPDX report."), +) +@click.option( + "--creator-organization", + help=_("Name of the organization signing off on the SPDX report."), +) +@click.option( + "--creator-organisation", + "creator_organization", + hidden=True, +) +@click.pass_obj +def spdx( + obj: ClickObj, + output: Optional[click.File], + add_license_concluded: bool, + 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 + if ( + add_license_concluded + and creator_person is None + and creator_organization is None + ): + raise click.UsageError( + _( + "'--creator-person' or '--creator-organization'" + " is required when '--add-license-concluded' is provided." + ) + ) + + if ( + output is not None + and output.name != "-" + and not any( + pattern.match(output.name) for pattern in _IGNORE_SPDX_PATTERNS + ) + ): + # pylint: disable=line-too-long + _LOGGER.warning( + _( + "'{path}' does not match a common SPDX file pattern. Find" + " the suggested naming conventions here:" + " https://spdx.github.io/spdx-spec/conformance/#44-standard-data-format-requirements" + ).format(path=output.name) + ) + + report = ProjectReport.generate( + cast(Project, obj.project), + multiprocessing=not obj.no_multiprocessing, + add_license_concluded=add_license_concluded, + ) + + with contextlib.ExitStack() as stack: + if output is not None: + out = stack.enter_context(output.open()) # type: ignore + else: + out = sys.stdout + click.echo( + report.bill_of_materials( + creator_person=creator_person, + creator_organization=creator_organization, + ), + file=out, + ) diff --git a/src/reuse/cli/supported_licenses.py b/src/reuse/cli/supported_licenses.py new file mode 100644 index 000000000..65c8df96a --- /dev/null +++ b/src/reuse/cli/supported_licenses.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: 2021 Free Software Foundation Europe e.V. +# SPDX-FileCopyrightTect: 2021 Michael Weimann +# SPDX-FileCopyrightText: 2022 Florian Snow +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Click code for supported-licenses subcommand.""" + +import click + +from .._licenses import _LICENSES, _load_license_list +from ..i18n import _ +from .main import main + +_HELP = _("List all licenses on the SPDX License List.") + + +@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(): + license_name = license_info["name"] + license_reference = license_info["reference"] + click.echo( + f"{license_id: <40}\t{license_name: <80}\t" + f"{license_reference: <50}" + ) diff --git a/src/reuse/comment.py b/src/reuse/comment.py index ac1253ab5..ac2e415f5 100644 --- a/src/reuse/comment.py +++ b/src/reuse/comment.py @@ -28,8 +28,11 @@ import logging import operator import re +from pathlib import Path from textwrap import dedent -from typing import NamedTuple, Optional, Type +from typing import NamedTuple, Optional, Type, cast + +from .types import StrPath _LOGGER = logging.getLogger(__name__) @@ -923,3 +926,25 @@ def _all_style_classes() -> list[Type[CommentStyle]]: #: A map of human-friendly names against style classes. NAME_STYLE_MAP = {style.SHORTHAND: style for style in _result} + + +def get_comment_style(path: StrPath) -> Optional[Type[CommentStyle]]: + """Return value of CommentStyle detected for *path* or None.""" + path = Path(path) + style = FILENAME_COMMENT_STYLE_MAP_LOWERCASE.get(path.name.lower()) + if style is None: + style = cast( + Optional[Type[CommentStyle]], + EXTENSION_COMMENT_STYLE_MAP_LOWERCASE.get(path.suffix.lower()), + ) + return style + + +def is_uncommentable(path: Path) -> bool: + """*path*'s extension has the UncommentableCommentStyle.""" + return get_comment_style(path) == UncommentableCommentStyle + + +def has_style(path: Path) -> bool: + """*path*'s extension has a CommentStyle.""" + return get_comment_style(path) is not None diff --git a/src/reuse/convert_dep5.py b/src/reuse/convert_dep5.py index b33a8b219..cba50ea5d 100644 --- a/src/reuse/convert_dep5.py +++ b/src/reuse/convert_dep5.py @@ -5,16 +5,12 @@ """Logic to convert a .reuse/dep5 file to a REUSE.toml file.""" import re -import sys -from argparse import ArgumentParser, Namespace -from gettext import gettext as _ -from typing import IO, Any, Iterable, Optional, TypeVar, Union, cast +from typing import Any, Iterable, Optional, TypeVar, Union, cast import tomlkit from debian.copyright import Copyright, FilesParagraph, Header -from .global_licensing import REUSE_TOML_VERSION, ReuseDep5 -from .project import Project +from .global_licensing import REUSE_TOML_VERSION _SINGLE_ASTERISK_PATTERN = re.compile(r"(? str: result.update(header) result["annotations"] = annotations return tomlkit.dumps(result) - - -# pylint: disable=unused-argument -def add_arguments(parser: ArgumentParser) -> None: - """Add arguments to parser.""" - # Nothing to do. - - -# pylint: disable=unused-argument -def run(args: Namespace, project: Project, out: IO[str] = sys.stdout) -> int: - """Convert .reuse/dep5 to REUSE.toml.""" - if not (project.root / ".reuse/dep5").exists(): - args.parser.error(_("no '.reuse/dep5' file")) - - text = toml_from_dep5( - cast(ReuseDep5, project.global_licensing).dep5_copyright - ) - (project.root / "REUSE.toml").write_text(text) - (project.root / ".reuse/dep5").unlink() - - return 0 diff --git a/src/reuse/covered_files.py b/src/reuse/covered_files.py index 549b331f7..ad4e019b6 100644 --- a/src/reuse/covered_files.py +++ b/src/reuse/covered_files.py @@ -19,7 +19,7 @@ _IGNORE_FILE_PATTERNS, _IGNORE_MESON_PARENT_DIR_PATTERNS, ) -from ._util import StrPath +from .types import StrPath from .vcs import VCSStrategy _LOGGER = logging.getLogger(__name__) diff --git a/src/reuse/download.py b/src/reuse/download.py index 29ce3f71b..06ef1c944 100644 --- a/src/reuse/download.py +++ b/src/reuse/download.py @@ -9,25 +9,15 @@ import logging import os import shutil -import sys import urllib.request -from argparse import ArgumentParser, Namespace -from gettext import gettext as _ from pathlib import Path -from typing import IO, Optional, cast +from typing import Optional from urllib.error import URLError from urllib.parse import urljoin -from ._licenses import ALL_NON_DEPRECATED_MAP -from ._util import ( - _LICENSEREF_PATTERN, - PathType, - StrPath, - find_licenses_directory, - print_incorrect_spdx_identifier, -) +from ._util import _LICENSEREF_PATTERN, find_licenses_directory from .project import Project -from .report import ProjectReport +from .types import StrPath from .vcs import VCSStrategyNone _LOGGER = logging.getLogger(__name__) @@ -63,8 +53,10 @@ def download_license(spdx_identifier: str) -> str: def _path_to_license_file(spdx_identifier: str, project: Project) -> Path: root: Optional[Path] = project.root # Hack - if cast(Path, root).name == "LICENSES" and isinstance( - project.vcs_strategy, VCSStrategyNone + if ( + root + and root.name == "LICENSES" + and isinstance(project.vcs_strategy, VCSStrategyNone) ): root = None @@ -119,100 +111,3 @@ def put_license_in_file( with destination.open("w", encoding="utf-8") as fp: fp.write(header) fp.write(text) - - -def add_arguments(parser: ArgumentParser) -> None: - """Add arguments to parser.""" - parser.add_argument( - "license", - action="store", - nargs="*", - help=_("SPDX License Identifier of license"), - ) - parser.add_argument( - "--all", - action="store_true", - help=_("download all missing licenses detected in the project"), - ) - parser.add_argument( - "--output", "-o", dest="file", action="store", type=PathType("w") - ) - parser.add_argument( - "--source", - action="store", - type=PathType("r"), - help=_( - "source from which to copy custom LicenseRef- licenses, either" - " a directory that contains the file or the file itself" - ), - ) - - -def run(args: Namespace, project: Project, out: IO[str] = sys.stdout) -> int: - """Download license and place it in the LICENSES/ directory.""" - - def _already_exists(path: StrPath) -> None: - out.write( - _("Error: {spdx_identifier} already exists.").format( - spdx_identifier=path - ) - ) - out.write("\n") - - def _not_found(path: StrPath) -> None: - out.write(_("Error: {path} does not exist.").format(path=path)) - - def _could_not_download(identifier: str) -> None: - out.write(_("Error: Failed to download license.")) - out.write(" ") - if identifier not in ALL_NON_DEPRECATED_MAP: - print_incorrect_spdx_identifier(identifier, out=out) - else: - out.write(_("Is your internet connection working?")) - out.write("\n") - - def _successfully_downloaded(destination: StrPath) -> None: - out.write( - _("Successfully downloaded {spdx_identifier}.").format( - spdx_identifier=destination - ) - ) - out.write("\n") - - licenses = args.license - if args.all: - # TODO: This is fairly inefficient, but gets the job done. - report = ProjectReport.generate(project) - licenses = report.missing_licenses - if args.file: - _LOGGER.warning( - _("--output has no effect when used together with --all") - ) - args.file = None - elif not args.license: - args.parser.error(_("the following arguments are required: license")) - elif len(args.license) > 1 and args.file: - args.parser.error(_("cannot use --output with more than one license")) - - return_code = 0 - for lic in licenses: - if args.file: - destination = args.file - else: - destination = _path_to_license_file(lic, project) - try: - put_license_in_file( - lic, destination=destination, source=args.source - ) - except URLError: - _could_not_download(lic) - return_code = 1 - except FileExistsError as err: - _already_exists(err.filename) - return_code = 1 - except FileNotFoundError as err: - _not_found(err.filename) - return_code = 1 - else: - _successfully_downloaded(destination) - return return_code diff --git a/src/reuse/global_licensing.py b/src/reuse/global_licensing.py index 1d9d4aec4..3a0415294 100644 --- a/src/reuse/global_licensing.py +++ b/src/reuse/global_licensing.py @@ -11,7 +11,6 @@ from abc import ABC, abstractmethod from collections import defaultdict from enum import Enum -from gettext import gettext as _ from pathlib import Path, PurePath from typing import ( Any, @@ -35,8 +34,10 @@ from license_expression import ExpressionError from . import ReuseException, ReuseInfo, SourceType -from ._util import _LICENSING, StrPath +from ._util import _LICENSING from .covered_files import iter_files +from .i18n import _ +from .types import StrPath from .vcs import VCSStrategy _LOGGER = logging.getLogger(__name__) diff --git a/src/reuse/header.py b/src/reuse/header.py index bf7b9abb7..06d838c6d 100644 --- a/src/reuse/header.py +++ b/src/reuse/header.py @@ -16,7 +16,6 @@ import logging import re -from gettext import gettext as _ from typing import NamedTuple, Optional, Sequence, Type, cast from boolean.boolean import ParseError @@ -36,6 +35,7 @@ EmptyCommentStyle, PythonCommentStyle, ) +from .i18n import _ _LOGGER = logging.getLogger(__name__) diff --git a/src/reuse/i18n.py b/src/reuse/i18n.py new file mode 100644 index 000000000..35ed3978f --- /dev/null +++ b/src/reuse/i18n.py @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2024 Carmen Bianca BAKKER +# SPDX-FileCopyrightText: 2024 Free Software Foundation Europe e.V. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +""":mod:`gettext` plumbing of :mod:`reuse`.""" + +import gettext as _gettext_module +import os + +_PACKAGE_PATH = os.path.dirname(__file__) +_LOCALE_DIR = os.path.join(_PACKAGE_PATH, "locale") + +#: Translations object used throughout :mod:`reuse`. The translations +#: are sourced from ``reuse/locale//LC_MESSAGES/reuse.mo``. +TRANSLATIONS: _gettext_module.NullTranslations = _gettext_module.translation( + "reuse", localedir=_LOCALE_DIR, fallback=True +) +#: :meth:`gettext.NullTranslations.gettext` of :data:`TRANSLATIONS` +_ = TRANSLATIONS.gettext +#: :meth:`gettext.NullTranslations.gettext` of :data:`TRANSLATIONS` +gettext = TRANSLATIONS.gettext +#: :meth:`gettext.NullTranslations.ngettext` of :data:`TRANSLATIONS` +ngettext = TRANSLATIONS.ngettext +#: :meth:`gettext.NullTranslations.pgettext` of :data:`TRANSLATIONS` +pgettext = TRANSLATIONS.pgettext +#: :meth:`gettext.NullTranslations.npgettext` of :data:`TRANSLATIONS` +npgettext = TRANSLATIONS.npgettext diff --git a/src/reuse/lint.py b/src/reuse/lint.py index 97277edf0..7d60d7129 100644 --- a/src/reuse/lint.py +++ b/src/reuse/lint.py @@ -10,42 +10,16 @@ """ import json -import sys -from argparse import ArgumentParser, Namespace -from gettext import gettext as _ from io import StringIO from pathlib import Path from textwrap import TextWrapper -from typing import IO, Any, Optional +from typing import Any, Optional from . import __REUSE_version__ -from .project import Project +from .i18n import _ from .report import ProjectReport, ProjectReportSubsetProtocol -def add_arguments(parser: ArgumentParser) -> None: - """Add arguments to parser.""" - mutex_group = parser.add_mutually_exclusive_group() - mutex_group.add_argument( - "-q", "--quiet", action="store_true", help=_("prevents output") - ) - mutex_group.add_argument( - "-j", "--json", action="store_true", help=_("formats output as JSON") - ) - mutex_group.add_argument( - "-p", - "--plain", - action="store_true", - help=_("formats output as plain text (default)"), - ) - mutex_group.add_argument( - "-l", - "--lines", - action="store_true", - help=_("formats output as errors per line"), - ) - - # pylint: disable=too-many-branches,too-many-statements,too-many-locals def format_plain(report: ProjectReport) -> str: """Formats data dictionary as plaintext string to be printed to sys.stdout @@ -347,21 +321,3 @@ def license_path(lic: str) -> Optional[Path]: subset_output = format_lines_subset(report) return output.getvalue() + subset_output - - -def run(args: Namespace, project: Project, out: IO[str] = sys.stdout) -> int: - """List all non-compliant files.""" - report = ProjectReport.generate( - project, do_checksum=False, multiprocessing=not args.no_multiprocessing - ) - - if args.quiet: - pass - elif args.json: - out.write(format_json(report)) - elif args.lines: - out.write(format_lines(report)) - else: - out.write(format_plain(report)) - - return 0 if report.is_compliant else 1 diff --git a/src/reuse/project.py b/src/reuse/project.py index c4f12038c..7895313fa 100644 --- a/src/reuse/project.py +++ b/src/reuse/project.py @@ -15,7 +15,6 @@ import os import warnings from collections import defaultdict -from gettext import gettext as _ from pathlib import Path from typing import Collection, Iterator, NamedTuple, Optional, Type @@ -26,7 +25,6 @@ from ._licenses import EXCEPTION_MAP, LICENSE_MAP from ._util import ( _LICENSEREF_PATTERN, - StrPath, _determine_license_path, relative_from_root, reuse_info_of_file, @@ -39,6 +37,8 @@ ReuseDep5, ReuseTOML, ) +from .i18n import _ +from .types import StrPath from .vcs import VCSStrategy, VCSStrategyNone, all_vcs_strategies _LOGGER = logging.getLogger(__name__) diff --git a/src/reuse/report.py b/src/reuse/report.py index fff28242b..dd2a0355a 100644 --- a/src/reuse/report.py +++ b/src/reuse/report.py @@ -16,7 +16,6 @@ import logging import multiprocessing as mp import random -from gettext import gettext as _ from hashlib import md5 from io import StringIO from os import cpu_count @@ -33,9 +32,11 @@ from uuid import uuid4 from . import __REUSE_version__, __version__ -from ._util import _LICENSEREF_PATTERN, _LICENSING, StrPath, _checksum +from ._util import _LICENSEREF_PATTERN, _LICENSING, _checksum from .global_licensing import ReuseDep5 +from .i18n import _ from .project import Project, ReuseInfo +from .types import StrPath _LOGGER = logging.getLogger(__name__) @@ -142,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/src/reuse/spdx.py b/src/reuse/spdx.py deleted file mode 100644 index 20cb2739f..000000000 --- a/src/reuse/spdx.py +++ /dev/null @@ -1,94 +0,0 @@ -# SPDX-FileCopyrightText: 2017 Free Software Foundation Europe e.V. -# SPDX-FileCopyrightText: 2022 Pietro Albini -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Compilation of the SPDX Document.""" - -import contextlib -import logging -import sys -from argparse import ArgumentParser, Namespace -from gettext import gettext as _ -from typing import IO - -from . import _IGNORE_SPDX_PATTERNS -from ._util import PathType -from .project import Project -from .report import ProjectReport - -_LOGGER = logging.getLogger(__name__) - - -def add_arguments(parser: ArgumentParser) -> None: - """Add arguments to the parser.""" - parser.add_argument( - "--output", "-o", dest="file", action="store", type=PathType("w") - ) - parser.add_argument( - "--add-license-concluded", - action="store_true", - help=_( - "populate the LicenseConcluded field; note that reuse cannot " - "guarantee the field is accurate" - ), - ) - parser.add_argument( - "--creator-person", - metavar="NAME", - help=_("name of the person signing off on the SPDX report"), - ) - parser.add_argument( - "--creator-organization", - metavar="NAME", - help=_("name of the organization signing off on the SPDX report"), - ) - - -def run(args: Namespace, project: Project, out: IO[str] = sys.stdout) -> int: - """Print the project's bill of materials.""" - # 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 - if ( - args.add_license_concluded - and args.creator_person is None - and args.creator_organization is None - ): - args.parser.error( - _( - "error: --creator-person=NAME or --creator-organization=NAME" - " required when --add-license-concluded is provided" - ), - ) - - with contextlib.ExitStack() as stack: - if args.file: - out = stack.enter_context(args.file.open("w", encoding="utf-8")) - if not any( - pattern.match(args.file.name) - for pattern in _IGNORE_SPDX_PATTERNS - ): - # pylint: disable=line-too-long - _LOGGER.warning( - _( - "'{path}' does not match a common SPDX file pattern. Find" - " the suggested naming conventions here:" - " https://spdx.github.io/spdx-spec/conformance/#44-standard-data-format-requirements" - ).format(path=out.name) - ) - - report = ProjectReport.generate( - project, - multiprocessing=not args.no_multiprocessing, - add_license_concluded=args.add_license_concluded, - ) - - out.write( - report.bill_of_materials( - creator_person=args.creator_person, - creator_organization=args.creator_organization, - ) - ) - - return 0 diff --git a/src/reuse/supported_licenses.py b/src/reuse/supported_licenses.py deleted file mode 100644 index 736d3df3f..000000000 --- a/src/reuse/supported_licenses.py +++ /dev/null @@ -1,35 +0,0 @@ -# SPDX-FileCopyrightText: 2021 Free Software Foundation Europe e.V. -# SPDX-FileCopyrightText: 2022 Florian Snow -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""supported-licenses command handler""" - -import sys -from argparse import ArgumentParser, Namespace -from typing import IO - -from ._licenses import _LICENSES, _load_license_list -from .project import Project - - -# pylint: disable=unused-argument -def add_arguments(parser: ArgumentParser) -> None: - """Add arguments to the parser.""" - - -# pylint: disable=unused-argument -def run(args: Namespace, project: Project, out: IO[str] = sys.stdout) -> int: - """Print the supported SPDX licenses list""" - - licenses = _load_license_list(_LICENSES)[1] - - for license_id, license_info in licenses.items(): - license_name = license_info["name"] - license_reference = license_info["reference"] - out.write( - f"{license_id: <40}\t{license_name: <80}\t" - f"{license_reference: <50}\n" - ) - - return 0 diff --git a/src/reuse/types.py b/src/reuse/types.py new file mode 100644 index 000000000..0c86917b5 --- /dev/null +++ b/src/reuse/types.py @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2017 Free Software Foundation Europe e.V. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Some typing definitions.""" + +from os import PathLike +from typing import Union + +#: Something that looks like a path. +StrPath = Union[str, PathLike[str]] diff --git a/src/reuse/vcs.py b/src/reuse/vcs.py index eaec9f7ac..135c91393 100644 --- a/src/reuse/vcs.py +++ b/src/reuse/vcs.py @@ -22,10 +22,10 @@ HG_EXE, JUJUTSU_EXE, PIJUL_EXE, - StrPath, execute_command, relative_from_root, ) +from .types import StrPath if TYPE_CHECKING: from .project import Project diff --git a/tests/conftest.py b/tests/conftest.py index abcacfe00..075a5fe4d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,7 +21,7 @@ from inspect import cleandoc from io import StringIO from pathlib import Path -from typing import Generator +from typing import Generator, Optional from unittest.mock import create_autospec import pytest @@ -110,6 +110,17 @@ def git_exe() -> str: return str(GIT_EXE) +@pytest.fixture(params=[True, False]) +def optional_git_exe( + request, monkeypatch +) -> Generator[Optional[str], None, None]: + """Run the test with or without git.""" + exe = GIT_EXE if request.param else "" + monkeypatch.setattr("reuse.vcs.GIT_EXE", exe) + monkeypatch.setattr("reuse._util.GIT_EXE", exe) + yield exe + + @pytest.fixture() def hg_exe() -> str: """Run the test with mercurial (hg).""" @@ -118,6 +129,17 @@ def hg_exe() -> str: return str(HG_EXE) +@pytest.fixture(params=[True, False]) +def optional_hg_exe( + request, monkeypatch +) -> Generator[Optional[str], None, None]: + """Run the test with or without mercurial.""" + exe = HG_EXE if request.param else "" + monkeypatch.setattr("reuse.vcs.HG_EXE", exe) + monkeypatch.setattr("reuse._util.HG_EXE", exe) + yield exe + + @pytest.fixture() def jujutsu_exe() -> str: """Run the test with Jujutsu.""" @@ -126,6 +148,17 @@ def jujutsu_exe() -> str: return str(JUJUTSU_EXE) +@pytest.fixture(params=[True, False]) +def optional_jujutsu_exe( + request, monkeypatch +) -> Generator[Optional[str], None, None]: + """Run the test with or without Jujutsu.""" + exe = JUJUTSU_EXE if request.param else "" + monkeypatch.setattr("reuse.vcs.JUJUTSU_EXE", exe) + monkeypatch.setattr("reuse._util.JUJUTSU_EXE", exe) + yield exe + + @pytest.fixture() def pijul_exe() -> str: """Run the test with Pijul.""" @@ -134,6 +167,17 @@ def pijul_exe() -> str: return str(PIJUL_EXE) +@pytest.fixture(params=[True, False]) +def optional_pijul_exe( + request, monkeypatch +) -> Generator[Optional[str], None, None]: + """Run the test with or without Pijul.""" + exe = PIJUL_EXE if request.param else "" + monkeypatch.setattr("reuse.vcs.PIJUL_EXE", exe) + monkeypatch.setattr("reuse._util.PIJUL_EXE", exe) + yield exe + + @pytest.fixture(params=[True, False]) def multiprocessing(request, monkeypatch) -> Generator[bool, None, None]: """Run the test with or without multiprocessing.""" diff --git a/tests/test_cli_annotate.py b/tests/test_cli_annotate.py new file mode 100644 index 000000000..4aecfd85e --- /dev/null +++ b/tests/test_cli_annotate.py @@ -0,0 +1,1773 @@ +# SPDX-FileCopyrightText: 2019 Free Software Foundation Europe e.V. +# SPDX-FileCopyrightText: 2019 Stefan Bakker +# SPDX-FileCopyrightText: 2022 Carmen Bianca Bakker +# SPDX-FileCopyrightText: 2022 Florian Snow +# SPDX-FileCopyrightText: 2023 Maxim Cournoyer +# SPDX-FileCopyrightText: 2024 Rivos Inc. +# SPDX-FileCopyrightText: © 2020 Liferay, Inc. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tests for annotate.""" + +import stat +from inspect import cleandoc +from pathlib import PurePath + +import pytest +from click.testing import CliRunner + +from reuse._util import _COPYRIGHT_PREFIXES +from reuse.cli.main import main + +# pylint: disable=too-many-public-methods,too-many-lines,unused-argument + + +# REUSE-IgnoreStart + + +class TestAnnotate: + """Tests for annotate.""" + + # TODO: Replace this test with a monkeypatched test + def test_simple(self, fake_repository, mock_date_today): + """Add a header to a file that does not have one.""" + simple_file = fake_repository / "foo.py" + simple_file.write_text("pass") + expected = cleandoc( + """ + # SPDX-FileCopyrightText: 2018 Jane Doe + # + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "foo.py", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_simple_scheme(self, fake_repository, mock_date_today): + "Add a header to a Scheme file." + simple_file = fake_repository / "foo.scm" + simple_file.write_text("#t") + expected = cleandoc( + """ + ;;; SPDX-FileCopyrightText: 2018 Jane Doe + ;;; + ;;; SPDX-License-Identifier: GPL-3.0-or-later + + #t + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "foo.scm", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_scheme_standardised(self, fake_repository, mock_date_today): + """The comment block is rewritten/standardised.""" + simple_file = fake_repository / "foo.scm" + simple_file.write_text( + cleandoc( + """ + ; SPDX-FileCopyrightText: 2018 Jane Doe + ; + ; SPDX-License-Identifier: GPL-3.0-or-later + + #t + """ + ) + ) + expected = cleandoc( + """ + ;;; SPDX-FileCopyrightText: 2018 Jane Doe + ;;; + ;;; SPDX-License-Identifier: GPL-3.0-or-later + + #t + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "foo.scm", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_scheme_standardised2(self, fake_repository, mock_date_today): + """The comment block is rewritten/standardised.""" + simple_file = fake_repository / "foo.scm" + simple_file.write_text( + cleandoc( + """ + ;; SPDX-FileCopyrightText: 2018 Jane Doe + ;; + ;; SPDX-License-Identifier: GPL-3.0-or-later + + #t + """ + ) + ) + expected = cleandoc( + """ + ;;; SPDX-FileCopyrightText: 2018 Jane Doe + ;;; + ;;; SPDX-License-Identifier: GPL-3.0-or-later + + #t + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "foo.scm", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_directory_argument(self, fake_repository): + """Directory arguments are ignored.""" + result = CliRunner().invoke( + main, ["annotate", "--copyright", "Jane Doe", "src"] + ) + + assert result.exit_code == 0 + assert result.output == "" + assert (fake_repository / "src").is_dir() + + def test_simple_no_replace(self, fake_repository, mock_date_today): + """Add a header to a file without replacing the existing header.""" + simple_file = fake_repository / "foo.py" + simple_file.write_text( + cleandoc( + """ + # SPDX-FileCopyrightText: 2017 John Doe + # + # SPDX-License-Identifier: MIT + + pass + """ + ) + ) + expected = cleandoc( + """ + # SPDX-FileCopyrightText: 2018 Jane Doe + # + # SPDX-License-Identifier: GPL-3.0-or-later + + # SPDX-FileCopyrightText: 2017 John Doe + # + # SPDX-License-Identifier: MIT + + pass + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--no-replace", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "foo.py", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_year(self, fake_repository): + """Add a header to a file with a custom year.""" + simple_file = fake_repository / "foo.py" + simple_file.write_text("pass") + expected = cleandoc( + """ + # SPDX-FileCopyrightText: 2016 Jane Doe + # + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--year", + "2016", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "foo.py", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_no_year(self, fake_repository): + """Add a header to a file without a year.""" + simple_file = fake_repository / "foo.py" + simple_file.write_text("pass") + expected = cleandoc( + """ + # SPDX-FileCopyrightText: Jane Doe + # + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--exclude-year", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "foo.py", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + @pytest.mark.parametrize( + "copyright_prefix", ["--copyright-prefix", "--copyright-style"] + ) + def test_copyright_prefix( + self, fake_repository, copyright_prefix, mock_date_today + ): + """Add a header with a specific copyright prefix. Also test the old name + of the parameter. + """ + simple_file = fake_repository / "foo.py" + simple_file.write_text("pass") + expected = cleandoc( + """ + # Copyright 2018 Jane Doe + # + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + copyright_prefix, + "string", + "foo.py", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_shebang(self, fake_repository): + """Keep the shebang when annotating.""" + simple_file = fake_repository / "foo.py" + simple_file.write_text( + cleandoc( + """ + #!/usr/bin/env python3 + + pass + """ + ) + ) + expected = cleandoc( + """ + #!/usr/bin/env python3 + + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "foo.py", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_shebang_wrong_comment_style(self, fake_repository): + """If a comment style does not support the shebang at the top, don't + treat the shebang as special. + """ + simple_file = fake_repository / "foo.html" + simple_file.write_text( + cleandoc( + """ + #!/usr/bin/env python3 + + pass + """ + ) + ) + expected = cleandoc( + """ + + + #!/usr/bin/env python3 + + pass + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "foo.html", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_contributors_only( + self, fake_repository, mock_date_today, contributors + ): + """Add a header with only contributor information.""" + + if not contributors: + pytest.skip("No contributors to add") + + simple_file = fake_repository / "foo.py" + simple_file.write_text("pass") + content = [] + + for contributor in sorted(contributors): + content.append(f"# SPDX-FileContributor: {contributor}") + + content += ["", "pass"] + expected = cleandoc("\n".join(content)) + + args = [ + "annotate", + ] + for contributor in contributors: + args += ["--contributor", contributor] + args += ["foo.py"] + + result = CliRunner().invoke( + main, + args, + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_contributors(self, fake_repository, mock_date_today, contributors): + """Add a header with contributor information.""" + simple_file = fake_repository / "foo.py" + simple_file.write_text("pass") + content = ["# SPDX-FileCopyrightText: 2018 Jane Doe"] + + if contributors: + for contributor in sorted(contributors): + content.append(f"# SPDX-FileContributor: {contributor}") + + content += [ + "#", + "# SPDX-License-Identifier: GPL-3.0-or-later", + "", + "pass", + ] + expected = cleandoc("\n".join(content)) + + args = [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + ] + for contributor in contributors: + args += ["--contributor", contributor] + args += ["foo.py"] + + result = CliRunner().invoke( + main, + args, + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_specify_style(self, fake_repository, mock_date_today): + """Add header to a file that does not have one, using a custom style.""" + simple_file = fake_repository / "foo.py" + simple_file.write_text("pass") + expected = cleandoc( + """ + // SPDX-FileCopyrightText: 2018 Jane Doe + // + // SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--style", + "cpp", + "foo.py", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_specify_style_unrecognised(self, fake_repository, mock_date_today): + """Add a header to a file that is unrecognised.""" + + simple_file = fake_repository / "hello.foo" + simple_file.touch() + expected = "# SPDX-FileCopyrightText: 2018 Jane Doe" + + result = CliRunner().invoke( + main, + [ + "annotate", + "--copyright", + "Jane Doe", + "--style", + "python", + "hello.foo", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text().strip() == expected + + def test_implicit_style(self, fake_repository, mock_date_today): + """Add a header to a file that has a recognised extension.""" + simple_file = fake_repository / "foo.js" + simple_file.write_text("pass") + expected = cleandoc( + """ + // SPDX-FileCopyrightText: 2018 Jane Doe + // + // SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "foo.js", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_implicit_style_filename(self, fake_repository, mock_date_today): + """Add a header to a filename that is recognised.""" + simple_file = fake_repository / "Makefile" + simple_file.write_text("pass") + expected = cleandoc( + """ + # SPDX-FileCopyrightText: 2018 Jane Doe + # + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "Makefile", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_unrecognised_style(self, fake_repository): + """Add a header to a file that has an unrecognised extension.""" + simple_file = fake_repository / "foo.foo" + simple_file.write_text("pass") + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "foo.foo", + ], + ) + + assert result.exit_code != 0 + assert ( + "The following files do not have a recognised file extension" + in result.output + ) + assert "foo.foo" in result.output + + @pytest.mark.parametrize( + "skip_unrecognised", ["--skip-unrecognised", "--skip-unrecognized"] + ) + def test_skip_unrecognised(self, fake_repository, skip_unrecognised): + """Skip file that has an unrecognised extension.""" + simple_file = fake_repository / "foo.foo" + simple_file.write_text("pass") + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + skip_unrecognised, + "foo.foo", + ], + ) + + assert result.exit_code == 0 + assert "Skipped unrecognised file 'foo.foo'" in result.output + + @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") + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--style=c", + skip_unrecognised, + "foo.foo", + ], + ) + + assert result.exit_code != 0 + assert "mutually exclusive with" in result.output + + def test_no_data_to_add(self, fake_repository): + """Add a header, but supply no copyright, license, or contributor.""" + simple_file = fake_repository / "foo.py" + simple_file.write_text("pass") + + result = CliRunner().invoke(main, ["annotate", "foo.py"]) + + assert result.exit_code != 0 + assert ( + "Option '--copyright', '--license', or '--contributor' is required" + in result.output + ) + + def test_template_simple( + self, fake_repository, mock_date_today, template_simple_source + ): + """Add a header with a custom template.""" + simple_file = fake_repository / "foo.py" + simple_file.write_text("pass") + template_file = fake_repository / ".reuse/templates/mytemplate.jinja2" + template_file.parent.mkdir(parents=True, exist_ok=True) + template_file.write_text(template_simple_source) + expected = cleandoc( + """ + # Hello, world! + # + # SPDX-FileCopyrightText: 2018 Jane Doe + # + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--template", + "mytemplate.jinja2", + "foo.py", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_template_simple_multiple( + self, fake_repository, mock_date_today, template_simple_source + ): + """Add a header with a custom template to multiple files.""" + simple_files = [fake_repository / f"foo{i}.py" for i in range(10)] + for simple_file in simple_files: + simple_file.write_text("pass") + template_file = fake_repository / ".reuse/templates/mytemplate.jinja2" + template_file.parent.mkdir(parents=True, exist_ok=True) + template_file.write_text(template_simple_source) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--template", + "mytemplate.jinja2", + ] + + list(map(str, simple_files)), + ) + + assert result.exit_code == 0 + for simple_file in simple_files: + expected = cleandoc( + """ + # Hello, world! + # + # SPDX-FileCopyrightText: 2018 Jane Doe + # + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + assert simple_file.read_text() == expected + + def test_template_no_spdx(self, fake_repository, template_no_spdx_source): + """Add a header with a template that lacks REUSE info.""" + simple_file = fake_repository / "foo.py" + simple_file.write_text("pass") + template_file = fake_repository / ".reuse/templates/mytemplate.jinja2" + template_file.parent.mkdir(parents=True, exist_ok=True) + template_file.write_text(template_no_spdx_source) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--template", + "mytemplate.jinja2", + "foo.py", + ], + ) + + assert result.exit_code == 1 + + def test_template_commented( + self, fake_repository, mock_date_today, template_commented_source + ): + """Add a header with a custom template that is already commented.""" + simple_file = fake_repository / "foo.c" + simple_file.write_text("pass") + template_file = ( + fake_repository / ".reuse/templates/mytemplate.commented.jinja2" + ) + template_file.parent.mkdir(parents=True, exist_ok=True) + template_file.write_text(template_commented_source) + expected = cleandoc( + """ + # Hello, world! + # + # SPDX-FileCopyrightText: 2018 Jane Doe + # + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--template", + "mytemplate.commented.jinja2", + "foo.c", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_template_nonexistant(self, fake_repository): + """Raise an error when using a header that does not exist.""" + + simple_file = fake_repository / "foo.py" + simple_file.write_text("pass") + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--template", + "mytemplate.jinja2", + "foo.py", + ], + ) + + assert result.exit_code != 0 + assert ( + "Template 'mytemplate.jinja2' could not be found" in result.output + ) + + def test_template_without_extension( + self, fake_repository, mock_date_today, template_simple_source + ): + """Find the correct header even when not using an extension.""" + simple_file = fake_repository / "foo.py" + simple_file.write_text("pass") + template_file = fake_repository / ".reuse/templates/mytemplate.jinja2" + template_file.parent.mkdir(parents=True, exist_ok=True) + template_file.write_text(template_simple_source) + expected = cleandoc( + """ + # Hello, world! + # + # SPDX-FileCopyrightText: 2018 Jane Doe + # + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--template", + "mytemplate", + "foo.py", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + def test_binary(self, fake_repository, mock_date_today, binary_string): + """Add a header to a .license file if the file is a binary.""" + binary_file = fake_repository / "foo.png" + binary_file.write_bytes(binary_string) + expected = cleandoc( + """ + SPDX-FileCopyrightText: 2018 Jane Doe + + SPDX-License-Identifier: GPL-3.0-or-later + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "foo.png", + ], + ) + + assert result.exit_code == 0 + assert ( + binary_file.with_name(f"{binary_file.name}.license") + .read_text() + .strip() + == expected + ) + + def test_uncommentable_json(self, fake_repository, mock_date_today): + """Add a header to a .license file if the file is uncommentable, e.g., + JSON. + """ + json_file = fake_repository / "foo.json" + json_file.write_text('{"foo": 23, "bar": 42}') + expected = cleandoc( + """ + SPDX-FileCopyrightText: 2018 Jane Doe + + SPDX-License-Identifier: GPL-3.0-or-later + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "foo.json", + ], + ) + + assert result.exit_code == 0 + assert ( + json_file.with_name(f"{json_file.name}.license").read_text().strip() + == expected + ) + + def test_fallback_dot_license(self, fake_repository, mock_date_today): + """Add a header to .license if --fallback-dot-license is given, and no + style yet exists. + """ + (fake_repository / "foo.py").write_text("Foo") + (fake_repository / "foo.foo").write_text("Foo") + + expected_py = cleandoc( + """ + # SPDX-FileCopyrightText: 2018 Jane Doe + # + # SPDX-License-Identifier: GPL-3.0-or-later + """ + ) + expected_foo = cleandoc( + """ + SPDX-FileCopyrightText: 2018 Jane Doe + + SPDX-License-Identifier: GPL-3.0-or-later + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--fallback-dot-license", + "foo.py", + "foo.foo", + ], + ) + + assert result.exit_code == 0 + assert expected_py in (fake_repository / "foo.py").read_text() + assert (fake_repository / "foo.foo.license").exists() + assert ( + fake_repository / "foo.foo.license" + ).read_text().strip() == expected_foo + assert ( + "'foo.foo' is not recognised; creating 'foo.foo.license'" + in result.output + ) + + def test_force_dot_license(self, fake_repository, mock_date_today): + """Add a header to a .license file if --force-dot-license is given.""" + simple_file = fake_repository / "foo.py" + simple_file.write_text("pass") + expected = cleandoc( + """ + SPDX-FileCopyrightText: 2018 Jane Doe + + SPDX-License-Identifier: GPL-3.0-or-later + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--force-dot-license", + "foo.py", + ], + ) + + assert result.exit_code == 0 + assert ( + simple_file.with_name(f"{simple_file.name}.license") + .read_text() + .strip() + == expected + ) + assert simple_file.read_text() == "pass" + + def test_force_dot_license_double(self, fake_repository, mock_date_today): + """If path.license already exists, don't create path.license.license.""" + simple_file = fake_repository / "foo.txt" + simple_file_license = fake_repository / "foo.txt.license" + simple_file_license_license = ( + fake_repository / "foo.txt.license.license" + ) + + simple_file.write_text("foo") + simple_file_license.write_text("foo") + expected = cleandoc( + """ + SPDX-FileCopyrightText: 2018 Jane Doe + + SPDX-License-Identifier: GPL-3.0-or-later + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--force-dot-license", + "foo.txt", + ], + ) + + assert result.exit_code == 0 + assert not simple_file_license_license.exists() + assert simple_file_license.read_text().strip() == expected + + def test_force_dot_license_unsupported_filetype( + self, fake_repository, mock_date_today + ): + """Add a header to a .license file if --force-dot-license is given, with + the base file being an otherwise unsupported filetype. + """ + simple_file = fake_repository / "foo.txt" + simple_file.write_text("Preserve this") + expected = cleandoc( + """ + SPDX-FileCopyrightText: 2018 Jane Doe + + SPDX-License-Identifier: GPL-3.0-or-later + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--force-dot-license", + "foo.txt", + ], + ) + + assert result.exit_code == 0 + assert ( + simple_file.with_name(f"{simple_file.name}.license") + .read_text() + .strip() + == expected + ) + assert simple_file.read_text() == "Preserve this" + + def test_to_read_only_file_forbidden( + self, fake_repository, mock_date_today + ): + """Cannot add a header without having write permission.""" + _file = fake_repository / "test.sh" + _file.write_text("#!/bin/sh") + _file.chmod(mode=stat.S_IREAD) + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "Apache-2.0", + "--copyright", + "mycorp", + "--style", + "python", + "test.sh", + ], + ) + + assert result.exit_code != 0 + assert "'test.sh' is not writable." in result.output + + def test_license_file(self, fake_repository, mock_date_today): + """Add a header to a .license file if it exists.""" + simple_file = fake_repository / "foo.py" + simple_file.write_text("foo") + license_file = fake_repository / "foo.py.license" + license_file.write_text( + cleandoc( + """ + SPDX-FileCopyrightText: 2016 John Doe + + Hello + """ + ) + ) + expected = ( + cleandoc( + """ + SPDX-FileCopyrightText: 2016 John Doe + SPDX-FileCopyrightText: 2018 Jane Doe + + SPDX-License-Identifier: GPL-3.0-or-later + """ + ) + + "\n" + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "foo.py", + ], + ) + + assert result.exit_code == 0 + assert license_file.read_text() == expected + assert simple_file.read_text() == "foo" + + def test_license_file_only_one_newline( + self, fake_repository, mock_date_today + ): + """When a header is added to a .license file that already ends with a + newline, the new header should end with a single newline. + """ + simple_file = fake_repository / "foo.py" + simple_file.write_text("foo") + license_file = fake_repository / "foo.py.license" + license_file.write_text( + cleandoc( + """ + SPDX-FileCopyrightText: 2016 John Doe + + Hello + """ + ) + + "\n" + ) + expected = ( + cleandoc( + """ + SPDX-FileCopyrightText: 2016 John Doe + SPDX-FileCopyrightText: 2018 Jane Doe + + SPDX-License-Identifier: GPL-3.0-or-later + """ + ) + + "\n" + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "foo.py", + ], + ) + + assert result.exit_code == 0 + assert license_file.read_text() == expected + assert simple_file.read_text() == "foo" + + def test_year_mutually_exclusive(self, fake_repository): + """--exclude-year and --year are mutually exclusive.""" + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--exclude-year", + "--year", + "2020", + "src/source_code.py", + ], + ) + + assert result.exit_code != 0 + assert "is mutually exclusive with" in result.output + + def test_single_multi_line_mutually_exclusive(self, fake_repository): + """--single-line and --multi-line are mutually exclusive.""" + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--single-line", + "--multi-line", + "src/source_code.c", + ], + ) + + assert result.exit_code != 0 + assert "is mutually exclusive with" in result.output + + def test_skip_force_mutually_exclusive(self, fake_repository): + """--skip-unrecognised and --force-dot-license are mutually exclusive""" + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--force-dot-license", + "--skip-unrecognised", + "src/source_code.py", + ], + ) + + assert result.exit_code != 0 + assert "is mutually exclusive with" in result.output + + def test_multi_line_not_supported(self, fake_repository): + """Expect a fail if --multi-line is not supported for a file type.""" + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--multi-line", + "src/source_code.py", + ], + ) + + assert result.exit_code != 0 + assert ( + "'src/source_code.py' does not support multi-line comments" + in result.output + ) + + def test_multi_line_not_supported_custom_style(self, fake_repository): + """--multi-line also fails when used with a style that doesn't support + it through --style. + """ + (fake_repository / "foo.foo").write_text("foo") + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--multi-line", + "--force-dot-license", + "--style", + "python", + "foo.foo", + ], + ) + + assert result.exit_code != 0 + assert "'foo.foo' does not support multi-line" in result.output + + def test_single_line_not_supported(self, fake_repository): + """Expect a fail if --single-line is not supported for a file type.""" + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--single-line", + "src/source_code.html", + ], + ) + + assert result.exit_code != 0 + assert ( + "'src/source_code.html' does not support single-line comments" + in result.output + ) + + def test_force_multi_line_for_c(self, fake_repository, mock_date_today): + """--multi-line forces a multi-line comment for C.""" + simple_file = fake_repository / "foo.c" + simple_file.write_text("foo") + expected = cleandoc( + """ + /* + * SPDX-FileCopyrightText: 2018 Jane Doe + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + + foo + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--multi-line", + "foo.c", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == expected + + @pytest.mark.parametrize("line_ending", ["\r\n", "\r", "\n"]) + def test_line_endings(self, empty_directory, mock_date_today, line_ending): + """Given a file with a certain type of line ending, preserve it.""" + simple_file = empty_directory / "foo.py" + simple_file.write_bytes( + line_ending.encode("utf-8").join([b"hello", b"world"]) + ) + expected = cleandoc( + """ + # SPDX-FileCopyrightText: 2018 Jane Doe + # + # SPDX-License-Identifier: GPL-3.0-or-later + + hello + world + """ + ).replace("\n", line_ending) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "foo.py", + ], + ) + + assert result.exit_code == 0 + with open(simple_file, newline="", encoding="utf-8") as fp: + contents = fp.read() + + assert contents == expected + + def test_skip_existing(self, fake_repository, mock_date_today): + """When annotate --skip-existing on a file that already contains REUSE + info, don't write additional information to it. + """ + for path in ("foo.py", "bar.py"): + (fake_repository / path).write_text("pass") + expected_foo = cleandoc( + """ + # SPDX-FileCopyrightText: 2018 Jane Doe + # + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + expected_bar = cleandoc( + """ + # SPDX-FileCopyrightText: 2018 John Doe + # + # SPDX-License-Identifier: MIT + + pass + """ + ) + + CliRunner().invoke( + main, + [ + "annotate", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "foo.py", + ], + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "MIT", + "--copyright", + "John Doe", + "--skip-existing", + "foo.py", + "bar.py", + ], + ) + + assert result.exit_code == 0 + assert (fake_repository / "foo.py").read_text() == expected_foo + assert (fake_repository / "bar.py").read_text() == expected_bar + + def test_recursive(self, fake_repository, mock_date_today): + """Add a header to a directory recursively.""" + (fake_repository / "src/one/two").mkdir(parents=True) + (fake_repository / "src/one/two/foo.py").write_text( + cleandoc( + """ + # SPDX-License-Identifier: GPL-3.0-or-later + """ + ) + ) + (fake_repository / "src/hello.py").touch() + (fake_repository / "src/one/world.py").touch() + (fake_repository / "bar").mkdir(parents=True) + (fake_repository / "bar/bar.py").touch() + + result = CliRunner().invoke( + main, + [ + "annotate", + "--copyright", + "Joe Somebody", + "--recursive", + "src/", + ], + ) + + for path in (fake_repository / "src").rglob("src/**"): + content = path.read_text() + assert "SPDX-FileCopyrightText: 2018 Joe Somebody" in content + + assert ( + "Joe Somebody" not in (fake_repository / "bar/bar.py").read_text() + ) + assert result.exit_code == 0 + + def test_recursive_on_file(self, fake_repository, mock_date_today): + """Don't expect errors when annotate is run 'recursively' on a file.""" + result = CliRunner().invoke( + main, + [ + "annotate", + "--copyright", + "Joe Somebody", + "--recursive", + "src/source_code.py", + ], + ) + + assert ( + "Joe Somebody" + in (fake_repository / "src/source_code.py").read_text() + ) + assert result.exit_code == 0 + + def test_exit_if_unrecognised(self, fake_repository, mock_date_today): + """Expect error and no edited files if at least one file has not been + recognised, with --exit-if-unrecognised enabled.""" + (fake_repository / "baz").mkdir(parents=True) + (fake_repository / "baz/foo.py").write_text("foo") + (fake_repository / "baz/bar.unknown").write_text("bar") + (fake_repository / "baz/baz.sh").write_text("baz") + + result = CliRunner().invoke( + main, + [ + "annotate", + "--license", + "Apache-2.0", + "--copyright", + "Jane Doe", + "--recursive", + "baz/", + ], + ) + + assert result.exit_code != 0 + assert ( + "The following files do not have a recognised file extension" + in result.output + ) + assert str(PurePath("baz/bar.unknown")) in result.output + assert "foo.py" not in result.output + assert "Jane Doe" not in (fake_repository / "baz/foo.py").read_text() + + +class TestAnnotateMerge: + """Test merging copyright statements.""" + + def test_simple(self, fake_repository): + """Add multiple headers to a file with merge copyrights.""" + simple_file = fake_repository / "foo.py" + simple_file.write_text("pass") + + result = CliRunner().invoke( + main, + [ + "annotate", + "--year", + "2016", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Mary Sue", + "--merge-copyrights", + "foo.py", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == cleandoc( + """ + # SPDX-FileCopyrightText: 2016 Mary Sue + # + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--year", + "2018", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Mary Sue", + "--merge-copyrights", + "foo.py", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == cleandoc( + """ + # SPDX-FileCopyrightText: 2016 - 2018 Mary Sue + # + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + def test_multi_prefix(self, fake_repository): + """Add multiple headers to a file with merge copyrights.""" + simple_file = fake_repository / "foo.py" + simple_file.write_text("pass") + + for i in range(0, 3): + result = CliRunner().invoke( + main, + [ + "annotate", + "--year", + str(2010 + i), + "--license", + "GPL-3.0-or-later", + "--copyright", + "Mary Sue", + "foo.py", + ], + ) + + assert result.exit_code == 0 + + for i in range(0, 5): + result = CliRunner().invoke( + main, + [ + "annotate", + "--year", + str(2015 + i), + "--license", + "GPL-3.0-or-later", + "--copyright-prefix", + "string-c", + "--copyright", + "Mary Sue", + "foo.py", + ], + ) + + assert result.exit_code == 0 + + assert simple_file.read_text() == cleandoc( + """ + # Copyright (C) 2015 Mary Sue + # Copyright (C) 2016 Mary Sue + # Copyright (C) 2017 Mary Sue + # Copyright (C) 2018 Mary Sue + # Copyright (C) 2019 Mary Sue + # SPDX-FileCopyrightText: 2010 Mary Sue + # SPDX-FileCopyrightText: 2011 Mary Sue + # SPDX-FileCopyrightText: 2012 Mary Sue + # + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--year", + "2018", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Mary Sue", + "--merge-copyrights", + "foo.py", + ], + ) + + assert result.exit_code == 0 + assert simple_file.read_text() == cleandoc( + """ + # Copyright (C) 2010 - 2019 Mary Sue + # + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + def test_no_year_in_existing(self, fake_repository, mock_date_today): + """This checks the issue reported in + . If an existing + copyright line doesn't have a year, everything should still work. + """ + (fake_repository / "foo.py").write_text( + cleandoc( + """ + # SPDX-FileCopyrightText: Jane Doe + """ + ) + ) + CliRunner().invoke( + main, + [ + "annotate", + "--merge-copyrights", + "--copyright", + "John Doe", + "foo.py", + ], + ) + assert ( + cleandoc( + """ + # SPDX-FileCopyrightText: 2018 John Doe + # SPDX-FileCopyrightText: Jane Doe + """ + ) + in (fake_repository / "foo.py").read_text() + ) + + def test_all_prefixes(self, fake_repository, mock_date_today): + """Test that merging works for all copyright prefixes.""" + # TODO: there should probably also be a test for mixing copyright + # prefixes, but this behaviour is really unpredictable to me at the + # moment, and the whole copyright-line-as-string thing needs + # overhauling. + simple_file = fake_repository / "foo.py" + for copyright_prefix, copyright_string in _COPYRIGHT_PREFIXES.items(): + simple_file.write_text("pass") + result = CliRunner().invoke( + main, + [ + "annotate", + "--year", + "2016", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--copyright-style", + copyright_prefix, + "--merge-copyrights", + "foo.py", + ], + ) + assert result.exit_code == 0 + assert simple_file.read_text(encoding="utf-8") == cleandoc( + f""" + # {copyright_string} 2016 Jane Doe + # + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + result = CliRunner().invoke( + main, + [ + "annotate", + "--year", + "2018", + "--license", + "GPL-3.0-or-later", + "--copyright", + "Jane Doe", + "--copyright-style", + copyright_prefix, + "--merge-copyrights", + "foo.py", + ], + ) + assert result.exit_code == 0 + assert simple_file.read_text(encoding="utf-8") == cleandoc( + f""" + # {copyright_string} 2016 - 2018 Jane Doe + # + # SPDX-License-Identifier: GPL-3.0-or-later + + pass + """ + ) + + +# REUSE-IgnoreEnd diff --git a/tests/test_cli_convert_dep5.py b/tests/test_cli_convert_dep5.py new file mode 100644 index 000000000..679fc3513 --- /dev/null +++ b/tests/test_cli_convert_dep5.py @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: 2019 Free Software Foundation Europe e.V. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tests for convert-dep5.""" + +import warnings + +from click.testing import CliRunner + +from reuse._util import cleandoc_nl +from reuse.cli.main import main + +# pylint: disable=unused-argument + + +class TestConvertDep5: + """Tests for convert-dep5.""" + + def test_simple(self, fake_repository_dep5): + """Convert a DEP5 repository to a REUSE.toml repository.""" + result = CliRunner().invoke(main, ["convert-dep5"]) + assert result.exit_code == 0 + assert not (fake_repository_dep5 / ".reuse/dep5").exists() + assert (fake_repository_dep5 / "REUSE.toml").exists() + assert (fake_repository_dep5 / "REUSE.toml").read_text() == cleandoc_nl( + """ + version = 1 + + [[annotations]] + path = "doc/**" + precedence = "aggregate" + SPDX-FileCopyrightText = "2017 Jane Doe" + SPDX-License-Identifier = "CC0-1.0" + """ + ) + + def test_no_dep5_file(self, fake_repository): + """Cannot convert when there is no .reuse/dep5 file.""" + result = CliRunner().invoke(main, ["convert-dep5"]) + assert result.exit_code != 0 + + def test_no_warning(self, fake_repository_dep5): + """No PendingDeprecationWarning when running convert-dep5.""" + with warnings.catch_warnings(record=True) as caught_warnings: + result = CliRunner().invoke(main, ["convert-dep5"]) + assert result.exit_code == 0 + assert not caught_warnings diff --git a/tests/test_cli_download.py b/tests/test_cli_download.py new file mode 100644 index 000000000..d1aa6630c --- /dev/null +++ b/tests/test_cli_download.py @@ -0,0 +1,225 @@ +# SPDX-FileCopyrightText: 2019 Free Software Foundation Europe e.V. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tests for download.""" + +# pylint: disable=redefined-outer-name,unused-argument + +import errno +import os +from pathlib import Path +from unittest.mock import create_autospec +from urllib.error import URLError + +import pytest +from click.testing import CliRunner + +from reuse.cli import download +from reuse.cli.main import main + + +@pytest.fixture() +def mock_put_license_in_file(monkeypatch): + """Create a mocked version of put_license_in_file.""" + result = create_autospec(download.put_license_in_file) + monkeypatch.setattr(download, "put_license_in_file", result) + return result + + +class TestDownload: + """Tests for download.""" + + def test_simple(self, empty_directory, mock_put_license_in_file): + """Straightforward test.""" + result = CliRunner().invoke(main, ["download", "0BSD"]) + + assert result.exit_code == 0 + mock_put_license_in_file.assert_called_with( + "0BSD", Path(os.path.realpath("LICENSES/0BSD.txt")), source=None + ) + + def test_all_and_license_mutually_exclusive(self, empty_directory): + """--all and license args are mutually exclusive.""" + result = CliRunner().invoke(main, ["download", "--all", "0BSD"]) + assert result.exit_code != 0 + assert "are mutually exclusive" in result.output + + def test_all_and_output_mutually_exclusive(self, empty_directory): + """--all and --output are mutually exclusive.""" + result = CliRunner().invoke( + main, ["download", "--all", "--output", "foo"] + ) + assert result.exit_code != 0 + assert "is mutually exclusive with" in result.output + + def test_file_exists(self, fake_repository, mock_put_license_in_file): + """The to-be-downloaded file already exists.""" + mock_put_license_in_file.side_effect = FileExistsError( + errno.EEXIST, "", "GPL-3.0-or-later.txt" + ) + + result = CliRunner().invoke(main, ["download", "GPL-3.0-or-later"]) + + assert result.exit_code == 1 + assert "GPL-3.0-or-later.txt already exists" in result.output + + def test_exception(self, empty_directory, mock_put_license_in_file): + """There was an error while downloading the license file.""" + mock_put_license_in_file.side_effect = URLError("test") + + result = CliRunner().invoke(main, ["download", "0BSD"]) + + assert result.exit_code == 1 + assert "internet" in result.output + + def test_invalid_spdx(self, empty_directory, mock_put_license_in_file): + """An invalid SPDX identifier was provided.""" + mock_put_license_in_file.side_effect = URLError("test") + + result = CliRunner().invoke(main, ["download", "does-not-exist"]) + + assert result.exit_code == 1 + assert "not a valid SPDX License Identifier" in result.output + + def test_custom_output(self, empty_directory, mock_put_license_in_file): + """Download the license into a custom file.""" + result = CliRunner().invoke(main, ["download", "-o", "foo", "0BSD"]) + + assert result.exit_code == 0 + mock_put_license_in_file.assert_called_with( + "0BSD", destination=Path("foo"), source=None + ) + + def test_custom_output_too_many( + self, empty_directory, mock_put_license_in_file + ): + """Providing more than one license with a custom output results in an + error. + """ + result = CliRunner().invoke( + main, + ["download", "-o", "foo", "0BSD", "GPL-3.0-or-later"], + ) + + assert result.exit_code != 0 + assert ( + "Cannot use '--output' with more than one license" in result.output + ) + + def test_inside_licenses_dir( + self, fake_repository, mock_put_license_in_file + ): + """While inside the LICENSES/ directory, don't create another LICENSES/ + directory. + """ + os.chdir(fake_repository / "LICENSES") + result = CliRunner().invoke(main, ["download", "0BSD"]) + assert result.exit_code == 0 + mock_put_license_in_file.assert_called_with( + "0BSD", destination=Path("0BSD.txt").absolute(), source=None + ) + + def test_inside_licenses_dir_in_git( + self, git_repository, mock_put_license_in_file + ): + """While inside a random LICENSES/ directory in a Git repository, use + the root LICENSES/ directory. + """ + (git_repository / "doc/LICENSES").mkdir() + os.chdir(git_repository / "doc/LICENSES") + result = CliRunner().invoke(main, ["download", "0BSD"]) + assert result.exit_code == 0 + mock_put_license_in_file.assert_called_with( + "0BSD", destination=Path("../../LICENSES/0BSD.txt"), source=None + ) + + def test_different_root(self, fake_repository, mock_put_license_in_file): + """Download using a different root.""" + (fake_repository / "new_root").mkdir() + + result = CliRunner().invoke( + main, + [ + "--root", + str((fake_repository / "new_root").resolve()), + "download", + "MIT", + ], + ) + assert result.exit_code == 0 + mock_put_license_in_file.assert_called_with( + "MIT", Path("new_root/LICENSES/MIT.txt").resolve(), source=None + ) + + def test_licenseref_no_source(self, empty_directory): + """Downloading a LicenseRef license creates an empty file.""" + CliRunner().invoke(main, ["download", "LicenseRef-hello"]) + assert ( + empty_directory / "LICENSES/LicenseRef-hello.txt" + ).read_text() == "" + + def test_licenseref_source_file( + self, + empty_directory, + ): + """Downloading a LicenseRef license with a source file copies that + file's contents. + """ + (empty_directory / "foo.txt").write_text("foo") + CliRunner().invoke( + main, + ["download", "--source", "foo.txt", "LicenseRef-hello"], + ) + assert ( + empty_directory / "LICENSES/LicenseRef-hello.txt" + ).read_text() == "foo" + + def test_licenseref_source_dir(self, empty_directory): + """Downloading a LicenseRef license with a source dir copies the text + from the corresponding file in the directory. + """ + (empty_directory / "lics").mkdir() + (empty_directory / "lics/LicenseRef-hello.txt").write_text("foo") + + CliRunner().invoke( + main, ["download", "--source", "lics", "LicenseRef-hello"] + ) + assert ( + empty_directory / "LICENSES/LicenseRef-hello.txt" + ).read_text() == "foo" + + def test_licenseref_false_source_dir(self, empty_directory): + """Downloading a LicenseRef license with a source that does not contain + the license results in an error. + """ + (empty_directory / "lics").mkdir() + + result = CliRunner().invoke( + main, ["download", "--source", "lics", "LicenseRef-hello"] + ) + assert result.exit_code == 1 + assert ( + f"{Path('lics') / 'LicenseRef-hello.txt'} does not exist" + in result.output + ) + + +class TestSimilarIdentifiers: + """Test a private function _similar_spdx_identifiers.""" + + # pylint: disable=protected-access + + def test_typo(self): + """Given a misspelt SPDX License Identifier, suggest a better one.""" + result = download._similar_spdx_identifiers("GPL-3.0-or-lter") + + assert "GPL-3.0-or-later" in result + assert "AGPL-3.0-or-later" in result + assert "LGPL-3.0-or-later" in result + + def test_prefix(self): + """Given an incomplete SPDX License Identifier, suggest a better one.""" + result = download._similar_spdx_identifiers("CC0") + + assert "CC0-1.0" in result diff --git a/tests/test_cli_lint.py b/tests/test_cli_lint.py new file mode 100644 index 000000000..b29dcb7f9 --- /dev/null +++ b/tests/test_cli_lint.py @@ -0,0 +1,264 @@ +# SPDX-FileCopyrightText: 2019 Free Software Foundation Europe e.V. +# SPDX-FileCopyrightText: 2019 Stefan Bakker +# SPDX-FileCopyrightText: 2022 Florian Snow +# SPDX-FileCopyrightText: 2022 Pietro Albini +# SPDX-FileCopyrightText: 2024 Carmen Bianca BAKKER +# SPDX-FileCopyrightText: 2024 Skyler Grey +# SPDX-FileCopyrightText: © 2020 Liferay, Inc. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# pylint: disable=unused-argument,too-many-public-methods + +"""Tests for lint.""" + +import json +import os +import shutil +from inspect import cleandoc + +from click.testing import CliRunner +from conftest import RESOURCES_DIRECTORY + +from reuse._util import cleandoc_nl +from reuse.cli.main import main +from reuse.report import LINT_VERSION + + +class TestLint: + """Tests for lint.""" + + def test_simple(self, fake_repository, optional_git_exe, optional_hg_exe): + """Run a successful lint. The optional VCSs are there to make sure that + the test also works if these programs are not installed. + """ + result = CliRunner().invoke(main, ["lint"]) + + assert result.exit_code == 0 + assert ":-)" in result.output + + def test_reuse_toml(self, fake_repository_reuse_toml): + """Run a simple lint with REUSE.toml.""" + result = CliRunner().invoke(main, ["lint"]) + + assert result.exit_code == 0 + assert ":-)" in result.output + + def test_dep5(self, fake_repository_dep5): + """Run a simple lint with .reuse/dep5.""" + result = CliRunner().invoke(main, ["lint"]) + + assert result.exit_code == 0 + assert ":-)" in result.output + + def test_git(self, git_repository): + """Run a successful lint.""" + result = CliRunner().invoke(main, ["lint"]) + + assert result.exit_code == 0 + assert ":-)" in result.output + + def test_submodule(self, submodule_repository): + """Run a successful lint.""" + (submodule_repository / "submodule/foo.c").write_text("foo") + result = CliRunner().invoke(main, ["lint"]) + + assert result.exit_code == 0 + assert ":-)" in result.output + + def test_submodule_included(self, submodule_repository): + """Run a successful lint.""" + result = CliRunner().invoke(main, ["--include-submodules", "lint"]) + + assert result.exit_code == 0 + assert ":-)" in result.output + + def test_submodule_included_fail(self, submodule_repository): + """Run a failed lint.""" + (submodule_repository / "submodule/foo.c").write_text("foo") + result = CliRunner().invoke(main, ["--include-submodules", "lint"]) + + assert result.exit_code == 1 + assert ":-(" in result.output + + def test_meson_subprojects(self, fake_repository): + """Verify that subprojects are ignored.""" + result = CliRunner().invoke(main, ["lint"]) + + assert result.exit_code == 0 + assert ":-)" in result.output + + def test_meson_subprojects_fail(self, subproject_repository): + """Verify that files in './subprojects' are not ignored.""" + # ./subprojects/foo.wrap misses license and linter fails + (subproject_repository / "subprojects/foo.wrap").write_text("foo") + result = CliRunner().invoke(main, ["lint"]) + + assert result.exit_code == 1 + assert ":-(" in result.output + + def test_meson_subprojects_included_fail(self, subproject_repository): + """When Meson subprojects are included, fail on errors.""" + result = CliRunner().invoke( + main, ["--include-meson-subprojects", "lint"] + ) + + assert result.exit_code == 1 + assert ":-(" in result.output + + def test_meson_subprojects_included(self, subproject_repository): + """Successfully lint when Meson subprojects are included.""" + # ./subprojects/libfoo/foo.c has license and linter succeeds + (subproject_repository / "subprojects/libfoo/foo.c").write_text( + cleandoc( + """ + SPDX-FileCopyrightText: 2022 Jane Doe + SPDX-License-Identifier: GPL-3.0-or-later + """ + ) + ) + result = CliRunner().invoke( + main, ["--include-meson-subprojects", "lint"] + ) + + assert result.exit_code == 0 + assert ":-)" in result.output + + def test_fail(self, fake_repository): + """Run a failed lint.""" + (fake_repository / "foo.py").write_text("foo") + result = CliRunner().invoke(main, ["lint"]) + + assert result.exit_code > 0 + assert "foo.py" in result.output + assert ":-(" in result.output + + def test_fail_quiet(self, fake_repository): + """Run a failed lint.""" + (fake_repository / "foo.py").write_text("foo") + result = CliRunner().invoke(main, ["lint", "--quiet"]) + + assert result.exit_code > 0 + assert result.output == "" + + def test_dep5_decode_error(self, fake_repository_dep5): + """Display an error if dep5 cannot be decoded.""" + shutil.copy( + RESOURCES_DIRECTORY / "fsfe.png", + fake_repository_dep5 / ".reuse/dep5", + ) + result = CliRunner().invoke(main, ["lint"]) + assert result.exit_code != 0 + assert str(fake_repository_dep5 / ".reuse/dep5") in result.output + assert "could not be parsed" in result.output + assert "'utf-8' codec can't decode byte" in result.output + + def test_dep5_parse_error(self, fake_repository_dep5, capsys): + """Display an error if there's a dep5 parse error.""" + (fake_repository_dep5 / ".reuse/dep5").write_text("foo") + result = CliRunner().invoke(main, ["lint"]) + assert result.exit_code != 0 + assert str(fake_repository_dep5 / ".reuse/dep5") in result.output + assert "could not be parsed" in result.output + + def test_toml_parse_error_version(self, fake_repository_reuse_toml, capsys): + """If version has the wrong type, print an error.""" + (fake_repository_reuse_toml / "REUSE.toml").write_text("version = 'a'") + result = CliRunner().invoke(main, ["lint"]) + assert result.exit_code != 0 + assert str(fake_repository_reuse_toml / "REUSE.toml") in result.output + assert "could not be parsed" in result.output + + def test_toml_parse_error_annotation( + self, fake_repository_reuse_toml, capsys + ): + """If there is an error in an annotation, print an error.""" + (fake_repository_reuse_toml / "REUSE.toml").write_text( + cleandoc_nl( + """ + version = 1 + + [[annotations]] + path = 1 + """ + ) + ) + result = CliRunner().invoke(main, ["lint"]) + assert result.exit_code != 0 + assert str(fake_repository_reuse_toml / "REUSE.toml") in result.output + assert "could not be parsed" in result.output + + def test_json(self, fake_repository): + """Run a failed lint.""" + result = CliRunner().invoke(main, ["lint", "--json"]) + output = json.loads(result.output) + + assert result.exit_code == 0 + assert output["lint_version"] == LINT_VERSION + assert len(output["files"]) == 8 + + def test_json_fail(self, fake_repository): + """Run a failed lint.""" + (fake_repository / "foo.py").write_text("foo") + result = CliRunner().invoke(main, ["lint", "--json"]) + output = json.loads(result.output) + + assert result.exit_code > 0 + assert output["lint_version"] == LINT_VERSION + assert len(output["non_compliant"]["missing_licensing_info"]) == 1 + assert len(output["non_compliant"]["missing_copyright_info"]) == 1 + assert len(output["files"]) == 9 + + def test_no_file_extension(self, fake_repository): + """If a license has no file extension, the lint fails.""" + (fake_repository / "LICENSES/CC0-1.0.txt").rename( + fake_repository / "LICENSES/CC0-1.0" + ) + result = CliRunner().invoke(main, ["lint"]) + + assert result.exit_code > 0 + assert "Licenses without file extension: CC0-1.0" in result.output + assert ":-(" in result.output + + def test_custom_root(self, fake_repository): + """Use a custom root location.""" + result = CliRunner().invoke(main, ["--root", "doc", "lint"]) + + assert result.exit_code > 0 + assert "usage.md" in result.output + assert ":-(" in result.output + + def test_custom_root_git(self, git_repository): + """Use a custom root location in a git repo.""" + result = CliRunner().invoke(main, ["--root", "doc", "lint"]) + + assert result.exit_code > 0 + assert "usage.md" in result.output + assert ":-(" in result.output + + def test_custom_root_different_cwd(self, fake_repository_reuse_toml): + """Use a custom root while CWD is different.""" + os.chdir("/") + result = CliRunner().invoke( + main, ["--root", str(fake_repository_reuse_toml), "lint"] + ) + + assert result.exit_code == 0 + assert ":-)" in result.output + + def test_custom_root_is_file(self, fake_repository): + """Custom root cannot be a file.""" + result = CliRunner().invoke(main, ["--root", ".reuse/dep5", "lint"]) + assert result.exit_code != 0 + + def test_custom_root_not_exists(self, fake_repository): + """Custom root must exist.""" + result = CliRunner().invoke(main, ["--root", "does-not-exist", "lint"]) + assert result.exit_code != 0 + + def test_no_multiprocessing(self, fake_repository, multiprocessing): + """--no-multiprocessing works.""" + result = CliRunner().invoke(main, ["--no-multiprocessing", "lint"]) + + assert result.exit_code == 0 + assert ":-)" in result.output diff --git a/tests/test_cli_lint_file.py b/tests/test_cli_lint_file.py new file mode 100644 index 000000000..a02c25bee --- /dev/null +++ b/tests/test_cli_lint_file.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: 2024 Free Software Foundation Europe e.V. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tests for lint-file.""" + +# pylint: disable=unused-argument + +from click.testing import CliRunner + +from reuse.cli.main import main + + +class TestLintFile: + """Tests for lint-file.""" + + def test_simple(self, fake_repository): + """A simple test to make sure it works.""" + result = CliRunner().invoke(main, ["lint-file", "src/custom.py"]) + assert result.exit_code == 0 + assert not result.output + + def test_quiet_lines_mutually_exclusive(self, empty_directory): + """'--quiet' and '--lines' are mutually exclusive.""" + (empty_directory / "foo.py").write_text("foo") + result = CliRunner().invoke( + main, ["lint-file", "--quiet", "--lines", "foo"] + ) + assert result.exit_code != 0 + assert "mutually exclusive" in result.output + + def test_no_copyright_licensing(self, fake_repository): + """A file is correctly spotted when it has no copyright or licensing + info. + """ + (fake_repository / "foo.py").write_text("foo") + result = CliRunner().invoke(main, ["lint-file", "foo.py"]) + assert result.exit_code == 1 + output = result.output + assert "foo.py" in output + assert "no license identifier" in output + assert "no copyright notice" in output + + def test_path_outside_project(self, empty_directory): + """A file can't be outside the project.""" + result = CliRunner().invoke(main, ["lint-file", ".."]) + assert result.exit_code != 0 + assert "'..' is not in" in result.output + + def test_file_not_exists(self, empty_directory): + """A file must exist.""" + result = CliRunner().invoke(main, ["lint-file", "foo.py"]) + assert "'foo.py' does not exist" in result.output + + def test_ignored_file(self, fake_repository): + """A corner case where a specified file is ignored. It isn't checked at + all. + """ + (fake_repository / "COPYING").write_text("foo") + result = CliRunner().invoke(main, ["lint-file", "COPYING"]) + assert result.exit_code == 0 + + def test_file_covered_by_toml(self, fake_repository_reuse_toml): + """If a file is covered by REUSE.toml, use its infos.""" + (fake_repository_reuse_toml / "doc/foo.md").write_text("foo") + result = CliRunner().invoke(main, ["lint-file", "doc/foo.md"]) + assert result.exit_code == 0 diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py new file mode 100644 index 000000000..641dd5e65 --- /dev/null +++ b/tests/test_cli_main.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2023 Carmen Bianca BAKKER +# SPDX-FileCopyrightText: 2024 Free Software Foundation Europe e.V. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tests for reuse.cli.main.""" + +from click.testing import CliRunner + +from reuse import __version__ +from reuse.cli.main import main + + +class TestMain: + """Collect all tests for main.""" + + def test_help_is_default(self): + """--help is optional.""" + without_help = CliRunner().invoke(main, []) + with_help = CliRunner().invoke(main, ["--help"]) + assert without_help.output == with_help.output + assert without_help.exit_code == with_help.exit_code == 0 + assert with_help.output.startswith("Usage: reuse") + + def test_version(self): + """--version returns the correct version.""" + result = CliRunner().invoke(main, ["--version"]) + assert result.output.startswith(f"reuse, version {__version__}\n") + assert "This program is free software:" in result.output + assert "GNU General Public License" in result.output diff --git a/tests/test_cli_spdx.py b/tests/test_cli_spdx.py new file mode 100644 index 000000000..b9bb466c6 --- /dev/null +++ b/tests/test_cli_spdx.py @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: 2019 Free Software Foundation Europe e.V. +# SPDX-FileCopyrightText: 2019 Stefan Bakker +# SPDX-FileCopyrightText: 2022 Florian Snow +# SPDX-FileCopyrightText: 2022 Pietro Albini +# SPDX-FileCopyrightText: 2024 Carmen Bianca BAKKER +# SPDX-FileCopyrightText: 2024 Skyler Grey +# SPDX-FileCopyrightText: © 2020 Liferay, Inc. +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tests for spdx.""" + +from click.testing import CliRunner +from freezegun import freeze_time + +from reuse.cli.main import main + +# pylint: disable=unused-argument + + +class TestSpdx: + """Tests for spdx.""" + + @freeze_time("2024-04-08T17:34:00Z") + def test_simple(self, fake_repository): + """Compile to an SPDX document.""" + result = CliRunner().invoke(main, ["spdx"]) + output = result.output + + # Ensure no LicenseConcluded is included without the flag + assert "\nLicenseConcluded: NOASSERTION\n" in output + assert "\nLicenseConcluded: GPL-3.0-or-later\n" not in output + assert "\nCreator: Person: Anonymous ()\n" in output + assert "\nCreator: Organization: Anonymous ()\n" in output + assert "\nCreated: 2024-04-08T17:34:00Z\n" in output + + # TODO: This test is rubbish. + assert result.exit_code == 0 + + def test_creator_info(self, fake_repository): + """Ensure the --creator-* flags are properly formatted""" + result = CliRunner().invoke( + main, + [ + "spdx", + "--creator-person=Jane Doe (jane.doe@example.org)", + "--creator-organization=FSFE", + ], + ) + output = result.output + + assert result.exit_code == 0 + assert "\nCreator: Person: Jane Doe (jane.doe@example.org)\n" in output + assert "\nCreator: Organization: FSFE ()\n" in output + + def test_add_license_concluded(self, fake_repository): + """Compile to an SPDX document with the LicenseConcluded field.""" + result = CliRunner().invoke( + main, + [ + "spdx", + "--add-license-concluded", + "--creator-person=Jane Doe", + "--creator-organization=FSFE", + ], + ) + output = result.output + + # Ensure no LicenseConcluded is included without the flag + assert result.exit_code == 0 + assert "\nLicenseConcluded: NOASSERTION\n" not in output + assert "\nLicenseConcluded: GPL-3.0-or-later\n" in output + assert "\nCreator: Person: Jane Doe ()\n" in output + assert "\nCreator: Organization: FSFE ()\n" in output + + def test_add_license_concluded_without_creator_info(self, fake_repository): + """Adding LicenseConcluded should require creator information""" + result = CliRunner().invoke(main, ["spdx", "--add-license-concluded"]) + assert result.exit_code != 0 + assert "--add-license-concluded" in result.output + + def test_spdx_no_multiprocessing(self, fake_repository, multiprocessing): + """--no-multiprocessing works.""" + result = CliRunner().invoke(main, ["--no-multiprocessing", "spdx"]) + + # TODO: This test is rubbish. + assert result.exit_code == 0 + assert result.output diff --git a/tests/test_cli_supported_licenses.py b/tests/test_cli_supported_licenses.py new file mode 100644 index 000000000..2d909e306 --- /dev/null +++ b/tests/test_cli_supported_licenses.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2019 Free Software Foundation Europe e.V. +# SPDX-FileCopyrightTect: 2021 Michael Weimann +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Tests for supported-licenses.""" + +import re + +from click.testing import CliRunner + +from reuse.cli.main import main + + +class TestSupportedLicenses: + """Tests for supported-licenses.""" + + def test_simple(self): + """Invoke the supported-licenses command and check whether the result + contains at least one license in the expected format. + """ + + result = CliRunner().invoke(main, ["supported-licenses"]) + + assert result.exit_code == 0 + assert re.search( + # pylint: disable=line-too-long + r"GPL-3\.0-or-later\s+GNU General Public License v3\.0 or later\s+https:\/\/spdx\.org\/licenses\/GPL-3\.0-or-later\.html\s+\n", + result.output, + ) diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index d3443d3ae..000000000 --- a/tests/test_main.py +++ /dev/null @@ -1,700 +0,0 @@ -# SPDX-FileCopyrightText: 2019 Free Software Foundation Europe e.V. -# SPDX-FileCopyrightText: 2019 Stefan Bakker -# SPDX-FileCopyrightText: 2022 Florian Snow -# SPDX-FileCopyrightText: 2022 Pietro Albini -# SPDX-FileCopyrightText: 2024 Carmen Bianca BAKKER -# SPDX-FileCopyrightText: 2024 Skyler Grey -# SPDX-FileCopyrightText: © 2020 Liferay, Inc. -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Tests for reuse._main: lint, spdx, download""" - -# pylint: disable=redefined-outer-name,unused-argument - -import errno -import json -import os -import re -import shutil -import warnings -from inspect import cleandoc -from pathlib import Path -from typing import Generator, Optional -from unittest.mock import create_autospec -from urllib.error import URLError - -import pytest -from conftest import RESOURCES_DIRECTORY -from freezegun import freeze_time - -from reuse import download -from reuse._main import main, shtab -from reuse._util import GIT_EXE, HG_EXE, JUJUTSU_EXE, PIJUL_EXE, cleandoc_nl -from reuse.report import LINT_VERSION - -# REUSE-IgnoreStart - - -@pytest.fixture(params=[True, False]) -def optional_git_exe( - request, monkeypatch -) -> Generator[Optional[str], None, None]: - """Run the test with or without git.""" - exe = GIT_EXE if request.param else "" - monkeypatch.setattr("reuse.vcs.GIT_EXE", exe) - monkeypatch.setattr("reuse._util.GIT_EXE", exe) - yield exe - - -@pytest.fixture(params=[True, False]) -def optional_hg_exe( - request, monkeypatch -) -> Generator[Optional[str], None, None]: - """Run the test with or without mercurial.""" - exe = HG_EXE if request.param else "" - monkeypatch.setattr("reuse.vcs.HG_EXE", exe) - monkeypatch.setattr("reuse._util.HG_EXE", exe) - yield exe - - -@pytest.fixture(params=[True, False]) -def optional_jujutsu_exe( - request, monkeypatch -) -> Generator[Optional[str], None, None]: - """Run the test with or without Jujutsu.""" - exe = JUJUTSU_EXE if request.param else "" - monkeypatch.setattr("reuse.vcs.JUJUTSU_EXE", exe) - monkeypatch.setattr("reuse._util.JUJUTSU_EXE", exe) - yield exe - - -@pytest.fixture(params=[True, False]) -def optional_pijul_exe( - request, monkeypatch -) -> Generator[Optional[str], None, None]: - """Run the test with or without Pijul.""" - exe = PIJUL_EXE if request.param else "" - monkeypatch.setattr("reuse.vcs.PIJUL_EXE", exe) - monkeypatch.setattr("reuse._util.PIJUL_EXE", exe) - yield exe - - -@pytest.fixture() -def mock_put_license_in_file(monkeypatch): - """Create a mocked version of put_license_in_file.""" - result = create_autospec(download.put_license_in_file) - monkeypatch.setattr(download, "put_license_in_file", result) - return result - - -@pytest.mark.skipif(not shtab, reason="shtab required") -def test_print_completion(capsys): - """shtab completions are printed.""" - with pytest.raises(SystemExit) as error: - main(["--print-completion", "bash"]) - - assert error.value.code == 0 - assert "AUTOMATICALLY GENERATED by `shtab`" in capsys.readouterr().out - - -def test_lint(fake_repository, stringio, optional_git_exe, optional_hg_exe): - """Run a successful lint. The optional VCSs are there to make sure that the - test also works if these programs are not installed. - """ - result = main(["lint"], out=stringio) - - assert result == 0 - assert ":-)" in stringio.getvalue() - - -def test_lint_reuse_toml(fake_repository_reuse_toml, stringio): - """Run a simple lint with REUSE.toml.""" - result = main(["lint"], out=stringio) - - assert result == 0 - assert ":-)" in stringio.getvalue() - - -def test_lint_dep5(fake_repository_dep5, stringio): - """Run a simple lint with .reuse/dep5.""" - result = main(["lint"], out=stringio) - - assert result == 0 - assert ":-)" in stringio.getvalue() - - -def test_lint_git(git_repository, stringio): - """Run a successful lint.""" - result = main(["lint"], out=stringio) - - assert result == 0 - assert ":-)" in stringio.getvalue() - - -def test_lint_submodule(submodule_repository, stringio): - """Run a successful lint.""" - (submodule_repository / "submodule/foo.c").write_text("foo") - result = main(["lint"], out=stringio) - - assert result == 0 - assert ":-)" in stringio.getvalue() - - -def test_lint_submodule_included(submodule_repository, stringio): - """Run a successful lint.""" - result = main(["--include-submodules", "lint"], out=stringio) - - assert result == 0 - assert ":-)" in stringio.getvalue() - - -def test_lint_submodule_included_fail(submodule_repository, stringio): - """Run a failed lint.""" - (submodule_repository / "submodule/foo.c").write_text("foo") - result = main(["--include-submodules", "lint"], out=stringio) - - assert result == 1 - assert ":-(" in stringio.getvalue() - - -def test_lint_meson_subprojects(fake_repository, stringio): - """Verify that subprojects are ignored.""" - result = main(["lint"], out=stringio) - - assert result == 0 - assert ":-)" in stringio.getvalue() - - -def test_lint_meson_subprojects_fail(subproject_repository, stringio): - """Verify that files in './subprojects' are not ignored.""" - # ./subprojects/foo.wrap misses license and linter fails - (subproject_repository / "subprojects/foo.wrap").write_text("foo") - result = main(["lint"], out=stringio) - - assert result == 1 - assert ":-(" in stringio.getvalue() - - -def test_lint_meson_subprojects_included_fail(subproject_repository, stringio): - """When Meson subprojects are included, fail on errors.""" - result = main(["--include-meson-subprojects", "lint"], out=stringio) - - assert result == 1 - assert ":-(" in stringio.getvalue() - - -def test_lint_meson_subprojects_included(subproject_repository, stringio): - """Successfully lint when Meson subprojects are included.""" - # ./subprojects/libfoo/foo.c has license and linter succeeds - (subproject_repository / "subprojects/libfoo/foo.c").write_text( - cleandoc( - """ - SPDX-FileCopyrightText: 2022 Jane Doe - SPDX-License-Identifier: GPL-3.0-or-later - """ - ) - ) - result = main(["--include-meson-subprojects", "lint"], out=stringio) - - assert result == 0 - assert ":-)" in stringio.getvalue() - - -def test_lint_fail(fake_repository, stringio): - """Run a failed lint.""" - (fake_repository / "foo.py").write_text("foo") - result = main(["lint"], out=stringio) - - assert result > 0 - assert "foo.py" in stringio.getvalue() - assert ":-(" in stringio.getvalue() - - -def test_lint_fail_quiet(fake_repository, stringio): - """Run a failed lint.""" - (fake_repository / "foo.py").write_text("foo") - result = main(["lint", "--quiet"], out=stringio) - - assert result > 0 - assert stringio.getvalue() == "" - - -def test_lint_dep5_decode_error(fake_repository_dep5, capsys): - """Display an error if dep5 cannot be decoded.""" - shutil.copy( - RESOURCES_DIRECTORY / "fsfe.png", fake_repository_dep5 / ".reuse/dep5" - ) - with pytest.raises(SystemExit): - main(["lint"]) - error = capsys.readouterr().err - assert str(fake_repository_dep5 / ".reuse/dep5") in error - assert "could not be parsed" in error - assert "'utf-8' codec can't decode byte" in error - - -def test_lint_dep5_parse_error(fake_repository_dep5, capsys): - """Display an error if there's a dep5 parse error.""" - (fake_repository_dep5 / ".reuse/dep5").write_text("foo") - with pytest.raises(SystemExit): - main(["lint"]) - error = capsys.readouterr().err - assert str(fake_repository_dep5 / ".reuse/dep5") in error - assert "could not be parsed" in error - - -def test_lint_toml_parse_error_version(fake_repository_reuse_toml, capsys): - """If version has the wrong type, print an error.""" - (fake_repository_reuse_toml / "REUSE.toml").write_text("version = 'a'") - with pytest.raises(SystemExit): - main(["lint"]) - error = capsys.readouterr().err - assert str(fake_repository_reuse_toml / "REUSE.toml") in error - assert "could not be parsed" in error - - -def test_lint_toml_parse_error_annotation(fake_repository_reuse_toml, capsys): - """If there is an error in an annotation, print an error.""" - (fake_repository_reuse_toml / "REUSE.toml").write_text( - cleandoc_nl( - """ - version = 1 - - [[annotations]] - path = 1 - """ - ) - ) - with pytest.raises(SystemExit): - main(["lint"]) - error = capsys.readouterr().err - assert str(fake_repository_reuse_toml / "REUSE.toml") in error - assert "could not be parsed" in error - - -def test_lint_json(fake_repository, stringio): - """Run a failed lint.""" - result = main(["lint", "--json"], out=stringio) - output = json.loads(stringio.getvalue()) - - assert result == 0 - assert output["lint_version"] == LINT_VERSION - assert len(output["files"]) == 8 - - -def test_lint_json_fail(fake_repository, stringio): - """Run a failed lint.""" - (fake_repository / "foo.py").write_text("foo") - result = main(["lint", "--json"], out=stringio) - output = json.loads(stringio.getvalue()) - - assert result > 0 - assert output["lint_version"] == LINT_VERSION - assert len(output["non_compliant"]["missing_licensing_info"]) == 1 - assert len(output["non_compliant"]["missing_copyright_info"]) == 1 - assert len(output["files"]) == 9 - - -def test_lint_no_file_extension(fake_repository, stringio): - """If a license has no file extension, the lint fails.""" - (fake_repository / "LICENSES/CC0-1.0.txt").rename( - fake_repository / "LICENSES/CC0-1.0" - ) - result = main(["lint"], out=stringio) - - assert result > 0 - assert "Licenses without file extension: CC0-1.0" in stringio.getvalue() - assert ":-(" in stringio.getvalue() - - -def test_lint_custom_root(fake_repository, stringio): - """Use a custom root location.""" - result = main(["--root", "doc", "lint"], out=stringio) - - assert result > 0 - assert "usage.md" in stringio.getvalue() - assert ":-(" in stringio.getvalue() - - -def test_lint_custom_root_git(git_repository, stringio): - """Use a custom root location in a git repo.""" - result = main(["--root", "doc", "lint"], out=stringio) - - assert result > 0 - assert "usage.md" in stringio.getvalue() - assert ":-(" in stringio.getvalue() - - -def test_lint_custom_root_different_cwd(fake_repository_reuse_toml, stringio): - """Use a custom root while CWD is different.""" - os.chdir("/") - result = main( - ["--root", str(fake_repository_reuse_toml), "lint"], out=stringio - ) - - assert result == 0 - assert ":-)" in stringio.getvalue() - - -def test_lint_custom_root_is_file(fake_repository, stringio): - """Custom root cannot be a file.""" - with pytest.raises(SystemExit): - main(["--root", ".reuse/dep5", "lint"], out=stringio) - - -def test_lint_custom_root_not_exists(fake_repository, stringio): - """Custom root must exist.""" - with pytest.raises(SystemExit): - main(["--root", "does-not-exist", "lint"], out=stringio) - - -def test_lint_no_multiprocessing(fake_repository, stringio, multiprocessing): - """--no-multiprocessing works.""" - result = main(["--no-multiprocessing", "lint"], out=stringio) - - assert result == 0 - assert ":-)" in stringio.getvalue() - - -class TestLintFile: - """Tests for lint-file.""" - - def test_simple(self, fake_repository, stringio): - """A simple test to make sure it works.""" - result = main(["lint-file", "src/custom.py"], out=stringio) - assert result == 0 - assert not stringio.getvalue() - - def test_no_copyright_licensing(self, fake_repository, stringio): - """A file is correctly spotted when it has no copyright or licensing - info. - """ - (fake_repository / "foo.py").write_text("foo") - result = main(["lint-file", "foo.py"], out=stringio) - assert result == 1 - output = stringio.getvalue() - assert "foo.py" in output - assert "no license identifier" in output - assert "no copyright notice" in output - - def test_path_outside_project(self, empty_directory, capsys): - """A file can't be outside the project.""" - with pytest.raises(SystemExit): - main(["lint-file", ".."]) - assert "'..' is not in" in capsys.readouterr().err - - def test_file_not_exists(self, empty_directory, capsys): - """A file must exist.""" - with pytest.raises(SystemExit): - main(["lint-file", "foo.py"]) - assert "can't open 'foo.py'" in capsys.readouterr().err - - def test_ignored_file(self, fake_repository, stringio): - """A corner case where a specified file is ignored. It isn't checked at - all. - """ - (fake_repository / "COPYING").write_text("foo") - result = main(["lint-file", "COPYING"], out=stringio) - assert result == 0 - - def test_file_covered_by_toml(self, fake_repository_reuse_toml, stringio): - """If a file is covered by REUSE.toml, use its infos.""" - (fake_repository_reuse_toml / "doc/foo.md").write_text("foo") - result = main(["lint-file", "doc/foo.md"], out=stringio) - assert result == 0 - - -@freeze_time("2024-04-08T17:34:00Z") -def test_spdx(fake_repository, stringio): - """Compile to an SPDX document.""" - os.chdir(str(fake_repository)) - result = main(["spdx"], out=stringio) - output = stringio.getvalue() - - # Ensure no LicenseConcluded is included without the flag - assert "\nLicenseConcluded: NOASSERTION\n" in output - assert "\nLicenseConcluded: GPL-3.0-or-later\n" not in output - assert "\nCreator: Person: Anonymous ()\n" in output - assert "\nCreator: Organization: Anonymous ()\n" in output - assert "\nCreated: 2024-04-08T17:34:00Z\n" in output - - # TODO: This test is rubbish. - assert result == 0 - assert output - - -def test_spdx_creator_info(fake_repository, stringio): - """Ensure the --creator-* flags are properly formatted""" - os.chdir(str(fake_repository)) - result = main( - [ - "spdx", - "--creator-person=Jane Doe (jane.doe@example.org)", - "--creator-organization=FSFE", - ], - out=stringio, - ) - output = stringio.getvalue() - - assert result == 0 - assert "\nCreator: Person: Jane Doe (jane.doe@example.org)\n" in output - assert "\nCreator: Organization: FSFE ()\n" in output - - -def test_spdx_add_license_concluded(fake_repository, stringio): - """Compile to an SPDX document with the LicenseConcluded field.""" - os.chdir(str(fake_repository)) - result = main( - [ - "spdx", - "--add-license-concluded", - "--creator-person=Jane Doe", - "--creator-organization=FSFE", - ], - out=stringio, - ) - output = stringio.getvalue() - - # Ensure no LicenseConcluded is included without the flag - assert result == 0 - assert "\nLicenseConcluded: NOASSERTION\n" not in output - assert "\nLicenseConcluded: GPL-3.0-or-later\n" in output - assert "\nCreator: Person: Jane Doe ()\n" in output - assert "\nCreator: Organization: FSFE ()\n" in output - - -def test_spdx_add_license_concluded_without_creator_info( - fake_repository, stringio -): - """Adding LicenseConcluded should require creator information""" - os.chdir(str(fake_repository)) - with pytest.raises(SystemExit): - main(["spdx", "--add-license-concluded"], out=stringio) - - -def test_spdx_no_multiprocessing(fake_repository, stringio, multiprocessing): - """--no-multiprocessing works.""" - os.chdir(str(fake_repository)) - result = main(["--no-multiprocessing", "spdx"], out=stringio) - - # TODO: This test is rubbish. - assert result == 0 - assert stringio.getvalue() - - -def test_download(fake_repository, stringio, mock_put_license_in_file): - """Straightforward test.""" - result = main(["download", "0BSD"], out=stringio) - - assert result == 0 - mock_put_license_in_file.assert_called_with( - "0BSD", Path("LICENSES/0BSD.txt").resolve(), source=None - ) - - -def test_download_file_exists( - fake_repository, stringio, mock_put_license_in_file -): - """The to-be-downloaded file already exists.""" - mock_put_license_in_file.side_effect = FileExistsError( - errno.EEXIST, "", "GPL-3.0-or-later.txt" - ) - - result = main(["download", "GPL-3.0-or-later"], out=stringio) - - assert result == 1 - assert "GPL-3.0-or-later.txt already exists" in stringio.getvalue() - - -def test_download_exception( - fake_repository, stringio, mock_put_license_in_file -): - """There was an error while downloading the license file.""" - mock_put_license_in_file.side_effect = URLError("test") - - result = main(["download", "0BSD"], out=stringio) - - assert result == 1 - assert "internet" in stringio.getvalue() - - -def test_download_invalid_spdx( - fake_repository, stringio, mock_put_license_in_file -): - """An invalid SPDX identifier was provided.""" - mock_put_license_in_file.side_effect = URLError("test") - - result = main(["download", "does-not-exist"], out=stringio) - - assert result == 1 - assert "not a valid SPDX License Identifier" in stringio.getvalue() - - -def test_download_custom_output( - empty_directory, stringio, mock_put_license_in_file -): - """Download the license into a custom file.""" - result = main(["download", "-o", "foo", "0BSD"], out=stringio) - - assert result == 0 - mock_put_license_in_file.assert_called_with( - "0BSD", destination=Path("foo"), source=None - ) - - -def test_download_custom_output_too_many( - empty_directory, stringio, mock_put_license_in_file -): - """Providing more than one license with a custom output results in an - error. - """ - with pytest.raises(SystemExit): - main( - ["download", "-o", "foo", "0BSD", "GPL-3.0-or-later"], out=stringio - ) - - -def test_download_inside_licenses_dir( - fake_repository, stringio, mock_put_license_in_file -): - """While inside the LICENSES/ directory, don't create another LICENSES/ - directory. - """ - os.chdir(fake_repository / "LICENSES") - result = main(["download", "0BSD"], out=stringio) - assert result == 0 - mock_put_license_in_file.assert_called_with( - "0BSD", destination=Path("0BSD.txt").absolute(), source=None - ) - - -def test_download_inside_licenses_dir_in_git( - git_repository, stringio, mock_put_license_in_file -): - """While inside a random LICENSES/ directory in a Git repository,.use the - root LICENSES/ directory. - """ - (git_repository / "doc/LICENSES").mkdir() - os.chdir(git_repository / "doc/LICENSES") - result = main(["download", "0BSD"], out=stringio) - assert result == 0 - mock_put_license_in_file.assert_called_with( - "0BSD", destination=Path("../../LICENSES/0BSD.txt"), source=None - ) - - -def test_download_different_root( - fake_repository, stringio, mock_put_license_in_file -): - """Download using a different root.""" - (fake_repository / "new_root").mkdir() - - result = main( - [ - "--root", - str((fake_repository / "new_root").resolve()), - "download", - "MIT", - ], - out=stringio, - ) - assert result == 0 - mock_put_license_in_file.assert_called_with( - "MIT", Path("new_root/LICENSES/MIT.txt").resolve(), source=None - ) - - -def test_download_licenseref_no_source(empty_directory, stringio): - """Downloading a LicenseRef license creates an empty file.""" - main(["download", "LicenseRef-hello"], out=stringio) - assert (empty_directory / "LICENSES/LicenseRef-hello.txt").read_text() == "" - - -def test_download_licenseref_source_file(empty_directory, stringio): - """Downloading a LicenseRef license with a source file copies that file's - contents. - """ - (empty_directory / "foo.txt").write_text("foo") - main(["download", "--source", "foo.txt", "LicenseRef-hello"], out=stringio) - assert ( - empty_directory / "LICENSES/LicenseRef-hello.txt" - ).read_text() == "foo" - - -def test_download_licenseref_source_dir(empty_directory, stringio): - """Downloading a LicenseRef license with a source dir copies the text from - the corresponding file in the directory. - """ - (empty_directory / "lics").mkdir() - (empty_directory / "lics/LicenseRef-hello.txt").write_text("foo") - - main(["download", "--source", "lics", "LicenseRef-hello"], out=stringio) - assert ( - empty_directory / "LICENSES/LicenseRef-hello.txt" - ).read_text() == "foo" - - -def test_download_licenseref_false_source_dir(empty_directory, stringio): - """Downloading a LicenseRef license with a source that does not contain the - license results in an error. - """ - (empty_directory / "lics").mkdir() - - result = main( - ["download", "--source", "lics", "LicenseRef-hello"], out=stringio - ) - assert result != 0 - assert ( - f"{Path('lics') / 'LicenseRef-hello.txt'} does not exist" - in stringio.getvalue() - ) - - -def test_supported_licenses(stringio): - """Invoke the supported-licenses command and check whether the result - contains at least one license in the expected format. - """ - - assert main(["supported-licenses"], out=stringio) == 0 - assert re.search( - # pylint: disable=line-too-long - r"GPL-3\.0-or-later\s+GNU General Public License v3\.0 or later\s+https:\/\/spdx\.org\/licenses\/GPL-3\.0-or-later\.html\s+\n", - stringio.getvalue(), - ) - - -def test_convert_dep5(fake_repository_dep5, stringio): - """Convert a DEP5 repository to a REUSE.toml repository.""" - result = main(["convert-dep5"], out=stringio) - - assert result == 0 - assert not (fake_repository_dep5 / ".reuse/dep5").exists() - assert (fake_repository_dep5 / "REUSE.toml").exists() - assert (fake_repository_dep5 / "REUSE.toml").read_text() == cleandoc_nl( - """ - version = 1 - - [[annotations]] - path = "doc/**" - precedence = "aggregate" - SPDX-FileCopyrightText = "2017 Jane Doe" - SPDX-License-Identifier = "CC0-1.0" - """ - ) - - -def test_convert_dep5_no_dep5_file(fake_repository, stringio): - """Cannot convert when there is no .reuse/dep5 file.""" - with pytest.raises(SystemExit): - main(["convert-dep5"], out=stringio) - - -def test_convert_dep5_no_warning(fake_repository_dep5, stringio): - """No PendingDeprecationWarning when running convert-dep5.""" - with warnings.catch_warnings(record=True) as caught_warnings: - result = main(["convert-dep5"], out=stringio) - assert result == 0 - assert not caught_warnings - - -# REUSE-IgnoreEnd diff --git a/tests/test_main_annotate.py b/tests/test_main_annotate.py deleted file mode 100644 index db184cf83..000000000 --- a/tests/test_main_annotate.py +++ /dev/null @@ -1,1577 +0,0 @@ -# SPDX-FileCopyrightText: 2019 Free Software Foundation Europe e.V. -# SPDX-FileCopyrightText: 2019 Stefan Bakker -# SPDX-FileCopyrightText: 2022 Carmen Bianca Bakker -# SPDX-FileCopyrightText: 2022 Florian Snow -# SPDX-FileCopyrightText: 2023 Maxim Cournoyer -# SPDX-FileCopyrightText: 2024 Rivos Inc. -# SPDX-FileCopyrightText: © 2020 Liferay, Inc. -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Tests for reuse._main: annotate""" -import logging -import stat -from inspect import cleandoc - -import pytest - -from reuse._main import main - -# pylint: disable=too-many-lines,unused-argument - - -# REUSE-IgnoreStart - - -# TODO: Replace this test with a monkeypatched test -def test_annotate_simple(fake_repository, stringio, mock_date_today): - """Add a header to a file that does not have one.""" - simple_file = fake_repository / "foo.py" - simple_file.write_text("pass") - expected = cleandoc( - """ - # SPDX-FileCopyrightText: 2018 Jane Doe - # - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_simple_scheme(fake_repository, stringio, mock_date_today): - "Add a header to a Scheme file." - simple_file = fake_repository / "foo.scm" - simple_file.write_text("#t") - expected = cleandoc( - """ - ;;; SPDX-FileCopyrightText: 2018 Jane Doe - ;;; - ;;; SPDX-License-Identifier: GPL-3.0-or-later - - #t - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "foo.scm", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_scheme_standardised( - fake_repository, stringio, mock_date_today -): - """The comment block is rewritten/standardised.""" - simple_file = fake_repository / "foo.scm" - simple_file.write_text( - cleandoc( - """ - ; SPDX-FileCopyrightText: 2018 Jane Doe - ; - ; SPDX-License-Identifier: GPL-3.0-or-later - - #t - """ - ) - ) - expected = cleandoc( - """ - ;;; SPDX-FileCopyrightText: 2018 Jane Doe - ;;; - ;;; SPDX-License-Identifier: GPL-3.0-or-later - - #t - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "foo.scm", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_scheme_standardised2( - fake_repository, stringio, mock_date_today -): - """The comment block is rewritten/standardised.""" - simple_file = fake_repository / "foo.scm" - simple_file.write_text( - cleandoc( - """ - ;; SPDX-FileCopyrightText: 2018 Jane Doe - ;; - ;; SPDX-License-Identifier: GPL-3.0-or-later - - #t - """ - ) - ) - expected = cleandoc( - """ - ;;; SPDX-FileCopyrightText: 2018 Jane Doe - ;;; - ;;; SPDX-License-Identifier: GPL-3.0-or-later - - #t - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "foo.scm", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_simple_no_replace(fake_repository, stringio, mock_date_today): - """Add a header to a file without replacing the existing header.""" - simple_file = fake_repository / "foo.py" - simple_file.write_text( - cleandoc( - """ - # SPDX-FileCopyrightText: 2017 John Doe - # - # SPDX-License-Identifier: MIT - - pass - """ - ) - ) - expected = cleandoc( - """ - # SPDX-FileCopyrightText: 2018 Jane Doe - # - # SPDX-License-Identifier: GPL-3.0-or-later - - # SPDX-FileCopyrightText: 2017 John Doe - # - # SPDX-License-Identifier: MIT - - pass - """ - ) - - result = main( - [ - "annotate", - "--no-replace", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_year(fake_repository, stringio): - """Add a header to a file with a custom year.""" - simple_file = fake_repository / "foo.py" - simple_file.write_text("pass") - expected = cleandoc( - """ - # SPDX-FileCopyrightText: 2016 Jane Doe - # - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - result = main( - [ - "annotate", - "--year", - "2016", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_no_year(fake_repository, stringio): - """Add a header to a file without a year.""" - simple_file = fake_repository / "foo.py" - simple_file.write_text("pass") - expected = cleandoc( - """ - # SPDX-FileCopyrightText: Jane Doe - # - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - result = main( - [ - "annotate", - "--exclude-year", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -@pytest.mark.parametrize( - "copyright_prefix", ["--copyright-prefix", "--copyright-style"] -) -def test_annotate_copyright_prefix( - fake_repository, copyright_prefix, stringio, mock_date_today -): - """Add a header with a specific copyright prefix. Also test the old name of - the parameter. - """ - simple_file = fake_repository / "foo.py" - simple_file.write_text("pass") - expected = cleandoc( - """ - # Copyright 2018 Jane Doe - # - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - copyright_prefix, - "string", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_shebang(fake_repository, stringio): - """Keep the shebang when annotating.""" - simple_file = fake_repository / "foo.py" - simple_file.write_text( - cleandoc( - """ - #!/usr/bin/env python3 - - pass - """ - ) - ) - expected = cleandoc( - """ - #!/usr/bin/env python3 - - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_shebang_wrong_comment_style(fake_repository, stringio): - """If a comment style does not support the shebang at the top, don't treat - the shebang as special. - """ - simple_file = fake_repository / "foo.html" - simple_file.write_text( - cleandoc( - """ - #!/usr/bin/env python3 - - pass - """ - ) - ) - expected = cleandoc( - """ - - - #!/usr/bin/env python3 - - pass - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "foo.html", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_contributors_only( - fake_repository, stringio, mock_date_today, contributors -): - """Add a header with only contributor information.""" - - if not contributors: - pytest.skip("No contributors to add") - - simple_file = fake_repository / "foo.py" - simple_file.write_text("pass") - content = [] - - for contributor in sorted(contributors): - content.append(f"# SPDX-FileContributor: {contributor}") - - content += ["", "pass"] - expected = cleandoc("\n".join(content)) - - args = [ - "annotate", - ] - for contributor in contributors: - args += ["--contributor", contributor] - args += ["foo.py"] - - result = main( - args, - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_contributors( - fake_repository, stringio, mock_date_today, contributors -): - """Add a header with contributor information.""" - simple_file = fake_repository / "foo.py" - simple_file.write_text("pass") - content = ["# SPDX-FileCopyrightText: 2018 Jane Doe"] - - if contributors: - for contributor in sorted(contributors): - content.append(f"# SPDX-FileContributor: {contributor}") - - content += ["#", "# SPDX-License-Identifier: GPL-3.0-or-later", "", "pass"] - expected = cleandoc("\n".join(content)) - - args = [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - ] - for contributor in contributors: - args += ["--contributor", contributor] - args += ["foo.py"] - - result = main( - args, - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_specify_style(fake_repository, stringio, mock_date_today): - """Add a header to a file that does not have one, using a custom style.""" - simple_file = fake_repository / "foo.py" - simple_file.write_text("pass") - expected = cleandoc( - """ - // SPDX-FileCopyrightText: 2018 Jane Doe - // - // SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--style", - "cpp", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_specify_style_unrecognised( - fake_repository, stringio, mock_date_today -): - """Add a header to a file that is unrecognised.""" - - simple_file = fake_repository / "hello.foo" - simple_file.touch() - expected = "# SPDX-FileCopyrightText: 2018 Jane Doe" - - result = main( - [ - "annotate", - "--copyright", - "Jane Doe", - "--style", - "python", - "hello.foo", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text().strip() == expected - - -def test_annotate_implicit_style(fake_repository, stringio, mock_date_today): - """Add a header to a file that has a recognised extension.""" - simple_file = fake_repository / "foo.js" - simple_file.write_text("pass") - expected = cleandoc( - """ - // SPDX-FileCopyrightText: 2018 Jane Doe - // - // SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "foo.js", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_implicit_style_filename( - fake_repository, stringio, mock_date_today -): - """Add a header to a filename that is recognised.""" - simple_file = fake_repository / "Makefile" - simple_file.write_text("pass") - expected = cleandoc( - """ - # SPDX-FileCopyrightText: 2018 Jane Doe - # - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "Makefile", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_unrecognised_style(fake_repository, capsys): - """Add a header to a file that has an unrecognised extension.""" - simple_file = fake_repository / "foo.foo" - simple_file.write_text("pass") - - with pytest.raises(SystemExit): - main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "foo.foo", - ], - ) - - stdout = capsys.readouterr().err - assert ( - "The following files do not have a recognised file extension" in stdout - ) - assert "foo.foo" in stdout - - -@pytest.mark.parametrize( - "skip_unrecognised", ["--skip-unrecognised", "--skip-unrecognized"] -) -def test_annotate_skip_unrecognised( - fake_repository, skip_unrecognised, stringio -): - """Skip file that has an unrecognised extension.""" - simple_file = fake_repository / "foo.foo" - simple_file.write_text("pass") - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - skip_unrecognised, - "foo.foo", - ], - out=stringio, - ) - - assert result == 0 - assert "Skipped unrecognised file 'foo.foo'" in stringio.getvalue() - - -def test_annotate_skip_unrecognised_and_style( - fake_repository, stringio, caplog -): - """--skip-unrecognised and --style show warning message.""" - simple_file = fake_repository / "foo.foo" - simple_file.write_text("pass") - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--style=c", - "--skip-unrecognised", - "foo.foo", - ], - out=stringio, - ) - - assert result == 0 - loglevel = logging.getLogger("reuse").level - if loglevel > logging.WARNING: - pytest.skip( - "Test needs LogLevel <= WARNING (e.g. WARNING, INFO, DEBUG)." - ) - else: - assert "no effect" in caplog.text - - -def test_annotate_no_copyright_or_license(fake_repository): - """Add a header, but supply no copyright or license.""" - simple_file = fake_repository / "foo.py" - simple_file.write_text("pass") - - with pytest.raises(SystemExit): - main(["annotate", "foo.py"]) - - -def test_annotate_template_simple( - fake_repository, stringio, mock_date_today, template_simple_source -): - """Add a header with a custom template.""" - simple_file = fake_repository / "foo.py" - simple_file.write_text("pass") - template_file = fake_repository / ".reuse/templates/mytemplate.jinja2" - template_file.parent.mkdir(parents=True, exist_ok=True) - template_file.write_text(template_simple_source) - expected = cleandoc( - """ - # Hello, world! - # - # SPDX-FileCopyrightText: 2018 Jane Doe - # - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--template", - "mytemplate.jinja2", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_template_simple_multiple( - fake_repository, stringio, mock_date_today, template_simple_source -): - """Add a header with a custom template to multiple files.""" - simple_files = [fake_repository / f"foo{i}.py" for i in range(10)] - for simple_file in simple_files: - simple_file.write_text("pass") - template_file = fake_repository / ".reuse/templates/mytemplate.jinja2" - template_file.parent.mkdir(parents=True, exist_ok=True) - template_file.write_text(template_simple_source) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--template", - "mytemplate.jinja2", - ] - + list(map(str, simple_files)), - out=stringio, - ) - - assert result == 0 - for simple_file in simple_files: - expected = cleandoc( - """ - # Hello, world! - # - # SPDX-FileCopyrightText: 2018 Jane Doe - # - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - assert simple_file.read_text() == expected - - -def test_annotate_template_no_spdx( - fake_repository, stringio, template_no_spdx_source -): - """Add a header with a template that lacks REUSE info.""" - simple_file = fake_repository / "foo.py" - simple_file.write_text("pass") - template_file = fake_repository / ".reuse/templates/mytemplate.jinja2" - template_file.parent.mkdir(parents=True, exist_ok=True) - template_file.write_text(template_no_spdx_source) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--template", - "mytemplate.jinja2", - "foo.py", - ], - out=stringio, - ) - - assert result == 1 - - -def test_annotate_template_commented( - fake_repository, stringio, mock_date_today, template_commented_source -): - """Add a header with a custom template that is already commented.""" - simple_file = fake_repository / "foo.c" - simple_file.write_text("pass") - template_file = ( - fake_repository / ".reuse/templates/mytemplate.commented.jinja2" - ) - template_file.parent.mkdir(parents=True, exist_ok=True) - template_file.write_text(template_commented_source) - expected = cleandoc( - """ - # Hello, world! - # - # SPDX-FileCopyrightText: 2018 Jane Doe - # - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--template", - "mytemplate.commented.jinja2", - "foo.c", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_template_nonexistant(fake_repository): - """Raise an error when using a header that does not exist.""" - - simple_file = fake_repository / "foo.py" - simple_file.write_text("pass") - - with pytest.raises(SystemExit): - main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--template", - "mytemplate.jinja2", - "foo.py", - ] - ) - - -def test_annotate_template_without_extension( - fake_repository, stringio, mock_date_today, template_simple_source -): - """Find the correct header even when not using an extension.""" - simple_file = fake_repository / "foo.py" - simple_file.write_text("pass") - template_file = fake_repository / ".reuse/templates/mytemplate.jinja2" - template_file.parent.mkdir(parents=True, exist_ok=True) - template_file.write_text(template_simple_source) - expected = cleandoc( - """ - # Hello, world! - # - # SPDX-FileCopyrightText: 2018 Jane Doe - # - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--template", - "mytemplate", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -def test_annotate_binary( - fake_repository, stringio, mock_date_today, binary_string -): - """Add a header to a .license file if the file is a binary.""" - binary_file = fake_repository / "foo.png" - binary_file.write_bytes(binary_string) - expected = cleandoc( - """ - SPDX-FileCopyrightText: 2018 Jane Doe - - SPDX-License-Identifier: GPL-3.0-or-later - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "foo.png", - ], - out=stringio, - ) - - assert result == 0 - assert ( - binary_file.with_name(f"{binary_file.name}.license").read_text().strip() - == expected - ) - - -def test_annotate_uncommentable_json( - fake_repository, stringio, mock_date_today -): - """Add a header to a .license file if the file is uncommentable, e.g., - JSON. - """ - json_file = fake_repository / "foo.json" - json_file.write_text('{"foo": 23, "bar": 42}') - expected = cleandoc( - """ - SPDX-FileCopyrightText: 2018 Jane Doe - - SPDX-License-Identifier: GPL-3.0-or-later - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "foo.json", - ], - out=stringio, - ) - - assert result == 0 - assert ( - json_file.with_name(f"{json_file.name}.license").read_text().strip() - == expected - ) - - -def test_annotate_fallback_dot_license( - fake_repository, stringio, mock_date_today -): - """Add a header to .license if --fallback-dot-license is given, and no style - yet exists. - """ - (fake_repository / "foo.py").write_text("Foo") - (fake_repository / "foo.foo").write_text("Foo") - - expected_py = cleandoc( - """ - # SPDX-FileCopyrightText: 2018 Jane Doe - # - # SPDX-License-Identifier: GPL-3.0-or-later - """ - ) - expected_foo = cleandoc( - """ - SPDX-FileCopyrightText: 2018 Jane Doe - - SPDX-License-Identifier: GPL-3.0-or-later - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--fallback-dot-license", - "foo.py", - "foo.foo", - ], - out=stringio, - ) - - assert result == 0 - assert expected_py in (fake_repository / "foo.py").read_text() - assert (fake_repository / "foo.foo.license").exists() - assert ( - fake_repository / "foo.foo.license" - ).read_text().strip() == expected_foo - assert ( - "'foo.foo' is not recognised; creating 'foo.foo.license'" - in stringio.getvalue() - ) - - -def test_annotate_force_dot_license(fake_repository, stringio, mock_date_today): - """Add a header to a .license file if --force-dot-license is given.""" - simple_file = fake_repository / "foo.py" - simple_file.write_text("pass") - expected = cleandoc( - """ - SPDX-FileCopyrightText: 2018 Jane Doe - - SPDX-License-Identifier: GPL-3.0-or-later - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--force-dot-license", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - assert ( - simple_file.with_name(f"{simple_file.name}.license").read_text().strip() - == expected - ) - assert simple_file.read_text() == "pass" - - -def test_annotate_force_dot_license_double( - fake_repository, stringio, mock_date_today -): - """When path.license already exists, don't create path.license.license.""" - simple_file = fake_repository / "foo.txt" - simple_file_license = fake_repository / "foo.txt.license" - simple_file_license_license = fake_repository / "foo.txt.license.license" - - simple_file.write_text("foo") - simple_file_license.write_text("foo") - expected = cleandoc( - """ - SPDX-FileCopyrightText: 2018 Jane Doe - - SPDX-License-Identifier: GPL-3.0-or-later - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--force-dot-license", - "foo.txt", - ], - out=stringio, - ) - - assert result == 0 - assert not simple_file_license_license.exists() - assert simple_file_license.read_text().strip() == expected - - -def test_annotate_force_dot_license_unsupported_filetype( - fake_repository, stringio, mock_date_today -): - """Add a header to a .license file if --force-dot-license is given, with the - base file being an otherwise unsupported filetype. - """ - simple_file = fake_repository / "foo.txt" - simple_file.write_text("Preserve this") - expected = cleandoc( - """ - SPDX-FileCopyrightText: 2018 Jane Doe - - SPDX-License-Identifier: GPL-3.0-or-later - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--force-dot-license", - "foo.txt", - ], - out=stringio, - ) - - assert result == 0 - assert ( - simple_file.with_name(f"{simple_file.name}.license").read_text().strip() - == expected - ) - assert simple_file.read_text() == "Preserve this" - - -def test_annotate_force_dot_license_doesnt_write_to_file( - fake_repository, stringio, mock_date_today -): - """Adding a header to a .license file if --force-dot-license is given, - doesn't require write permission to the file, just the directory. - """ - simple_file = fake_repository / "foo.txt" - simple_file.write_text("Preserve this") - simple_file.chmod(mode=stat.S_IREAD) - expected = cleandoc( - """ - SPDX-FileCopyrightText: 2018 Jane Doe - - SPDX-License-Identifier: GPL-3.0-or-later - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--force-dot-license", - "foo.txt", - ], - out=stringio, - ) - - assert result == 0 - assert ( - simple_file.with_name(f"{simple_file.name}.license").read_text().strip() - == expected - ) - assert simple_file.read_text() == "Preserve this" - - -def test_annotate_to_read_only_file_does_not_traceback( - fake_repository, stringio, mock_date_today -): - """Trying to add a header without having write permission, shouldn't result - in a traceback. See issue #398""" - _file = fake_repository / "test.sh" - _file.write_text("#!/bin/sh") - _file.chmod(mode=stat.S_IREAD) - with pytest.raises(SystemExit) as info: - main( - [ - "annotate", - "--license", - "Apache-2.0", - "--copyright", - "mycorp", - "--style", - "python", - "test.sh", - ] - ) - assert info.value # should not exit with 0 - - -def test_annotate_license_file(fake_repository, stringio, mock_date_today): - """Add a header to a .license file if it exists.""" - simple_file = fake_repository / "foo.py" - simple_file.write_text("foo") - license_file = fake_repository / "foo.py.license" - license_file.write_text( - cleandoc( - """ - SPDX-FileCopyrightText: 2016 John Doe - - Hello - """ - ) - ) - expected = ( - cleandoc( - """ - SPDX-FileCopyrightText: 2016 John Doe - SPDX-FileCopyrightText: 2018 Jane Doe - - SPDX-License-Identifier: GPL-3.0-or-later - """ - ) - + "\n" - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - assert license_file.read_text() == expected - assert simple_file.read_text() == "foo" - - -def test_annotate_license_file_only_one_newline( - fake_repository, stringio, mock_date_today -): - """When a header is added to a .license file that already ends with a - newline, the new header should end with a single newline. - """ - simple_file = fake_repository / "foo.py" - simple_file.write_text("foo") - license_file = fake_repository / "foo.py.license" - license_file.write_text( - cleandoc( - """ - SPDX-FileCopyrightText: 2016 John Doe - - Hello - """ - ) - + "\n" - ) - expected = ( - cleandoc( - """ - SPDX-FileCopyrightText: 2016 John Doe - SPDX-FileCopyrightText: 2018 Jane Doe - - SPDX-License-Identifier: GPL-3.0-or-later - """ - ) - + "\n" - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - assert license_file.read_text() == expected - assert simple_file.read_text() == "foo" - - -def test_annotate_year_mutually_exclusive(fake_repository): - """--exclude-year and --year are mutually exclusive.""" - with pytest.raises(SystemExit): - main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--exclude-year", - "--year", - "2020", - "src/source_code.py", - ] - ) - - -def test_annotate_single_multi_line_mutually_exclusive(fake_repository): - """--single-line and --multi-line are mutually exclusive.""" - with pytest.raises(SystemExit): - main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--single-line", - "--multi-line", - "src/source_code.c", - ] - ) - - -def test_annotate_skip_force_mutually_exclusive(fake_repository): - """--skip-unrecognised and --force-dot-license are mutually exclusive.""" - with pytest.raises(SystemExit): - main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--force-dot-license", - "--skip-unrecognised", - "src/source_code.py", - ] - ) - - -def test_annotate_multi_line_not_supported(fake_repository): - """Expect a fail if --multi-line is not supported for a file type.""" - with pytest.raises(SystemExit): - main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--multi-line", - "src/source_code.py", - ] - ) - - -def test_annotate_multi_line_not_supported_custom_style( - fake_repository, capsys -): - """--multi-line also fails when used with a style that doesn't support it - through --style. - """ - (fake_repository / "foo.foo").write_text("foo") - with pytest.raises(SystemExit): - main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--multi-line", - "--force-dot-license", - "--style", - "python", - "foo.foo", - ], - ) - - assert "'foo.foo' does not support multi-line" in capsys.readouterr().err - - -def test_annotate_single_line_not_supported(fake_repository): - """Expect a fail if --single-line is not supported for a file type.""" - with pytest.raises(SystemExit): - main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--single-line", - "src/source_code.html", - ] - ) - - -def test_annotate_force_multi_line_for_c( - fake_repository, stringio, mock_date_today -): - """--multi-line forces a multi-line comment for C.""" - simple_file = fake_repository / "foo.c" - simple_file.write_text("foo") - expected = cleandoc( - """ - /* - * SPDX-FileCopyrightText: 2018 Jane Doe - * - * SPDX-License-Identifier: GPL-3.0-or-later - */ - - foo - """ - ) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--multi-line", - "foo.c", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == expected - - -@pytest.mark.parametrize("line_ending", ["\r\n", "\r", "\n"]) -def test_annotate_line_endings( - empty_directory, stringio, mock_date_today, line_ending -): - """Given a file with a certain type of line ending, preserve it.""" - simple_file = empty_directory / "foo.py" - simple_file.write_bytes( - line_ending.encode("utf-8").join([b"hello", b"world"]) - ) - expected = cleandoc( - """ - # SPDX-FileCopyrightText: 2018 Jane Doe - # - # SPDX-License-Identifier: GPL-3.0-or-later - - hello - world - """ - ).replace("\n", line_ending) - - result = main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - with open(simple_file, newline="", encoding="utf-8") as fp: - contents = fp.read() - - assert contents == expected - - -def test_annotate_skip_existing(fake_repository, stringio, mock_date_today): - """When annotate --skip-existing on a file that already contains REUSE info, - don't write additional information to it. - """ - for path in ("foo.py", "bar.py"): - (fake_repository / path).write_text("pass") - expected_foo = cleandoc( - """ - # SPDX-FileCopyrightText: 2018 Jane Doe - # - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - expected_bar = cleandoc( - """ - # SPDX-FileCopyrightText: 2018 John Doe - # - # SPDX-License-Identifier: MIT - - pass - """ - ) - - main( - [ - "annotate", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "foo.py", - ], - out=stringio, - ) - - result = main( - [ - "annotate", - "--license", - "MIT", - "--copyright", - "John Doe", - "--skip-existing", - "foo.py", - "bar.py", - ] - ) - - assert result == 0 - assert (fake_repository / "foo.py").read_text() == expected_foo - assert (fake_repository / "bar.py").read_text() == expected_bar - - -def test_annotate_recursive(fake_repository, stringio, mock_date_today): - """Add a header to a directory recursively.""" - (fake_repository / "src/one/two").mkdir(parents=True) - (fake_repository / "src/one/two/foo.py").write_text( - cleandoc( - """ - # SPDX-License-Identifier: GPL-3.0-or-later - """ - ) - ) - (fake_repository / "src/hello.py").touch() - (fake_repository / "src/one/world.py").touch() - (fake_repository / "bar").mkdir(parents=True) - (fake_repository / "bar/bar.py").touch() - - result = main( - [ - "annotate", - "--copyright", - "Joe Somebody", - "--recursive", - "src/", - ], - out=stringio, - ) - - for path in (fake_repository / "src").rglob("src/**"): - content = path.read_text() - assert "SPDX-FileCopyrightText: 2018 Joe Somebody" in content - - assert "Joe Somebody" not in (fake_repository / "bar/bar.py").read_text() - assert result == 0 - - -def test_annotate_recursive_on_file(fake_repository, stringio, mock_date_today): - """Don't expect errors when annotate is run 'recursively' on a file.""" - result = main( - [ - "annotate", - "--copyright", - "Joe Somebody", - "--recursive", - "src/source_code.py", - ], - out=stringio, - ) - - assert ( - "Joe Somebody" in (fake_repository / "src/source_code.py").read_text() - ) - assert result == 0 - - -def test_annotate_exit_if_unrecognised( - fake_repository, stringio, mock_date_today -): - """Expect error and no edited files if at least one file has not been - recognised, with --exit-if-unrecognised enabled.""" - (fake_repository / "baz").mkdir(parents=True) - (fake_repository / "baz/foo.py").write_text("foo") - (fake_repository / "baz/bar.unknown").write_text("bar") - (fake_repository / "baz/baz.sh").write_text("baz") - - with pytest.raises(SystemExit): - main( - [ - "annotate", - "--license", - "Apache-2.0", - "--copyright", - "Jane Doe", - "--recursive", - "--exit-if-unrecognised", - "baz/", - ] - ) - - assert "Jane Doe" not in (fake_repository / "baz/foo.py").read_text() - - -# REUSE-IgnoreEnd diff --git a/tests/test_main_annotate_merge.py b/tests/test_main_annotate_merge.py deleted file mode 100644 index 9f60de082..000000000 --- a/tests/test_main_annotate_merge.py +++ /dev/null @@ -1,260 +0,0 @@ -# SPDX-FileCopyrightText: 2021 Liam Beguin -# SPDX-FileCopyrightText: 2024 Rivos Inc. -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Tests for reuse._main: annotate merge-copyrights option""" - -from inspect import cleandoc - -from reuse._main import main -from reuse._util import _COPYRIGHT_PREFIXES - -# pylint: disable=unused-argument - -# REUSE-IgnoreStart - - -def test_annotate_merge_copyrights_simple(fake_repository, stringio): - """Add multiple headers to a file with merge copyrights.""" - simple_file = fake_repository / "foo.py" - simple_file.write_text("pass") - - result = main( - [ - "annotate", - "--year", - "2016", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Mary Sue", - "--merge-copyrights", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == cleandoc( - """ - # SPDX-FileCopyrightText: 2016 Mary Sue - # - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - result = main( - [ - "annotate", - "--year", - "2018", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Mary Sue", - "--merge-copyrights", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == cleandoc( - """ - # SPDX-FileCopyrightText: 2016 - 2018 Mary Sue - # - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - -def test_annotate_merge_copyrights_multi_prefix(fake_repository, stringio): - """Add multiple headers to a file with merge copyrights.""" - simple_file = fake_repository / "foo.py" - simple_file.write_text("pass") - - for i in range(0, 3): - result = main( - [ - "annotate", - "--year", - str(2010 + i), - "--license", - "GPL-3.0-or-later", - "--copyright", - "Mary Sue", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - - for i in range(0, 5): - result = main( - [ - "annotate", - "--year", - str(2015 + i), - "--license", - "GPL-3.0-or-later", - "--copyright-prefix", - "string-c", - "--copyright", - "Mary Sue", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - - assert simple_file.read_text() == cleandoc( - """ - # Copyright (C) 2015 Mary Sue - # Copyright (C) 2016 Mary Sue - # Copyright (C) 2017 Mary Sue - # Copyright (C) 2018 Mary Sue - # Copyright (C) 2019 Mary Sue - # SPDX-FileCopyrightText: 2010 Mary Sue - # SPDX-FileCopyrightText: 2011 Mary Sue - # SPDX-FileCopyrightText: 2012 Mary Sue - # - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - result = main( - [ - "annotate", - "--year", - "2018", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Mary Sue", - "--merge-copyrights", - "foo.py", - ], - out=stringio, - ) - - assert result == 0 - assert simple_file.read_text() == cleandoc( - """ - # Copyright (C) 2010 - 2019 Mary Sue - # - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - -def test_annotate_merge_copyrights_no_year_in_existing( - fake_repository, stringio, mock_date_today -): - """This checks the issue reported in - . If an existing copyright - line doesn't have a year, everything should still work. - """ - (fake_repository / "foo.py").write_text( - cleandoc( - """ - # SPDX-FileCopyrightText: Jane Doe - """ - ) - ) - main( - [ - "annotate", - "--merge-copyrights", - "--copyright", - "John Doe", - "foo.py", - ] - ) - assert ( - cleandoc( - """ - # SPDX-FileCopyrightText: 2018 John Doe - # SPDX-FileCopyrightText: Jane Doe - """ - ) - in (fake_repository / "foo.py").read_text() - ) - - -def test_annotate_merge_copyrights_all_prefixes( - fake_repository, stringio, mock_date_today -): - """Test that merging works for all copyright prefixes.""" - # TODO: there should probably also be a test for mixing copyright prefixes, - # but this behaviour is really unpredictable to me at the moment, and the - # whole copyright-line-as-string thing needs overhauling. - simple_file = fake_repository / "foo.py" - for copyright_prefix, copyright_string in _COPYRIGHT_PREFIXES.items(): - simple_file.write_text("pass") - result = main( - [ - "annotate", - "--year", - "2016", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--copyright-style", - copyright_prefix, - "--merge-copyrights", - "foo.py", - ], - out=stringio, - ) - assert result == 0 - assert simple_file.read_text(encoding="utf-8") == cleandoc( - f""" - # {copyright_string} 2016 Jane Doe - # - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - result = main( - [ - "annotate", - "--year", - "2018", - "--license", - "GPL-3.0-or-later", - "--copyright", - "Jane Doe", - "--copyright-style", - copyright_prefix, - "--merge-copyrights", - "foo.py", - ], - out=stringio, - ) - assert result == 0 - assert simple_file.read_text(encoding="utf-8") == cleandoc( - f""" - # {copyright_string} 2016 - 2018 Jane Doe - # - # SPDX-License-Identifier: GPL-3.0-or-later - - pass - """ - ) - - -# REUSE-IgnoreEnd diff --git a/tests/test_util.py b/tests/test_util.py index a081b3d0f..e26445fe8 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -11,14 +11,11 @@ """Tests for reuse._util""" import os -from argparse import ArgumentTypeError from inspect import cleandoc from io import BytesIO -from pathlib import Path import pytest from boolean.boolean import ParseError -from conftest import no_root, posix from reuse import _util from reuse._util import _LICENSING @@ -489,138 +486,6 @@ def test_make_copyright_line_multine_error(): _util.make_copyright_line("hello\nworld") -# pylint: disable=unused-argument - - -def test_pathtype_read_simple(fake_repository): - """Get a Path to a readable file.""" - result = _util.PathType("r")("src/source_code.py") - - assert result == Path("src/source_code.py") - - -def test_pathtype_read_directory(fake_repository): - """Get a Path to a readable directory.""" - result = _util.PathType("r")("src") - - assert result == Path("src") - - -def test_pathtype_read_directory_force_file(fake_repository): - """Cannot read a directory when a file is forced.""" - with pytest.raises(ArgumentTypeError): - _util.PathType("r", force_file=True)("src") - - -@no_root -@posix -def test_pathtype_read_not_readable(fake_repository): - """Cannot read a nonreadable file.""" - try: - os.chmod("src/source_code.py", 0o000) - - with pytest.raises(ArgumentTypeError): - _util.PathType("r")("src/source_code.py") - finally: - os.chmod("src/source_code.py", 0o777) - - -def test_pathtype_read_not_exists(empty_directory): - """Cannot read a file that does not exist.""" - with pytest.raises(ArgumentTypeError): - _util.PathType("r")("foo.py") - - -def test_pathtype_read_write_not_exists(empty_directory): - """Cannot read/write a file that does not exist.""" - with pytest.raises(ArgumentTypeError): - _util.PathType("r+")("foo.py") - - -@no_root -@posix -def test_pathtype_read_write_only_write(empty_directory): - """A write-only file loaded with read/write needs both permissions.""" - path = Path("foo.py") - path.touch() - - try: - path.chmod(0o222) - - with pytest.raises(ArgumentTypeError): - _util.PathType("r+")("foo.py") - finally: - path.chmod(0o777) - - -@no_root -@posix -def test_pathtype_read_write_only_read(empty_directory): - """A read-only file loaded with read/write needs both permissions.""" - path = Path("foo.py") - path.touch() - - try: - path.chmod(0o444) - - with pytest.raises(ArgumentTypeError): - _util.PathType("r+")("foo.py") - finally: - path.chmod(0o777) - - -def test_pathtype_write_not_exists(empty_directory): - """Get a Path for a file that does not exist.""" - result = _util.PathType("w")("foo.py") - - assert result == Path("foo.py") - - -def test_pathtype_write_exists(fake_repository): - """Get a Path for a file that exists.""" - result = _util.PathType("w")("src/source_code.py") - - assert result == Path("src/source_code.py") - - -def test_pathtype_write_directory(fake_repository): - """Cannot write to directory.""" - with pytest.raises(ArgumentTypeError): - _util.PathType("w")("src") - - -@no_root -@posix -def test_pathtype_write_exists_but_not_writeable(fake_repository): - """Cannot get Path of file that exists but isn't writeable.""" - os.chmod("src/source_code.py", 0o000) - - with pytest.raises(ArgumentTypeError): - _util.PathType("w")("src/source_code.py") - - os.chmod("src/source_code.py", 0o777) - - -@no_root -@posix -def test_pathtype_write_not_exist_but_directory_not_writeable(fake_repository): - """Cannot get Path of file that does not exist but directory isn't - writeable. - """ - os.chmod("src", 0o000) - - with pytest.raises(ArgumentTypeError): - _util.PathType("w")("src/foo.py") - - os.chmod("src", 0o777) - - -def test_pathtype_invalid_mode(empty_directory): - """Only valid modes are 'r' and 'w'.""" - with pytest.raises(ValueError): - _util.PathType("o") - - def test_decoded_text_from_binary_simple(): """A unicode string encoded as bytes object decodes back correctly.""" text = "Hello, world ☺" @@ -642,22 +507,6 @@ def test_decoded_text_from_binary_crlf(): assert _util.decoded_text_from_binary(BytesIO(encoded)) == "Hello\nworld" -def test_similar_spdx_identifiers_typo(): - """Given a misspelt SPDX License Identifier, suggest a better one.""" - result = _util.similar_spdx_identifiers("GPL-3.0-or-lter") - - assert "GPL-3.0-or-later" in result - assert "AGPL-3.0-or-later" in result - assert "LGPL-3.0-or-later" in result - - -def test_similar_spdx_identifiers_prefix(): - """Given an incomplete SPDX License Identifier, suggest a better one.""" - result = _util.similar_spdx_identifiers("CC0") - - assert "CC0-1.0" in result - - def test_detect_line_endings_windows(): """Given a CRLF string, detect the line endings.""" assert _util.detect_line_endings("hello\r\nworld") == "\r\n"