# -*- coding: utf-8 -*-
"""
This module provides the core functionality for converting an existing project
into a cookiecutter template. It handles file replacement, directory structure
transformation, and generation of cookiecutter.json configuration.
The main class is :class:`Maker`, which orchestrates the entire template conversion process.
"""
import typing as T
import json
import shutil
import dataclasses
from pathlib import Path
from functools import cached_property
from .str_replace import replace_double_curly_brackets
from .parameter import Parameter, replace_with_parameter
from .path_matcher import PathMatcher
[docs]
@dataclasses.dataclass
class Maker:
"""
Cookiecutter maker class for converting concrete projects into cookiecutter templates.
:param dir_input: The directory you want to use as a seed project.
:param dir_output: Where to place the generated template project.
:param parameter: a list of :class:`~.cookiecutter_maker.parameter.Parameter`
defining substitution rules.
:param include: List of file path patterns to include from the input dir.
If empty, we include all files and directories.
:param exclude: List of file path patterns to exclude from the input dir.
If empty, we exclude nothing.
:param no_render: List of file path patterns to copy without rendering.
:param dir_hooks: Optional directory containing cookiecutter hooks.
:param verbose: Whether to print verbose output during processing
"""
dir_input: Path = dataclasses.field()
dir_output: Path = dataclasses.field()
parameters: list[Parameter] = dataclasses.field()
include: list[str] = dataclasses.field(default_factory=list)
exclude: list[str] = dataclasses.field(default_factory=list)
no_render: list[str] = dataclasses.field(default_factory=list)
dir_hooks: T.Optional[Path] = dataclasses.field(default=None)
verbose: bool = dataclasses.field(default=True)
def __post_init__(self):
pass
@cached_property
def path_matcher(self) -> PathMatcher:
"""
Create and return a :class:`~.cookiecutter_maker.path_matcher.PathMatcher`
instance for filtering files and directories.
"""
return PathMatcher.new(
include=self.include,
exclude=self.exclude,
no_render=self.no_render,
)
@cached_property
def dir_template(self) -> Path:
"""
Determine the output template directory path.
This creates the path for the project template folder with cookiecutter
variables in the name, based on the original input directory name.
"""
folder_name = replace_with_parameter(
text=self.dir_input.name,
param_list=self.parameters,
)
return self.dir_output.joinpath(folder_name)
@cached_property
def path_cookiecutter_json(self) -> Path:
"""
Get the path for the ``cookiecutter.json`` configuration file.
This file will contain the parameter definitions, default values,
and other cookiecutter configuration options.
"""
return self.dir_output.joinpath("cookiecutter.json")
def _make_template_file(self, p_before: Path) -> T.Optional[Path]:
"""
Convert a file to a template in the output directory.
:param p_before: the file path in the input directory.
:returns: the file path in the output directory.
If the file is ignored, then return None.
"""
# Get the relative path from the input directory
relpath = p_before.relative_to(self.dir_input)
# Check if this file should be included based on include/exclude rules
if self.path_matcher.is_match(str(relpath)) is False:
return None
# Apply parameter substitutions to the file path
new_relpath = replace_with_parameter(
text=str(relpath),
param_list=self.parameters,
)
p_after = self.dir_template.joinpath(new_relpath)
# Print processing information if verbose mode is enabled
if self.verbose:
print(f"from: {p_before.relative_to(self.dir_input)}")
print(f" to: {p_after.relative_to(self.dir_output)}")
# For files that should be copied without rendering, just copy as-is
if self.path_matcher.is_render(str(relpath)) is False:
p_after.write_bytes(p_before.read_bytes())
return p_after
# Read the file content as bytes
b = p_before.read_bytes()
# Try to decode as text; if it fails, treat as binary
try:
text_content = b.decode("utf-8")
text_content = p_before.read_text(encoding="utf-8")
except UnicodeDecodeError:
# For binary files, copy as-is without processing
p_after.write_bytes(b)
return p_after
# For text files, process the content
# 1. Escape any existing Jinja2/cookiecutter syntax
text_content = replace_double_curly_brackets(text_content)
# 2. Apply parameter substitutions
text_content = replace_with_parameter(
text=text_content,
param_list=self.parameters,
)
# Write the processed content to the output file
p_after.write_text(text_content, encoding="utf-8")
return p_after
def _make_template_dir(self, p_before: Path) -> T.Optional[Path]:
"""
Convert a directory to a template in the output directory.
:param p_before: the directory path in the input directory.
:returns: the directory path in the output directory.
If the directory is ignored, then return None.
"""
# Get the relative path from the input directory
relpath = p_before.relative_to(self.dir_input)
# Check if this directory should be included based on include/exclude rules
if self.path_matcher.is_match(str(relpath)) is False:
return None
# Apply parameter substitutions to the directory path
new_relpath = replace_with_parameter(
text=str(relpath),
param_list=self.parameters,
)
p_after = self.dir_template.joinpath(new_relpath)
# Print processing information if verbose mode is enabled
if self.verbose:
print(f"from: {p_before.relative_to(self.dir_input)}")
print(f" to: {p_after.relative_to(self.dir_output)}")
# Create the directory (and parent directories if needed)
p_after.mkdir(parents=True, exist_ok=True)
return p_after
def _make_template(
self,
dir_src: Path,
):
"""
Recursively convert a directory to a template.
This method walks through the directory tree and processes each item:
- For directories, it calls :meth:`_make_template_dir`
- For files, it calls :meth:`_make_template_file`
- Skips items that are not files or directories
"""
p_after = self._make_template_dir(dir_src)
# If this directory is ignored, skip processing its contents
if p_after is None:
return
# Process each item in the directory
for p in dir_src.iterdir():
if p.is_dir():
# Recursively process subdirectories
self._make_template(p)
elif p.is_file():
# Process files
self._make_template_file(p)
else: # pragma: no cover
# Skip any items that are neither files nor directories
# (like symbolic links, device files, etc.)
pass
[docs]
def readiness_check(self):
"""
Perform pre-execution checks to ensure the operation can proceed.
"""
# Check if input directory exists
if self.dir_input.exists() is False:
raise FileNotFoundError(
f"Input directory {self.dir_input!r} does not exist!!"
)
# Check if output directory already exists
if self.dir_output.exists():
raise FileExistsError(
f"Output directory {self.dir_output!r} already exists!!"
)
# If hooks directory is specified, check if it exists
if self.dir_hooks is not None:
if self.dir_hooks.exists() is False:
raise FileNotFoundError(
f"Hooks directory {self.dir_hooks!r} does not exist!!"
)
def _print_parameters(self):
"""
Print the parameters and their placeholders.
This is a debugging helper method that prints each parameter's
selector and the corresponding placeholder that will replace it.
Only prints if verbose mode is enabled.
"""
if self.verbose:
print("---------- parameters ----------")
for param in self.parameters:
print(f"- {param.selector[0]!r} -> {param.placeholder!r}")
[docs]
def write_cookiecutter_json(self):
"""
Create the ``cookiecutter.json`` configuration file.
See https://cookiecutter.readthedocs.io/en/stable/tutorials/tutorial2.html#step-2-create-cookiecutter-json
"""
# Start with an empty dictionary
data = {}
prompts = {}
# Add each parameter's configuration
for param in self.parameters:
if param.in_cookiecutter_json:
key, value = param.to_cookiecutter_key_value()
data[key] = value
if param.prompt:
prompts[key] = param.prompt
if prompts:
data["__prompts__"] = prompts
# If no_render patterns are specified, add them to the config
if self.no_render:
data["_copy_without_render"] = self.no_render
# Force to use POSIX line endings
data["_new_lines"] = "\n"
# Write the JSON file with nice formatting
self.path_cookiecutter_json.write_text(
json.dumps(data, indent=4, ensure_ascii=False),
encoding="utf-8",
)
[docs]
def copy_hooks(self):
"""
Copy the hooks directory to the output template if specified.
`Cookiecutter hooks <https://cookiecutter.readthedocs.io/en/stable/advanced/hooks.html>`_
are scripts that run before or after template generation.
If a hooks directory is specified, it is copied to the output template.
"""
if self.dir_hooks is None:
return
dir_hooks_output = self.dir_output.joinpath("hooks")
shutil.copytree(src=self.dir_hooks, dst=dir_hooks_output)
[docs]
def make_template(self):
"""
Execute the full template generation process.
"""
# Perform pre-execution validation
self.readiness_check()
# Print parameter information if verbose
self._print_parameters()
# Print processing start message if verbose
if self.verbose:
print("---------- make template ----------")
# Process the input directory recursively
self._make_template(dir_src=self.dir_input)
# Generate the cookiecutter.json configuration file
self.write_cookiecutter_json()
# Copy hooks if specified
self.copy_hooks()