Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable 'strict' Mypy linting #1527

Merged
merged 23 commits into from
Mar 23, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 19 additions & 18 deletions copier/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,21 @@
```
"""

from __future__ import annotations

import sys
from os import PathLike
from pathlib import Path
from textwrap import dedent
from typing import Callable
from typing import Any, Callable, Iterable

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,35 +82,32 @@ 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."
DESCRIPTION_MORE = (
dedent(
"""\
DESCRIPTION_MORE = dedent(
"""\
danieleades marked this conversation as resolved.
Show resolved Hide resolved
Docs in https://copier.readthedocs.io/

"""
)
+ (
colors.yellow
| dedent(
"""\
) + (
colors.yellow
| dedent(
"""\
WARNING! Use only trusted project templates, as they might
execute code with the same level of access as your user.\n
"""
)
)
)
VERSION = copier_version()
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 +157,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:
danieleades marked this conversation as resolved.
Show resolved Hide resolved
"""Update [data][] with provided values.

Arguments:
Expand All @@ -176,7 +175,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 +194,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: str | None = None, dst_path: str = ".", **kwargs: Any
) -> Worker:
"""Run Copier's internal API using CLI switches.

Arguments:
Expand Down
84 changes: 50 additions & 34 deletions copier/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Main functions and classes, used to generate or update projects."""

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those empty lines were introduced by Ruff v0.3.0 (IIRC). Would you mind reverting those (and the other occurences), too? They aren't related to this PR.

from __future__ import annotations

import os
Expand All @@ -13,7 +14,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 +47,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 +165,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 +182,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 +212,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 +242,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 +289,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 +321,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 +484,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 +782,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()
pawamoy marked this conversation as resolved.
Show resolved Hide resolved

def run_update(self) -> None:
"""Update a subproject that was already generated.
Expand Down Expand Up @@ -818,7 +834,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 +856,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 +879,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 +901,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 +984,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 +1020,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 +1036,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 +1054,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 +1069,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
7 changes: 5 additions & 2 deletions copier/subproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

A *subproject* is a project that gets rendered and/or updated with Copier.
"""

from __future__ import annotations

from dataclasses import field
Expand Down Expand Up @@ -33,7 +34,7 @@
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 +46,7 @@
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 +79,11 @@
result = Template(url=last_url, ref=last_ref)
self._cleanup_hooks.append(result._cleanup)
return result
return None

Check warning on line 82 in copier/subproject.py

View check run for this annotation

Codecov / codecov/patch

copier/subproject.py#L82

Added line #L82 was not covered by tests

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

Check warning on line 89 in copier/subproject.py

View check run for this annotation

Codecov / codecov/patch

copier/subproject.py#L89

Added line #L89 was not covered by tests
Loading
Loading