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

introducing project mode #1022

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all 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
168 changes: 48 additions & 120 deletions copier/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Main functions and classes, used to generate or update projects."""

import collections.abc
import os
import platform
import subprocess
Expand All @@ -11,10 +12,12 @@
from itertools import chain
from pathlib import Path
from shutil import rmtree
from typing import Callable, Iterable, List, Mapping, Optional, Sequence
from typing import Callable, Iterable, List, Mapping, Optional, Sequence, Type, cast
from unicodedata import normalize

from jinja2 import Environment
from jinja2.loaders import FileSystemLoader
from jinja2.nativetypes import NativeEnvironment
from jinja2.sandbox import SandboxedEnvironment
from pathspec import PathSpec
from plumbum import ProcessExecutionError, colors
Expand All @@ -27,6 +30,7 @@
from questionary import unsafe_prompt

from .errors import CopierAnswersInterrupt, ExtensionNotFoundError, UserMessageError
from .renderer import Renderer
from .subproject import Subproject
from .template import Task, Template
from .tools import Style, TemporaryDirectory, printf
Expand Down Expand Up @@ -421,6 +425,13 @@ def all_exclusions(self) -> StrSeq:

@cached_property
def jinja_env(self) -> SandboxedEnvironment:
return cast(SandboxedEnvironment, self.abstract_jinja_env(SandboxedEnvironment))

@cached_property
def native_jinja_env(self) -> NativeEnvironment:
Copy link
Member

Choose a reason for hiding this comment

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

I don't see any uses of this...

return cast(NativeEnvironment, self.abstract_jinja_env(NativeEnvironment))

def abstract_jinja_env(self, env_cls: Type) -> Environment:
"""Return a pre-configured Jinja environment.

Respects template settings.
Expand All @@ -436,7 +447,7 @@ def jinja_env(self) -> SandboxedEnvironment:
# Of course we still have the post-copy tasks to worry about, but at least
# they are more visible to the final user.
try:
env = SandboxedEnvironment(
env = env_cls(
loader=loader, extensions=extensions, **self.template.envops
)
except ModuleNotFoundError as error:
Expand Down Expand Up @@ -466,113 +477,6 @@ def match_skip(self) -> Callable[[Path], bool]:
)
)

def _render_file(self, src_abspath: Path) -> None:
"""Render one file.

Args:
src_abspath:
The absolute path to the file that will be rendered.
"""
# TODO Get from main.render_file()
assert src_abspath.is_absolute()
src_relpath = src_abspath.relative_to(self.template.local_abspath).as_posix()
src_renderpath = src_abspath.relative_to(self.template_copy_root)
dst_relpath = self._render_path(src_renderpath)
if dst_relpath is None:
return
if src_abspath.name.endswith(self.template.templates_suffix):
try:
tpl = self.jinja_env.get_template(src_relpath)
except UnicodeDecodeError:
if self.template.templates_suffix:
# suffix is not empty, re-raise
raise
# suffix is empty, fallback to copy
new_content = src_abspath.read_bytes()
else:
new_content = tpl.render(**self._render_context()).encode()
else:
new_content = src_abspath.read_bytes()
dst_abspath = Path(self.subproject.local_abspath, dst_relpath)
src_mode = src_abspath.stat().st_mode
if not self._render_allowed(dst_relpath, expected_contents=new_content):
return
if not self.pretend:
dst_abspath.parent.mkdir(parents=True, exist_ok=True)
dst_abspath.write_bytes(new_content)
dst_abspath.chmod(src_mode)

def _render_folder(self, src_abspath: Path) -> None:
"""Recursively render a folder.

Args:
src_path:
Folder to be rendered. It must be an absolute path within
the template.
"""
assert src_abspath.is_absolute()
src_relpath = src_abspath.relative_to(self.template_copy_root)
dst_relpath = self._render_path(src_relpath)
if dst_relpath is None:
return
if not self._render_allowed(dst_relpath, is_dir=True):
return
dst_abspath = Path(self.subproject.local_abspath, dst_relpath)
if not self.pretend:
dst_abspath.mkdir(parents=True, exist_ok=True)
for file in src_abspath.iterdir():
if file.is_dir():
self._render_folder(file)
else:
self._render_file(file)

def _render_path(self, relpath: Path) -> Optional[Path]:
"""Render one relative path.

Args:
relpath:
The relative path to be rendered. Obviously, it can be templated.
"""
is_template = relpath.name.endswith(self.template.templates_suffix)
templated_sibling = (
self.template.local_abspath / f"{relpath}{self.template.templates_suffix}"
)
# With an empty suffix, the templated sibling always exists.
if templated_sibling.exists() and self.template.templates_suffix:
return None
if self.template.templates_suffix and is_template:
relpath = relpath.with_suffix("")
rendered_parts = []
for part in relpath.parts:
# Skip folder if any part is rendered as an empty string
part = self._render_string(part)
if not part:
return None
# {{ _copier_conf.answers_file }} becomes the full path; in that case,
# restore part to be just the end leaf
if str(self.answers_relpath) == part:
part = Path(part).name
rendered_parts.append(part)
result = Path(*rendered_parts)
if not is_template:
templated_sibling = (
self.template.local_abspath
/ f"{result}{self.template.templates_suffix}"
)
if templated_sibling.exists():
return None
return result

def _render_string(self, string: str) -> str:
"""Render one templated string.

Args:
string:
The template source string.
"""
tpl = self.jinja_env.from_string(string)
return tpl.render(**self._render_context())

@cached_property
def subproject(self) -> Subproject:
"""Get related subproject."""
Expand All @@ -591,15 +495,6 @@ def template(self) -> Template:
url = str(self.subproject.template.url)
return Template(url=url, ref=self.vcs_ref, use_prereleases=self.use_prereleases)

@cached_property
def template_copy_root(self) -> Path:
"""Absolute path from where to start copying.

It points to the cloned template local abspath + the rendered subdir, if any.
"""
subdir = self._render_string(self.template.subdirectory) or ""
return self.template.local_abspath / subdir

# Main operations
def run_auto(self) -> None:
"""Copy or update automatically.
Expand All @@ -624,15 +519,14 @@ def run_copy(self) -> None:
See [generating a project][generating-a-project].
"""
was_existing = self.subproject.local_abspath.exists()
src_abspath = self.template_copy_root
try:
if not self.quiet:
# TODO Unify printing tools
print(
f"\nCopying from template version {self.template.version}",
file=sys.stderr,
)
self._render_folder(src_abspath)
self._render_with_items()
if not self.quiet:
# TODO Unify printing tools
print("") # padding space
Expand All @@ -645,6 +539,13 @@ def run_copy(self) -> None:
# TODO Unify printing tools
print("") # padding space

def _render_with_items(self):
self._default_renderer.render()

for items_key in self.project_mode_keys:
for project_mode_item in self.answers.combined[items_key]:
self._default_renderer.with_item(items_key, project_mode_item).render()

def run_update(self) -> None:
"""Update a subproject that was already generated.

Expand Down Expand Up @@ -810,6 +711,33 @@ def _git_initialize_repo(self):
git("config", "--unset", "user.name")
git("config", "--unset", "user.email")

@cached_property
def _default_renderer(self) -> Renderer:
return Renderer(
template=self.template,
subproject=self.subproject,
_render_allowed=self._render_allowed,
pretend=self.pretend,
jinja_env=self.jinja_env,
answers_relpath=self.answers_relpath,
_render_context=self._render_context(),
_items_keys=self.project_mode_keys,
)

def _render_string(self, string: str) -> str:
return self._default_renderer.render_string(string)

@cached_property
def project_mode_keys(self) -> Iterable[str]:
Copy link
Member

Choose a reason for hiding this comment

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

All have been projects since forever... let's give it a better name:

Suggested change
def project_mode_keys(self) -> Iterable[str]:
def looped_mode_keys(self) -> Iterable[str]:

raw_items = self.template.config_data.get("items")
if not raw_items:
return ()
return (
[raw_items]
if isinstance(raw_items, str)
else cast(collections.abc.Iterable, raw_items)
Comment on lines +736 to +738
Copy link
Member

Choose a reason for hiding this comment

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

If it is a string, then it should probably get rendered using the native jinja env, to allow for complex scenarios. Right?

)


def run_copy(
src_path: str,
Expand Down
Loading