From d026bcfa52b1f055f03f49db9141a7457bfd122b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Avil=C3=A9s?= Date: Tue, 19 Sep 2023 03:45:03 +0200 Subject: [PATCH] Fix mypy errors --- src/unbeheader/__init__.py | 56 ++++++++++++++++------------------- src/unbeheader/cli.py | 22 +++++++------- src/unbeheader/config.py | 10 ++++--- src/unbeheader/headers.py | 21 ++++++++----- src/unbeheader/typing.py | 27 +++++++++++++++++ src/unbeheader/util.py | 7 +++-- stubs/colorclass/__init__.pyi | 6 ++++ tests/test_headers.py | 16 +++++----- 8 files changed, 101 insertions(+), 64 deletions(-) create mode 100644 src/unbeheader/typing.py create mode 100644 stubs/colorclass/__init__.pyi diff --git a/src/unbeheader/__init__.py b/src/unbeheader/__init__.py index 9014008..b1f586a 100644 --- a/src/unbeheader/__init__.py +++ b/src/unbeheader/__init__.py @@ -3,35 +3,29 @@ import re -# Dictionary listing the files for which to change the header. -# The key is the extension of the file (without the dot) and the value is another -# dictionary containing two keys: -# - 'regex' : A regular expression matching comments in the given file type -# - 'comments': A dictionary with the comment characters to add to the header. -# There must be a `comment_start` inserted before the header, -# `comment_middle` inserted at the beginning of each line except the -# first and last one, and `comment_end` inserted at the end of the -# header. -SUPPORTED_FILES = { - 'py': { - 'regex': re.compile(r'((^#|[\r\n]#).*)*'), - 'comments': {'comment_start': '#', 'comment_middle': '#', 'comment_end': ''}}, - 'wsgi': { - 'regex': re.compile(r'((^#|[\r\n]#).*)*'), - 'comments': {'comment_start': '#', 'comment_middle': '#', 'comment_end': ''}}, - 'js': { - 'regex': re.compile(r'/\*(.|[\r\n])*?\*/|((^//|[\r\n]//).*)*'), - 'comments': {'comment_start': '//', 'comment_middle': '//', 'comment_end': ''}}, - 'jsx': { - 'regex': re.compile(r'/\*(.|[\r\n])*?\*/|((^//|[\r\n]//).*)*'), - 'comments': {'comment_start': '//', 'comment_middle': '//', 'comment_end': ''}}, - 'css': { - 'regex': re.compile(r'/\*(.|[\r\n])*?\*/'), - 'comments': {'comment_start': '/*', 'comment_middle': ' *', 'comment_end': ' */'}}, - 'scss': { - 'regex': re.compile(r'/\*(.|[\r\n])*?\*/|((^//|[\r\n]//).*)*'), - 'comments': {'comment_start': '//', 'comment_middle': '//', 'comment_end': ''}}, - 'sh': { - 'regex': re.compile(r'((^#|[\r\n]#).*)*'), - 'comments': {'comment_start': '#', 'comment_middle': '#', 'comment_end': ''}}, +from .typing import CommentSkeleton +from .typing import SupportedFileType + +SUPPORTED_FILE_TYPES: dict[str, SupportedFileType] = { + 'py': SupportedFileType( + re.compile(r'((^#|[\r\n]#).*)*'), + CommentSkeleton(comment_start='#', comment_middle='#', comment_end='')), + 'wsgi': SupportedFileType( + re.compile(r'((^#|[\r\n]#).*)*'), + CommentSkeleton(comment_start='#', comment_middle='#', comment_end='')), + 'js': SupportedFileType( + re.compile(r'/\*(.|[\r\n])*?\*/|((^//|[\r\n]//).*)*'), + CommentSkeleton(comment_start='//', comment_middle='//', comment_end='')), + 'jsx': SupportedFileType( + re.compile(r'/\*(.|[\r\n])*?\*/|((^//|[\r\n]//).*)*'), + CommentSkeleton(comment_start='//', comment_middle='//', comment_end='')), + 'css': SupportedFileType( + re.compile(r'/\*(.|[\r\n])*?\*/'), + CommentSkeleton(comment_start='/*', comment_middle=' *', comment_end=' */')), + 'scss': SupportedFileType( + re.compile(r'/\*(.|[\r\n])*?\*/|((^//|[\r\n]//).*)*'), + CommentSkeleton(comment_start='//', comment_middle='//', comment_end='')), + 'sh': SupportedFileType( + re.compile(r'((^#|[\r\n]#).*)*'), + CommentSkeleton(comment_start='#', comment_middle='#', comment_end='')), } diff --git a/src/unbeheader/cli.py b/src/unbeheader/cli.py index 074ff0f..0a8e9c6 100644 --- a/src/unbeheader/cli.py +++ b/src/unbeheader/cli.py @@ -9,13 +9,13 @@ import click from click import UsageError -from . import SUPPORTED_FILES +from . import SUPPORTED_FILE_TYPES from .headers import update_header from .util import cformat from .util import is_excluded USAGE = ''' -Updates all the headers in the supported files ({supported_files}). +Updates all the headers in the supported files ({supported_file_types}). By default, all the files tracked by git in the current repository are updated to the current year. @@ -23,7 +23,7 @@ This will update all the supported files in the scope including those not tracked by git. If the directory does not contain any supported files (or if the file specified is not supported) nothing will be updated. -'''.format(supported_files=', '.join(SUPPORTED_FILES)).strip() +'''.format(supported_file_types=', '.join(SUPPORTED_FILE_TYPES)).strip() @click.command(help=USAGE) @@ -33,12 +33,12 @@ @click.option('--year', '-y', type=click.IntRange(min=1000), default=date.today().year, metavar='YEAR', help='Indicate the target year') @click.option('--path', '-p', type=click.Path(exists=True), help='Restrict updates to a specific file or directory') -def main(check: bool, year: int, path: str): - path = Path(path).resolve() if path else None - if path and path.is_dir(): - error = _run_on_directory(path, year, check) - elif path and path.is_file(): - error = _run_on_file(path, year, check) +def main(check: bool, year: int, path: str) -> None: + _path = Path(path).resolve() if path else None + if _path and _path.is_dir(): + error = _run_on_directory(_path, year, check) + elif _path and _path.is_file(): + error = _run_on_file(_path, year, check) else: error = _run_on_repo(year, check) if not error: @@ -89,8 +89,8 @@ def _run_on_repo(year: int, check: bool) -> bool: git_file_paths |= set(subprocess.check_output(cmd + untracked_flags, text=True).splitlines()) # Exclude deleted files git_file_paths -= set(subprocess.check_output(cmd + deleted_flags, text=True).splitlines()) - for file_path in git_file_paths: - file_path = Path(file_path).absolute() + for file_path_str in git_file_paths: + file_path = Path(file_path_str).absolute() if not is_excluded(file_path.parent, Path.cwd()): if update_header(file_path, year, check): error = True diff --git a/src/unbeheader/config.py b/src/unbeheader/config.py index ded287a..cd9c1d9 100644 --- a/src/unbeheader/config.py +++ b/src/unbeheader/config.py @@ -8,6 +8,8 @@ import click import yaml +from .typing import ConfigDict + # The substring which must be part of a comment block in order for the comment to be updated by the header DEFAULT_SUBSTRING = 'This file is part of' @@ -15,7 +17,7 @@ CONFIG_FILE_NAME = '.header.yaml' -def get_config(path: Path, end_year: int) -> dict: +def get_config(path: Path, end_year: int) -> ConfigDict: """Get configuration from headers files.""" config = _load_config(path) _validate_config(config) @@ -24,8 +26,8 @@ def get_config(path: Path, end_year: int) -> dict: return config -def _load_config(path: Path) -> dict: - config = {} +def _load_config(path: Path) -> ConfigDict: + config: ConfigDict = {} found = False for dir_path in _walk_to_root(path): check_path = dir_path / CONFIG_FILE_NAME @@ -40,7 +42,7 @@ def _load_config(path: Path) -> dict: return config -def _validate_config(config: dict): +def _validate_config(config: ConfigDict) -> None: valid_keys = {'owner', 'start_year', 'substring', 'template'} mandatory_keys = {'owner', 'template'} config_keys = set(config) diff --git a/src/unbeheader/headers.py b/src/unbeheader/headers.py index 450f1ed..faa8344 100644 --- a/src/unbeheader/headers.py +++ b/src/unbeheader/headers.py @@ -8,8 +8,10 @@ import click -from . import SUPPORTED_FILES +from . import SUPPORTED_FILE_TYPES from .config import get_config +from .typing import CommentSkeleton +from .typing import ConfigDict from .util import cformat @@ -17,14 +19,17 @@ def update_header(file_path: Path, year: int, check: bool = False) -> bool: """Update the header of a file.""" config = get_config(file_path, year) ext = file_path.suffix[1:] - if ext not in SUPPORTED_FILES or not file_path.is_file(): + if ext not in SUPPORTED_FILE_TYPES or not file_path.is_file(): return False if file_path.name.startswith('.'): return False - return _do_update_header(file_path, config, SUPPORTED_FILES[ext]['regex'], SUPPORTED_FILES[ext]['comments'], check) + return _do_update_header( + file_path, config, SUPPORTED_FILE_TYPES[ext].regex, SUPPORTED_FILE_TYPES[ext].comments, check + ) -def _do_update_header(file_path: Path, config: dict, regex: Pattern[str], comments: dict, check: bool) -> bool: +def _do_update_header(file_path: Path, config: ConfigDict, regex: Pattern[str], comments: CommentSkeleton, + check: bool) -> bool: found = False content = orig_content = file_path.read_text() # Do nothing for empty files @@ -44,12 +49,12 @@ def _do_update_header(file_path: Path, config: dict, regex: Pattern[str], commen # file is otherwise empty, we do not want a header in there content = '' else: - content = content[:match.start()] + _generate_header(comments | config) + match_end + content = content[:match.start()] + _generate_header(comments._asdict() | config) + match_end # Strip leading empty characters content = content.lstrip() # Add the header if it was not found if not found: - content = _generate_header(comments | config) + '\n' + content + content = _generate_header(comments._asdict() | config) + '\n' + content # Readd the shebang line if it was there if shebang_line: content = shebang_line + '\n' + content @@ -64,7 +69,7 @@ def _do_update_header(file_path: Path, config: dict, regex: Pattern[str], commen return True -def _generate_header(data: dict) -> str: +def _generate_header(data: ConfigDict) -> str: if 'start_year' not in data: data['start_year'] = data['end_year'] if data['start_year'] == data['end_year']: @@ -80,7 +85,7 @@ def _generate_header(data: dict) -> str: return f'{comment}\n' -def _print_results(file_path: Path, found: bool, check: bool): +def _print_results(file_path: Path, found: bool, check: bool) -> None: ci = os.environ.get('CI') == '1' if found: check_msg = 'Incorrect header in {}' if ci else 'Incorrect header in %{white!}{}' diff --git a/src/unbeheader/typing.py b/src/unbeheader/typing.py new file mode 100644 index 0000000..c5a7ffe --- /dev/null +++ b/src/unbeheader/typing.py @@ -0,0 +1,27 @@ +# This file is part of Unbeheader. +# Copyright (C) CERN & UNCONVENTIONAL + +from pathlib import Path +from re import Pattern +from typing import Any +from typing import NamedTuple +from typing import TypeAlias + +ConfigDict: TypeAlias = dict[str, Any] +PathCache: TypeAlias = dict[Path, bool] + + +class CommentSkeleton(NamedTuple): + # The string that indicates the start of a comment + comment_start: str + # The string that indicates the continuation of a comment + comment_middle: str + # The string that indicates the end of a comment + comment_end: str + + +class SupportedFileType(NamedTuple): + # A regular expression matching header comments + regex: Pattern[str] + # A dictionary defining the skeleton of comments + comments: CommentSkeleton diff --git a/src/unbeheader/util.py b/src/unbeheader/util.py index f6e98d0..b918792 100644 --- a/src/unbeheader/util.py +++ b/src/unbeheader/util.py @@ -3,9 +3,12 @@ import re from pathlib import Path +from re import Match from colorclass import Color +from .typing import PathCache + # The name of the files that exclude the directory from header updates EXCLUDE_FILE_NAME = '.no-header' @@ -15,7 +18,7 @@ def cformat(string: str) -> Color: Bold foreground can be achieved by suffixing the color with a '!'. """ - def repl(m): + def repl(m: Match[str]) -> Color: bg = bold = '' if m.group('fg_bold'): bold = '{b}' @@ -32,7 +35,7 @@ def repl(m): return Color(string) -def is_excluded(path: Path, root_path: Path = None, cache: dict = None) -> bool: +def is_excluded(path: Path, root_path: Path | None = None, cache: PathCache | None = None) -> bool: """"Whether the path is excluded by a .no-headers file. The .no-headers file is searched for in the path and all parents up to the root. diff --git a/stubs/colorclass/__init__.pyi b/stubs/colorclass/__init__.pyi new file mode 100644 index 0000000..a50b6e6 --- /dev/null +++ b/stubs/colorclass/__init__.pyi @@ -0,0 +1,6 @@ +# This file is part of Unbeheader. +# Copyright (C) CERN & UNCONVENTIONAL + +from typing import TypeAlias + +Color: TypeAlias = str diff --git a/tests/test_headers.py b/tests/test_headers.py index 373fbd2..07bce39 100644 --- a/tests/test_headers.py +++ b/tests/test_headers.py @@ -8,7 +8,7 @@ import pytest from colorclass import Color -from unbeheader import SUPPORTED_FILES +from unbeheader import SUPPORTED_FILE_TYPES from unbeheader.config import DEFAULT_SUBSTRING from unbeheader.headers import _do_update_header from unbeheader.headers import _generate_header @@ -49,8 +49,8 @@ def create_py_file(file_content): @pytest.fixture def py_files_settings(): return { - 'regex': SUPPORTED_FILES['py']['regex'], - 'comments': SUPPORTED_FILES['py']['comments'] + 'regex': SUPPORTED_FILE_TYPES['py'].regex, + 'comments': SUPPORTED_FILE_TYPES['py'].comments } @@ -65,7 +65,7 @@ def test_update_header(_do_update_header, get_config, create_py_file): file_ext = file_path.suffix[1:] update_header(file_path, year, check) _do_update_header.assert_called_once_with( - file_path, config, SUPPORTED_FILES[file_ext]['regex'], SUPPORTED_FILES[file_ext]['comments'], check + file_path, config, SUPPORTED_FILE_TYPES[file_ext].regex, SUPPORTED_FILE_TYPES[file_ext].comments, check ) @@ -84,7 +84,7 @@ def test_update_header_for_unsupported_file(_do_update_header, get_config, tmp_p year = date.today().year file_path = tmp_path / 'manuscript.txt' file_path.touch() - assert 'txt' not in SUPPORTED_FILES + assert 'txt' not in SUPPORTED_FILE_TYPES assert update_header(file_path, year) is False assert _do_update_header.call_count == 0 @@ -294,7 +294,7 @@ def test_do_update_header_for_empty_file(create_py_file, py_files_settings): '''), )) def test_generate_header(extension, expected, config): - data = SUPPORTED_FILES[extension]['comments'] | config + data = SUPPORTED_FILE_TYPES[extension].comments._asdict() | config header = _generate_header(data) assert header == dedent(expected).lstrip() @@ -302,7 +302,7 @@ def test_generate_header(extension, expected, config): def test_generate_header_for_different_end_year(config): end_year = date.today().year config['end_year'] = end_year - data = SUPPORTED_FILES['py']['comments'] | config + data = SUPPORTED_FILE_TYPES['py'].comments._asdict() | config header = _generate_header(data) assert header == dedent(f''' # This file is part of Thelema. @@ -314,7 +314,7 @@ def test_generate_header_for_different_end_year(config): '{root}', '{template}', '{substring}' )) def test_generate_header_for_invalid_placeholder(template, config): - data = SUPPORTED_FILES['py']['comments'] | config + data = SUPPORTED_FILE_TYPES['py'].comments._asdict() | config data['template'] = template with pytest.raises(SystemExit) as exc: _generate_header(data)