Skip to content

Commit

Permalink
build(typing): enable 'strict' mypy linting (#1527)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Timothée Mazzucotelli <dev@pawamoy.fr>
  • Loading branch information
danieleades and pawamoy authored Mar 23, 2024
1 parent bcad276 commit 0c6b0b9
Show file tree
Hide file tree
Showing 23 changed files with 215 additions and 113 deletions.
20 changes: 11 additions & 9 deletions copier/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,15 @@
from os import PathLike
from pathlib import Path
from textwrap import dedent
from typing import Callable
from typing import Any, Callable, Iterable, Optional

import yaml
from plumbum import cli, colors

from .errors import UnsafeTemplateError, UserMessageError
from .main import Worker
from .tools import copier_version
from .types import AnyByStrDict, OptStr, StrSeq
from .types import AnyByStrDict


def _handle_exceptions(method: Callable[[], None]) -> int:
Expand All @@ -80,7 +80,7 @@ def _handle_exceptions(method: Callable[[], None]) -> int:
return 0


class CopierApp(cli.Application):
class CopierApp(cli.Application): # type: ignore[misc]
"""The Copier CLI application."""

DESCRIPTION = "Create a new project from a template."
Expand All @@ -105,10 +105,10 @@ class CopierApp(cli.Application):
CALL_MAIN_IF_NESTED_COMMAND = False


class _Subcommand(cli.Application):
class _Subcommand(cli.Application): # type: ignore[misc]
"""Base class for Copier subcommands."""

def __init__(self, executable: PathLike) -> None:
def __init__(self, executable: "PathLike[str]") -> None:
self.data: AnyByStrDict = {}
super().__init__(executable)

Expand Down Expand Up @@ -158,14 +158,14 @@ def __init__(self, executable: PathLike) -> None:
),
)

@cli.switch(
@cli.switch( # type: ignore[misc]
["-d", "--data"],
str,
"VARIABLE=VALUE",
list=True,
help="Make VARIABLE available as VALUE when rendering the template",
)
def data_switch(self, values: StrSeq) -> None:
def data_switch(self, values: Iterable[str]) -> None:
"""Update [data][] with provided values.
Arguments:
Expand All @@ -176,7 +176,7 @@ def data_switch(self, values: StrSeq) -> None:
key, value = arg.split("=", 1)
self.data[key] = value

@cli.switch(
@cli.switch( # type: ignore[misc]
["--data-file"],
cli.ExistingFile,
help="Load data from a YAML file",
Expand All @@ -195,7 +195,9 @@ def data_file_switch(self, path: cli.ExistingFile) -> None:
}
self.data.update(updates_without_cli_overrides)

def _worker(self, src_path: OptStr = None, dst_path: str = ".", **kwargs) -> Worker:
def _worker(
self, src_path: Optional[str] = None, dst_path: str = ".", **kwargs: Any # noqa: FA100
) -> Worker:
"""Run Copier's internal API using CLI switches.
Arguments:
Expand Down
83 changes: 49 additions & 34 deletions copier/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,17 @@
from pathlib import Path
from shutil import rmtree
from tempfile import TemporaryDirectory
from typing import Callable, Iterable, Literal, Mapping, Sequence, get_args
from types import TracebackType
from typing import (
Any,
Callable,
Iterable,
Literal,
Mapping,
Sequence,
get_args,
overload,
)
from unicodedata import normalize

from jinja2.loaders import FileSystemLoader
Expand All @@ -36,15 +46,7 @@
from .subproject import Subproject
from .template import Task, Template
from .tools import OS, Style, normalize_git_path, printf, readlink
from .types import (
MISSING,
AnyByStrDict,
JSONSerializable,
OptStr,
RelativePath,
StrOrPath,
StrSeq,
)
from .types import MISSING, AnyByStrDict, JSONSerializable, RelativePath, StrOrPath
from .user_data import DEFAULT_DATA, AnswersMap, Question
from .vcs import get_git

Expand Down Expand Up @@ -162,11 +164,11 @@ class Worker:
src_path: str | None = None
dst_path: Path = Path(".")
answers_file: RelativePath | None = None
vcs_ref: OptStr = None
vcs_ref: str | None = None
data: AnyByStrDict = field(default_factory=dict)
exclude: StrSeq = ()
exclude: Sequence[str] = ()
use_prereleases: bool = False
skip_if_exists: StrSeq = ()
skip_if_exists: Sequence[str] = ()
cleanup_on_error: bool = True
defaults: bool = False
user_defaults: AnyByStrDict = field(default_factory=dict)
Expand All @@ -179,13 +181,26 @@ class Worker:
skip_answered: bool = False

answers: AnswersMap = field(default_factory=AnswersMap, init=False)
_cleanup_hooks: list[Callable] = field(default_factory=list, init=False)
_cleanup_hooks: list[Callable[[], None]] = field(default_factory=list, init=False)

def __enter__(self):
def __enter__(self) -> Worker:
"""Allow using worker as a context manager."""
return self

def __exit__(self, type, value, traceback):
@overload
def __exit__(self, type: None, value: None, traceback: None) -> None: ...

@overload
def __exit__(
self, type: type[BaseException], value: BaseException, traceback: TracebackType
) -> None: ...

def __exit__(
self,
type: type[BaseException] | None,
value: BaseException | None,
traceback: TracebackType | None,
) -> None:
"""Clean up garbage files after worker usage ends."""
if value is not None:
# exception was raised from code inside context manager:
Expand All @@ -196,7 +211,7 @@ def __exit__(self, type, value, traceback):
# otherwise clean up and let any exception bubble up
self._cleanup()

def _cleanup(self):
def _cleanup(self) -> None:
"""Execute all stored cleanup methods."""
for method in self._cleanup_hooks:
method()
Expand Down Expand Up @@ -226,7 +241,7 @@ def _print_message(self, message: str) -> None:
if message and not self.quiet:
print(self._render_string(message), file=sys.stderr)

def _answers_to_remember(self) -> Mapping:
def _answers_to_remember(self) -> Mapping[str, Any]:
"""Get only answers that will be remembered in the copier answers file."""
# All internal values must appear first
answers: AnyByStrDict = {}
Expand Down Expand Up @@ -273,7 +288,7 @@ def _execute_tasks(self, tasks: Sequence[Task]) -> None:
with local.cwd(self.subproject.local_abspath), local.env(**task.extra_env):
subprocess.run(task_cmd, shell=use_shell, check=True, env=local.env)

def _render_context(self) -> Mapping:
def _render_context(self) -> Mapping[str, Any]:
"""Produce render context for Jinja."""
# Backwards compatibility
# FIXME Remove it?
Expand Down Expand Up @@ -305,7 +320,7 @@ def _path_matcher(self, patterns: Iterable[str]) -> Callable[[Path], bool]:
spec = PathSpec.from_lines("gitwildmatch", normalized_patterns)
return spec.match_file

def _solve_render_conflict(self, dst_relpath: Path):
def _solve_render_conflict(self, dst_relpath: Path) -> bool:
"""Properly solve render conflicts.
It can ask the user if running in interactive mode.
Expand Down Expand Up @@ -468,7 +483,7 @@ def answers_relpath(self) -> Path:
return Path(template.render(**self.answers.combined))

@cached_property
def all_exclusions(self) -> StrSeq:
def all_exclusions(self) -> Sequence[str]:
"""Combine default, template and user-chosen exclusions."""
return self.template.exclude + tuple(self.exclude)

Expand Down Expand Up @@ -766,7 +781,7 @@ def run_recopy(self) -> None:
f"from `{self.subproject.answers_relpath}`."
)
with replace(self, src_path=self.subproject.template.url) as new_worker:
return new_worker.run_copy()
new_worker.run_copy()

def run_update(self) -> None:
"""Update a subproject that was already generated.
Expand Down Expand Up @@ -818,7 +833,7 @@ def run_update(self) -> None:
self._apply_update()
self._print_message(self.template.message_after_update)

def _apply_update(self): # noqa: C901
def _apply_update(self) -> None: # noqa: C901
git = get_git()
subproject_top = Path(
git(
Expand All @@ -840,8 +855,8 @@ def _apply_update(self): # noqa: C901
data=self.subproject.last_answers,
defaults=True,
quiet=True,
src_path=self.subproject.template.url,
vcs_ref=self.subproject.template.commit,
src_path=self.subproject.template.url, # type: ignore[union-attr]
vcs_ref=self.subproject.template.commit, # type: ignore[union-attr]
) as old_worker:
old_worker.run_copy()
# Extract diff between temporary destination and real destination
Expand All @@ -863,7 +878,7 @@ def _apply_update(self): # noqa: C901
diff = diff_cmd("--inter-hunk-context=0")
# Run pre-migration tasks
self._execute_tasks(
self.template.migration_tasks("before", self.subproject.template)
self.template.migration_tasks("before", self.subproject.template) # type: ignore[arg-type]
)
# Clear last answers cache to load possible answers migration, if skip_answered flag is not set
if self.skip_answered is False:
Expand All @@ -885,10 +900,10 @@ def _apply_update(self): # noqa: C901
with replace(
self,
dst_path=new_copy / subproject_subdir,
data=self.answers.combined,
data=self.answers.combined, # type: ignore[arg-type]
defaults=True,
quiet=True,
src_path=self.subproject.template.url,
src_path=self.subproject.template.url, # type: ignore[union-attr]
) as new_worker:
new_worker.run_copy()
compared = dircmp(old_copy, new_copy)
Expand Down Expand Up @@ -968,10 +983,10 @@ def _apply_update(self): # noqa: C901

# Run post-migration tasks
self._execute_tasks(
self.template.migration_tasks("after", self.subproject.template)
self.template.migration_tasks("after", self.subproject.template) # type: ignore[arg-type]
)

def _git_initialize_repo(self):
def _git_initialize_repo(self) -> None:
"""Initialize a git repository in the current directory."""
git = get_git()
git("init", retcode=None)
Expand Down Expand Up @@ -1004,7 +1019,7 @@ def run_copy(
src_path: str,
dst_path: StrOrPath = ".",
data: AnyByStrDict | None = None,
**kwargs,
**kwargs: Any,
) -> Worker:
"""Copy a template to a destination, from zero.
Expand All @@ -1020,7 +1035,7 @@ def run_copy(


def run_recopy(
dst_path: StrOrPath = ".", data: AnyByStrDict | None = None, **kwargs
dst_path: StrOrPath = ".", data: AnyByStrDict | None = None, **kwargs: Any
) -> Worker:
"""Update a subproject from its template, discarding subproject evolution.
Expand All @@ -1038,7 +1053,7 @@ def run_recopy(
def run_update(
dst_path: StrOrPath = ".",
data: AnyByStrDict | None = None,
**kwargs,
**kwargs: Any,
) -> Worker:
"""Update a subproject, from its template.
Expand All @@ -1053,7 +1068,7 @@ def run_update(
return worker


def _remove_old_files(prefix: Path, cmp: dircmp, rm_common: bool = False) -> None:
def _remove_old_files(prefix: Path, cmp: dircmp[str], rm_common: bool = False) -> None:
"""Remove files and directories only found in "old" template.
This is an internal helper method used to process a comparison of 2
Expand Down
6 changes: 4 additions & 2 deletions copier/subproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class Subproject:
local_abspath: AbsolutePath
answers_relpath: Path = Path(".copier-answers.yml")

_cleanup_hooks: list[Callable] = field(default_factory=list, init=False)
_cleanup_hooks: list[Callable[[], None]] = field(default_factory=list, init=False)

def is_dirty(self) -> bool:
"""Indicate if the local template root is dirty.
Expand All @@ -45,7 +45,7 @@ def is_dirty(self) -> bool:
return bool(get_git()("status", "--porcelain").strip())
return False

def _cleanup(self):
def _cleanup(self) -> None:
"""Remove temporary files and folders created by the subproject."""
for method in self._cleanup_hooks:
method()
Expand Down Expand Up @@ -78,9 +78,11 @@ def template(self) -> Template | None:
result = Template(url=last_url, ref=last_ref)
self._cleanup_hooks.append(result._cleanup)
return result
return None

@cached_property
def vcs(self) -> VCSTypes | None:
"""VCS type of the subproject."""
if is_in_git_repo(self.local_abspath):
return "git"
return None
Loading

0 comments on commit 0c6b0b9

Please sign in to comment.