diff --git a/CHANGELOG.md b/CHANGELOG.md index 71342a74..19b371b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ CLI command and its behaviour. There are no guarantees of stability for the ### Added +- Implement handling LicenseRef in `download` and `init`. (#697) - Declared support for Python 3.12. (#846) - More file types are recognised: - Julia (`.jl`) (#815) diff --git a/src/reuse/_util.py b/src/reuse/_util.py index 2ef7ad04..d58cea38 100644 --- a/src/reuse/_util.py +++ b/src/reuse/_util.py @@ -138,6 +138,8 @@ "symbol": "©", } +_LICENSEREF_PATTERN = re.compile("LicenseRef-[a-zA-Z0-9-.]+$") + # Amount of bytes that we assume will be big enough to contain the entire # comment header (including SPDX tags), so that we don't need to read the # entire file. diff --git a/src/reuse/download.py b/src/reuse/download.py index 8bf46390..50590d14 100644 --- a/src/reuse/download.py +++ b/src/reuse/download.py @@ -7,17 +7,20 @@ import errno 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 +from typing import IO, 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, @@ -61,7 +64,11 @@ def _path_to_license_file(spdx_identifier: str, root: StrPath) -> Path: return licenses_path / "".join((spdx_identifier, ".txt")) -def put_license_in_file(spdx_identifier: str, destination: StrPath) -> None: +def put_license_in_file( + spdx_identifier: str, + destination: StrPath, + source: Optional[StrPath] = None, +) -> None: """Download a license and put it in the destination file. This function exists solely for convenience. @@ -69,22 +76,41 @@ def put_license_in_file(spdx_identifier: str, destination: StrPath) -> None: Args: spdx_identifier: SPDX License Identifier of the license. destination: Where to put the license. + source: Path to file or directory containing the text for LicenseRef + licenses. Raises: URLError: if the license could not be downloaded. FileExistsError: if the license file already exists. + FileNotFoundError: if the source could not be found in the directory. """ header = "" destination = Path(destination) destination.parent.mkdir(exist_ok=True) if destination.exists(): - raise FileExistsError(errno.EEXIST, "File exists", str(destination)) + raise FileExistsError( + errno.EEXIST, os.strerror(errno.EEXIST), str(destination) + ) - text = download_license(spdx_identifier) - with destination.open("w", encoding="utf-8") as fp: - fp.write(header) - fp.write(text) + # LicenseRef- license; don't download anything. + if _LICENSEREF_PATTERN.match(spdx_identifier): + if source: + source = Path(source) + if source.is_dir(): + source = source / f"{spdx_identifier}.txt" + if not source.exists(): + raise FileNotFoundError( + errno.ENOENT, os.strerror(errno.ENOENT), str(source) + ) + shutil.copyfile(source, destination) + else: + destination.touch() + else: + text = download_license(spdx_identifier) + with destination.open("w", encoding="utf-8") as fp: + fp.write(header) + fp.write(text) def add_arguments(parser: ArgumentParser) -> None: @@ -103,6 +129,15 @@ def add_arguments(parser: ArgumentParser) -> None: 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: @@ -116,6 +151,9 @@ def _already_exists(path: StrPath) -> None: ) 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(" ") @@ -156,13 +194,18 @@ def _successfully_downloaded(destination: StrPath) -> None: else: destination = _path_to_license_file(lic, project.root) try: - put_license_in_file(lic, destination=destination) + 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/init.py b/src/reuse/init.py index 78913576..90d2b940 100644 --- a/src/reuse/init.py +++ b/src/reuse/init.py @@ -4,6 +4,7 @@ """Functions for REUSE-ifying a project.""" +import re import sys from argparse import ArgumentParser, Namespace from gettext import gettext as _ @@ -13,7 +14,11 @@ from urllib.error import URLError from ._licenses import ALL_NON_DEPRECATED_MAP -from ._util import PathType, print_incorrect_spdx_identifier +from ._util import ( + _LICENSEREF_PATTERN, + PathType, + print_incorrect_spdx_identifier, +) from .download import _path_to_license_file, put_license_in_file from .project import Project from .vcs import find_root @@ -44,7 +49,9 @@ def prompt_licenses(out: IO[str] = sys.stdout) -> List[str]: out.write("\n") if not result: return licenses - if result not in ALL_NON_DEPRECATED_MAP: + if result not in ALL_NON_DEPRECATED_MAP and not re.match( + _LICENSEREF_PATTERN, result + ): print_incorrect_spdx_identifier(result, out=out) out.write("\n\n") else: @@ -116,8 +123,9 @@ def run( for lic in licenses: destination = _path_to_license_file(lic, root=root) + try: - out.write(_("Downloading {}").format(lic)) + out.write(_("Retrieving {}").format(lic)) out.write("\n") put_license_in_file(lic, destination=destination) # TODO: exceptions @@ -127,6 +135,14 @@ def run( except URLError: out.write(_("Could not download {}").format(lic)) out.write("\n") + except FileNotFoundError as err: + out.write( + _( + "Error: Could not copy {path}, " + "please add {lic}.txt manually in the LICENCES/ directory." + ).format(path=err.filename, lic=lic) + ) + out.write("\n") out.write("\n") diff --git a/src/reuse/project.py b/src/reuse/project.py index e7d7cd25..356e0175 100644 --- a/src/reuse/project.py +++ b/src/reuse/project.py @@ -32,6 +32,7 @@ from ._licenses import EXCEPTION_MAP, LICENSE_MAP from ._util import ( _HEADER_BYTES, + _LICENSEREF_PATTERN, GIT_EXE, HG_EXE, StrPath, @@ -304,7 +305,7 @@ def _identifier_of_license(self, path: Path) -> str: raise IdentifierNotFound(f"{path} has no file extension") if path.stem in self.license_map: return path.stem - if path.stem.startswith("LicenseRef-"): + if _LICENSEREF_PATTERN.match(path.stem): return path.stem raise IdentifierNotFound( @@ -396,7 +397,7 @@ def _licenses(self) -> Dict[str, Path]: # Add the identifiers license_files[identifier] = path if ( - identifier.startswith("LicenseRef-") + _LICENSEREF_PATTERN.match(identifier) and "Unknown" not in identifier ): self.license_map[identifier] = { diff --git a/src/reuse/report.py b/src/reuse/report.py index 20523521..bb123b02 100644 --- a/src/reuse/report.py +++ b/src/reuse/report.py @@ -21,7 +21,7 @@ from uuid import uuid4 from . import __REUSE_version__, __version__ -from ._util import _LICENSING, StrPath, _checksum +from ._util import _LICENSEREF_PATTERN, _LICENSING, StrPath, _checksum from .project import Project, ReuseInfo _LOGGER = logging.getLogger(__name__) @@ -226,7 +226,7 @@ def bill_of_materials( # Licenses for lic, path in sorted(self.licenses.items()): - if lic.startswith("LicenseRef-"): + if _LICENSEREF_PATTERN.match(lic): out.write("\n") out.write(f"LicenseID: {lic}\n") out.write("LicenseName: NOASSERTION\n") diff --git a/tests/test_download.py b/tests/test_download.py index 44e9dc69..e5eb310b 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -104,3 +104,65 @@ def test_put_empty_dir(empty_directory, monkeypatch): assert (empty_directory / "LICENSES").exists() assert (empty_directory / "LICENSES/0BSD.txt").read_text() == "hello\n" + + +def test_put_custom_without_source(fake_repository): + """When 'downloading' a LicenseRef license without source, create an empty + file. + """ + put_license_in_file("LicenseRef-hello", "LICENSES/LicenseRef-hello.txt") + + assert (fake_repository / "LICENSES/LicenseRef-hello.txt").exists() + assert (fake_repository / "LICENSES/LicenseRef-hello.txt").read_text() == "" + + +def test_put_custom_with_source(fake_repository): + """When 'downloading' a LicenseRef license with source file, copy the source + text. + """ + (fake_repository / "foo.txt").write_text("foo") + + put_license_in_file( + "LicenseRef-hello", + "LICENSES/LicenseRef-hello.txt", + source=fake_repository / "foo.txt", + ) + + assert (fake_repository / "LICENSES/LicenseRef-hello.txt").exists() + assert ( + fake_repository / "LICENSES/LicenseRef-hello.txt" + ).read_text() == "foo" + + +def test_put_custom_with_source_dir(fake_repository): + """When 'downloading' a LicenseRef license with source directory, copy the + source text from a matching file in the directory. + """ + (fake_repository / "lics").mkdir() + (fake_repository / "lics/LicenseRef-hello.txt").write_text("foo") + + put_license_in_file( + "LicenseRef-hello", + "LICENSES/LicenseRef-hello.txt", + source=fake_repository / "lics", + ) + + assert (fake_repository / "LICENSES/LicenseRef-hello.txt").exists() + assert ( + fake_repository / "LICENSES/LicenseRef-hello.txt" + ).read_text() == "foo" + + +def test_put_custom_with_false_source_dir(fake_repository): + """When 'downloading' a LicenseRef license with source directory, but the + source directory does not contain the license, expect a FileNotFoundError. + """ + (fake_repository / "lics").mkdir() + + with pytest.raises(FileNotFoundError) as exc_info: + put_license_in_file( + "LicenseRef-hello", + "LICENSES/LicenseRef-hello.txt", + source=fake_repository / "lics", + ) + assert exc_info.value.filename.endswith("lics/LicenseRef-hello.txt") diff --git a/tests/test_main.py b/tests/test_main.py index 342b4b6c..cd5f78de 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -385,7 +385,7 @@ def test_download(fake_repository, stringio, mock_put_license_in_file): assert result == 0 mock_put_license_in_file.assert_called_with( - "0BSD", Path("LICENSES/0BSD.txt").resolve() + "0BSD", Path("LICENSES/0BSD.txt").resolve(), source=None ) @@ -434,7 +434,9 @@ def test_download_custom_output( result = main(["download", "-o", "foo", "0BSD"], out=stringio) assert result == 0 - mock_put_license_in_file.assert_called_with("0BSD", destination=Path("foo")) + mock_put_license_in_file.assert_called_with( + "0BSD", destination=Path("foo"), source=None + ) def test_download_custom_output_too_many( @@ -449,6 +451,49 @@ def test_download_custom_output_too_many( ) +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 "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.