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 13 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
31 changes: 15 additions & 16 deletions copier/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
from os import PathLike
from pathlib import Path
from textwrap import dedent
from typing import Callable
from typing import Any, Callable

import yaml
from plumbum import cli, colors
Expand Down Expand Up @@ -80,35 +80,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:
pawamoy marked this conversation as resolved.
Show resolved Hide resolved
self.data: AnyByStrDict = {}
super().__init__(executable)

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

@cli.switch(
@cli.switch( # type: ignore[misc]
["-d", "--data"],
str,
"VARIABLE=VALUE",
Expand All @@ -176,7 +173,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 +192,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: OptStr = None, dst_path: str = ".", **kwargs: Any
) -> Worker:
"""Run Copier's internal API using CLI switches.

Arguments:
Expand Down
68 changes: 47 additions & 21 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 Down Expand Up @@ -179,13 +190,28 @@
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:
...

Check warning on line 201 in copier/main.py

View check run for this annotation

Codecov / codecov/patch

copier/main.py#L201

Added line #L201 was not covered by tests

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

Check warning on line 207 in copier/main.py

View check run for this annotation

Codecov / codecov/patch

copier/main.py#L207

Added line #L207 was not covered by tests

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 +222,7 @@
# 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 +252,7 @@
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 +299,7 @@
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 +331,7 @@
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 @@ -766,7 +792,7 @@
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 +844,7 @@
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 +866,8 @@
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 +889,7 @@
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 +911,10 @@
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 +994,10 @@

# 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 +1030,7 @@
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 +1046,7 @@


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 +1064,7 @@
def run_update(
dst_path: StrOrPath = ".",
data: AnyByStrDict | None = None,
**kwargs,
**kwargs: Any,
) -> Worker:
"""Update a subproject, from its template.

Expand All @@ -1053,7 +1079,7 @@
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
18 changes: 11 additions & 7 deletions copier/template.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Tools related to template management."""

from __future__ import annotations

import re
Expand Down Expand Up @@ -28,7 +29,7 @@
UnsupportedVersionError,
)
from .tools import copier_version, handle_remove_readonly
from .types import AnyByStrDict, Env, OptStr, StrSeq, Union, VCSTypes
from .types import AnyByStrDict, Env, StrSeq, VCSTypes
from .vcs import checkout_latest_tag, clone, get_git, get_repo

# Default list of files in the template to exclude from the rendered project
Expand Down Expand Up @@ -157,7 +158,7 @@ class Task:
Additional environment variables to set while executing the command.
"""

cmd: Union[str, Sequence[str]]
cmd: str | Sequence[str]
extra_env: Env = field(default_factory=dict)


Expand Down Expand Up @@ -199,7 +200,7 @@ class Template:
"""

url: str
ref: OptStr = None
ref: str | None = None
use_prereleases: bool = False

def _cleanup(self) -> None:
Expand Down Expand Up @@ -264,17 +265,19 @@ def answers_relpath(self) -> Path:
return result

@cached_property
def commit(self) -> OptStr:
def commit(self) -> str | None:
"""If the template is VCS-tracked, get its commit description."""
if self.vcs == "git":
with local.cwd(self.local_abspath):
return get_git()("describe", "--tags", "--always").strip()
return None

@cached_property
def commit_hash(self) -> OptStr:
def commit_hash(self) -> str | None:
"""If the template is VCS-tracked, get its commit full hash."""
if self.vcs == "git":
return get_git()("-C", self.local_abspath, "rev-parse", "HEAD").strip()
return None

@cached_property
def config_data(self) -> AnyByStrDict:
Expand All @@ -289,7 +292,7 @@ def config_data(self) -> AnyByStrDict:
return result

@cached_property
def envops(self) -> Mapping:
def envops(self) -> Mapping[str, Any]:
"""Get the Jinja configuration specified in the template, or default values.

See [envops][].
Expand Down Expand Up @@ -378,7 +381,7 @@ def migration_tasks(
"VERSION_PEP440_FROM": str(from_template.version),
"VERSION_PEP440_TO": str(self.version),
}
migration: dict
migration: dict[str, Any]
for migration in self._raw_config.get("_migrations", []):
current = parse(migration["version"])
if self.version >= current > from_template.version:
Expand Down Expand Up @@ -543,3 +546,4 @@ def vcs(self) -> VCSTypes | None:
"""Get VCS system used by the template, if any."""
if get_repo(self.url):
return "git"
return None
Loading
Loading