-
-
Notifications
You must be signed in to change notification settings - Fork 182
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
introducing project mode #1022
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||||||
|
@@ -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 | ||||||
|
@@ -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 | ||||||
|
@@ -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: | ||||||
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. | ||||||
|
@@ -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: | ||||||
|
@@ -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.""" | ||||||
|
@@ -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. | ||||||
|
@@ -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 | ||||||
|
@@ -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. | ||||||
|
||||||
|
@@ -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]: | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||||||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||||||
|
There was a problem hiding this comment.
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...