diff --git a/.pylintrc b/.pylintrc index f71bd6ce..5d5ace11 100644 --- a/.pylintrc +++ b/.pylintrc @@ -11,7 +11,8 @@ jobs=0 disable=duplicate-code, logging-fstring-interpolation, - implicit-str-concat + implicit-str-concat, + inconsistent-quotes enable=useless-suppression [REPORTS] diff --git a/src/reuse/lint_file.py b/src/reuse/_lint_file.py similarity index 59% rename from src/reuse/lint_file.py rename to src/reuse/_lint_file.py index 00307697..ceeb0265 100644 --- a/src/reuse/lint_file.py +++ b/src/reuse/_lint_file.py @@ -9,11 +9,13 @@ import sys from argparse import ArgumentParser, Namespace from gettext import gettext as _ +from pathlib import Path from typing import IO -from .lint import format_json, format_lines, format_plain +from ._util import PathType +from .lint import format_lines_subset from .project import Project -from .report import ProjectReport +from .report import ProjectSubsetReport def add_arguments(parser: ArgumentParser) -> None: @@ -22,40 +24,34 @@ def add_arguments(parser: ArgumentParser) -> None: 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"), - ) mutex_group.add_argument( "-l", "--lines", action="store_true", - help=_("formats output as errors per line"), + help=_("formats output as errors per line (default)"), ) - parser.add_argument("files", nargs="*") + parser.add_argument("files", action="store", nargs="*", type=PathType("r")) def run(args: Namespace, project: Project, out: IO[str] = sys.stdout) -> int: """List all non-compliant files from specified file list.""" - report = ProjectReport.generate( + 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, - do_checksum=False, - file_list=args.files, + subset_files, 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)) + 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 index 6e52b474..37d13305 100644 --- a/src/reuse/_main.py +++ b/src/reuse/_main.py @@ -21,10 +21,10 @@ __REUSE_version__, __version__, _annotate, + _lint_file, convert_dep5, download, lint, - lint_file, spdx, supported_licenses, ) @@ -178,8 +178,8 @@ def parser() -> argparse.ArgumentParser: add_command( subparsers, "lint-file", - lint_file.add_arguments, - lint_file.run, + _lint_file.add_arguments, + _lint_file.run, help=_("list non-compliant files from specified list of files"), ) diff --git a/src/reuse/lint.py b/src/reuse/lint.py index 257d41b9..97277edf 100644 --- a/src/reuse/lint.py +++ b/src/reuse/lint.py @@ -20,7 +20,7 @@ from . import __REUSE_version__ from .project import Project -from .report import ProjectReport +from .report import ProjectReport, ProjectReportSubsetProtocol def add_arguments(parser: ArgumentParser) -> None: @@ -36,7 +36,7 @@ def add_arguments(parser: ArgumentParser) -> None: "-p", "--plain", action="store_true", - help=_("formats output as plain text"), + help=_("formats output as plain text (default)"), ) mutex_group.add_argument( "-l", @@ -264,13 +264,43 @@ def custom_serializer(obj: Any) -> Any: ) +def format_lines_subset(report: ProjectReportSubsetProtocol) -> str: + """Formats a subset of a report, namely missing licenses, read errors, files + without licenses, and files without copyright. + + Args: + report: A populated report. + """ + output = StringIO() + + # Missing licenses + for lic, files in sorted(report.missing_licenses.items()): + for path in sorted(files): + output.write( + _("{path}: missing license {lic}\n").format(path=path, lic=lic) + ) + + # Read errors + for path in sorted(report.read_errors): + output.write(_("{path}: read error\n").format(path=path)) + + # Without licenses + for path in report.files_without_licenses: + output.write(_("{path}: no license identifier\n").format(path=path)) + + # Without copyright + for path in report.files_without_copyright: + output.write(_("{path}: no copyright notice\n").format(path=path)) + + return output.getvalue() + + def format_lines(report: ProjectReport) -> str: - """Formats data dictionary as plaintext strings to be printed to sys.stdout - Sorting of output is not guaranteed. - Symbolic links can result in multiple entries per file. + """Formats report as plaintext strings to be printed to sys.stdout. Sorting + of output is not guaranteed. Args: - report: ProjectReport data + report: A populated report. Returns: String (in plaintext) that can be output to sys.stdout @@ -281,6 +311,7 @@ def license_path(lic: str) -> Optional[Path]: """Resolve a license identifier to a license path.""" return report.licenses.get(lic) + subset_output = "" if not report.is_compliant: # Bad licenses for lic, files in sorted(report.bad_licenses.items()): @@ -312,28 +343,10 @@ def license_path(lic: str) -> Optional[Path]: _("{lic_path}: unused license\n").format(lic_path=lic_path) ) - # Missing licenses - for lic, files in sorted(report.missing_licenses.items()): - for path in sorted(files): - output.write( - _("{path}: missing license {lic}\n").format( - path=path, lic=lic - ) - ) - - # Read errors - for path in sorted(report.read_errors): - output.write(_("{path}: read error\n").format(path=path)) - - # Without licenses - for path in report.files_without_licenses: - output.write(_("{path}: no license identifier\n").format(path=path)) - - # Without copyright - for path in report.files_without_copyright: - output.write(_("{path}: no copyright notice\n").format(path=path)) + # Everything else. + subset_output = format_lines_subset(report) - return output.getvalue() + return output.getvalue() + subset_output def run(args: Namespace, project: Project, out: IO[str] = sys.stdout) -> int: diff --git a/src/reuse/project.py b/src/reuse/project.py index a002320a..0585f85a 100644 --- a/src/reuse/project.py +++ b/src/reuse/project.py @@ -18,7 +18,18 @@ from collections import defaultdict from gettext import gettext as _ from pathlib import Path -from typing import DefaultDict, Dict, Iterator, List, NamedTuple, Optional, Type +from typing import ( + Collection, + DefaultDict, + Dict, + Iterator, + List, + NamedTuple, + Optional, + Set, + Type, + cast, +) from binaryornot.check import is_binary @@ -158,53 +169,19 @@ def from_directory( return project - def specific_files( - self, files: Optional[List], directory: Optional[StrPath] = None + def _iter_files( + self, + directory: Optional[StrPath] = None, + subset_files: Optional[Collection[StrPath]] = None, ) -> Iterator[Path]: - """Yield all files in the specified file list within a directory. - - The files that are not yielded are: - - - Files ignored by VCS (e.g., see .gitignore) - - - Files matching IGNORE_*_PATTERNS. - """ - if directory is None: - directory = self.root - directory = Path(directory) - - if files is not None: - # Filter files. - for file_ in files: - the_file = directory / file_ - if self._is_path_ignored(the_file): - _LOGGER.debug("ignoring '%s'", the_file) - continue - if the_file.is_symlink(): - _LOGGER.debug("skipping symlink '%s'", the_file) - continue - # Suppressing this error because I simply don't want to deal - # with that here. - with contextlib.suppress(OSError): - if the_file.stat().st_size == 0: - _LOGGER.debug("skipping 0-sized file '%s'", the_file) - continue - - _LOGGER.debug("yielding '%s'", the_file) - yield the_file - - def all_files(self, directory: Optional[StrPath] = None) -> Iterator[Path]: - """Yield all files in *directory* and its subdirectories. - - The files that are not yielded are: - - - Files ignored by VCS (e.g., see .gitignore) - - - Files/directories matching IGNORE_*_PATTERNS. - """ + # pylint: disable=too-many-branches if directory is None: directory = self.root directory = Path(directory) + if subset_files is not None: + subset_files = cast( + Set[Path], {Path(file_).resolve() for file_ in subset_files} + ) for root_str, dirs, files in os.walk(directory): root = Path(root_str) @@ -213,6 +190,11 @@ def all_files(self, directory: Optional[StrPath] = None) -> Iterator[Path]: # Don't walk ignored directories for dir_ in list(dirs): the_dir = root / dir_ + if subset_files is not None and not any( + file_.is_relative_to(the_dir.resolve()) + for file_ in subset_files + ): + continue if self._is_path_ignored(the_dir): _LOGGER.debug("ignoring '%s'", the_dir) dirs.remove(dir_) @@ -231,6 +213,11 @@ def all_files(self, directory: Optional[StrPath] = None) -> Iterator[Path]: # Filter files. for file_ in files: the_file = root / file_ + if ( + subset_files is not None + and the_file.resolve() not in subset_files + ): + continue if self._is_path_ignored(the_file): _LOGGER.debug("ignoring '%s'", the_file) continue @@ -247,6 +234,42 @@ def all_files(self, directory: Optional[StrPath] = None) -> Iterator[Path]: _LOGGER.debug("yielding '%s'", the_file) yield the_file + def all_files(self, directory: Optional[StrPath] = None) -> Iterator[Path]: + """Yield all files in *directory* and its subdirectories. + + The files that are not yielded are those explicitly ignored by the REUSE + Specification. That means: + + - LICENSE/COPYING files. + - VCS directories. + - .license files. + - .spdx files. + - Files ignored by VCS. + - Symlinks. + - Submodules (depending on the value of :attr:`include_submodules`). + - Meson subprojects (depending on the value of + :attr:`include_meson_subprojects`). + - 0-sized files. + + Args: + directory: The directory in which to search. + """ + return self._iter_files(directory=directory) + + def subset_files( + self, files: Collection[StrPath], directory: Optional[StrPath] = None + ) -> Iterator[Path]: + """Like :meth:`all_files`, but all files that are not in *files* are + filtered out. + + Args: + files: A collection of paths relative to the current working + directory. Any files that are not in this collection are not + yielded. + directory: The directory in which to search. + """ + return self._iter_files(directory=directory, subset_files=files) + def reuse_info_of(self, path: StrPath) -> List[ReuseInfo]: """Return REUSE info of *path*. diff --git a/src/reuse/report.py b/src/reuse/report.py index 508eb51c..e9267b28 100644 --- a/src/reuse/report.py +++ b/src/reuse/report.py @@ -23,13 +23,14 @@ from pathlib import Path, PurePath from typing import ( Any, + Collection, Dict, Iterable, List, NamedTuple, Optional, + Protocol, Set, - Union, cast, ) from uuid import uuid4 @@ -112,12 +113,81 @@ class _MultiprocessingResult(NamedTuple): error: Optional[Exception] +def _generate_file_reports( + project: Project, + do_checksum: bool = True, + subset_files: Optional[Collection[StrPath]] = None, + multiprocessing: bool = cpu_count() > 1, # type: ignore + add_license_concluded: bool = False, +) -> Iterable[_MultiprocessingResult]: + """Create a :class:`FileReport` for every file in the project, filtered + by *subset_files*. + """ + container = _MultiprocessingContainer( + project, do_checksum, add_license_concluded + ) + + files = ( + project.subset_files(subset_files) + if subset_files is not None + else project.all_files() + ) + if multiprocessing: + with mp.Pool() as pool: + results: Iterable[_MultiprocessingResult] = pool.map( + container, files + ) + pool.join() + else: + results = map(container, files) + return results + + +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, (OSError, UnicodeError)): + _LOGGER.error( + _("Could not read '{path}'").format(path=path), + exc_info=error, + ) + else: + _LOGGER.error( + _("Unexpected error occurred while parsing '{path}'").format( + path=path + ), + exc_info=error, + ) + + +class ProjectReportSubsetProtocol(Protocol): + """A :class:`Protocol` that defines a subset of functionality of + :class:`ProjectReport`, implemented by :class:`ProjectSubsetReport`. + """ + + path: StrPath + missing_licenses: Dict[str, Set[Path]] + read_errors: Set[Path] + file_reports: Set["FileReport"] + + @property + def files_without_licenses(self) -> Set[Path]: + """Set of paths that have no licensing information.""" + + @property + def files_without_copyright(self) -> Set[Path]: + """Set of paths that have no copyright information.""" + + @property + def is_compliant(self) -> bool: + """Whether the report subset is compliant with the REUSE Spec.""" + + class ProjectReport: # pylint: disable=too-many-instance-attributes """Object that holds linting report about the project.""" - def __init__( - self, do_checksum: bool = True, file_list: Optional[List[str]] = None - ): + def __init__(self, do_checksum: bool = True): self.path: StrPath = "" self.licenses: Dict[str, Path] = {} self.missing_licenses: Dict[str, Set[Path]] = {} @@ -128,7 +198,6 @@ def __init__( self.licenses_without_extension: Dict[str, Path] = {} self.do_checksum = do_checksum - self.file_list = file_list self._unused_licenses: Optional[Set[str]] = None self._used_licenses: Optional[Set[str]] = None @@ -286,48 +355,24 @@ def bill_of_materials( return out.getvalue() - @classmethod - def get_lint_results( - cls, - project: Project, - do_checksum: bool = True, - file_list: Optional[List[str]] = None, - multiprocessing: bool = cpu_count() > 1, # type: ignore - add_license_concluded: bool = False, - ) -> Union[list, Iterable[_MultiprocessingResult]]: - """Get lint results based on multiprocessing and file_list.""" - container = _MultiprocessingContainer( - project, do_checksum, add_license_concluded - ) - - # Iterate over specific file list if files are provided with - # `reuse lint-file`. Otherwise, lint all files. - iter_files = ( - project.specific_files(file_list) - if file_list - else project.all_files() - ) - if multiprocessing: - with mp.Pool() as pool: - results: Iterable[_MultiprocessingResult] = pool.map( - container, iter_files - ) - pool.join() - else: - results = map(container, iter_files) - - return results - @classmethod def generate( cls, project: Project, do_checksum: bool = True, - file_list: Optional[List[str]] = None, multiprocessing: bool = cpu_count() > 1, # type: ignore add_license_concluded: bool = False, ) -> "ProjectReport": - """Generate a ProjectReport from a Project.""" + """Generate a :class:`ProjectReport` from a :class:`Project`. + + Args: + project: The :class:`Project` to lint. + do_checksum: Generate a checksum of every file. If this is + :const:`False`, generate a random checksum for every file. + multiprocessing: Whether to use multiprocessing. + add_license_concluded: Whether to aggregate all found SPDX + expressions into a concluded license. + """ project_report = cls(do_checksum=do_checksum) project_report.path = project.root project_report.licenses = project.licenses @@ -335,32 +380,15 @@ def generate( project.licenses_without_extension ) - results = cls.get_lint_results( + results = _generate_file_reports( project, - do_checksum, - file_list, - multiprocessing, # type: ignore - add_license_concluded, + do_checksum=do_checksum, + multiprocessing=multiprocessing, + add_license_concluded=add_license_concluded, ) - for result in results: if result.error: - # Facilitate better debugging by being able to quit the program. - if isinstance(result.error, bdb.BdbQuit): - raise bdb.BdbQuit() from result.error - if isinstance(result.error, (OSError, UnicodeError)): - _LOGGER.error( - _("Could not read '{path}'").format(path=result.path), - exc_info=result.error, - ) - project_report.read_errors.add(Path(result.path)) - continue - _LOGGER.error( - _( - "Unexpected error occurred while parsing '{path}'" - ).format(path=result.path), - exc_info=result.error, - ) + _process_error(result.error, result.path) project_report.read_errors.add(Path(result.path)) continue @@ -554,6 +582,86 @@ def recommendations(self) -> List[str]: return recommendations +class ProjectSubsetReport: + """Like a :class:`ProjectReport`, but for a subset of the files using a + subset of features. + """ + + def __init__(self) -> None: + self.path: StrPath = "" + self.missing_licenses: Dict[str, Set[Path]] = {} + self.read_errors: Set[Path] = set() + self.file_reports: Set[FileReport] = set() + + @classmethod + def generate( + cls, + project: Project, + subset_files: Collection[StrPath], + multiprocessing: bool = cpu_count() > 1, # type: ignore + ) -> "ProjectSubsetReport": + """Generate a :class:`ProjectSubsetReport` from a :class:`Project`. + + Args: + project: The :class:`Project` to lint. + subset_files: Only lint the files in this list. + multiprocessing: Whether to use multiprocessing. + """ + subset_report = cls() + subset_report.path = project.root + results = _generate_file_reports( + project, + do_checksum=False, + subset_files=subset_files, + multiprocessing=multiprocessing, + add_license_concluded=False, + ) + for result in results: + if result.error: + _process_error(result.error, result.path) + subset_report.read_errors.add(Path(result.path)) + continue + + file_report = cast(FileReport, result.report) + subset_report.file_reports.add(file_report) + + for missing_license in file_report.missing_licenses: + subset_report.missing_licenses.setdefault( + missing_license, set() + ).add(file_report.path) + return subset_report + + @property + def files_without_licenses(self) -> Set[Path]: + """Set of paths that have no licensing information.""" + return { + file_report.path + for file_report in self.file_reports + if not file_report.licenses_in_file + } + + @property + def files_without_copyright(self) -> Set[Path]: + """Set of paths that have no copyright information.""" + return { + file_report.path + for file_report in self.file_reports + if not file_report.copyright + } + + @property + def is_compliant(self) -> bool: + """Whether the report subset is compliant with the REUSE Spec.""" + return not any( + ( + self.missing_licenses, + self.files_without_copyright, + self.files_without_licenses, + self.read_errors, + ) + ) + + class FileReport: # pylint: disable=too-many-instance-attributes """Object that holds a linting report about a single file.""" diff --git a/tests/test_lint.py b/tests/test_lint.py index 1b89084f..e680b82d 100644 --- a/tests/test_lint.py +++ b/tests/test_lint.py @@ -4,7 +4,7 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -"""All tests for reuse.lint and reuse.lint_files""" +"""All tests for reuse.lint.""" import re import shutil @@ -271,18 +271,4 @@ def test_lint_lines_read_errors(fake_repository): assert "read error" in result -def test_lint_specific_files(fake_repository): - """Check lint-file subcommand.""" - (fake_repository / "foo.py").write_text("foo") - (fake_repository / "bar.py").write_text("bar") - - project = Project.from_directory(fake_repository) - report = ProjectReport.generate(project, file_list=["foo.py"]) - result = format_plain(report) - - assert ":-(" in result - assert "# UNUSED LICENSES" in result - assert "bar.py" not in result - - # REUSE-IgnoreEnd diff --git a/tests/test_main.py b/tests/test_main.py index 461a15ca..d5d97733 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -367,6 +367,54 @@ def test_lint_no_multiprocessing(fake_repository, stringio, multiprocessing): 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.""" diff --git a/tests/test_project.py b/tests/test_project.py index fceba03e..70a5af7e 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -309,6 +309,77 @@ def test_all_files_pijul_ignored_contains_newline(pijul_repository): assert Path("hello\nworld.pyc").absolute() not in project.all_files() +class TestSubsetFiles: + """Tests for subset_files.""" + + def test_single(self, fake_repository): + """Only yield the single specified file.""" + project = Project.from_directory(fake_repository) + result = list(project.subset_files({fake_repository / "src/custom.py"})) + assert result == [fake_repository / "src/custom.py"] + + def test_two(self, fake_repository): + """Yield multiple specified files.""" + project = Project.from_directory(fake_repository) + result = list( + project.subset_files( + { + fake_repository / "src/custom.py", + fake_repository / "src/exception.py", + } + ) + ) + assert result == [ + fake_repository / "src/custom.py", + fake_repository / "src/exception.py", + ] + + def test_non_existent(self, fake_repository): + """If a file does not exist, don't yield it.""" + project = Project.from_directory(fake_repository) + result = list( + project.subset_files( + { + fake_repository / "src/custom.py", + fake_repository / "not_exist.py", + fake_repository / "also/does/not/exist.py", + } + ) + ) + assert result == [fake_repository / "src/custom.py"] + + def test_outside_cwd(self, fake_repository): + """If a file is outside of the project, don't yield it.""" + project = Project.from_directory(fake_repository) + result = list( + project.subset_files( + { + fake_repository / "src/custom.py", + (fake_repository / "../outside.py").resolve(), + } + ) + ) + assert result == [fake_repository / "src/custom.py"] + + def test_empty(self, fake_repository): + """If no files are provided, yield nothing.""" + project = Project.from_directory(fake_repository) + result = list(project.subset_files(set())) + assert not result + + def test_list_arg(self, fake_repository): + """Also accepts a list argument.""" + project = Project.from_directory(fake_repository) + result = list(project.subset_files([fake_repository / "src/custom.py"])) + assert result == [fake_repository / "src/custom.py"] + + def test_relative_path(self, fake_repository): + """Also handles relative paths.""" + project = Project.from_directory(fake_repository) + result = list(project.subset_files({"src/custom.py"})) + assert result == [fake_repository / "src/custom.py"] + + def test_reuse_info_of_file_does_not_exist(fake_repository): """Raise FileNotFoundError when asking for the REUSE info of a file that does not exist. diff --git a/tests/test_report.py b/tests/test_report.py index b1c01ed3..3afc9d95 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -17,7 +17,7 @@ from reuse import SourceType from reuse.project import Project -from reuse.report import FileReport, ProjectReport +from reuse.report import FileReport, ProjectReport, ProjectSubsetReport # REUSE-IgnoreStart @@ -278,9 +278,7 @@ def test_simple(self, fake_repository, multiprocessing): assert not result.read_errors assert result.file_reports - def test__licenses_without_extension( - self, fake_repository, multiprocessing - ): + def test_licenses_without_extension(self, fake_repository, multiprocessing): """Licenses without extension are detected.""" (fake_repository / "LICENSES/CC0-1.0.txt").rename( fake_repository / "LICENSES/CC0-1.0" @@ -478,6 +476,69 @@ def test_partial_info_in_toml(self, empty_directory, multiprocessing): assert file_report.licenses_in_file == ["0BSD"] +class TestProjectSubsetReport: + """Tests for ProjectSubsetReport.""" + + def test_simple(self, fake_repository, multiprocessing): + """Simple generate test.""" + project = Project.from_directory(fake_repository) + result = ProjectSubsetReport.generate( + project, + {fake_repository / "src/custom.py"}, + multiprocessing=multiprocessing, + ) + + assert not result.missing_licenses + assert not result.read_errors + assert not result.files_without_licenses + assert not result.files_without_copyright + assert len(result.file_reports) == 1 + + @cpython + @posix + def test_read_error(self, fake_repository, multiprocessing): + """Files that cannot be read are added to the read error list.""" + (fake_repository / "bad").write_text("foo") + (fake_repository / "bad").chmod(0o000) + + project = Project.from_directory(fake_repository) + result = ProjectSubsetReport.generate( + project, {fake_repository / "bad"}, multiprocessing=multiprocessing + ) + + # pylint: disable=superfluous-parens + assert (fake_repository / "bad") in result.read_errors + + def test_missing_license(self, fake_repository, multiprocessing): + """Missing licenses are detected.""" + (fake_repository / "LICENSES/GPL-3.0-or-later.txt").unlink() + + project = Project.from_directory(fake_repository) + result = ProjectSubsetReport.generate( + project, + {fake_repository / "src/exception.py"}, + multiprocessing=multiprocessing, + ) + + assert result.missing_licenses == { + "GPL-3.0-or-later": {fake_repository / "src/exception.py"} + } + + def test_missing_copyright_license(self, empty_directory, multiprocessing): + """Missing copyright and license is detected.""" + (empty_directory / "foo.py").write_text("foo") + project = Project.from_directory(empty_directory) + result = ProjectSubsetReport.generate( + project, + {empty_directory / "foo.py"}, + multiprocessing=multiprocessing, + ) + + # pylint: disable=superfluous-parens + assert (empty_directory / "foo.py") in result.files_without_copyright + assert (empty_directory / "foo.py") in result.files_without_licenses + + def test_bill_of_materials(fake_repository, multiprocessing): """Generate a bill of materials.""" project = Project.from_directory(fake_repository)