diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 7a056a1a5..d278c2598 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -20,12 +20,12 @@ jobs: # do not abort the whole test job if one combination in the matrix fails fail-fast: false matrix: - python-version: ["3.8", "3.12"] + python-version: ["3.9", "3.12"] os: [ubuntu-22.04] include: - - python-version: "3.8" + - python-version: "3.9" os: macos-latest - - python-version: "3.8" + - python-version: "3.9" os: windows-latest steps: @@ -49,7 +49,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | pip install poetry~=1.3.0 @@ -65,7 +65,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | pip install poetry~=1.3.0 @@ -82,7 +82,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | pip install poetry~=1.3.0 @@ -108,7 +108,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | pip install poetry~=1.3.0 @@ -123,7 +123,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | pip install poetry~=1.3.0 diff --git a/.github/workflows/third_party_lint.yaml b/.github/workflows/third_party_lint.yaml index 98abad12b..ed2a7c33c 100644 --- a/.github/workflows/third_party_lint.yaml +++ b/.github/workflows/third_party_lint.yaml @@ -33,7 +33,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | pip install poetry~=1.3.0 @@ -56,7 +56,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | pip install poetry~=1.3.0 diff --git a/.readthedocs.yaml b/.readthedocs.yaml index d43bc7d3d..d9a1eb19d 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,7 +8,7 @@ build: # This should mirror the configuration in ./.github/workflows/test.yaml os: ubuntu-22.04 tools: - python: "3.8" + python: "3.9" jobs: post_create_environment: - python -m pip install poetry diff --git a/README.md b/README.md index 66107ce92..151675b81 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ recommendations. - Source code: - PyPI: - REUSE: 3.3 -- Python: 3.8+ +- Python: 3.9+ ## Table of contents diff --git a/changelog.d/removed/python-3.8.md b/changelog.d/removed/python-3.8.md new file mode 100644 index 000000000..46bc7c986 --- /dev/null +++ b/changelog.d/removed/python-3.8.md @@ -0,0 +1 @@ +- Python 3.8 support removed. (#1080) diff --git a/poetry.lock b/poetry.lock index 728d880a5..f111b82d3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -59,9 +59,6 @@ files = [ {file = "babel-2.15.0.tar.gz", hash = "sha256:8daf0e265d05768bc6c7a314cf1321e9a123afc328cc635c18622a2f30a04413"}, ] -[package.dependencies] -pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} - [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] @@ -1312,18 +1309,6 @@ test = ["coverage", "flaky", "matplotlib", "numpy", "pandas", "pylint (>=3.1,<4) websockets = ["websockets (>=10.3)"] yapf = ["whatthepatch (>=1.0.2,<2.0.0)", "yapf (>=0.33.0)"] -[[package]] -name = "pytz" -version = "2024.1" -description = "World timezone definitions, modern and historical" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, - {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, -] - [[package]] name = "pyyaml" version = "6.0.1" @@ -1350,6 +1335,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -1812,5 +1798,5 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" -python-versions = "^3.8" -content-hash = "3f000ef7f191dd1e5648641ddfef4e469429e33e583299bbcac630bfd59ac10b" +python-versions = "^3.9" +content-hash = "be47ee3f7fe85c83152cadb5b1755beb125dc2766f19a10615c4eeae02d2349b" diff --git a/pyproject.toml b/pyproject.toml index 1f8e51d87..7c99550e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,12 +52,14 @@ classifiers = [ ] [tool.poetry.dependencies] -python = "^3.8" +python = "^3.9" Jinja2 = ">=3.0.0" binaryornot = ">=0.4.4" "boolean.py" = ">=3.8" license-expression = ">=1.0" python-debian = ">=0.1.34,!=0.1.45,!=0.1.46,!=0.1.47" +# TODO: Consider removing this dependency in favour of tomllib when dropping +# Python 3.10. tomlkit = ">=0.8" attrs = ">=21.3" diff --git a/src/reuse/__init__.py b/src/reuse/__init__.py index bc7474199..495f35ed6 100644 --- a/src/reuse/__init__.py +++ b/src/reuse/__init__.py @@ -16,6 +16,9 @@ change the public API. """ +# TODO: When Python 3.9 is dropped, consider using `type | None` instead of +# `Optional[type]`. + import gettext import logging import os @@ -23,7 +26,7 @@ from dataclasses import dataclass, field from enum import Enum from importlib.metadata import PackageNotFoundError, version -from typing import Any, Dict, NamedTuple, Optional, Set, Type +from typing import Any, Optional from boolean.boolean import Expression @@ -108,9 +111,9 @@ class SourceType(Enum): class ReuseInfo: """Simple dataclass holding licensing and copyright information""" - spdx_expressions: Set[Expression] = field(default_factory=set) - copyright_lines: Set[str] = field(default_factory=set) - contributor_lines: Set[str] = field(default_factory=set) + spdx_expressions: set[Expression] = field(default_factory=set) + copyright_lines: set[str] = field(default_factory=set) + contributor_lines: set[str] = field(default_factory=set) path: Optional[str] = None source_path: Optional[str] = None source_type: Optional[SourceType] = None @@ -134,10 +137,10 @@ def copy(self, **kwargs: Any) -> "ReuseInfo": return self.__class__(**new_kwargs) # type: ignore def union(self, value: "ReuseInfo") -> "ReuseInfo": - """Return a new instance of ReuseInfo where all Set attributes are equal + """Return a new instance of ReuseInfo where all set attributes are equal to the union of the set in *self* and the set in *value*. - All non-Set attributes are set to their values in *self*. + All non-set attributes are set to their values in *self*. >>> one = ReuseInfo(copyright_lines={"Jane Doe"}, source_path="foo.py") >>> two = ReuseInfo(copyright_lines={"John Doe"}, source_path="bar.py") diff --git a/src/reuse/_annotate.py b/src/reuse/_annotate.py index c45918554..40daec635 100644 --- a/src/reuse/_annotate.py +++ b/src/reuse/_annotate.py @@ -22,7 +22,7 @@ from argparse import SUPPRESS, ArgumentParser, Namespace from gettext import gettext as _ from pathlib import Path -from typing import IO, Iterable, Optional, Set, Tuple, Type, cast +from typing import IO, Iterable, Optional, Type, cast from binaryornot.check import is_binary from jinja2 import Environment, FileSystemLoader, Template @@ -232,13 +232,13 @@ def test_mandatory_option_required(args: Namespace) -> None: ) -def all_paths(args: Namespace, project: Project) -> Set[Path]: +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() + paths: set[Path] = set() all_files = [path.resolve() for path in project.all_files()] for path in args.path: if path.is_file(): @@ -256,7 +256,7 @@ def all_paths(args: Namespace, project: Project) -> Set[Path]: def get_template( args: Namespace, project: Project -) -> Tuple[Optional[Template], bool]: +) -> tuple[Optional[Template], bool]: """If a template is specified on the CLI, find and return it, including whether it is a 'commented' template. diff --git a/src/reuse/_licenses.py b/src/reuse/_licenses.py index 76ffc3827..e3c947b0f 100644 --- a/src/reuse/_licenses.py +++ b/src/reuse/_licenses.py @@ -11,7 +11,6 @@ import json import os -from typing import Dict, List, Tuple _BASE_DIR = os.path.dirname(__file__) _RESOURCES_DIR = os.path.join(_BASE_DIR, "resources") @@ -19,7 +18,7 @@ _EXCEPTIONS = os.path.join(_RESOURCES_DIR, "exceptions.json") -def _load_license_list(file_name: str) -> Tuple[List[int], Dict[str, Dict]]: +def _load_license_list(file_name: str) -> tuple[list[int], dict[str, dict]]: """Return the licenses list version tuple and a mapping of licenses id->name loaded from a JSON file from https://github.com/spdx/license-list-data @@ -34,7 +33,7 @@ def _load_license_list(file_name: str) -> Tuple[List[int], Dict[str, Dict]]: return version, licenses_map -def _load_exception_list(file_name: str) -> Tuple[List[int], Dict[str, Dict]]: +def _load_exception_list(file_name: str) -> tuple[list[int], dict[str, dict]]: """Return the exceptions list version tuple and a mapping of exceptions id->name loaded from a JSON file from https://github.com/spdx/license-list-data diff --git a/src/reuse/_lint_file.py b/src/reuse/_lint_file.py index 39e5cd469..2c8d53eb3 100644 --- a/src/reuse/_lint_file.py +++ b/src/reuse/_lint_file.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import IO -from ._util import PathType, is_relative_to +from ._util import PathType from .lint import format_lines_subset from .project import Project from .report import ProjectSubsetReport @@ -43,7 +43,7 @@ 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 is_relative_to(file_.resolve(), project.root.resolve()): + 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 diff --git a/src/reuse/_main.py b/src/reuse/_main.py index b5df4a7f7..d395f302d 100644 --- a/src/reuse/_main.py +++ b/src/reuse/_main.py @@ -15,7 +15,7 @@ import warnings from gettext import gettext as _ from pathlib import Path -from typing import IO, Callable, List, Optional, Type, cast +from typing import IO, Callable, Optional, Type, cast from . import ( __REUSE_version__, @@ -219,7 +219,7 @@ def add_command( # pylint: disable=too-many-arguments,redefined-builtin formatter_class: Optional[Type[argparse.HelpFormatter]] = None, description: Optional[str] = None, help: Optional[str] = None, - aliases: Optional[List[str]] = None, + aliases: Optional[list[str]] = None, ) -> None: """Add a subparser for a command.""" if formatter_class is None: @@ -236,10 +236,10 @@ def add_command( # pylint: disable=too-many-arguments,redefined-builtin subparser.set_defaults(parser=subparser) -def main(args: Optional[List[str]] = None, out: IO[str] = sys.stdout) -> int: +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:]) + args = cast(list[str], sys.argv[1:]) main_parser = parser() parsed_args = main_parser.parse_args(args) diff --git a/src/reuse/_util.py b/src/reuse/_util.py index 58de0d1f3..c906dd64e 100644 --- a/src/reuse/_util.py +++ b/src/reuse/_util.py @@ -14,7 +14,6 @@ """Misc. utilities for reuse.""" -import contextlib import logging import os import re @@ -29,20 +28,8 @@ from inspect import cleandoc from itertools import chain from os import PathLike -from pathlib import Path, PurePath -from typing import ( - IO, - Any, - BinaryIO, - Dict, - Iterator, - List, - Optional, - Set, - Type, - Union, - cast, -) +from pathlib import Path +from typing import IO, Any, BinaryIO, Iterator, Optional, Type, Union, cast from boolean.boolean import Expression, ParseError from license_expression import ExpressionError, Licensing @@ -57,8 +44,7 @@ _all_style_classes, ) -# TODO: When removing Python 3.8 support, use PathLike[str] -StrPath = Union[str, PathLike] +StrPath = Union[str, PathLike[str]] GIT_EXE = shutil.which("git") HG_EXE = shutil.which("hg") @@ -109,7 +95,7 @@ r"^(.*?)SPDX-FileContributor:[ \t]+(.*?)" + _END_PATTERN, re.MULTILINE ) # The keys match the relevant attributes of ReuseInfo. -_SPDX_TAGS: Dict[str, re.Pattern] = { +_SPDX_TAGS: dict[str, re.Pattern] = { "spdx_expressions": _LICENSE_IDENTIFIER_PATTERN, "contributor_lines": _CONTRIBUTOR_PATTERN, } @@ -171,7 +157,7 @@ def setup_logging(level: int = logging.WARNING) -> None: def execute_command( - command: List[str], + command: list[str], logger: logging.Logger, cwd: Optional[StrPath] = None, **kwargs: Any, @@ -251,9 +237,9 @@ def _determine_license_suffix_path(path: StrPath) -> Path: return Path(f"{path}.license") -def _parse_copyright_year(year: Optional[str]) -> List[str]: +def _parse_copyright_year(year: Optional[str]) -> list[str]: """Parse copyright years and return list.""" - ret: List[str] = [] + ret: list[str] = [] if not year: return ret if re.match(r"\d{4}$", year): @@ -295,7 +281,7 @@ def _has_style(path: Path) -> bool: return _get_comment_style(path) is not None -def merge_copyright_lines(copyright_lines: Set[str]) -> Set[str]: +def merge_copyright_lines(copyright_lines: set[str]) -> set[str]: """Parse all copyright lines and merge identical statements making years into a range. @@ -339,7 +325,7 @@ def merge_copyright_lines(copyright_lines: Set[str]) -> Set[str]: break # get year range if any - years: List[str] = [] + years: list[str] = [] for copy in copyright_list: years += copy["year"] @@ -362,7 +348,7 @@ def extract_reuse_info(text: str) -> ReuseInfo: ParseError: if an SPDX expression could not be parsed. """ text = filter_ignore_block(text) - spdx_tags: Dict[str, Set[str]] = {} + spdx_tags: dict[str, set[str]] = {} for tag, pattern in _SPDX_TAGS.items(): spdx_tags[tag] = set(find_spdx_tag(text, pattern)) # License expressions and copyright matches are special cases. @@ -605,9 +591,9 @@ def spdx_identifier(text: str) -> Expression: ) from error -def similar_spdx_identifiers(identifier: str) -> List[str]: +def similar_spdx_identifiers(identifier: str) -> list[str]: """Given an incorrect SPDX identifier, return a list of similar ones.""" - suggestions: List[str] = [] + suggestions: list[str] = [] if identifier in ALL_NON_DEPRECATED_MAP: return suggestions @@ -665,13 +651,4 @@ def cleandoc_nl(text: str) -> str: return cleandoc(text) + "\n" -def is_relative_to(path: PurePath, target: PurePath) -> bool: - """Like Path.is_relative_to, but working for Python <3.9.""" - # TODO: When Python 3.8 is dropped, remove this function. - with contextlib.suppress(ValueError): - path.relative_to(target) - return True - return False - - # REUSE-IgnoreEnd diff --git a/src/reuse/comment.py b/src/reuse/comment.py index ad7b4f2db..ac1253ab5 100644 --- a/src/reuse/comment.py +++ b/src/reuse/comment.py @@ -29,7 +29,7 @@ import operator import re from textwrap import dedent -from typing import List, NamedTuple, Optional, Type +from typing import NamedTuple, Optional, Type _LOGGER = logging.getLogger(__name__) @@ -65,7 +65,7 @@ class CommentStyle: INDENT_BEFORE_MIDDLE = "" INDENT_AFTER_MIDDLE = "" INDENT_BEFORE_END = "" - SHEBANGS: List[str] = [] + SHEBANGS: list[str] = [] @classmethod def can_handle_single(cls) -> bool: @@ -162,10 +162,9 @@ def _parse_comment_single(cls, text: str) -> str: result_lines = [] for line in text.splitlines(): - # TODO: When Python 3.8 is dropped, consider using str.removeprefix if cls.SINGLE_LINE_REGEXP: if match := cls.SINGLE_LINE_REGEXP.match(line): - line = line.lstrip(match.group(0)) + line = line.removeprefix(match.group(0)) result_lines.append(line) continue @@ -173,7 +172,7 @@ def _parse_comment_single(cls, text: str) -> str: raise CommentParseError( f"'{line}' does not start with a comment marker" ) - line = line.lstrip(cls.SINGLE_LINE) + line = line.removeprefix(cls.SINGLE_LINE) result_lines.append(line) result = "\n".join(result_lines) @@ -909,7 +908,7 @@ class XQueryCommentStyle(CommentStyle): } -def _all_style_classes() -> List[Type[CommentStyle]]: +def _all_style_classes() -> list[Type[CommentStyle]]: """Return a list of all defined style classes, excluding the base class.""" result = [] for key, value in globals().items(): diff --git a/src/reuse/convert_dep5.py b/src/reuse/convert_dep5.py index da6dca79f..b33a8b219 100644 --- a/src/reuse/convert_dep5.py +++ b/src/reuse/convert_dep5.py @@ -8,7 +8,7 @@ import sys from argparse import ArgumentParser, Namespace from gettext import gettext as _ -from typing import IO, Any, Dict, Iterable, List, Optional, TypeVar, Union, cast +from typing import IO, Any, Iterable, Optional, TypeVar, Union, cast import tomlkit from debian.copyright import Copyright, FilesParagraph, Header @@ -23,8 +23,8 @@ def _collapse_list_if_one_item( # Technically this should be Sequence[_T], but I can't get that to work. - sequence: List[_T], -) -> Union[List[_T], _T]: + sequence: list[_T], +) -> Union[list[_T], _T]: """Return the only item of the list if the length of the list is one, else return the list. """ @@ -35,8 +35,8 @@ def _collapse_list_if_one_item( def _header_from_dep5_header( header: Header, -) -> Dict[str, Union[str, List[str]]]: - result: Dict[str, Union[str, List[str]]] = {} +) -> dict[str, Union[str, list[str]]]: + result: dict[str, Union[str, list[str]]] = {} if header.upstream_name: result["SPDX-PackageName"] = str(header.upstream_name) if header.upstream_contact: @@ -52,7 +52,7 @@ def _header_from_dep5_header( def _copyrights_from_paragraph( paragraph: FilesParagraph, -) -> Union[str, List[str]]: +) -> Union[str, list[str]]: return _collapse_list_if_one_item( [line.strip() for line in cast(str, paragraph.copyright).splitlines()] ) @@ -65,7 +65,7 @@ def _convert_asterisk(path: str) -> str: return _SINGLE_ASTERISK_PATTERN.sub("**", path) -def _paths_from_paragraph(paragraph: FilesParagraph) -> Union[str, List[str]]: +def _paths_from_paragraph(paragraph: FilesParagraph) -> Union[str, list[str]]: return _collapse_list_if_one_item( [_convert_asterisk(path) for path in list(paragraph.files)] ) @@ -77,7 +77,7 @@ def _comment_from_paragraph(paragraph: FilesParagraph) -> Optional[str]: def _annotations_from_paragraphs( paragraphs: Iterable[FilesParagraph], -) -> List[Dict[str, Union[str, List[str]]]]: +) -> list[dict[str, Union[str, list[str]]]]: annotations = [] for paragraph in paragraphs: copyrights = _copyrights_from_paragraph(paragraph) @@ -99,7 +99,7 @@ def toml_from_dep5(dep5: Copyright) -> str: """Given a Copyright object, return an equivalent REUSE.toml string.""" header = _header_from_dep5_header(dep5.header) annotations = _annotations_from_paragraphs(dep5.all_files_paragraphs()) - result: Dict[str, Any] = {"version": REUSE_TOML_VERSION} + result: dict[str, Any] = {"version": REUSE_TOML_VERSION} result.update(header) result["annotations"] = annotations return tomlkit.dumps(result) diff --git a/src/reuse/covered_files.py b/src/reuse/covered_files.py index 753e643b9..549b331f7 100644 --- a/src/reuse/covered_files.py +++ b/src/reuse/covered_files.py @@ -12,14 +12,14 @@ import logging import os from pathlib import Path -from typing import Collection, Generator, Optional, Set, cast +from typing import Collection, Generator, Optional, cast from . import ( _IGNORE_DIR_PATTERNS, _IGNORE_FILE_PATTERNS, _IGNORE_MESON_PARENT_DIR_PATTERNS, ) -from ._util import StrPath, is_relative_to +from ._util import StrPath from .vcs import VCSStrategy _LOGGER = logging.getLogger(__name__) @@ -60,8 +60,7 @@ def is_path_ignored( elif path.is_dir(): if subset_files is not None and not any( - is_relative_to(Path(file_), path.resolve()) - for file_ in subset_files + Path(file_).is_relative_to(path.resolve()) for file_ in subset_files ): return True for pattern in _IGNORE_DIR_PATTERNS: @@ -102,7 +101,7 @@ def iter_files( directory = Path(directory) if subset_files is not None: subset_files = cast( - Set[Path], {Path(file_).resolve() for file_ in subset_files} + set[Path], {Path(file_).resolve() for file_ in subset_files} ) for root_str, dirs, files in os.walk(directory): diff --git a/src/reuse/global_licensing.py b/src/reuse/global_licensing.py index 18fef001f..f0b1b9b77 100644 --- a/src/reuse/global_licensing.py +++ b/src/reuse/global_licensing.py @@ -17,12 +17,8 @@ Any, Callable, Collection, - Dict, Generator, - List, Optional, - Set, - Tuple, Type, TypeVar, Union, @@ -39,7 +35,7 @@ from license_expression import ExpressionError from . import ReuseException, ReuseInfo, SourceType -from ._util import _LICENSING, StrPath, is_relative_to +from ._util import _LICENSING, StrPath from .covered_files import iter_files from .vcs import VCSStrategy @@ -198,18 +194,18 @@ def _str_to_global_precedence(value: Any) -> PrecedenceType: @overload -def _str_to_set(value: str) -> Set[str]: ... +def _str_to_set(value: str) -> set[str]: ... @overload -def _str_to_set(value: Union[None, _T, Collection[_T]]) -> Set[_T]: ... +def _str_to_set(value: Union[None, _T, Collection[_T]]) -> set[_T]: ... def _str_to_set( value: Union[str, None, _T, Collection[_T]] -) -> Union[Set[str], Set[_T]]: +) -> Union[set[str], set[_T]]: if value is None: - return cast(Set[str], set()) + return cast(set[str], set()) if isinstance(value, str): return {value} if hasattr(value, "__iter__"): @@ -217,7 +213,7 @@ def _str_to_set( return {value} -def _str_to_set_of_expr(value: Any) -> Set[Expression]: +def _str_to_set_of_expr(value: Any) -> set[Expression]: value = _str_to_set(value) result = set() for expression in value: @@ -255,7 +251,7 @@ def from_file(cls, path: StrPath, **kwargs: Any) -> "GlobalLicensing": @abstractmethod def reuse_info_of( self, path: StrPath - ) -> Dict[PrecedenceType, List[ReuseInfo]]: + ) -> dict[PrecedenceType, list[ReuseInfo]]: """Find the REUSE information of *path* defined in the configuration. The path must be relative to the root of a :class:`reuse.project.Project`. @@ -290,7 +286,7 @@ def from_file(cls, path: StrPath, **kwargs: Any) -> "ReuseDep5": def reuse_info_of( self, path: StrPath - ) -> Dict[PrecedenceType, List[ReuseInfo]]: + ) -> dict[PrecedenceType, list[ReuseInfo]]: path = PurePath(path).as_posix() result = self.dep5_copyright.find_files_paragraph(path) @@ -330,19 +326,19 @@ class AnnotationsItem: "spdx_expressions": "SPDX-License-Identifier", } - paths: Set[str] = attrs.field( + paths: set[str] = attrs.field( converter=_str_to_set, validator=_validate_collection_of(set, str, optional=False), ) precedence: PrecedenceType = attrs.field( converter=_str_to_global_precedence, default=PrecedenceType.CLOSEST ) - copyright_lines: Set[str] = attrs.field( + copyright_lines: set[str] = attrs.field( converter=_str_to_set, validator=_validate_collection_of(set, str, optional=True), default=None, ) - spdx_expressions: Set[Expression] = attrs.field( + spdx_expressions: set[Expression] = attrs.field( converter=_str_to_set_of_expr, validator=_validate_collection_of(set, Expression, optional=True), default=None, @@ -394,7 +390,7 @@ def translate(path: str) -> str: ) @classmethod - def from_dict(cls, values: Dict[str, Any]) -> "AnnotationsItem": + def from_dict(cls, values: dict[str, Any]) -> "AnnotationsItem": """Create an :class:`AnnotationsItem` from a dictionary that uses the key-value pairs for an [[annotations]] table in REUSE.toml. """ @@ -423,12 +419,12 @@ class ReuseTOML(GlobalLicensing): """A class that contains the data parsed from a REUSE.toml file.""" version: int = attrs.field(validator=_instance_of(int)) - annotations: List[AnnotationsItem] = attrs.field( + annotations: list[AnnotationsItem] = attrs.field( validator=_validate_collection_of(list, AnnotationsItem, optional=True) ) @classmethod - def from_dict(cls, values: Dict[str, Any], source: str) -> "ReuseTOML": + def from_dict(cls, values: dict[str, Any], source: str) -> "ReuseTOML": """Create a :class:`ReuseTOML` from the dict version of REUSE.toml.""" new_dict = {} new_dict["version"] = values.get("version") @@ -481,7 +477,7 @@ def find_annotations_item(self, path: StrPath) -> Optional[AnnotationsItem]: def reuse_info_of( self, path: StrPath - ) -> Dict[PrecedenceType, List[ReuseInfo]]: + ) -> dict[PrecedenceType, list[ReuseInfo]]: path = PurePath(path).as_posix() item = self.find_annotations_item(path) if item: @@ -508,7 +504,7 @@ def directory(self) -> PurePath: class NestedReuseTOML(GlobalLicensing): """A class that represents a hierarchy of :class:`ReuseTOML` objects.""" - reuse_tomls: List[ReuseTOML] = attrs.field() + reuse_tomls: list[ReuseTOML] = attrs.field() @classmethod def from_file(cls, path: StrPath, **kwargs: Any) -> "GlobalLicensing": @@ -531,10 +527,10 @@ def from_file(cls, path: StrPath, **kwargs: Any) -> "GlobalLicensing": def reuse_info_of( self, path: StrPath - ) -> Dict[PrecedenceType, List[ReuseInfo]]: + ) -> dict[PrecedenceType, list[ReuseInfo]]: path = PurePath(path) - toml_items: List[Tuple[ReuseTOML, AnnotationsItem]] = ( + toml_items: list[tuple[ReuseTOML, AnnotationsItem]] = ( self._find_relevant_tomls_and_items(path) ) @@ -564,7 +560,7 @@ def reuse_info_of( # Consider copyright and licensing separately. copyright_found = False licence_found = False - to_keep: List[ReuseInfo] = [] + to_keep: list[ReuseInfo] = [] for info in reversed(result[PrecedenceType.CLOSEST]): new_info = info.copy(copyright_lines=set(), spdx_expressions=set()) if not copyright_found and info.copyright_lines: @@ -604,11 +600,10 @@ def find_reuse_tomls( if item.name == "REUSE.toml" ) - def _find_relevant_tomls(self, path: StrPath) -> List[ReuseTOML]: + def _find_relevant_tomls(self, path: StrPath) -> list[ReuseTOML]: found = [] for toml in self.reuse_tomls: - # TODO: When Python 3.8 is dropped, use is_relative_to instead. - if is_relative_to(PurePath(path), toml.directory): + if PurePath(path).is_relative_to(toml.directory): found.append(toml) # Sort from topmost to deepest directory. found.sort(key=lambda toml: toml.directory.parts) @@ -616,7 +611,7 @@ def _find_relevant_tomls(self, path: StrPath) -> List[ReuseTOML]: def _find_relevant_tomls_and_items( self, path: StrPath - ) -> List[Tuple[ReuseTOML, AnnotationsItem]]: + ) -> list[tuple[ReuseTOML, AnnotationsItem]]: # *path* is relative to the Project root, which is the *source* of # NestedReuseTOML, which itself is a relative (to CWD) or absolute # path. @@ -624,7 +619,7 @@ def _find_relevant_tomls_and_items( adjusted_path = PurePath(self.source) / path tomls = self._find_relevant_tomls(adjusted_path) - toml_items: List[Tuple[ReuseTOML, AnnotationsItem]] = [] + toml_items: list[tuple[ReuseTOML, AnnotationsItem]] = [] for toml in tomls: relpath = adjusted_path.relative_to(toml.directory) item = toml.find_annotations_item(relpath) diff --git a/src/reuse/header.py b/src/reuse/header.py index b125e9464..bf7b9abb7 100644 --- a/src/reuse/header.py +++ b/src/reuse/header.py @@ -17,7 +17,7 @@ import logging import re from gettext import gettext as _ -from typing import NamedTuple, Optional, Sequence, Tuple, Type, cast +from typing import NamedTuple, Optional, Sequence, Type, cast from boolean.boolean import ParseError from jinja2 import Environment, PackageLoader, Template @@ -206,7 +206,7 @@ def _find_first_spdx_comment( raise MissingReuseInfo() -def _extract_shebang(prefix: str, text: str) -> Tuple[str, str]: +def _extract_shebang(prefix: str, text: str) -> tuple[str, str]: """Remove all lines that start with the shebang prefix from *text*. Return a tuple of (shebang, reduced_text). """ diff --git a/src/reuse/project.py b/src/reuse/project.py index 3f6148931..c4f12038c 100644 --- a/src/reuse/project.py +++ b/src/reuse/project.py @@ -17,16 +17,7 @@ from collections import defaultdict from gettext import gettext as _ from pathlib import Path -from typing import ( - Collection, - DefaultDict, - Dict, - Iterator, - List, - NamedTuple, - Optional, - Type, -) +from typing import Collection, Iterator, NamedTuple, Optional, Type import attrs from binaryornot.check import is_binary @@ -81,10 +72,10 @@ class Project: global_licensing: Optional[GlobalLicensing] = None # TODO: I want to get rid of these, or somehow refactor this mess. - license_map: Dict[str, Dict] = attrs.field() - licenses: Dict[str, Path] = attrs.field(factory=dict) + license_map: dict[str, dict] = attrs.field() + licenses: dict[str, Path] = attrs.field(factory=dict) - licenses_without_extension: Dict[str, Path] = attrs.field( + licenses_without_extension: dict[str, Path] = attrs.field( init=False, factory=dict ) @@ -93,7 +84,7 @@ def _default_vcs_strategy(self) -> VCSStrategy: return VCSStrategyNone(self.root) @license_map.default - def _default_license_map(self) -> Dict[str, Dict]: + def _default_license_map(self) -> dict[str, dict]: license_map = LICENSE_MAP.copy() license_map.update(EXCEPTION_MAP) return license_map @@ -218,7 +209,7 @@ def subset_files( vcs_strategy=self.vcs_strategy, ) - def reuse_info_of(self, path: StrPath) -> List[ReuseInfo]: + def reuse_info_of(self, path: StrPath) -> list[ReuseInfo]: """Return REUSE info of *path*. This function will return any REUSE information that it can find: from @@ -250,11 +241,11 @@ def reuse_info_of(self, path: StrPath) -> List[ReuseInfo]: # This means that only one 'source' of licensing/copyright information # is captured in ReuseInfo - global_results: "DefaultDict[PrecedenceType, List[ReuseInfo]]" = ( + global_results: defaultdict[PrecedenceType, list[ReuseInfo]] = ( defaultdict(list) ) file_result = ReuseInfo() - result: List[ReuseInfo] = [] + result: list[ReuseInfo] = [] # Search the global licensing file for REUSE information. if self.global_licensing: @@ -327,7 +318,7 @@ def find_global_licensing( include_submodules: bool = False, include_meson_subprojects: bool = False, vcs_strategy: Optional[VCSStrategy] = None, - ) -> List[GlobalLicensingFound]: + ) -> list[GlobalLicensingFound]: """Find the path and corresponding class of a project directory's :class:`GlobalLicensing`. @@ -335,7 +326,7 @@ def find_global_licensing( GlobalLicensingConflict: if more than one global licensing config file is present. """ - candidates: List[GlobalLicensingFound] = [] + candidates: list[GlobalLicensingFound] = [] dep5_path = root / ".reuse/dep5" if (dep5_path).exists(): # Sneaky workaround to not print this warning. @@ -377,7 +368,7 @@ def find_global_licensing( @classmethod def _global_licensing_from_found( - cls, found: List[GlobalLicensingFound], root: StrPath + cls, found: list[GlobalLicensingFound], root: StrPath ) -> GlobalLicensing: if len(found) == 1 and found[0].cls == ReuseDep5: return ReuseDep5.from_file(found[0].path) @@ -403,12 +394,12 @@ def _identifier_of_license(self, path: Path) -> str: f"Could not find SPDX License Identifier for {path}" ) - def _find_licenses(self) -> Dict[str, Path]: + def _find_licenses(self) -> dict[str, Path]: """Return a dictionary of all licenses in the project, with their SPDX identifiers as names and paths as values. """ # TODO: This method does more than one thing. We ought to simplify it. - license_files: Dict[str, Path] = {} + license_files: dict[str, Path] = {} directory = str(self.root / "LICENSES/**") for path_str in glob.iglob(directory, recursive=True): diff --git a/src/reuse/report.py b/src/reuse/report.py index 70032d0b3..fff28242b 100644 --- a/src/reuse/report.py +++ b/src/reuse/report.py @@ -24,13 +24,10 @@ from typing import ( Any, Collection, - Dict, Iterable, - List, NamedTuple, Optional, Protocol, - Set, cast, ) from uuid import uuid4 @@ -167,16 +164,16 @@ class ProjectReportSubsetProtocol(Protocol): """ path: StrPath - missing_licenses: Dict[str, Set[Path]] - read_errors: Set[Path] - file_reports: Set["FileReport"] + missing_licenses: dict[str, set[Path]] + read_errors: set[Path] + file_reports: set["FileReport"] @property - def files_without_licenses(self) -> Set[Path]: + def files_without_licenses(self) -> set[Path]: """Set of paths that have no licensing information.""" @property - def files_without_copyright(self) -> Set[Path]: + def files_without_copyright(self) -> set[Path]: """Set of paths that have no copyright information.""" @property @@ -189,23 +186,23 @@ class ProjectReport: # pylint: disable=too-many-instance-attributes def __init__(self, do_checksum: bool = True): self.path: StrPath = "" - self.licenses: Dict[str, Path] = {} - self.missing_licenses: Dict[str, Set[Path]] = {} - self.bad_licenses: Dict[str, Set[Path]] = {} - self.deprecated_licenses: Set[str] = set() - self.read_errors: Set[Path] = set() - self.file_reports: Set[FileReport] = set() - self.licenses_without_extension: Dict[str, Path] = {} + self.licenses: dict[str, Path] = {} + self.missing_licenses: dict[str, set[Path]] = {} + self.bad_licenses: dict[str, set[Path]] = {} + self.deprecated_licenses: set[str] = set() + self.read_errors: set[Path] = set() + self.file_reports: set[FileReport] = set() + self.licenses_without_extension: dict[str, Path] = {} self.do_checksum = do_checksum - self._unused_licenses: Optional[Set[str]] = None - self._used_licenses: Optional[Set[str]] = None - self._files_without_licenses: Optional[Set[Path]] = None - self._files_without_copyright: Optional[Set[Path]] = None + self._unused_licenses: Optional[set[str]] = None + self._used_licenses: Optional[set[str]] = None + self._files_without_licenses: Optional[set[Path]] = None + self._files_without_copyright: Optional[set[Path]] = None self._is_compliant: Optional[bool] = None - def to_dict_lint(self) -> Dict[str, Any]: + def to_dict_lint(self) -> dict[str, Any]: """Collects and formats data relevant to linting from report and returns it as a dictionary. @@ -213,7 +210,7 @@ def to_dict_lint(self) -> Dict[str, Any]: Dictionary containing data from the ProjectReport object. """ # Setup report data container - data: Dict[str, Any] = { + data: dict[str, Any] = { "non_compliant": { "missing_licenses": self.missing_licenses, "unused_licenses": [str(file) for file in self.unused_licenses], @@ -419,7 +416,7 @@ def generate( return project_report @property - def used_licenses(self) -> Set[str]: + def used_licenses(self) -> set[str]: """Set of license identifiers that are found in file reports.""" if self._used_licenses is not None: return self._used_licenses @@ -432,7 +429,7 @@ def used_licenses(self) -> Set[str]: return self._used_licenses @property - def unused_licenses(self) -> Set[str]: + def unused_licenses(self) -> set[str]: """Set of license identifiers that are not found in any file report.""" if self._unused_licenses is not None: return self._unused_licenses @@ -450,7 +447,7 @@ def unused_licenses(self) -> Set[str]: return self._unused_licenses @property - def files_without_licenses(self) -> Set[Path]: + def files_without_licenses(self) -> set[Path]: """Set of paths that have no licensing information.""" if self._files_without_licenses is not None: return self._files_without_licenses @@ -464,7 +461,7 @@ def files_without_licenses(self) -> Set[Path]: return self._files_without_licenses @property - def files_without_copyright(self) -> Set[Path]: + def files_without_copyright(self) -> set[Path]: """Set of paths that have no copyright information.""" if self._files_without_copyright is not None: return self._files_without_copyright @@ -499,7 +496,7 @@ def is_compliant(self) -> bool: return self._is_compliant @property - def recommendations(self) -> List[str]: + def recommendations(self) -> list[str]: """Generate help for next steps based on found REUSE issues""" recommendations = [] @@ -589,9 +586,9 @@ class ProjectSubsetReport: 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() + self.missing_licenses: dict[str, set[Path]] = {} + self.read_errors: set[Path] = set() + self.file_reports: set[FileReport] = set() @classmethod def generate( @@ -632,7 +629,7 @@ def generate( return subset_report @property - def files_without_licenses(self) -> Set[Path]: + def files_without_licenses(self) -> set[Path]: """Set of paths that have no licensing information.""" return { file_report.path @@ -641,7 +638,7 @@ def files_without_licenses(self) -> Set[Path]: } @property - def files_without_copyright(self) -> Set[Path]: + def files_without_copyright(self) -> set[Path]: """Set of paths that have no copyright information.""" return { file_report.path @@ -670,24 +667,22 @@ def __init__(self, name: str, path: StrPath, do_checksum: bool = True): self.path = Path(path) self.do_checksum = do_checksum - self.reuse_infos: List[ReuseInfo] = [] + self.reuse_infos: list[ReuseInfo] = [] self.spdx_id: Optional[str] = None self.chk_sum: Optional[str] = None - self.licenses_in_file: List[str] = [] + self.licenses_in_file: list[str] = [] self.license_concluded: str = "" self.copyright: str = "" - self.bad_licenses: Set[str] = set() - self.missing_licenses: Set[str] = set() + self.bad_licenses: set[str] = set() + self.missing_licenses: set[str] = set() - def to_dict_lint(self) -> Dict[str, Any]: + def to_dict_lint(self) -> dict[str, Any]: """Turn the report into a json-like dictionary with exclusively information relevant for linting. """ return { - # This gets rid of the './' prefix. In Python 3.9, use - # str.removeprefix. "path": PurePath(self.name).as_posix(), "copyrights": [ { diff --git a/src/reuse/vcs.py b/src/reuse/vcs.py index daca12da8..eaec9f7ac 100644 --- a/src/reuse/vcs.py +++ b/src/reuse/vcs.py @@ -15,7 +15,7 @@ from abc import ABC, abstractmethod from inspect import isclass from pathlib import Path -from typing import TYPE_CHECKING, Generator, Optional, Set, Type +from typing import TYPE_CHECKING, Generator, Optional, Type from ._util import ( GIT_EXE, @@ -99,7 +99,7 @@ def __init__(self, root: StrPath): self._all_ignored_files = self._find_all_ignored_files() self._submodules = self._find_submodules() - def _find_all_ignored_files(self) -> Set[Path]: + def _find_all_ignored_files(self) -> set[Path]: """Return a set of all files ignored by git. If a whole directory is ignored, don't return all files inside of it. """ @@ -121,7 +121,7 @@ def _find_all_ignored_files(self) -> Set[Path]: all_files = result.stdout.decode("utf-8").split("\0") return {Path(file_) for file_ in all_files} - def _find_submodules(self) -> Set[Path]: + def _find_submodules(self) -> set[Path]: command = [ str(self.EXE), "config", @@ -191,7 +191,7 @@ def __init__(self, root: StrPath): raise FileNotFoundError("Could not find binary for Mercurial") self._all_ignored_files = self._find_all_ignored_files() - def _find_all_ignored_files(self) -> Set[Path]: + def _find_all_ignored_files(self) -> set[Path]: """Return a set of all files ignored by mercurial. If a whole directory is ignored, don't return all files inside of it. """ @@ -258,7 +258,7 @@ def __init__(self, root: StrPath): raise FileNotFoundError("Could not find binary for Jujutsu") self._all_tracked_files = self._find_all_tracked_files() - def _find_all_tracked_files(self) -> Set[Path]: + def _find_all_tracked_files(self) -> set[Path]: """ Return a set of all files tracked in the current jj revision """ @@ -323,7 +323,7 @@ def __init__(self, root: StrPath): raise FileNotFoundError("Could not find binary for Pijul") self._all_tracked_files = self._find_all_tracked_files() - def _find_all_tracked_files(self) -> Set[Path]: + def _find_all_tracked_files(self) -> set[Path]: """Return a set of all files tracked by pijul.""" command = [str(self.EXE), "list"] result = execute_command(command, _LOGGER, cwd=self.root)