Skip to content

Commit

Permalink
Merge pull request #193 from pawamoy/feat-add-subdirectory-option
Browse files Browse the repository at this point in the history
Add subdirectory option
  • Loading branch information
yajo authored May 27, 2020
2 parents 968f975 + c4b38a1 commit 2f087bd
Show file tree
Hide file tree
Showing 11 changed files with 230 additions and 5 deletions.
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,11 @@ _exclude:
# Can be overridden with the `skip_if_exist` API option.
_skip_if_exists:

# Subdirectory to use as the template root when generating a project.
# If not specified, the root of the git repository is used.
# Can be overridden with the `subdirectory` CLI/API option.
_subdirectory: "project"

# Commands to execute after generating or updating a project from your template.
# They run ordered, and with the $STAGE=task variable in their environment.
# Can be overridden with the `tasks` API option.
Expand Down Expand Up @@ -547,7 +552,8 @@ copier.copy(
force=False,
skip=False,
quiet=False,
cleanup_on_error=True
cleanup_on_error=True,
subdirectory=None,
)
```

Expand Down Expand Up @@ -623,14 +629,17 @@ Uses the template in _src_path_ to generate a new project at _dst_path_.
- **cleanup_on_error** (bool):<br> Remove the destination folder if the copy process or
one of the tasks fails. True by default.

- **subdirectory** (str):<br> Path to a sub-folder to use as the root of the template
when generating the project. If not specified, the root of the git repository is used.

## Comparison with other project generators

### Cookiecutter

Cookiecutter and Copier are quite similar in functionality, except that:

- Cookiecutter uses a subdirectory to generate the project, while Copier uses the root
directory.
- Cookiecutter uses a subdirectory to generate the project, while Copier can use either
the root directory (default) or a subdirectory.
- Cookiecutter uses default Jinja templating characters: `{{`, `{%`, etc., while Copier
uses `[[`, `[%`, etc., and can be configured to change those.
- Cookiecutter puts context variables in a namespace: `{{ cookiecutter.name }}`, while
Expand Down
10 changes: 10 additions & 0 deletions copier/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ class CopierApp(cli.Application):
"the latest version, use `--vcs-ref=HEAD`."
),
)
subdirectory = cli.SwitchAttr(
["-b", "--subdirectory"],
str,
help=(
"Subdirectory to use when generating the project. "
"If you do not specify it, the root of the template is used."
),
)

pretend = cli.Flag(["-n", "--pretend"], help="Run but do not make any changes")
force = cli.Flag(
["-f", "--force"], help="Overwrite files that already exist, without asking"
Expand Down Expand Up @@ -108,6 +117,7 @@ def _copy(self, src_path: OptStr = None, dst_path: str = ".", **kwargs) -> None:
skip=self.skip,
src_path=src_path,
vcs_ref=self.vcs_ref,
subdirectory=self.subdirectory,
**kwargs,
)

Expand Down
1 change: 1 addition & 0 deletions copier/config/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def make_config(
quiet: OptBool = None,
cleanup_on_error: OptBool = None,
vcs_ref: OptStr = None,
subdirectory: OptStr = None,
**kwargs,
) -> ConfigData:
"""Provides the configuration object, merged from the different sources.
Expand Down
1 change: 1 addition & 0 deletions copier/config/objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class Migrations(BaseModel):

class ConfigData(BaseModel):
src_path: Path
subdirectory: OptStr
dst_path: Path
data: AnyByStrDict = {}
extra_paths: PathSeq = ()
Expand Down
13 changes: 11 additions & 2 deletions copier/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def copy(
cleanup_on_error: OptBool = True,
vcs_ref: OptStr = None,
only_diff: OptBool = True,
subdirectory: OptStr = None,
) -> None:
"""
Uses the template in src_path to generate a new project at dst_path.
Expand Down Expand Up @@ -120,6 +121,9 @@ def copy(
- only_diff (bool):
Try to update only the template diff.
- subdirectory (str):
Specify a subdirectory to use when generating the project.
"""
conf = make_config(**locals())
is_update = conf.original_src_path != conf.src_path and vcs.is_git_repo_root(
Expand Down Expand Up @@ -159,8 +163,13 @@ def copy_local(conf: ConfigData) -> None:

folder: StrOrPath
rel_folder: StrOrPath
for folder, sub_dirs, files in os.walk(conf.src_path):
rel_folder = str(folder).replace(str(conf.src_path), "", 1).lstrip(os.path.sep)

src_path = conf.src_path
if conf.subdirectory is not None:
src_path /= conf.subdirectory

for folder, sub_dirs, files in os.walk(src_path):
rel_folder = str(folder).replace(str(src_path), "", 1).lstrip(os.path.sep)
rel_folder = render.string(rel_folder)
rel_folder = str(rel_folder).replace("." + os.path.sep, ".", 1)

Expand Down
Empty file.
3 changes: 3 additions & 0 deletions tests/demo_subdirectory/conf_project/conf_readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Demo subdirectory

Generated using previous answers `_subdirectory` value.
1 change: 1 addition & 0 deletions tests/demo_subdirectory/copier.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
_subdirectory: conf_project
1 change: 1 addition & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ def test_config_data_good_data(dst):
"vcs_ref": None,
"migrations": (),
"secret_questions": (),
"subdirectory": None,
}
conf = ConfigData(**expected)
expected["data"]["_folder_name"] = dst.name
Expand Down
8 changes: 8 additions & 0 deletions tests/test_copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,11 @@ def test_pretend_option(dst):
assert not (dst / "doc").exists()
assert not (dst / "config.py").exists()
assert not (dst / "pyproject.toml").exists()


def test_subdirectory(tmp_path: Path):
render(tmp_path, subdirectory="doc")
assert not (tmp_path / "doc").exists()
assert not (tmp_path / "config.py").exists()
assert (tmp_path / "images").exists()
assert (tmp_path / "manana.txt").exists()
182 changes: 182 additions & 0 deletions tests/test_subdirectory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import os

from plumbum import local
from plumbum.cmd import git

import copier
from copier.config import make_config
from copier.main import update_diff


def git_init(message="hello world"):
git("init")
git("config", "user.name", "Copier Test")
git("config", "user.email", "test@copier")
git("add", ".")
git("commit", "-m", message)


def test_copy_subdirectory_api_option(tmp_path):
copier.copy(
"./tests/demo_subdirectory", tmp_path, force=True, subdirectory="api_project"
)
assert (tmp_path / "api_readme.md").exists()
assert not (tmp_path / "conf_readme.md").exists()


def test_copy_subdirectory_config(tmp_path):
copier.copy("./tests/demo_subdirectory", tmp_path, force=True)
assert (tmp_path / "conf_readme.md").exists()
assert not (tmp_path / "api_readme.md").exists()


def test_update_subdirectory(tmp_path):
copier.copy("./tests/demo_subdirectory", tmp_path, force=True)

with local.cwd(tmp_path):
git_init()

conf = make_config("./tests/demo_subdirectory", str(tmp_path), force=True)
update_diff(conf)
assert not (tmp_path / "conf_project").exists()
assert not (tmp_path / "api_project").exists()
assert not (tmp_path / "api_readme.md").exists()
assert (tmp_path / "conf_readme.md").exists()


def test_new_version_uses_subdirectory(tmp_path_factory):
# Template in v1 doesn't have a _subdirectory;
# in v2 it moves all things into a subdir and adds that key to copier.yml.
# Some files change. Downstream project has evolved too. Does that work as expected?
template_path = tmp_path_factory.mktemp("subdirectory_template")
project_path = tmp_path_factory.mktemp("subdirectory_project")

# First, create the template with an initial README
with local.cwd(template_path):
with open("README.md", "w") as fd:
fd.write("upstream version 1\n")

with open("[[_copier_conf.answers_file]].tmpl", "w") as fd:
fd.write("[[_copier_answers|to_nice_yaml]]\n")

git_init("hello template")
git("tag", "v1")

# Generate the project a first time, assert the README exists
copier.copy(str(template_path), project_path, force=True)
assert (project_path / "README.md").exists()
assert "_commit: v1" in (project_path / ".copier-answers.yml").read_text()

# Start versioning the generated project
with local.cwd(project_path):
git_init("hello project")

# After first commit, change the README, commit again
with open("README.md", "w") as fd:
fd.write("downstream version 1\n")
git("commit", "-am", "updated readme")

# Now change the template
with local.cwd(template_path):

# Update the README
with open("README.md", "w") as fd:
fd.write("upstream version 2\n")

# Create a subdirectory, move files into it
os.mkdir("subdir")
os.rename("README.md", "subdir/README.md")
os.rename(
"[[_copier_conf.answers_file]].tmpl",
"subdir/[[_copier_conf.answers_file]].tmpl",
)

# Add the subdirectory option to copier.yml
with open("copier.yml", "w") as fd:
fd.write("_subdirectory: subdir\n")

# Commit the changes
git("add", ".", "-A")
git("commit", "-m", "use a subdirectory now")
git("tag", "v2")

# Finally, update the generated project
copier.copy(dst_path=project_path, force=True)
assert "_commit: v2" in (project_path / ".copier-answers.yml").read_text()

# Assert that the README still exists, and was force updated to "upstream version 2"
assert (project_path / "README.md").exists()
with (project_path / "README.md").open() as fd:
assert fd.read() == "upstream version 2\n"

# Also assert the subdirectory itself was not rendered
assert not (project_path / "subdir").exists()


def test_new_version_changes_subdirectory(tmp_path_factory):
# Template in v3 changes from one subdirectory to another.
# Some file evolves also. Sub-project evolves separately.
# Sub-project is updated. Does that work as expected?
template_path = tmp_path_factory.mktemp("subdirectory_template")
project_path = tmp_path_factory.mktemp("subdirectory_project")

# First, create the template with an initial subdirectory and README inside it
with local.cwd(template_path):
os.mkdir("subdir1")

with open("subdir1/README.md", "w") as fd:
fd.write("upstream version 1\n")

with open("subdir1/[[_copier_conf.answers_file]].tmpl", "w") as fd:
fd.write("[[_copier_answers|to_nice_yaml]]\n")

# Add the subdirectory option to copier.yml
with open("copier.yml", "w") as fd:
fd.write("_subdirectory: subdir1\n")

git_init("hello template")

# Generate the project a first time, assert the README exists
copier.copy(str(template_path), project_path, force=True)
assert (project_path / "README.md").exists()

# Start versioning the generated project
with local.cwd(project_path):
git_init("hello project")

# After first commit, change the README, commit again
with open("README.md", "w") as fd:
fd.write("downstream version 1\n")
git("commit", "-am", "updated readme")

# Now change the template
with local.cwd(template_path):

# Update the README
with open("subdir1/README.md", "w") as fd:
fd.write("upstream version 2\n")

# Rename the subdirectory
os.rename("subdir1", "subdir2")

# Update copier.yml to reflect this change
with open("copier.yml", "w") as fd:
fd.write("_subdirectory: subdir2\n")

# Commit the changes
git("add", ".", "-A")
git("commit", "-m", "changed from subdir1 to subdir2")

# Finally, update the generated project
copier.copy(
str(template_path), project_path, force=True, skip_if_exists=["README.md"]
)

# Assert that the README still exists, and was NOT force updated
assert (project_path / "README.md").exists()
with (project_path / "README.md").open() as fd:
assert fd.read() == "downstream version 1\n"

# Also assert the subdirectories themselves were not rendered
assert not (project_path / "subdir1").exists()
assert not (project_path / "subdir2").exists()

0 comments on commit 2f087bd

Please sign in to comment.