* Refactor
* Fix #110.
* Rewrite test_config_exclude, test_config_exclude_overridden and test_config_include. These tests were badly designed, using a monkeypatch that would never happen in the real world, and actually producing false positives. I moved them to test_exclude.py and rewritten to test the what and not the how.
* Fix #214 by removing skip option. Relevant tests use the better skip_if_exists=["**"].
* Remove subdirectory flag from API/CLI. It was confusing and could lead to bad maintenance situations. Fixes #315.
* Remove extra_paths and fix #321
* Remember that you cannot use _copier_conf.src_path as a path now
* use dataclasses
* Create errors module, simplify some tests, fix many others
* Fix some tests, complete EnvOps removal
* Fix #214 and some tests related to it
* Reorder code
* Update docs and imports
* Modularize test_complex_questions
* Interlink worker and questionary a bit better
* Removal of Questionary class, which only had 1 meaningful method that is now merged into Worker to avoid circular dependencies.
* Fix #280 in a simple way: only user answers are type-casted inside API, and CLI transforms all `--data` using YAML always. Predictable.
* Use prereleases correctly.
* Reorder AnswersMap to have a more significative repr.
* Simpler cache for old `Question.get_choices()` method (renamed now).
* fix wrong test
* Fix test_subdirectory
* Fix test_tasks (and remove tests/demo_tasks)
* Fix path filter tests, and move it to test_exclude, where it belongs
* Make test_config pass
* Fix more wrongly designed tests
* Use cached_property backport if needed
* xfail test known to fail on mac
* force posix paths on windows
* Add typing_extensions for python < 3.8
* Sort dependencies in pyproject.toml
* Support python 3.6 str-to-datetime conversion
* Workaround https://bugs.python.org/issue43095
* xfail test_path_filter on windows
* Upgrade mkdocs and other dependencies to fix https://github.com/pawamoy/mkdocstrings/issues/222
* Add missing reference docs.
* Add workaround for https://github.com/pawamoy/mkdocstrings/pull/209
* Docs.
* Remove validators module
* Add workaround for https://github.com/pawamoy/mkdocstrings/issues/225
* Restore docs autorefs as explained in https://github.com/pawamoy/mkdocstrings/issues/226#issuecomment-775413562.
* Workaround https://github.com/pawamoy/pytkdocs/issues/86
This commit is contained in:
Jairo Llopis 2021-02-09 18:22:47 +00:00 committed by GitHub
parent 07a66f793e
commit 0441b86f0c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 2220 additions and 1881 deletions

4
.flake8 Normal file
View File

@ -0,0 +1,4 @@
[flake8]
extend-ignore = W503,E203,E501,D100,D101,D102,D103,D104,D105,D107
max-complexity = 20
max-line-length = 88

View File

@ -28,7 +28,6 @@ repos:
entry: poetry run flake8
language: system
types: [python]
args: ["--config", "pyproject.toml"]
require_serial: true
- id: poetry_check
description: Check the integrity of pyproject.toml

View File

@ -1,7 +1,10 @@
"""Copier (previously known as "Voodoo")
"""Copier.
Docs: https://copier.readthedocs.io/
"""
from .main import * # noqa
from .tools import * # noqa
from .main import * # noqa: F401,F403
from .main import run_auto as copy # noqa: F401; Backwards compatibility
# This version is a placeholder autoupdated by poetry-dynamic-versioning
__version__ = "0.0.0"

View File

@ -20,11 +20,12 @@ from io import StringIO
from textwrap import dedent
from unittest.mock import patch
import yaml
from plumbum import cli, colors
from . import __version__
from .config.objects import UserMessageError
from .main import copy
from .errors import UserMessageError
from .main import Worker
from .types import AnyByStrDict, List, OptStr
@ -50,10 +51,8 @@ class CopierApp(cli.Application):
Attributes:
answers_file: Set [answers_file][] option.
extra_paths: Set [extra_paths][] option.
exclude: Set [exclude][] option.
vcs_ref: Set [vcs_ref][] option.
subdirectory: Set [subdirectory][] option.
pretend: Set [pretend][] option.
force: Set [force][] option.
skip: Set [skip_if_exists][] option.
@ -97,12 +96,6 @@ class CopierApp(cli.Application):
"to find the answers file"
),
)
extra_paths: cli.SwitchAttr = cli.SwitchAttr(
["-p", "--extra-paths"],
str,
list=True,
help="Additional directories to find parent templates in",
)
exclude: cli.SwitchAttr = cli.SwitchAttr(
["-x", "--exclude"],
str,
@ -122,23 +115,17 @@ class CopierApp(cli.Application):
"the latest version, use `--vcs-ref=HEAD`."
),
)
subdirectory: cli.SwitchAttr = 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 = cli.Flag(
["-n", "--pretend"], help="Run but do not make any changes"
)
force: cli.Flag = cli.Flag(
["-f", "--force"], help="Overwrite files that already exist, without asking"
)
skip: cli.Flag = cli.Flag(
["-s", "--skip"], help="Skip files that already exist, without asking"
skip: cli.Flag = cli.SwitchAttr(
["-s", "--skip"],
str,
list=True,
help="Skip specified files if they exist already",
)
quiet: cli.Flag = cli.Flag(["-q", "--quiet"], help="Suppress status output")
prereleases: cli.Flag = cli.Flag(
@ -160,30 +147,30 @@ class CopierApp(cli.Application):
values: The list of values to apply.
Each value in the list is of the following form: `NAME=VALUE`.
"""
self.data.update(value.split("=", 1) for value in values) # type: ignore
for arg in values:
key, value = arg.split("=", 1)
value = yaml.safe_load(value)
self.data[key] = value
def _copy(self, src_path: OptStr = None, dst_path: str = ".", **kwargs) -> None:
def _worker(self, src_path: OptStr = None, dst_path: str = ".", **kwargs) -> Worker:
"""
Run Copier's internal API using CLI switches.
Arguments:
src_path: The source path of the template to generate the project from.
dst_path: The path to generate the project to.
**kwargs: Arguments passed to [`copy`][copier.main.copy].
**kwargs: Arguments passed to [Worker][copier.main.Worker].
"""
return copy(
return Worker(
data=self.data,
dst_path=dst_path,
answers_file=self.answers_file,
exclude=self.exclude,
extra_paths=self.extra_paths,
force=self.force,
pretend=self.pretend,
quiet=self.quiet,
skip=self.skip,
src_path=src_path,
vcs_ref=self.vcs_ref,
subdirectory=self.subdirectory,
use_prereleases=self.prereleases,
**kwargs,
)
@ -249,7 +236,7 @@ class CopierCopySubApp(cli.Application):
@handle_exceptions
def main(self, template_src: str, destination_path: str) -> int:
"""Call [`copy`][copier.main.copy] in copy mode.
"""Call [run_copy][copier.main.Worker.run_copy].
Params:
template_src:
@ -260,12 +247,11 @@ class CopierCopySubApp(cli.Application):
destination_path:
Where to generate the new subproject. It must not exist or be empty.
"""
self.parent._copy(
self.parent._worker(
template_src,
destination_path,
cleanup_on_error=self.cleanup_on_error,
only_diff=False,
)
).run_copy()
return 0
@ -293,7 +279,7 @@ class CopierUpdateSubApp(cli.Application):
@handle_exceptions
def main(self, destination_path: cli.ExistingDirectory = ".") -> int:
"""Call [`copy`][copier.main.copy] in update mode.
"""Call [run_update][copier.main.Worker.run_update].
Parameters:
destination_path:
@ -303,10 +289,9 @@ class CopierUpdateSubApp(cli.Application):
The subproject must exist. If not specified, the currently
working directory is used.
"""
self.parent._copy(
self.parent._worker(
dst_path=destination_path,
only_diff=True,
)
).run_update()
return 0

View File

@ -1 +0,0 @@
from .factory import make_config # noqa

View File

@ -1,149 +0,0 @@
"""Functions used to generate configuration data."""
from collections import ChainMap
from typing import Tuple
from packaging import version
from plumbum import local
from plumbum.cmd import git
from .. import vcs
from ..types import AnyByStrDict, OptAnyByStrDict, OptBool, OptStr, OptStrSeq
from .objects import (
DEFAULT_EXCLUDE,
ConfigData,
EnvOps,
NoSrcPathError,
UserMessageError,
)
from .user_data import load_answersfile_data, load_config_data, query_user_data
__all__ = ("make_config",)
def filter_config(data: AnyByStrDict) -> Tuple[AnyByStrDict, AnyByStrDict]:
"""Separates config and questions data."""
conf_data: AnyByStrDict = {"secret_questions": set()}
questions_data = {}
for k, v in data.items():
if k == "_secret_questions":
conf_data["secret_questions"].update(v)
elif k.startswith("_"):
conf_data[k[1:]] = v
else:
# Transform simplified questions format into complex
if not isinstance(v, dict):
v = {"default": v}
questions_data[k] = v
if v.get("secret"):
conf_data["secret_questions"].add(k)
return conf_data, questions_data
def verify_minimum_version(version_str: str) -> None:
"""Raise an error if the current Copier version is less than the given version."""
# Importing __version__ at the top of the module creates a circular import
# ("cannot import name '__version__' from partially initialized module 'copier'"),
# so instead we do a lazy import here
from .. import __version__
# Disable check when running copier as editable installation
if __version__ == "0.0.0":
return
if version.parse(__version__) < version.parse(version_str):
raise UserMessageError(
f"This template requires Copier version >= {version_str}, "
f"while your version of Copier is {__version__}."
)
def make_config(
src_path: OptStr = None,
dst_path: str = ".",
*,
answers_file: OptStr = None,
exclude: OptStrSeq = None,
skip_if_exists: OptStrSeq = None,
tasks: OptStrSeq = None,
envops: OptAnyByStrDict = None,
extra_paths: OptStrSeq = None,
pretend: OptBool = None,
force: OptBool = None,
skip: OptBool = None,
quiet: OptBool = None,
cleanup_on_error: OptBool = None,
vcs_ref: OptStr = None,
only_diff: OptBool = True,
subdirectory: OptStr = None,
use_prereleases: OptBool = False,
**kwargs,
) -> ConfigData:
"""Provides the configuration object, merged from the different sources.
The order of precedence for the merger of configuration objects is:
function_args > user_data > defaults.
"""
exclude = list(exclude or [])
# These args are provided by API or CLI call
init_args = {k: v for k, v in locals().items() if v is not None and v != []}
# Store different answer sources
init_args["data_from_answers_file"] = load_answersfile_data(dst_path, answers_file)
if "_commit" in init_args["data_from_answers_file"]:
init_args["old_commit"] = init_args["data_from_answers_file"]["_commit"]
# Detect original source if running in update mode
if src_path is None:
try:
src_path = init_args["data_from_answers_file"]["_src_path"]
except KeyError:
raise NoSrcPathError(
"No copier answers file found, or it didn't include "
"original template information (_src_path). "
"Run `copier copy` instead."
)
init_args["original_src_path"] = src_path
if src_path:
repo = vcs.get_repo(src_path)
if repo:
src_path = vcs.clone(repo, vcs_ref or "HEAD")
vcs_ref = vcs_ref or vcs.checkout_latest_tag(src_path, use_prereleases)
with local.cwd(src_path):
init_args["commit"] = git("describe", "--tags", "--always").strip()
init_args["src_path"] = src_path
# Obtain config and query data, asking the user if needed
file_data = load_config_data(src_path, quiet=True)
try:
verify_minimum_version(file_data["_min_copier_version"])
except KeyError:
pass
template_config_data, questions_data = filter_config(file_data)
init_args["exclude"] = (
list(template_config_data.get("exclude", DEFAULT_EXCLUDE)) + exclude
)
init_args["data_from_template_defaults"] = {
k: v.get("default") for k, v in questions_data.items()
}
init_args["envops"] = EnvOps(**template_config_data.get("envops", {}))
data = kwargs.get("data") or {}
init_args["data_from_init"] = ChainMap(
query_user_data(
{k: v for k, v in questions_data.items() if k in data},
{},
data,
init_args["data_from_template_defaults"],
False,
init_args["envops"],
),
data,
)
init_args["data_from_asking_user"] = query_user_data(
questions_data,
init_args["data_from_answers_file"],
init_args["data_from_init"],
init_args["data_from_template_defaults"],
not force,
init_args["envops"],
)
return ConfigData(**ChainMap(init_args, template_config_data))

View File

@ -1,149 +0,0 @@
"""Pydantic models, exceptions and default values."""
import datetime
from collections import ChainMap
from copy import deepcopy
from hashlib import sha512
from os import urandom
from pathlib import Path
from typing import Any, ChainMap as t_ChainMap, Sequence, Tuple, Union
from pydantic import BaseModel, Extra, Field, PrivateAttr, StrictBool, validator
from ..types import AnyByStrDict, OptStr, PathSeq, StrOrPathSeq, StrSeq
# Default list of files in the template to exclude from the rendered project
DEFAULT_EXCLUDE: Tuple[str, ...] = (
"copier.yaml",
"copier.yml",
"~*",
"*.py[co]",
"__pycache__",
".git",
".DS_Store",
".svn",
)
DEFAULT_DATA: AnyByStrDict = {
"now": datetime.datetime.utcnow,
"make_secret": lambda: sha512(urandom(48)).hexdigest(),
}
DEFAULT_TEMPLATES_SUFFIX = ".tmpl"
class UserMessageError(Exception):
"""Exit the program giving a message to the user."""
class NoSrcPathError(UserMessageError):
pass
class EnvOps(BaseModel):
"""Jinja2 environment options."""
autoescape: StrictBool = False
block_start_string: str = "[%"
block_end_string: str = "%]"
comment_start_string: str = "[#"
comment_end_string: str = "#]"
variable_start_string: str = "[["
variable_end_string: str = "]]"
keep_trailing_newline: StrictBool = True
class Config:
allow_mutation = False
extra = Extra.allow
class Migrations(BaseModel):
version: str
before: Sequence[Union[str, StrSeq]] = ()
after: Sequence[Union[str, StrSeq]] = ()
class ConfigData(BaseModel):
"""A model holding configuration data."""
src_path: Path
subdirectory: OptStr
dst_path: Path
extra_paths: PathSeq = ()
exclude: StrOrPathSeq = DEFAULT_EXCLUDE
skip_if_exists: StrOrPathSeq = ()
tasks: Sequence[Union[str, StrSeq]] = ()
envops: EnvOps = EnvOps()
templates_suffix: str = DEFAULT_TEMPLATES_SUFFIX
original_src_path: OptStr
commit: OptStr
old_commit: OptStr
cleanup_on_error: StrictBool = True
force: StrictBool = False
only_diff: StrictBool = True
pretend: StrictBool = False
quiet: StrictBool = False
skip: StrictBool = False
use_prereleases: StrictBool = False
vcs_ref: OptStr
migrations: Sequence[Migrations] = ()
secret_questions: StrSeq = ()
answers_file: Path = Path(".copier-answers.yml")
data_from_init: AnyByStrDict = Field(default_factory=dict)
data_from_asking_user: AnyByStrDict = Field(default_factory=dict)
data_from_answers_file: AnyByStrDict = Field(default_factory=dict)
data_from_template_defaults: AnyByStrDict = Field(default_factory=dict)
# Private
_data_mutable: AnyByStrDict = PrivateAttr(default_factory=dict)
def __init__(self, **kwargs: AnyByStrDict):
super().__init__(**kwargs)
self.data_from_template_defaults.setdefault("_folder_name", self.dst_path.name)
@validator("skip", always=True)
def mutually_exclusive_flags(cls, v, values): # noqa: B902
if v and values["force"]:
raise ValueError("Flags `force` and `skip` are mutually exclusive.")
return v
# sanitizers
@validator("src_path", "dst_path", "extra_paths", pre=True, each_item=True)
def resolve_path(cls, v: Path) -> Path: # noqa: B902
return Path(v).expanduser().resolve()
@validator("src_path", "extra_paths", pre=True, each_item=True)
def dir_must_exist(cls, v: Path) -> Path: # noqa: B902
if not v.exists():
raise ValueError("Project template not found.")
if not v.is_dir():
raise ValueError("Project template not a folder.")
return v
@validator(
"data_from_init",
"data_from_asking_user",
"data_from_answers_file",
"data_from_template_defaults",
pre=True,
each_item=True,
)
def dict_copy(cls, v: AnyByStrDict) -> AnyByStrDict:
"""Make sure all dicts are copied."""
return deepcopy(v)
@property
def data(self) -> t_ChainMap[str, Any]:
"""The data object comes from different sources, sorted by priority."""
return ChainMap(
self._data_mutable,
self.data_from_asking_user,
self.data_from_init,
self.data_from_answers_file,
self.data_from_template_defaults,
DEFAULT_DATA,
)
# configuration
class Config:
allow_mutation = False
anystr_strip_whitespace = True

56
copier/errors.py Normal file
View File

@ -0,0 +1,56 @@
"""Custom exceptions used by Copier."""
from pathlib import Path
from pydantic.errors import _PathValueError
from .tools import printf_exception
from .types import PathSeq
class UserMessageError(Exception):
"""Exit the program giving a message to the user."""
class UnsupportedVersionError(UserMessageError):
"""Copier version does not support template version."""
class ConfigFileError(ValueError):
"""Parent class defining problems with the config file."""
class InvalidConfigFileError(ConfigFileError):
"""Indicates that the config file is wrong."""
def __init__(self, conf_path: Path, quiet: bool):
msg = str(conf_path)
printf_exception(self, "INVALID CONFIG FILE", msg=msg, quiet=quiet)
super().__init__(msg)
class MultipleConfigFilesError(ConfigFileError):
"""Both copier.yml and copier.yaml found, and that's an error."""
def __init__(self, conf_paths: "PathSeq", quiet: bool):
msg = str(conf_paths)
printf_exception(self, "MULTIPLE CONFIG FILES", msg=msg, quiet=quiet)
super().__init__(msg)
class InvalidTypeError(TypeError):
"""The question type is not among the supported ones."""
class PathNotAbsoluteError(_PathValueError):
"""The path is not absolute, but it should be."""
code = "path.not_absolute"
msg_template = '"{path}" is not an absolute path'
class PathNotRelativeError(_PathValueError):
"""The path is not relative, but it should be."""
code = "path.not_relative"
msg_template = '"{path}" is not a relative path'

File diff suppressed because it is too large Load Diff

80
copier/subproject.py Normal file
View File

@ -0,0 +1,80 @@
"""Objects to interact with subprojects.
A *subproject* is a project that gets rendered and/or updated with Copier.
"""
from pathlib import Path
from typing import Optional
import yaml
from plumbum.cmd import git
from plumbum.machines import local
from pydantic.dataclasses import dataclass
from .template import Template
from .types import AbsolutePath, AnyByStrDict, VCSTypes
from .vcs import is_git_repo_root
try:
from functools import cached_property
except ImportError:
from backports.cached_property import cached_property
@dataclass
class Subproject:
"""Object that represents the subproject and its current state.
Attributes:
local_abspath:
Absolute path on local disk pointing to the subproject root folder.
answers_relpath:
Relative path to [the answers file][the-copier-answersyml-file].
"""
local_abspath: AbsolutePath
answers_relpath: Path = Path(".copier-answers.yml")
def is_dirty(self) -> bool:
"""Indicates if the local template root is dirty.
Only applicable for VCS-tracked templates.
"""
if self.vcs == "git":
with local.cwd(self.local_abspath):
return bool(git("status", "--porcelain").strip())
return False
@property
def _raw_answers(self) -> AnyByStrDict:
"""The last answers, loaded raw as yaml."""
try:
return yaml.safe_load(
(self.local_abspath / self.answers_relpath).read_text()
)
except OSError:
return {}
@cached_property
def last_answers(self) -> AnyByStrDict:
"""Last answers, excluding private ones (except _src_path and _commit)."""
return {
key: value
for key, value in self._raw_answers.items()
if key in {"_src_path", "_commit"} or not key.startswith("_")
}
@cached_property
def template(self) -> Optional[Template]:
"""Template, as it was used the last time."""
last_url = self.last_answers.get("_src_path")
last_ref = self.last_answers.get("_commit")
if last_url:
return Template(url=last_url, ref=last_ref)
@cached_property
def vcs(self) -> Optional[VCSTypes]:
"""VCS type of the subproject."""
if is_git_repo_root(self.local_abspath):
return "git"

316
copier/template.py Normal file
View File

@ -0,0 +1,316 @@
"""Tools related to template management."""
from contextlib import suppress
from pathlib import Path
from typing import List, Mapping, Optional, Sequence, Set, Tuple
from packaging import version
from packaging.version import parse
from plumbum.cmd import git
from plumbum.machines import local
from pydantic.dataclasses import dataclass
from .errors import UnsupportedVersionError
from .types import AnyByStrDict, OptStr, StrSeq, VCSTypes
from .user_data import load_config_data
from .vcs import checkout_latest_tag, clone, get_repo
try:
from functools import cached_property
except ImportError:
from backports.cached_property import cached_property
# Default list of files in the template to exclude from the rendered project
DEFAULT_EXCLUDE: Tuple[str, ...] = (
"copier.yaml",
"copier.yml",
"~*",
"*.py[co]",
"__pycache__",
".git",
".DS_Store",
".svn",
)
DEFAULT_TEMPLATES_SUFFIX = ".tmpl"
def filter_config(data: AnyByStrDict) -> Tuple[AnyByStrDict, AnyByStrDict]:
"""Separates config and questions data."""
conf_data: AnyByStrDict = {"secret_questions": set()}
questions_data = {}
for k, v in data.items():
if k == "_secret_questions":
conf_data["secret_questions"].update(v)
elif k.startswith("_"):
conf_data[k[1:]] = v
else:
# Transform simplified questions format into complex
if not isinstance(v, dict):
v = {"default": v}
questions_data[k] = v
if v.get("secret"):
conf_data["secret_questions"].add(k)
return conf_data, questions_data
def verify_minimum_version(version_str: str) -> None:
"""Raise an error if the current Copier version is less than the given version."""
# Importing __version__ at the top of the module creates a circular import
# ("cannot import name '__version__' from partially initialized module 'copier'"),
# so instead we do a lazy import here
from . import __version__
# Disable check when running copier as editable installation
if __version__ == "0.0.0":
return
if version.parse(__version__) < version.parse(version_str):
raise UnsupportedVersionError(
f"This template requires Copier version >= {version_str}, "
f"while your version of Copier is {__version__}."
)
@dataclass
class Template:
"""Object that represents a template and its current state.
See [configuring a template][configuring-a-template].
Attributes:
url:
Absolute origin that points to the template.
It can be:
- A local path.
- A Git url. Note: if something fails, prefix the URL with `git+`.
ref:
The tag to checkout in the template.
Only used if `url` points to a VCS-tracked template.
If `None`, then it will checkout the latest tag, sorted by PEP440.
Otherwise it will checkout the reference used here.
Usually it should be a tag, or `None`.
use_prereleases:
When `True`, the template's *latest* release will consider prereleases.
Only used if:
- `url` points to a VCS-tracked template
- `ref` is `None`.
Helpful if you want to test templates before doing a proper release, but you
need some features that require a proper PEP440 version identifier.
"""
url: str
ref: OptStr = None
use_prereleases: bool = False
@cached_property
def _raw_config(self) -> AnyByStrDict:
"""Get template configuration, raw.
It reads [the `copier.yml` file][the-copieryml-file].
"""
result = load_config_data(self.local_abspath)
with suppress(KeyError):
verify_minimum_version(result["_min_copier_version"])
return result
@cached_property
def answers_relpath(self) -> Path:
"""Get the answers file relative path, as specified in the template.
If not specified, returns the default `.copier-answers.yml`.
See [answers_file][].
"""
result = Path(self.config_data.get("answers_file", ".copier-answers.yml"))
assert not result.is_absolute()
return result
@cached_property
def commit(self) -> OptStr:
"""If the template is VCS-tracked, get its commit description."""
if self.vcs == "git":
with local.cwd(self.local_abspath):
return git("describe", "--tags", "--always").strip()
@cached_property
def config_data(self) -> AnyByStrDict:
"""Get config from the template.
It reads [the `copier.yml` file][the-copieryml-file] to get its
[settings][available-settings].
"""
return filter_config(self._raw_config)[0]
@cached_property
def default_answers(self) -> AnyByStrDict:
"""Get default answers for template's questions."""
return {key: value.get("default") for key, value in self.questions_data.items()}
@cached_property
def envops(self) -> Mapping:
"""Get the Jinja configuration specified in the template, or default values.
See [envops][].
"""
# TODO Use Jinja defaults
result = {
"autoescape": False,
"block_end_string": "%]",
"block_start_string": "[%",
"comment_end_string": "#]",
"comment_start_string": "[#",
"keep_trailing_newline": True,
"variable_end_string": "]]",
"variable_start_string": "[[",
}
result.update(self.config_data.get("envops", {}))
return result
@cached_property
def exclude(self) -> Tuple[str, ...]:
"""Get exclusions specified in the template, or default ones.
See [exclude][].
"""
return tuple(self.config_data.get("exclude", DEFAULT_EXCLUDE))
@cached_property
def metadata(self) -> AnyByStrDict:
"""Get template metadata.
This data, if any, should be saved in the answers file to be able to
restore the template to this same state.
"""
result: AnyByStrDict = {"_src_path": self.url}
if self.commit:
result["_commit"] = self.commit
return result
def migration_tasks(self, stage: str, from_: str, to: str) -> Sequence[Mapping]:
"""Get migration objects that match current version spec.
Versions are compared using PEP 440.
See [migrations][].
"""
result: List[dict] = []
if not from_ or not to:
return result
parsed_from = parse(from_)
parsed_to = parse(to)
extra_env = {
"STAGE": stage,
"VERSION_FROM": from_,
"VERSION_TO": to,
}
migration: dict
for migration in self._raw_config.get("_migrations", []):
if parsed_to >= parse(migration["version"]) > parsed_from:
extra_env = dict(extra_env, VERSION_CURRENT=str(migration["version"]))
result += [
{"task": task, "extra_env": extra_env}
for task in migration.get(stage, [])
]
return result
@cached_property
def questions_data(self) -> AnyByStrDict:
"""Get questions from the template.
See [questions][].
"""
return filter_config(self._raw_config)[1]
@cached_property
def secret_questions(self) -> Set[str]:
"""Get names of secret questions from the template.
These questions shouldn't be saved into the answers file.
"""
result = set(self.config_data.get("secret_questions", {}))
for key, value in self.questions_data.items():
if value.get("secret"):
result.add(key)
return result
@cached_property
def skip_if_exists(self) -> StrSeq:
"""Get skip patterns from the template.
These files will never be rewritten when rendering the template.
See [skip_if_exists][].
"""
return self.config_data.get("skip_if_exists", ())
@cached_property
def subdirectory(self) -> str:
"""Get the subdirectory as specified in the template.
The subdirectory points to the real template code, allowing the
templater to separate it from other template assets, such as docs,
tests, etc.
See [subdirectory][].
"""
return self.config_data.get("subdirectory", "")
@cached_property
def tasks(self) -> Sequence:
"""Get tasks defined in the template.
See [tasks][].
"""
return self.config_data.get("tasks", [])
@cached_property
def templates_suffix(self) -> str:
"""Get the suffix defined for templates.
By default: `.tmpl`.
See [templates_suffix][].
"""
return self.config_data.get("templates_suffix", DEFAULT_TEMPLATES_SUFFIX)
@cached_property
def local_abspath(self) -> Path:
"""Get the absolute path to the template on disk.
This may clone it if `url` points to a
VCS-tracked template.
"""
result = Path(self.url)
if self.vcs == "git":
result = Path(clone(self.url_expanded, self.ref))
if self.ref is None:
checkout_latest_tag(result, self.use_prereleases)
if not result.is_dir():
raise ValueError("Local template must be a directory.")
return result.absolute()
@cached_property
def url_expanded(self) -> str:
"""Get usable URL.
`url` can be specified in shortcut
format, which wouldn't be understood by the underlying VCS system. This
property returns the expanded version, which should work properly.
"""
return get_repo(self.url) or self.url
@cached_property
def vcs(self) -> Optional[VCSTypes]:
"""Get VCS system used by the template, if any."""
if get_repo(self.url):
return "git"

View File

@ -1,34 +1,16 @@
"""Some utility functions."""
import errno
import os
import shutil
import sys
import unicodedata
from contextlib import suppress
from pathlib import Path
from typing import Any, Dict, List, Optional, TextIO, Union
from typing import Any, Optional, TextIO, Union
import colorama
import pathspec
from jinja2 import FileSystemLoader
from jinja2.sandbox import SandboxedEnvironment
from packaging import version
from pydantic import StrictBool
from yaml import safe_dump
from .config.objects import ConfigData, EnvOps
from .types import (
AnyByStrDict,
CheckPathFunc,
Filters,
IntSeq,
JSONSerializable,
LoaderPaths,
StrOrPath,
StrOrPathSeq,
T,
)
from .types import IntSeq
__all__ = ("Style", "printf")
@ -80,12 +62,6 @@ def printf_exception(
print(HLINE, file=sys.stderr)
def required(value: T, **kwargs: Any) -> T:
if not value:
raise ValueError()
return value
def cast_str_to_bool(value: Any) -> bool:
"""Parse anything to bool.
@ -113,15 +89,6 @@ def cast_str_to_bool(value: Any) -> bool:
return bool(value)
def make_folder(folder: Path) -> None:
if not folder.exists():
try:
os.makedirs(str(folder))
except OSError as e:
if e.errno != errno.EEXIST:
raise
def copy_file(src_path: Path, dst_path: Path, follow_symlinks: bool = True) -> None:
shutil.copy2(src_path, dst_path, follow_symlinks=follow_symlinks)
@ -137,69 +104,6 @@ def to_nice_yaml(data: Any, **kwargs) -> str:
return result or ""
def get_jinja_env(
envops: "EnvOps",
filters: Optional[Filters] = None,
paths: Optional[LoaderPaths] = None,
**kwargs: Any,
) -> SandboxedEnvironment:
"""Return a pre-configured Jinja environment."""
loader = FileSystemLoader(paths) if paths else None
# We want to minimize the risk of hidden malware in the templates
# so we use the SandboxedEnvironment instead of the regular one.
# Of couse we still have the post-copy tasks to worry about, but at least
# they are more visible to the final user.
env = SandboxedEnvironment(loader=loader, **envops.dict(), **kwargs)
default_filters = {"to_nice_yaml": to_nice_yaml}
default_filters.update(filters or {})
env.filters.update(default_filters)
return env
class Renderer:
"""The Jinja template renderer."""
def __init__(self, conf: "ConfigData") -> None:
envops: "EnvOps" = conf.envops
paths = [str(conf.src_path)] + list(map(str, conf.extra_paths or []))
self.env = get_jinja_env(envops=envops, paths=paths)
self.conf = conf
answers: AnyByStrDict = {}
# All internal values must appear first
if conf.commit:
answers["_commit"] = conf.commit
if conf.original_src_path is not None:
answers["_src_path"] = conf.original_src_path
# Other data goes next
answers.update(
(k, v)
for (k, v) in conf.data.items()
if not k.startswith("_")
and k not in conf.secret_questions
and isinstance(k, JSONSerializable)
and isinstance(v, JSONSerializable)
)
self.data = dict(
conf.data,
_copier_answers=answers,
_copier_conf=conf.copy(deep=True, exclude={"data": {"now", "make_secret"}}),
)
def __call__(self, fullpath: StrOrPath) -> str:
relpath = Path(fullpath).relative_to(self.conf.src_path).as_posix()
tmpl = self.env.get_template(str(relpath))
return tmpl.render(**self.data)
def string(self, string: StrOrPath) -> str:
tmpl = self.env.from_string(str(string))
return tmpl.render(**self.data)
def normalize_str(text: StrOrPath, form: str = "NFD") -> str:
"""Normalize unicode text. Uses the NFD algorithm by default."""
return unicodedata.normalize(form, str(text))
def force_str_end(original_str: str, end: str = "\n") -> str:
"""Make sure a `original_str` ends with `end`.
@ -210,39 +114,3 @@ def force_str_end(original_str: str, end: str = "\n") -> str:
if not original_str.endswith(end):
return original_str + end
return original_str
def create_path_filter(patterns: StrOrPathSeq) -> CheckPathFunc:
"""Returns a function that matches a path against given patterns."""
patterns = [normalize_str(p) for p in patterns]
spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
def match(path: StrOrPath) -> bool:
return spec.match_file(str(path))
return match
def get_migration_tasks(conf: "ConfigData", stage: str) -> List[Dict]:
"""Get migration objects that match current version spec.
Versions are compared using PEP 440.
"""
result: List[Dict] = []
if not conf.old_commit or not conf.commit:
return result
vfrom = version.parse(conf.old_commit)
vto = version.parse(conf.commit)
extra_env = {
"STAGE": stage,
"VERSION_FROM": conf.old_commit,
"VERSION_TO": conf.commit,
}
for migration in conf.migrations:
if vto >= version.parse(migration.version) > vfrom:
extra_env = dict(extra_env, VERSION_CURRENT=str(migration.version))
result += [
{"task": task, "extra_env": extra_env}
for task in migration.dict().get(stage, [])
]
return result

View File

@ -1,7 +1,8 @@
"""All complex types and annotations are declared here."""
"""Complex types, annotations, validators."""
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
@ -9,13 +10,26 @@ from typing import (
List,
Optional,
Sequence,
Tuple,
TypeVar,
Union,
)
from pydantic.validators import path_validator
try:
from typing import Literal
except ImportError:
from typing_extensions import Literal
if TYPE_CHECKING:
from pydantic.typing import CallableGenerator
# simple types
StrOrPath = Union[str, Path]
AnyByStrDict = Dict[str, Any]
AnyByStrDictOrTuple = Union[AnyByStrDict, Tuple[str, Any]]
# sequences
IntSeq = Sequence[int]
@ -38,3 +52,40 @@ T = TypeVar("T")
JSONSerializable = (dict, list, str, int, float, bool, type(None))
Filters = Dict[str, Callable]
LoaderPaths = Union[str, Iterable[str]]
VCSTypes = Literal["git"]
class AllowArbitraryTypes:
arbitrary_types_allowed = True
# Validators
def path_is_absolute(value: Path) -> Path:
if not value.is_absolute():
from .errors import PathNotAbsoluteError
raise PathNotAbsoluteError(path=value)
return value
def path_is_relative(value: Path) -> Path:
if value.is_absolute():
from .errors import PathNotRelativeError
raise PathNotRelativeError(path=value)
return value
# Validated types
class AbsolutePath(Path):
@classmethod
def __get_validators__(cls) -> "CallableGenerator":
yield path_validator
yield path_is_absolute
class RelativePath(Path):
@classmethod
def __get_validators__(cls) -> "CallableGenerator":
yield path_validator
yield path_is_relative

View File

@ -1,53 +1,118 @@
"""Functions used to load user data."""
import datetime
import json
import re
from collections import ChainMap
from contextlib import suppress
from dataclasses import field
from hashlib import sha512
from os import urandom
from pathlib import Path
from typing import Any, Callable, ChainMap as t_ChainMap, Dict, List, Union
from typing import (
TYPE_CHECKING,
Any,
Callable,
ChainMap as t_ChainMap,
Dict,
List,
Union,
)
import yaml
from iteration_utilities import deepflatten
from jinja2 import UndefinedError
from jinja2.sandbox import SandboxedEnvironment
from prompt_toolkit.lexers import PygmentsLexer
from pydantic import BaseModel, Field, PrivateAttr, validator
from pydantic import validator
from pydantic.dataclasses import dataclass
from pygments.lexers.data import JsonLexer, YamlLexer
from questionary import unsafe_prompt
from questionary.prompts.common import Choice
from yamlinclude import YamlIncludeConstructor
from ..tools import cast_str_to_bool, force_str_end, get_jinja_env, printf_exception
from ..types import AnyByStrDict, OptStrOrPath, PathSeq, StrOrPath
from .objects import DEFAULT_DATA, EnvOps, UserMessageError
from .errors import (
InvalidConfigFileError,
InvalidTypeError,
MultipleConfigFilesError,
UserMessageError,
)
from .tools import cast_str_to_bool, force_str_end
from .types import AllowArbitraryTypes, AnyByStrDict, OptStr, OptStrOrPath, StrOrPath
__all__ = ("load_config_data", "query_user_data")
try:
from functools import cached_property
except ImportError:
from backports.cached_property import cached_property
class ConfigFileError(ValueError):
if TYPE_CHECKING:
pass
class InvalidConfigFileError(ConfigFileError):
def __init__(self, conf_path: Path, quiet: bool):
msg = str(conf_path)
printf_exception(self, "INVALID CONFIG FILE", msg=msg, quiet=quiet)
super().__init__(msg)
DEFAULT_DATA: AnyByStrDict = {
"now": datetime.datetime.utcnow,
"make_secret": lambda: sha512(urandom(48)).hexdigest(),
}
class MultipleConfigFilesError(ConfigFileError):
def __init__(self, conf_paths: PathSeq, quiet: bool):
msg = str(conf_paths)
printf_exception(self, "MULTIPLE CONFIG FILES", msg=msg, quiet=quiet)
super().__init__(msg)
@dataclass
class AnswersMap:
"""Object that gathers answers from different sources.
Attributes:
local:
Local overrides to other answers.
user:
Answers provided by the user, interactively.
init:
Answers provided on init.
This will hold those answers that come from `--data` in
CLI mode.
See [data][].
metadata:
Data used to be able to reproduce the template.
It comes from [copier.template.Template.metadata][].
last:
Data from [the answers file][the-copier-answersyml-file].
default:
Default data from the template.
See [copier.template.Template.default_answers][].
"""
# Private
local: AnyByStrDict = field(default_factory=dict, init=False)
# Public
user: AnyByStrDict = field(default_factory=dict)
init: AnyByStrDict = field(default_factory=dict)
metadata: AnyByStrDict = field(default_factory=dict)
last: AnyByStrDict = field(default_factory=dict)
default: AnyByStrDict = field(default_factory=dict)
@cached_property
def combined(self) -> t_ChainMap[str, Any]:
"""Answers combined from different sources, sorted by priority."""
return ChainMap(
self.local,
self.user,
self.init,
self.metadata,
self.last,
self.default,
DEFAULT_DATA,
)
def old_commit(self) -> OptStr:
return self.last.get("_commit")
class InvalidTypeError(TypeError):
pass
class Question(BaseModel):
@dataclass(config=AllowArbitraryTypes)
class Question:
"""One question asked to the user.
All attributes are init kwargs.
@ -61,7 +126,7 @@ class Question(BaseModel):
Default value presented to the user to make it easier to respond.
Can be templated.
help_text:
help:
Additional text printed to the user, explaining the purpose of
this question. Can be templated.
@ -74,10 +139,6 @@ class Question(BaseModel):
Text that appears if there's nothing written in the input field,
but disappears as soon as the user writes anything. Can be templated.
questionary:
Reference to the [Questionary][] object where this [Question][] is
attached.
secret:
Indicates if the question should be removed from the answers file.
If the question type is str, it will hide user input on the screen
@ -97,53 +158,74 @@ class Question(BaseModel):
boolean values.
"""
choices: Union[Dict[Any, Any], List[Any]] = Field(default_factory=list)
var_name: str
answers: AnswersMap
jinja_env: SandboxedEnvironment
choices: Union[Dict[Any, Any], List[Any]] = field(default_factory=list)
default: Any = None
help_text: str = ""
help: str = ""
ask_user: bool = False
multiline: Union[str, bool] = False
placeholder: str = ""
questionary: "Questionary"
secret: bool = False
type_name: str = ""
var_name: str
type: str = ""
when: Union[str, bool] = True
# Private
_cached_choices: List[Choice] = PrivateAttr(default_factory=list)
class Config:
arbitrary_types_allowed = True
def __init__(self, **kwargs):
# Transform arguments that are named like python keywords
to_rename = (("help", "help_text"), ("type", "type_name"))
for from_, to in to_rename:
with suppress(KeyError):
kwargs.setdefault(to, kwargs.pop(from_))
# Infer type from default if missing
super().__init__(**kwargs)
self.questionary.questions.append(self)
def __repr__(self):
return f"Question({self.var_name})"
@validator("var_name")
def _check_var_name(cls, v):
if v in DEFAULT_DATA:
raise ValueError("Invalid question name")
return v
@validator("type_name", always=True)
def _check_type_name(cls, v, values):
@validator("type", always=True)
def _check_type(cls, v, values):
if v == "":
default_type_name = type(values.get("default")).__name__
v = default_type_name if default_type_name in CAST_STR_TO_NATIVE else "yaml"
return v
def _generate_choices(self) -> None:
"""Iterates choices in a format that the questionary lib likes."""
if self._cached_choices:
return
def get_default(self) -> Any:
"""Get the default value for this question, casted to its expected type."""
cast_fn = self.get_cast_fn()
try:
result = self.answers.init[self.var_name]
except KeyError:
try:
result = self.answers.last[self.var_name]
except KeyError:
result = self.render_value(self.default)
result = cast_answer_type(result, cast_fn)
return result
def get_default_rendered(self) -> Union[bool, str, Choice, None]:
"""Get default answer rendered for the questionary lib.
The questionary lib expects some specific data types, and returns
it when the user answers. Sometimes you need to compare the response
to the rendered one, or viceversa.
This helper allows such usages.
"""
default = self.get_default()
# If there are choices, return the one that matches the expressed default
if self.choices:
for choice in self._formatted_choices:
if choice.value == default:
return choice
return None
# Yes/No questions expect and return bools
if isinstance(default, bool) and self.type == "bool":
return default
# Emptiness is expressed as an empty str
if default is None:
return ""
# All other data has to be str
return str(default)
@cached_property
def _formatted_choices(self) -> List[Choice]:
"""Obtain choices rendered and properly formatted."""
result = []
choices = self.choices
if isinstance(self.choices, dict):
choices = list(self.choices.items())
@ -162,51 +244,9 @@ class Question(BaseModel):
name = str(name)
# The value can be templated
value = self.render_value(value)
self._cached_choices.append(Choice(name, value))
def get_default(self) -> Any:
"""Get the default value for this question, casted to its expected type."""
cast_fn = self.get_cast_fn()
try:
result = self.questionary.answers_forced[self.var_name]
except KeyError:
try:
result = self.questionary.answers_last[self.var_name]
except KeyError:
result = self.render_value(self.default)
result = cast_answer_type(result, cast_fn)
result.append(Choice(name, value))
return result
def get_default_rendered(self) -> Union[bool, str, Choice, None]:
"""Get default answer rendered for the questionary lib.
The questionary lib expects some specific data types, and returns
it when the user answers. Sometimes you need to compare the response
to the rendered one, or viceversa.
This helper allows such usages.
"""
default = self.get_default()
# If there are choices, return the one that matches the expressed default
if self.choices:
for choice in self.get_choices():
if choice.value == default:
return choice
return None
# Yes/No questions expect and return bools
if isinstance(default, bool) and self.type_name == "bool":
return default
# Emptiness is expressed as an empty str
if default is None:
return ""
# All other data has to be str
return str(default)
def get_choices(self) -> List[Choice]:
"""Obtain choices rendered and properly formatted."""
self._generate_choices()
return self._cached_choices
def filter_answer(self, answer) -> Any:
"""Cast the answer to the desired type."""
if answer == self.get_default_rendered():
@ -216,10 +256,10 @@ class Question(BaseModel):
def get_message(self) -> str:
"""Get the message that will be printed to the user."""
message = ""
if self.help_text:
rendered_help = self.render_value(self.help_text)
if self.help:
rendered_help = self.render_value(self.help)
message = force_str_end(rendered_help)
message += f"{self.var_name}? Format: {self.type_name}"
message += f"{self.var_name}? Format: {self.type}"
return message
def get_placeholder(self) -> str:
@ -239,17 +279,17 @@ class Question(BaseModel):
"when": self.get_when,
}
questionary_type = "input"
if self.type_name == "bool":
if self.type == "bool":
questionary_type = "confirm"
if self.choices:
questionary_type = "select"
result["choices"] = self.get_choices()
result["choices"] = self._formatted_choices
if questionary_type == "input":
if self.secret:
questionary_type = "password"
elif self.type_name == "yaml":
elif self.type == "yaml":
lexer = PygmentsLexer(YamlLexer)
elif self.type_name == "json":
elif self.type == "json":
lexer = PygmentsLexer(JsonLexer)
if lexer:
result["lexer"] = lexer
@ -263,7 +303,7 @@ class Question(BaseModel):
def get_cast_fn(self) -> Callable:
"""Obtain function to cast user answer to desired type."""
type_name = self.render_value(self.type_name)
type_name = self.render_value(self.type)
if type_name not in CAST_STR_TO_NATIVE:
raise InvalidTypeError("Invalid question type")
return CAST_STR_TO_NATIVE.get(type_name, parse_yaml_string)
@ -287,9 +327,9 @@ class Question(BaseModel):
"""Get skip condition for question."""
if (
# Skip on --force
not self.questionary.ask_user
not self.ask_user
# Skip on --data=this_question=some_answer
or self.var_name in self.questionary.answers_forced
or self.var_name in self.answers.init
):
return False
when = self.when
@ -303,97 +343,16 @@ class Question(BaseModel):
If the value cannot be used as a template, it will be returned as is.
"""
try:
template = self.questionary.env.from_string(value)
template = self.jinja_env.from_string(value)
except TypeError:
# value was not a string
return value
try:
return template.render(
**self.questionary.get_best_answers(), **DEFAULT_DATA
)
return template.render(**self.answers.combined)
except UndefinedError as error:
raise UserMessageError(str(error)) from error
class Questionary(BaseModel):
"""An object holding all [Question][] items and user answers.
All attributes are also init kwargs.
Attributes:
answers_default:
Default answers as specified in the template.
answers_forced:
Answers forced by the user, either by an API call like
`data={'some_question': 'forced_answer'}` or by a CLI call like
`--data=some_question=forced_answer`.
answers_last:
Answers obtained from the `.copier-answers.yml` file.
answers_user:
Dict containing user answers for the current questionary. It should
be empty always.
ask_user:
Indicates if the questionary should be asked, or just forced.
env:
The Jinja environment for rendering.
questions:
A list containing all [Question][] objects for this [Questionary][].
"""
answers_default: AnyByStrDict = Field(default_factory=dict)
answers_forced: AnyByStrDict = Field(default_factory=dict)
answers_last: AnyByStrDict = Field(default_factory=dict)
answers_user: AnyByStrDict = Field(default_factory=dict)
ask_user: bool = True
env: SandboxedEnvironment
questions: List[Question] = Field(default_factory=list)
class Config:
arbitrary_types_allowed = True
def __init__(self, **kwargs):
super().__init__(**kwargs)
def get_best_answers(self) -> t_ChainMap[str, Any]:
"""Get dict-like object with the best answers for each question."""
return ChainMap(
self.answers_user,
self.answers_last,
self.answers_forced,
self.answers_default,
)
def get_answers(self) -> AnyByStrDict:
"""Obtain answers for all questions.
It produces a TUI for querying the user if `ask_user` is true. Otherwise,
it gets answers from other sources.
"""
previous_answers = self.get_best_answers()
if self.ask_user:
self.answers_user = unsafe_prompt(
(question.get_questionary_structure() for question in self.questions),
answers=previous_answers,
)
else:
# Avoid prompting to not requiring a TTy when --force
for question in self.questions:
new_answer = question.get_default()
previous_answer = previous_answers.get(question.var_name)
if new_answer != previous_answer:
self.answers_user[question.var_name] = new_answer
return self.answers_user
Question.update_forward_refs()
def load_yaml_data(conf_path: Path, quiet: bool = False) -> AnyByStrDict:
"""Load the `copier.yml` file.
@ -493,24 +452,3 @@ CAST_STR_TO_NATIVE: Dict[str, Callable] = {
"str": str,
"yaml": parse_yaml_string,
}
def query_user_data(
questions_data: AnyByStrDict,
last_answers_data: AnyByStrDict,
forced_answers_data: AnyByStrDict,
default_answers_data: AnyByStrDict,
ask_user: bool,
envops: EnvOps,
) -> AnyByStrDict:
"""Query the user for questions given in the config file."""
questionary = Questionary(
answers_forced=forced_answers_data,
answers_last=last_answers_data,
answers_default=default_answers_data,
ask_user=ask_user,
env=get_jinja_env(envops=envops),
)
for question, details in questions_data.items():
Question(var_name=question, questionary=questionary, **details)
return questionary.get_answers()

View File

@ -10,8 +10,6 @@ from plumbum.cmd import git
from .types import OptBool, OptStr, StrOrPath
__all__ = ("get_repo", "clone")
GIT_PREFIX = ("git@", "git://", "git+")
GIT_POSTFIX = (".git",)
REPLACEMENTS = (
@ -22,10 +20,10 @@ REPLACEMENTS = (
)
def is_git_repo_root(path: Path) -> bool:
def is_git_repo_root(path: StrOrPath) -> bool:
"""Indicate if a given path is a git repo root directory."""
try:
with local.cwd(path / ".git"):
with local.cwd(Path(path, ".git")):
return bool(git("rev-parse", "--is-inside-git-dir").strip() == "true")
except OSError:
return False
@ -40,6 +38,19 @@ def is_git_bundle(path: Path) -> bool:
def get_repo(url: str) -> OptStr:
"""Transforms `url` into a git-parseable origin URL.
Args:
url:
Valid examples:
- gh:copier-org/copier
- gl:copier-org/copier
- git@github.com:copier-org/copier.git
- git+https://mywebsiteisagitrepo.example.com/
- /local/path/to/git/repo
- /local/path/to/git/bundle/file.bundle
"""
for pattern, replacement in REPLACEMENTS:
url = re.sub(pattern, replacement, url)
url_path = Path(url)
@ -85,10 +96,19 @@ def checkout_latest_tag(local_repo: StrOrPath, use_prereleases: OptBool = False)
return latest_tag
def clone(url: str, ref: str = "HEAD") -> str:
def clone(url: str, ref: OptStr = None) -> str:
"""Clone repo into some temporary destination.
Args:
url:
Git-parseable URL of the repo. As returned by
[get_repo][copier.vcs.get_repo].
ref:
Reference to checkout. For Git repos, defaults to `HEAD`.
"""
location = tempfile.mkdtemp(prefix=f"{__name__}.clone.")
git("clone", "--no-checkout", url, location)
with local.cwd(location):
git("checkout", ref)
git("checkout", ref or "HEAD")
git("submodule", "update", "--checkout", "--init", "--recursive", "--force")
return location

View File

@ -290,6 +290,32 @@ is overriden, and don't ask user anything else:
copier -fd 'user_name=Manuel Calavera' copy template destination
```
### `envops`
- Format: `dict`
- CLI flags: N/A
- Default value:
```yaml
{
"autoescape": False,
"block_end_string": "%]",
"block_start_string": "[%",
"comment_end_string": "#]",
"comment_start_string": "[#",
"keep_trailing_newline": True,
"variable_end_string": "]]",
"variable_start_string": "[[",
}
```
Configurations for the Jinja environment.
These defaults are different from upstream Jinja's.
See [upstream docs](https://jinja.palletsprojects.com/en/2.11.x/api/#jinja2.Environment)
to know available options.
### `exclude`
- Format: `List[str]`
@ -329,21 +355,6 @@ copier --exclude '*' --exclude '!file-i-want' copy template destination
Instead, CLI/API definitions **will extend** those from `copier.yml`.
### `extra_paths`
- Format: `List[str]`
- CLI flags: `-p`, `--extra-paths`
- Default value: N/A
Additional paths from where to search for templates.
Example `copier.yml`:
```yaml
_extra_paths:
- ~/Projects/templates
```
### `force`
- Format: `bool`
@ -484,7 +495,7 @@ _skip_if_exists: .secret_password.yml
### `subdirectory`
- Format: `str`
- CLI flags: `-b`, `--subdirectory`
- CLI flags: N/A
- Default value: N/A
Subdirectory to use as the template root when generating a project. If not specified,
@ -496,12 +507,6 @@ Example `copier.yml`:
_subdirectory: src
```
Example CLI usage to choose a different subdirectory template:
```sh
copier --subdirectory template2 -b copy template destination
```
### `tasks`
- Format: `List[str|List[str]]`
@ -617,7 +622,7 @@ will be the last ones he used.
The file **must be called exactly `[[ _copier_conf.answers_file ]].tmpl`** (or ended
with [your chosen suffix](#templates_suffix)) in your template's root folder) to allow
[applying multiple templates to the same subproject][applying-multiple-templates-to-the-same-subproject].
[applying multiple templates to the same subproject](#applying-multiple-templates-to-the-same-subproject).
The default name will be `.copier-answers.yml`, but
[you can define a different default path for this file](#answers_file).

View File

@ -1 +0,0 @@
::: copier.config.factory

View File

@ -1 +0,0 @@
::: copier.config.objects

View File

@ -1 +0,0 @@
::: copier.config.user_data

1
docs/reference/errors.md Normal file
View File

@ -0,0 +1 @@
::: copier.errors

View File

@ -0,0 +1 @@
::: copier.subproject

View File

@ -0,0 +1 @@
::: copier.template

View File

@ -0,0 +1 @@
::: copier.user_data

View File

@ -11,14 +11,14 @@ nav:
- Generating a project: "generating.md"
- Updating a project: "updating.md"
- Reference:
- config:
- factory.py: "reference/config/factory.md"
- objects.py: "reference/config/objects.md"
- user_data.py: "reference/config/user_data.md"
- cli.py: "reference/cli.md"
- errors.py: "reference/errors.md"
- main.py: "reference/main.md"
- subproject.py: "reference/subproject.md"
- template.py: "reference/template.md"
- tools.py: "reference/tools.md"
- types.py: "reference/types.md"
- user_data.py: "reference/user_data.md"
- vcs.py: "reference/vcs.md"
- Comparisons: comparisons.md
- Contributing: "contributing.md"
@ -41,6 +41,7 @@ markdown_extensions:
permalink: true
plugins:
- autorefs
- search
- mermaid2:
arguments:

3
mypy.ini Normal file
View File

@ -0,0 +1,3 @@
[mypy]
warn_no_return = False
ignore_missing_imports = True

463
poetry.lock generated
View File

@ -47,6 +47,17 @@ python-versions = "*"
[package.dependencies]
pyflakes = ">=1.1.0"
[[package]]
name = "backports.cached-property"
version = "1.0.1"
description = "cached_property() - computed once per instance, cached as attribute"
category = "main"
optional = false
python-versions = ">=3.6.0"
[package.dependencies]
typing = {version = ">=3.6", markers = "python_version < \"3.7\""}
[[package]]
name = "beautifulsoup4"
version = "4.9.3"
@ -85,6 +96,14 @@ typing-extensions = ">=3.7.4"
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
[[package]]
name = "cached-property"
version = "1.5.2"
description = "A decorator for caching properties in classes."
category = "main"
optional = true
python-versions = "*"
[[package]]
name = "certifi"
version = "2020.12.5"
@ -127,7 +146,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "coverage"
version = "5.3"
version = "5.4"
description = "Code coverage measurement for Python"
category = "dev"
optional = false
@ -154,7 +173,7 @@ python-versions = "*"
[[package]]
name = "editorconfig"
version = "0.12.2"
version = "0.12.3"
description = "EditorConfig File Locator and Interpreter for Python"
category = "main"
optional = true
@ -162,11 +181,11 @@ python-versions = "*"
[[package]]
name = "execnet"
version = "1.7.1"
version = "1.8.0"
description = "execnet: rapid multi-Python deployment"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.dependencies]
apipkg = ">=1.4"
@ -213,11 +232,11 @@ dev = ["coverage", "black", "hypothesis", "hypothesmith"]
[[package]]
name = "flake8-comprehensions"
version = "3.3.0"
version = "3.3.1"
description = "A flake8 plugin to help you write better list/set/dict comprehensions."
category = "dev"
optional = false
python-versions = ">=3.5"
python-versions = ">=3.6"
[package.dependencies]
flake8 = ">=3.0,<3.2.0 || >3.2.0,<4"
@ -245,7 +264,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "identify"
version = "1.5.10"
version = "1.5.13"
description = "File identification library for Python"
category = "dev"
optional = false
@ -264,7 +283,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "importlib-metadata"
version = "3.3.0"
version = "3.4.0"
description = "Read metadata from Python packages"
category = "main"
optional = false
@ -275,22 +294,23 @@ typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
zipp = ">=0.5"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
[[package]]
name = "importlib-resources"
version = "3.3.0"
version = "5.1.0"
description = "Read resources from Python packages"
category = "dev"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
python-versions = ">=3.6"
[package.dependencies]
zipp = {version = ">=0.4", markers = "python_version < \"3.8\""}
[package.extras]
docs = ["sphinx", "rst.linker", "jaraco.packaging"]
docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "pytest-black (>=0.3.7)", "pytest-mypy"]
[[package]]
name = "iniconfig"
@ -315,7 +335,7 @@ test = ["pytest"]
[[package]]
name = "jinja2"
version = "2.11.2"
version = "2.11.3"
description = "A very fast and expressive template engine."
category = "main"
optional = false
@ -329,7 +349,7 @@ i18n = ["Babel (>=0.8)"]
[[package]]
name = "joblib"
version = "1.0.0"
version = "1.0.1"
description = "Lightweight pipelining with Python functions"
category = "main"
optional = true
@ -337,7 +357,7 @@ python-versions = ">=3.6"
[[package]]
name = "jsbeautifier"
version = "1.13.0"
version = "1.13.5"
description = "JavaScript unobfuscator and beautifier."
category = "main"
optional = true
@ -467,20 +487,26 @@ requests = "*"
[[package]]
name = "mkdocstrings"
version = "0.13.6"
version = "0.14.0"
description = "Automatic documentation from sources, for MkDocs."
category = "main"
optional = true
python-versions = ">=3.6,<4.0"
python-versions = "^3.6"
develop = false
[package.dependencies]
beautifulsoup4 = ">=4.8.2,<5.0.0"
mkdocs = ">=1.1,<2.0"
pymdown-extensions = ">=6.3,<9.0"
pytkdocs = ">=0.2.0,<0.10.0"
Jinja2 = "^2.11"
Markdown = "^3.3"
MarkupSafe = "^1.1"
mkdocs = "^1.1"
pymdown-extensions = ">=6.3, <9.0"
pytkdocs = ">=0.2.0, <0.11.0"
[package.extras]
tests = ["coverage (>=5.2.1,<6.0.0)", "invoke (>=1.4.1,<2.0.0)", "mkdocs-material (>=5.5.12,<6.0.0)", "mypy (>=0.782,<0.783)", "pytest (>=6.0.1,<7.0.0)", "pytest-cov (>=2.10.1,<3.0.0)", "pytest-randomly (>=3.4.1,<4.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=2.1.0,<3.0.0)"]
[package.source]
type = "git"
url = "https://github.com/pawamoy/mkdocstrings.git"
reference = "master"
resolved_reference = "bf217e66c3b459781100d53c2a048272260b355c"
[[package]]
name = "mypy"
@ -538,7 +564,7 @@ python-versions = "*"
[[package]]
name = "packaging"
version = "20.8"
version = "20.9"
description = "Core utilities for Python packages"
category = "main"
optional = false
@ -590,11 +616,19 @@ dev = ["pre-commit", "tox"]
[[package]]
name = "plumbum"
version = "1.6.9"
version = "1.7.0"
description = "Plumbum: shell combinators library"
category = "main"
optional = false
python-versions = ">=2.6,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4,>=2.7"
[package.dependencies]
pypiwin32 = {version = "*", markers = "platform_system == \"Windows\" and platform_python_implementation != \"PyPy\""}
[package.extras]
dev = ["pytest", "pytest-cov", "pytest-mock", "pytest-timeout", "paramiko", "psutil"]
docs = ["recommonmark (>=0.5.0)", "Sphinx (>=3.0.0)", "sphinx-rtd-theme (>=0.5.0)"]
ssh = ["paramiko"]
[[package]]
name = "poethepoet"
@ -610,7 +644,7 @@ tomlkit = ">=0.6.0,<1.0.0"
[[package]]
name = "pre-commit"
version = "2.9.3"
version = "2.10.1"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
category = "dev"
optional = false
@ -628,7 +662,7 @@ virtualenv = ">=20.0.8"
[[package]]
name = "prompt-toolkit"
version = "3.0.8"
version = "3.0.14"
description = "Library for building powerful interactive command lines in Python"
category = "main"
optional = false
@ -639,7 +673,7 @@ wcwidth = "*"
[[package]]
name = "ptyprocess"
version = "0.6.0"
version = "0.7.0"
description = "Run a subprocess in a pseudo terminal"
category = "dev"
optional = false
@ -687,7 +721,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pygments"
version = "2.7.3"
version = "2.7.4"
description = "Pygments is a syntax highlighting package written in Python."
category = "main"
optional = false
@ -695,11 +729,11 @@ python-versions = ">=3.5"
[[package]]
name = "pymdown-extensions"
version = "8.0.1"
version = "8.1.1"
description = "Extension pack for Python Markdown."
category = "main"
optional = true
python-versions = ">=3.5"
python-versions = ">=3.6"
[package.dependencies]
Markdown = ">=3.2"
@ -712,9 +746,20 @@ category = "main"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "pypiwin32"
version = "223"
description = ""
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
pywin32 = ">=223"
[[package]]
name = "pytest"
version = "6.2.1"
version = "6.2.2"
description = "pytest: simple powerful testing with Python"
category = "dev"
optional = false
@ -736,14 +781,14 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm
[[package]]
name = "pytest-cov"
version = "2.10.1"
version = "2.11.1"
description = "Pytest plugin for measuring coverage."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.dependencies]
coverage = ">=4.4"
coverage = ">=5.2.1"
pytest = ">=4.6"
[package.extras]
@ -780,22 +825,38 @@ testing = ["filelock"]
[[package]]
name = "pytkdocs"
version = "0.9.0"
version = "0.10.1"
description = "Load Python objects documentation."
category = "main"
optional = true
python-versions = ">=3.6,<4.0"
python-versions = "^3.6"
develop = false
[package.extras]
tests = ["coverage (>=5.2.1,<6.0.0)", "invoke (>=1.4.1,<2.0.0)", "marshmallow (>=3.5.2,<4.0.0)", "mypy (>=0.782,<0.783)", "pydantic (>=1.5.1,<2.0.0)", "pytest (>=6.0.1,<7.0.0)", "pytest-cov (>=2.10.1,<3.0.0)", "pytest-randomly (>=3.4.1,<4.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=2.1.0,<3.0.0)"]
[package.dependencies]
cached-property = {version = "^1.5.2", markers = "python_version < \"3.8\""}
typing-extensions = {version = "^3.7.4.3", markers = "python_version < \"3.8\""}
[package.source]
type = "git"
url = "https://github.com/pawamoy/pytkdocs.git"
reference = "master"
resolved_reference = "4052eabdd45a7f4fe8c3fc8591bb23e1763a5a0f"
[[package]]
name = "pywin32"
version = "300"
description = "Python for Window Extensions"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "pyyaml"
version = "5.3.1"
version = "5.4.1"
description = "YAML parser and emitter for Python"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
[[package]]
name = "pyyaml-include"
@ -814,7 +875,7 @@ toml = ["toml"]
[[package]]
name = "questionary"
version = "1.8.1"
version = "1.9.0"
description = "Python library to build pretty command line user prompts ⭐️"
category = "main"
optional = false
@ -823,6 +884,9 @@ python-versions = ">=3.6,<3.10"
[package.dependencies]
prompt_toolkit = ">=2.0,<4.0"
[package.extras]
docs = ["Sphinx (>=3.3,<4.0)", "sphinx-rtd-theme (>=0.5.0,<0.6.0)", "sphinx-autobuild (>=2020.9.1,<2021.0.0)", "sphinx-copybutton (>=0.3.1,<0.4.0)", "sphinx-autodoc-typehints (>=1.11.1,<2.0.0)"]
[[package]]
name = "regex"
version = "2020.11.13"
@ -891,23 +955,32 @@ python-versions = ">= 3.5"
[[package]]
name = "tqdm"
version = "4.54.1"
version = "4.56.0"
description = "Fast, Extensible Progress Meter"
category = "main"
optional = true
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
[package.extras]
dev = ["py-make (>=0.1.0)", "twine", "argopt", "pydoc-markdown", "wheel"]
dev = ["py-make (>=0.1.0)", "twine", "wheel"]
telegram = ["requests"]
[[package]]
name = "typed-ast"
version = "1.4.1"
version = "1.4.2"
description = "a fork of Python 2 and 3 ast modules with type comment support"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "typing"
version = "3.7.4.3"
description = "Type Hints for Python"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "typing-extensions"
version = "3.7.4.3"
@ -918,7 +991,7 @@ python-versions = "*"
[[package]]
name = "urllib3"
version = "1.26.2"
version = "1.26.3"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = true
@ -931,7 +1004,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "virtualenv"
version = "20.2.2"
version = "20.4.2"
description = "Virtual Python Environment builder"
category = "dev"
optional = false
@ -947,7 +1020,7 @@ six = ">=1.9.0,<2"
[package.extras]
docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"]
testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "pytest-xdist (>=1.31.0)", "packaging (>=20.0)", "xonsh (>=0.9.16)"]
testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"]
[[package]]
name = "wcwidth"
@ -970,12 +1043,12 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
[extras]
docs = ["mkdocstrings", "mkdocs-material", "mkdocs-mermaid2-plugin"]
docs = ["mkdocstrings", "mkdocs-material", "mkdocs-mermaid2-plugin", "pytkdocs"]
[metadata]
lock-version = "1.1"
python-versions = ">=3.6.1,<3.10"
content-hash = "9dfbeeecd75c12b862b91d1902d5d398ecd6dca08d35a49fa043621f9b551df3"
content-hash = "43f9a0471608ea8927a222fb0f67c650dfa395888c7abc638be5ae6bb95a7fc4"
[metadata.files]
apipkg = [
@ -997,6 +1070,10 @@ attrs = [
autoflake = [
{file = "autoflake-1.4.tar.gz", hash = "sha256:61a353012cff6ab94ca062823d1fb2f692c4acda51c76ff83a8d77915fba51ea"},
]
"backports.cached-property" = [
{file = "backports.cached-property-1.0.1.tar.gz", hash = "sha256:1a5ef1e750f8bc7d0204c807aae8e0f450c655be0cf4b30407a35fd4bb27186c"},
{file = "backports.cached_property-1.0.1-py3-none-any.whl", hash = "sha256:687b5fe14be40aadcf547cae91337a1fdb84026046a39370274e54d3fe4fb4f9"},
]
beautifulsoup4 = [
{file = "beautifulsoup4-4.9.3-py2-none-any.whl", hash = "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35"},
{file = "beautifulsoup4-4.9.3-py3-none-any.whl", hash = "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"},
@ -1006,6 +1083,10 @@ black = [
{file = "black-20.8b1-py3-none-any.whl", hash = "sha256:70b62ef1527c950db59062cda342ea224d772abdf6adc58b86a45421bab20a6b"},
{file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"},
]
cached-property = [
{file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"},
{file = "cached_property-1.5.2-py2.py3-none-any.whl", hash = "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"},
]
certifi = [
{file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"},
{file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"},
@ -1027,40 +1108,55 @@ colorama = [
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
]
coverage = [
{file = "coverage-5.3-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270"},
{file = "coverage-5.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4"},
{file = "coverage-5.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9"},
{file = "coverage-5.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729"},
{file = "coverage-5.3-cp27-cp27m-win32.whl", hash = "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d"},
{file = "coverage-5.3-cp27-cp27m-win_amd64.whl", hash = "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418"},
{file = "coverage-5.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9"},
{file = "coverage-5.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5"},
{file = "coverage-5.3-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822"},
{file = "coverage-5.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097"},
{file = "coverage-5.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9"},
{file = "coverage-5.3-cp35-cp35m-win32.whl", hash = "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636"},
{file = "coverage-5.3-cp35-cp35m-win_amd64.whl", hash = "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f"},
{file = "coverage-5.3-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237"},
{file = "coverage-5.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54"},
{file = "coverage-5.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7"},
{file = "coverage-5.3-cp36-cp36m-win32.whl", hash = "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a"},
{file = "coverage-5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d"},
{file = "coverage-5.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"},
{file = "coverage-5.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f"},
{file = "coverage-5.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c"},
{file = "coverage-5.3-cp37-cp37m-win32.whl", hash = "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751"},
{file = "coverage-5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709"},
{file = "coverage-5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516"},
{file = "coverage-5.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f"},
{file = "coverage-5.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259"},
{file = "coverage-5.3-cp38-cp38-win32.whl", hash = "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82"},
{file = "coverage-5.3-cp38-cp38-win_amd64.whl", hash = "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221"},
{file = "coverage-5.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978"},
{file = "coverage-5.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21"},
{file = "coverage-5.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24"},
{file = "coverage-5.3-cp39-cp39-win32.whl", hash = "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7"},
{file = "coverage-5.3-cp39-cp39-win_amd64.whl", hash = "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7"},
{file = "coverage-5.3.tar.gz", hash = "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0"},
{file = "coverage-5.4-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:6d9c88b787638a451f41f97446a1c9fd416e669b4d9717ae4615bd29de1ac135"},
{file = "coverage-5.4-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:66a5aae8233d766a877c5ef293ec5ab9520929c2578fd2069308a98b7374ea8c"},
{file = "coverage-5.4-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9754a5c265f991317de2bac0c70a746efc2b695cf4d49f5d2cddeac36544fb44"},
{file = "coverage-5.4-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:fbb17c0d0822684b7d6c09915677a32319f16ff1115df5ec05bdcaaee40b35f3"},
{file = "coverage-5.4-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:b7f7421841f8db443855d2854e25914a79a1ff48ae92f70d0a5c2f8907ab98c9"},
{file = "coverage-5.4-cp27-cp27m-win32.whl", hash = "sha256:4a780807e80479f281d47ee4af2eb2df3e4ccf4723484f77da0bb49d027e40a1"},
{file = "coverage-5.4-cp27-cp27m-win_amd64.whl", hash = "sha256:87c4b38288f71acd2106f5d94f575bc2136ea2887fdb5dfe18003c881fa6b370"},
{file = "coverage-5.4-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:c6809ebcbf6c1049002b9ac09c127ae43929042ec1f1dbd8bb1615f7cd9f70a0"},
{file = "coverage-5.4-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ba7ca81b6d60a9f7a0b4b4e175dcc38e8fef4992673d9d6e6879fd6de00dd9b8"},
{file = "coverage-5.4-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:89fc12c6371bf963809abc46cced4a01ca4f99cba17be5e7d416ed7ef1245d19"},
{file = "coverage-5.4-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a8eb7785bd23565b542b01fb39115a975fefb4a82f23d407503eee2c0106247"},
{file = "coverage-5.4-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:7e40d3f8eb472c1509b12ac2a7e24158ec352fc8567b77ab02c0db053927e339"},
{file = "coverage-5.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1ccae21a076d3d5f471700f6d30eb486da1626c380b23c70ae32ab823e453337"},
{file = "coverage-5.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:755c56beeacac6a24c8e1074f89f34f4373abce8b662470d3aa719ae304931f3"},
{file = "coverage-5.4-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:322549b880b2d746a7672bf6ff9ed3f895e9c9f108b714e7360292aa5c5d7cf4"},
{file = "coverage-5.4-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:60a3307a84ec60578accd35d7f0c71a3a971430ed7eca6567399d2b50ef37b8c"},
{file = "coverage-5.4-cp35-cp35m-win32.whl", hash = "sha256:1375bb8b88cb050a2d4e0da901001347a44302aeadb8ceb4b6e5aa373b8ea68f"},
{file = "coverage-5.4-cp35-cp35m-win_amd64.whl", hash = "sha256:16baa799ec09cc0dcb43a10680573269d407c159325972dd7114ee7649e56c66"},
{file = "coverage-5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:2f2cf7a42d4b7654c9a67b9d091ec24374f7c58794858bff632a2039cb15984d"},
{file = "coverage-5.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:b62046592b44263fa7570f1117d372ae3f310222af1fc1407416f037fb3af21b"},
{file = "coverage-5.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:812eaf4939ef2284d29653bcfee9665f11f013724f07258928f849a2306ea9f9"},
{file = "coverage-5.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:859f0add98707b182b4867359e12bde806b82483fb12a9ae868a77880fc3b7af"},
{file = "coverage-5.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:04b14e45d6a8e159c9767ae57ecb34563ad93440fc1b26516a89ceb5b33c1ad5"},
{file = "coverage-5.4-cp36-cp36m-win32.whl", hash = "sha256:ebfa374067af240d079ef97b8064478f3bf71038b78b017eb6ec93ede1b6bcec"},
{file = "coverage-5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:84df004223fd0550d0ea7a37882e5c889f3c6d45535c639ce9802293b39cd5c9"},
{file = "coverage-5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1b811662ecf72eb2d08872731636aee6559cae21862c36f74703be727b45df90"},
{file = "coverage-5.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6b588b5cf51dc0fd1c9e19f622457cc74b7d26fe295432e434525f1c0fae02bc"},
{file = "coverage-5.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:3fe50f1cac369b02d34ad904dfe0771acc483f82a1b54c5e93632916ba847b37"},
{file = "coverage-5.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:32ab83016c24c5cf3db2943286b85b0a172dae08c58d0f53875235219b676409"},
{file = "coverage-5.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:68fb816a5dd901c6aff352ce49e2a0ffadacdf9b6fae282a69e7a16a02dad5fb"},
{file = "coverage-5.4-cp37-cp37m-win32.whl", hash = "sha256:a636160680c6e526b84f85d304e2f0bb4e94f8284dd765a1911de9a40450b10a"},
{file = "coverage-5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:bb32ca14b4d04e172c541c69eec5f385f9a075b38fb22d765d8b0ce3af3a0c22"},
{file = "coverage-5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4d7165a4e8f41eca6b990c12ee7f44fef3932fac48ca32cecb3a1b2223c21f"},
{file = "coverage-5.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:a565f48c4aae72d1d3d3f8e8fb7218f5609c964e9c6f68604608e5958b9c60c3"},
{file = "coverage-5.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fff1f3a586246110f34dc762098b5afd2de88de507559e63553d7da643053786"},
{file = "coverage-5.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:a839e25f07e428a87d17d857d9935dd743130e77ff46524abb992b962eb2076c"},
{file = "coverage-5.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:6625e52b6f346a283c3d563d1fd8bae8956daafc64bb5bbd2b8f8a07608e3994"},
{file = "coverage-5.4-cp38-cp38-win32.whl", hash = "sha256:5bee3970617b3d74759b2d2df2f6a327d372f9732f9ccbf03fa591b5f7581e39"},
{file = "coverage-5.4-cp38-cp38-win_amd64.whl", hash = "sha256:03ed2a641e412e42cc35c244508cf186015c217f0e4d496bf6d7078ebe837ae7"},
{file = "coverage-5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:14a9f1887591684fb59fdba8feef7123a0da2424b0652e1b58dd5b9a7bb1188c"},
{file = "coverage-5.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9564ac7eb1652c3701ac691ca72934dd3009997c81266807aef924012df2f4b3"},
{file = "coverage-5.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:0f48fc7dc82ee14aeaedb986e175a429d24129b7eada1b7e94a864e4f0644dde"},
{file = "coverage-5.4-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:107d327071061fd4f4a2587d14c389a27e4e5c93c7cba5f1f59987181903902f"},
{file = "coverage-5.4-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:0cdde51bfcf6b6bd862ee9be324521ec619b20590787d1655d005c3fb175005f"},
{file = "coverage-5.4-cp39-cp39-win32.whl", hash = "sha256:c67734cff78383a1f23ceba3b3239c7deefc62ac2b05fa6a47bcd565771e5880"},
{file = "coverage-5.4-cp39-cp39-win_amd64.whl", hash = "sha256:c669b440ce46ae3abe9b2d44a913b5fd86bb19eb14a8701e88e3918902ecd345"},
{file = "coverage-5.4-pp36-none-any.whl", hash = "sha256:c0ff1c1b4d13e2240821ef23c1efb1f009207cb3f56e16986f713c2b0e7cd37f"},
{file = "coverage-5.4-pp37-none-any.whl", hash = "sha256:cd601187476c6bed26a0398353212684c427e10a903aeafa6da40c63309d438b"},
{file = "coverage-5.4.tar.gz", hash = "sha256:6d2e262e5e8da6fa56e774fb8e2643417351427604c2b177f8e8c5f75fc928ca"},
]
dataclasses = [
{file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"},
@ -1071,12 +1167,12 @@ distlib = [
{file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"},
]
editorconfig = [
{file = "EditorConfig-0.12.2-py2-none-any.whl", hash = "sha256:60d6f10b87d2572ac1581cb8c9f018163e2b13a9e49588f9fb6dc8c715a1744c"},
{file = "EditorConfig-0.12.2.tar.gz", hash = "sha256:1b0ef345f9c3a673e492cfe608ed644b236139f7fceab5c6f513a71bcaf8a56c"},
{file = "EditorConfig-0.12.3-py3-none-any.whl", hash = "sha256:6b0851425aa875b08b16789ee0eeadbd4ab59666e9ebe728e526314c4a2e52c1"},
{file = "EditorConfig-0.12.3.tar.gz", hash = "sha256:57f8ce78afcba15c8b18d46b5170848c88d56fd38f05c2ec60dbbfcb8996e89e"},
]
execnet = [
{file = "execnet-1.7.1-py2.py3-none-any.whl", hash = "sha256:d4efd397930c46415f62f8a31388d6be4f27a91d7550eb79bc64a756e0056547"},
{file = "execnet-1.7.1.tar.gz", hash = "sha256:cacb9df31c9680ec5f95553976c4da484d407e85e41c83cb812aa014f0eddc50"},
{file = "execnet-1.8.0-py2.py3-none-any.whl", hash = "sha256:7a13113028b1e1cc4c6492b28098b3c6576c9dccc7973bfe47b342afadafb2ac"},
{file = "execnet-1.8.0.tar.gz", hash = "sha256:b73c5565e517f24b62dea8a5ceac178c661c4309d3aa0c3e420856c072c411b4"},
]
filelock = [
{file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"},
@ -1091,8 +1187,8 @@ flake8-bugbear = [
{file = "flake8_bugbear-20.11.1-py36.py37.py38-none-any.whl", hash = "sha256:f35b8135ece7a014bc0aee5b5d485334ac30a6da48494998cc1fabf7ec70d703"},
]
flake8-comprehensions = [
{file = "flake8-comprehensions-3.3.0.tar.gz", hash = "sha256:355ef47288523cad7977cb9c1bc81b71c82b7091e425cd9fbcd7e5c19a613677"},
{file = "flake8_comprehensions-3.3.0-py3-none-any.whl", hash = "sha256:c1dd6d8a00e9722619a5c5e0e6c5747f5cf23c089032c86eaf614c14a2e40adb"},
{file = "flake8-comprehensions-3.3.1.tar.gz", hash = "sha256:e734bf03806bb562886d9bf635d23a65a1a995c251b67d7e007a7b608af9bd22"},
{file = "flake8_comprehensions-3.3.1-py3-none-any.whl", hash = "sha256:6d80dfafda0d85633f88ea5bc7de949485f71f1e28db7af7719563fe5f62dcb1"},
]
flake8-debugger = [
{file = "flake8-debugger-3.2.1.tar.gz", hash = "sha256:712d7c1ff69ddf3f0130e94cc88c2519e720760bce45e8c330bfdcb61ab4090d"},
@ -1101,20 +1197,20 @@ future = [
{file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"},
]
identify = [
{file = "identify-1.5.10-py2.py3-none-any.whl", hash = "sha256:cc86e6a9a390879dcc2976cef169dd9cc48843ed70b7380f321d1b118163c60e"},
{file = "identify-1.5.10.tar.gz", hash = "sha256:943cd299ac7f5715fcb3f684e2fc1594c1e0f22a90d15398e5888143bd4144b5"},
{file = "identify-1.5.13-py2.py3-none-any.whl", hash = "sha256:9dfb63a2e871b807e3ba62f029813552a24b5289504f5b071dea9b041aee9fe4"},
{file = "identify-1.5.13.tar.gz", hash = "sha256:70b638cf4743f33042bebb3b51e25261a0a10e80f978739f17e7fd4837664a66"},
]
idna = [
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
]
importlib-metadata = [
{file = "importlib_metadata-3.3.0-py3-none-any.whl", hash = "sha256:bf792d480abbd5eda85794e4afb09dd538393f7d6e6ffef6e9f03d2014cf9450"},
{file = "importlib_metadata-3.3.0.tar.gz", hash = "sha256:5c5a2720817414a6c41f0a49993908068243ae02c1635a228126519b509c8aed"},
{file = "importlib_metadata-3.4.0-py3-none-any.whl", hash = "sha256:ace61d5fc652dc280e7b6b4ff732a9c2d40db2c0f92bc6cb74e07b73d53a1771"},
{file = "importlib_metadata-3.4.0.tar.gz", hash = "sha256:fa5daa4477a7414ae34e95942e4dd07f62adf589143c875c133c1e53c4eff38d"},
]
importlib-resources = [
{file = "importlib_resources-3.3.0-py2.py3-none-any.whl", hash = "sha256:a3d34a8464ce1d5d7c92b0ea4e921e696d86f2aa212e684451cb1482c8d84ed5"},
{file = "importlib_resources-3.3.0.tar.gz", hash = "sha256:7b51f0106c8ec564b1bef3d9c588bc694ce2b92125bbb6278f4f2f5b54ec3592"},
{file = "importlib_resources-5.1.0-py3-none-any.whl", hash = "sha256:885b8eae589179f661c909d699a546cf10d83692553e34dca1bf5eb06f7f6217"},
{file = "importlib_resources-5.1.0.tar.gz", hash = "sha256:bfdad047bce441405a49cf8eb48ddce5e56c696e185f59147a8b79e75e9e6380"},
]
iniconfig = [
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
@ -1144,15 +1240,15 @@ iteration-utilities = [
{file = "iteration_utilities-0.10.1.tar.gz", hash = "sha256:536e3e87c5c139c775f9d95bb771c4b366a1d58eb7a39436d4ac839b53742569"},
]
jinja2 = [
{file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"},
{file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"},
{file = "Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419"},
{file = "Jinja2-2.11.3.tar.gz", hash = "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"},
]
joblib = [
{file = "joblib-1.0.0-py3-none-any.whl", hash = "sha256:75ead23f13484a2a414874779d69ade40d4fa1abe62b222a23cd50d4bc822f6f"},
{file = "joblib-1.0.0.tar.gz", hash = "sha256:7ad866067ac1fdec27d51c8678ea760601b70e32ff1881d4dc8e1171f2b64b24"},
{file = "joblib-1.0.1-py3-none-any.whl", hash = "sha256:feeb1ec69c4d45129954f1b7034954241eedfd6ba39b5e9e4b6883be3332d5e5"},
{file = "joblib-1.0.1.tar.gz", hash = "sha256:9c17567692206d2f3fb9ecf5e991084254fe631665c450b443761c4186a613f7"},
]
jsbeautifier = [
{file = "jsbeautifier-1.13.0.tar.gz", hash = "sha256:f5565fbcd95f79945e124324815e586ae0d2e43df5af82a4400390e6ea789e8b"},
{file = "jsbeautifier-1.13.5.tar.gz", hash = "sha256:4532a6bc85ba91ffc542b55d65cd13cedc971a934f26f51ed56d4c680b3fbe66"},
]
livereload = [
{file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"},
@ -1220,10 +1316,7 @@ mkdocs-mermaid2-plugin = [
{file = "mkdocs-mermaid2-plugin-0.5.1.tar.gz", hash = "sha256:a267b77d0e80336ca12a72851209e90a07ba86b3551fa5422f3cd2ee1886f38a"},
{file = "mkdocs_mermaid2_plugin-0.5.1-py3-none-any.whl", hash = "sha256:03709ef450ddcbd0a07ebeee2b68d6a90e63c4611401c2d3cf8bca24e075ff3e"},
]
mkdocstrings = [
{file = "mkdocstrings-0.13.6-py3-none-any.whl", hash = "sha256:79d2a16b8c86a467bdc84846dfb90552551d2d9fd35578df9f92de13fb3b4537"},
{file = "mkdocstrings-0.13.6.tar.gz", hash = "sha256:79e5086c79f60d1ae1d4b222f658d348ebdd6302c970cc06ee8394f2839d7c4d"},
]
mkdocstrings = []
mypy = [
{file = "mypy-0.790-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:bd03b3cf666bff8d710d633d1c56ab7facbdc204d567715cb3b9f85c6e94f669"},
{file = "mypy-0.790-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:2170492030f6faa537647d29945786d297e4862765f0b4ac5930ff62e300d802"},
@ -1252,8 +1345,8 @@ nodeenv = [
{file = "nodeenv-1.5.0.tar.gz", hash = "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"},
]
packaging = [
{file = "packaging-20.8-py2.py3-none-any.whl", hash = "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858"},
{file = "packaging-20.8.tar.gz", hash = "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"},
{file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"},
{file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"},
]
pastel = [
{file = "pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364"},
@ -1272,24 +1365,24 @@ pluggy = [
{file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
]
plumbum = [
{file = "plumbum-1.6.9-py2.py3-none-any.whl", hash = "sha256:91418dcc66b58ab9d2e3b04b3d1e0d787dc45923154fb8b4a826bd9316dba0d6"},
{file = "plumbum-1.6.9.tar.gz", hash = "sha256:16b9e19d96c80f2e9d051ef5f04927b834a6ac0ce5d2768eb8662b5cd53e43df"},
{file = "plumbum-1.7.0-py2.py3-none-any.whl", hash = "sha256:139bbe08ee065b522a8a07d4f7e9f8eddffd78cc218b65b11cca2b33683e6b57"},
{file = "plumbum-1.7.0.tar.gz", hash = "sha256:317744342c755319907c773cc87c3a30adaa3a41b0d34c0ce02d9d1904922dce"},
]
poethepoet = [
{file = "poethepoet-0.9.0-py3-none-any.whl", hash = "sha256:6b1df9a755c297d5b10749cd4713924055b41edfa62055770c8bd6b5da8e2c69"},
{file = "poethepoet-0.9.0.tar.gz", hash = "sha256:ab2263fd7be81d16d38a4b4fe42a055d992d04421e61cad36498b1e4bd8ee2a6"},
]
pre-commit = [
{file = "pre_commit-2.9.3-py2.py3-none-any.whl", hash = "sha256:6c86d977d00ddc8a60d68eec19f51ef212d9462937acf3ea37c7adec32284ac0"},
{file = "pre_commit-2.9.3.tar.gz", hash = "sha256:ee784c11953e6d8badb97d19bc46b997a3a9eded849881ec587accd8608d74a4"},
{file = "pre_commit-2.10.1-py2.py3-none-any.whl", hash = "sha256:16212d1fde2bed88159287da88ff03796863854b04dc9f838a55979325a3d20e"},
{file = "pre_commit-2.10.1.tar.gz", hash = "sha256:399baf78f13f4de82a29b649afd74bef2c4e28eb4f021661fc7f29246e8c7a3a"},
]
prompt-toolkit = [
{file = "prompt_toolkit-3.0.8-py3-none-any.whl", hash = "sha256:7debb9a521e0b1ee7d2fe96ee4bd60ef03c6492784de0547337ca4433e46aa63"},
{file = "prompt_toolkit-3.0.8.tar.gz", hash = "sha256:25c95d2ac813909f813c93fde734b6e44406d1477a9faef7c915ff37d39c0a8c"},
{file = "prompt_toolkit-3.0.14-py3-none-any.whl", hash = "sha256:c96b30925025a7635471dc083ffb6af0cc67482a00611bd81aeaeeeb7e5a5e12"},
{file = "prompt_toolkit-3.0.14.tar.gz", hash = "sha256:7e966747c18ececaec785699626b771c1ba8344c8d31759a1915d6b12fad6525"},
]
ptyprocess = [
{file = "ptyprocess-0.6.0-py2.py3-none-any.whl", hash = "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f"},
{file = "ptyprocess-0.6.0.tar.gz", hash = "sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0"},
{file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
{file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"},
]
py = [
{file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"},
@ -1328,24 +1421,28 @@ pyflakes = [
{file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"},
]
pygments = [
{file = "Pygments-2.7.3-py3-none-any.whl", hash = "sha256:f275b6c0909e5dafd2d6269a656aa90fa58ebf4a74f8fcf9053195d226b24a08"},
{file = "Pygments-2.7.3.tar.gz", hash = "sha256:ccf3acacf3782cbed4a989426012f1c535c9a90d3a7fc3f16d231b9372d2b716"},
{file = "Pygments-2.7.4-py3-none-any.whl", hash = "sha256:bc9591213a8f0e0ca1a5e68a479b4887fdc3e75d0774e5c71c31920c427de435"},
{file = "Pygments-2.7.4.tar.gz", hash = "sha256:df49d09b498e83c1a73128295860250b0b7edd4c723a32e9bc0d295c7c2ec337"},
]
pymdown-extensions = [
{file = "pymdown-extensions-8.0.1.tar.gz", hash = "sha256:9ba704052d4bdc04a7cd63f7db4ef6add73bafcef22c0cf6b2e3386cf4ece51e"},
{file = "pymdown_extensions-8.0.1-py2.py3-none-any.whl", hash = "sha256:a3689c04f4cbddacd9d569425c571ae07e2673cc4df63a26cdbf1abc15229137"},
{file = "pymdown-extensions-8.1.1.tar.gz", hash = "sha256:632371fa3bf1b21a0e3f4063010da59b41db049f261f4c0b0872069a9b6d1735"},
{file = "pymdown_extensions-8.1.1-py3-none-any.whl", hash = "sha256:478b2c04513fbb2db61688d5f6e9030a92fb9be14f1f383535c43f7be9dff95b"},
]
pyparsing = [
{file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
{file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
]
pypiwin32 = [
{file = "pypiwin32-223-py3-none-any.whl", hash = "sha256:67adf399debc1d5d14dffc1ab5acacb800da569754fafdc576b2a039485aa775"},
{file = "pypiwin32-223.tar.gz", hash = "sha256:71be40c1fbd28594214ecaecb58e7aa8b708eabfa0125c8a109ebd51edbd776a"},
]
pytest = [
{file = "pytest-6.2.1-py3-none-any.whl", hash = "sha256:1969f797a1a0dbd8ccf0fecc80262312729afea9c17f1d70ebf85c5e76c6f7c8"},
{file = "pytest-6.2.1.tar.gz", hash = "sha256:66e419b1899bc27346cb2c993e12c5e5e8daba9073c1fbce33b9807abc95c306"},
{file = "pytest-6.2.2-py3-none-any.whl", hash = "sha256:b574b57423e818210672e07ca1fa90aaf194a4f63f3ab909a2c67ebb22913839"},
{file = "pytest-6.2.2.tar.gz", hash = "sha256:9d1edf9e7d0b84d72ea3dbcdfd22b35fb543a5e8f2a60092dd578936bf63d7f9"},
]
pytest-cov = [
{file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"},
{file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"},
{file = "pytest-cov-2.11.1.tar.gz", hash = "sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7"},
{file = "pytest_cov-2.11.1-py2.py3-none-any.whl", hash = "sha256:bdb9fdb0b85a7cc825269a4c56b48ccaa5c7e365054b6038772c32ddcdc969da"},
]
pytest-forked = [
{file = "pytest-forked-1.3.0.tar.gz", hash = "sha256:6aa9ac7e00ad1a539c41bec6d21011332de671e938c7637378ec9710204e37ca"},
@ -1355,29 +1452,48 @@ pytest-xdist = [
{file = "pytest-xdist-2.2.0.tar.gz", hash = "sha256:1d8edbb1a45e8e1f8e44b1260583107fc23f8bc8da6d18cb331ff61d41258ecf"},
{file = "pytest_xdist-2.2.0-py3-none-any.whl", hash = "sha256:f127e11e84ad37cc1de1088cb2990f3c354630d428af3f71282de589c5bb779b"},
]
pytkdocs = [
{file = "pytkdocs-0.9.0-py3-none-any.whl", hash = "sha256:12ed87d71b3518301c7b8c12c1a620e4b481a9d2fca1038aea665955000fad7f"},
{file = "pytkdocs-0.9.0.tar.gz", hash = "sha256:c8c39acb63824f69c3f6f58b3aed6ae55250c35804b76fd0cba09d5c11be13da"},
pytkdocs = []
pywin32 = [
{file = "pywin32-300-cp35-cp35m-win32.whl", hash = "sha256:1c204a81daed2089e55d11eefa4826c05e604d27fe2be40b6bf8db7b6a39da63"},
{file = "pywin32-300-cp35-cp35m-win_amd64.whl", hash = "sha256:350c5644775736351b77ba68da09a39c760d75d2467ecec37bd3c36a94fbed64"},
{file = "pywin32-300-cp36-cp36m-win32.whl", hash = "sha256:a3b4c48c852d4107e8a8ec980b76c94ce596ea66d60f7a697582ea9dce7e0db7"},
{file = "pywin32-300-cp36-cp36m-win_amd64.whl", hash = "sha256:27a30b887afbf05a9cbb05e3ffd43104a9b71ce292f64a635389dbad0ed1cd85"},
{file = "pywin32-300-cp37-cp37m-win32.whl", hash = "sha256:d7e8c7efc221f10d6400c19c32a031add1c4a58733298c09216f57b4fde110dc"},
{file = "pywin32-300-cp37-cp37m-win_amd64.whl", hash = "sha256:8151e4d7a19262d6694162d6da85d99a16f8b908949797fd99c83a0bfaf5807d"},
{file = "pywin32-300-cp38-cp38-win32.whl", hash = "sha256:fbb3b1b0fbd0b4fc2a3d1d81fe0783e30062c1abed1d17c32b7879d55858cfae"},
{file = "pywin32-300-cp38-cp38-win_amd64.whl", hash = "sha256:60a8fa361091b2eea27f15718f8eb7f9297e8d51b54dbc4f55f3d238093d5190"},
{file = "pywin32-300-cp39-cp39-win32.whl", hash = "sha256:638b68eea5cfc8def537e43e9554747f8dee786b090e47ead94bfdafdb0f2f50"},
{file = "pywin32-300-cp39-cp39-win_amd64.whl", hash = "sha256:b1609ce9bd5c411b81f941b246d683d6508992093203d4eb7f278f4ed1085c3f"},
]
pyyaml = [
{file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"},
{file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"},
{file = "PyYAML-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2"},
{file = "PyYAML-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c"},
{file = "PyYAML-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2"},
{file = "PyYAML-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648"},
{file = "PyYAML-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"},
{file = "PyYAML-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf"},
{file = "PyYAML-5.3.1-cp38-cp38-win32.whl", hash = "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97"},
{file = "PyYAML-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"},
{file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"},
{file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"},
{file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"},
{file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"},
{file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"},
{file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"},
{file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"},
{file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"},
{file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"},
{file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"},
{file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"},
{file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"},
{file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"},
{file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"},
{file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"},
{file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"},
{file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"},
{file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"},
{file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"},
{file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"},
{file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"},
{file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"},
]
pyyaml-include = [
{file = "pyyaml_include-1.2.post2-py3-none-any.whl", hash = "sha256:d74c7209f5150d841a529acf47d7a023769c9160949f366fb4e17efa8999c4be"},
]
questionary = [
{file = "questionary-1.8.1-py3-none-any.whl", hash = "sha256:e03ec1a585b2ff5a1463238fe63c12d82c740834e17e7b8efae9be58393a8fc9"},
{file = "questionary-1.8.1.tar.gz", hash = "sha256:f2999f01735db77a80d6cb119766cb15b84c468cab325168941a3e0d91207437"},
{file = "questionary-1.9.0-py3-none-any.whl", hash = "sha256:fa50c06af4e3826d986efbc90be16e42ff367a634e6a169e42a3f9fccd90648b"},
{file = "questionary-1.9.0.tar.gz", hash = "sha256:a050fdbb81406cddca679a6f492c6272da90cb09988963817828f697cf091c55"},
]
regex = [
{file = "regex-2020.11.13-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85"},
@ -1486,31 +1602,44 @@ tornado = [
{file = "tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791"},
]
tqdm = [
{file = "tqdm-4.54.1-py2.py3-none-any.whl", hash = "sha256:d4f413aecb61c9779888c64ddf0c62910ad56dcbe857d8922bb505d4dbff0df1"},
{file = "tqdm-4.54.1.tar.gz", hash = "sha256:38b658a3e4ecf9b4f6f8ff75ca16221ae3378b2e175d846b6b33ea3a20852cf5"},
{file = "tqdm-4.56.0-py2.py3-none-any.whl", hash = "sha256:4621f6823bab46a9cc33d48105753ccbea671b68bab2c50a9f0be23d4065cb5a"},
{file = "tqdm-4.56.0.tar.gz", hash = "sha256:fe3d08dd00a526850568d542ff9de9bbc2a09a791da3c334f3213d8d0bbbca65"},
]
typed-ast = [
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"},
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"},
{file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"},
{file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"},
{file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"},
{file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"},
{file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"},
{file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"},
{file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"},
{file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"},
{file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"},
{file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"},
{file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"},
{file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"},
{file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"},
{file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"},
{file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"},
{file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"},
{file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"},
{file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"},
{file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"},
{file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70"},
{file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487"},
{file = "typed_ast-1.4.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412"},
{file = "typed_ast-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400"},
{file = "typed_ast-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606"},
{file = "typed_ast-1.4.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64"},
{file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07"},
{file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc"},
{file = "typed_ast-1.4.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a"},
{file = "typed_ast-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151"},
{file = "typed_ast-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3"},
{file = "typed_ast-1.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41"},
{file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f"},
{file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581"},
{file = "typed_ast-1.4.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37"},
{file = "typed_ast-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd"},
{file = "typed_ast-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496"},
{file = "typed_ast-1.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc"},
{file = "typed_ast-1.4.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"},
{file = "typed_ast-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea"},
{file = "typed_ast-1.4.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787"},
{file = "typed_ast-1.4.2-cp38-cp38-win32.whl", hash = "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2"},
{file = "typed_ast-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937"},
{file = "typed_ast-1.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1"},
{file = "typed_ast-1.4.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6"},
{file = "typed_ast-1.4.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166"},
{file = "typed_ast-1.4.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d"},
{file = "typed_ast-1.4.2-cp39-cp39-win32.whl", hash = "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b"},
{file = "typed_ast-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440"},
{file = "typed_ast-1.4.2.tar.gz", hash = "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a"},
]
typing = [
{file = "typing-3.7.4.3-py2-none-any.whl", hash = "sha256:283d868f5071ab9ad873e5e52268d611e851c870a2ba354193026f2dfb29d8b5"},
{file = "typing-3.7.4.3.tar.gz", hash = "sha256:1187fb9c82fd670d10aa07bbb6cfcfe4bdda42d6fab8d5134f04e8c4d0b71cc9"},
]
typing-extensions = [
{file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"},
@ -1518,12 +1647,12 @@ typing-extensions = [
{file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"},
]
urllib3 = [
{file = "urllib3-1.26.2-py2.py3-none-any.whl", hash = "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"},
{file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"},
{file = "urllib3-1.26.3-py2.py3-none-any.whl", hash = "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80"},
{file = "urllib3-1.26.3.tar.gz", hash = "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"},
]
virtualenv = [
{file = "virtualenv-20.2.2-py2.py3-none-any.whl", hash = "sha256:54b05fc737ea9c9ee9f8340f579e5da5b09fb64fd010ab5757eb90268616907c"},
{file = "virtualenv-20.2.2.tar.gz", hash = "sha256:b7a8ec323ee02fb2312f098b6b4c9de99559b462775bc8fe3627a73706603c1b"},
{file = "virtualenv-20.4.2-py2.py3-none-any.whl", hash = "sha256:2be72df684b74df0ea47679a7df93fd0e04e72520022c57b479d8f881485dbe3"},
{file = "virtualenv-20.4.2.tar.gz", hash = "sha256:147b43894e51dd6bba882cf9c282447f780e2251cd35172403745fc381a0a80d"},
]
wcwidth = [
{file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},

View File

@ -26,24 +26,30 @@ copier = "copier.cli:CopierApp.run"
[tool.poetry.dependencies]
python = ">=3.6.1,<3.10"
"backports.cached-property" = {version = "^1.0.0", python = "<3.8"}
colorama = "^0.4.3"
iteration_utilities = "^0.10.1"
jinja2 = "^2.11.2"
mkdocs-material = {version = "^5.5.2", optional = true}
mkdocs-mermaid2-plugin = {version = "^0.5.0", optional = true}
# HACK https://github.com/pawamoy/mkdocstrings/issues/222#issuecomment-774394285
# TODO install from Pypi when 0.15 is released
mkdocstrings = {git = "https://github.com/pawamoy/mkdocstrings.git", rev = "master", optional = true}
packaging = "^20.4" # packaging is needed when installing from PyPI
pathspec = "^0.8.0"
plumbum = "^1.6.9"
pydantic = "^1.7.2"
Pygments = "^2.7.1"
questionary = "^1.8.1"
pyyaml = "^5.3.1"
pyyaml-include = "^1.2"
# packaging is needed when installing from PyPI
packaging = "^20.4"
mkdocstrings = {version = "^0.13.1", optional = true}
mkdocs-material = {version = "^5.5.2", optional = true}
mkdocs-mermaid2-plugin = {version = "^0.5.0", optional = true}
questionary = "^1.8.1"
typing-extensions = {version = "^3.7.4", python = "<3.8"}
# HACK https://github.com/pawamoy/pytkdocs/issues/86
# HACK https://github.com/pawamoy/mkdocstrings/issues/225#issuecomment-774669999
pytkdocs = {git = "https://github.com/pawamoy/pytkdocs.git", rev = "master", optional = true}
[tool.poetry.extras]
docs = ["mkdocstrings", "mkdocs-material", "mkdocs-mermaid2-plugin"]
docs = ["mkdocstrings", "mkdocs-material", "mkdocs-mermaid2-plugin", "pytkdocs"]
[tool.poetry.dev-dependencies]
autoflake = "^1.4"
@ -103,10 +109,6 @@ line_length = 88
multi_line_output = 3 # black interop
use_parentheses = true
[flake8]
max-complexity = 20
ignore = ",W503,E203,E501,D100,D101,D102,D103,D104,D105,D107,"
[build-system]
# TODO Switch back to poetry-core when it's possible again
# HACK https://github.com/mtkennerly/poetry-dynamic-versioning/issues/35

View File

@ -1,2 +0,0 @@
# Changes here will be overwritten by Copier
[[ _copier_answers|to_nice_yaml ]]

View File

@ -1,16 +0,0 @@
_answers_file: .answers-file-changed-in-template.yml
round: 1st
# A str question without a default should default to None
str_question_without_default:
type: str
# password_1 and password_2 must not appear in answers file
_secret_questions:
- password_1
password_1: password one
password_2:
secret: yes
default: password two

View File

@ -1,3 +0,0 @@
It's the [[round]] round.
password_1=[[password_1]]
password_2=[[password_2]]

View File

@ -1,53 +0,0 @@
love_me:
help: I need to know it. Do you love me?
type: bool
your_name:
help: Please tell me your name.
type: str
your_age:
help: How old are you?
type: int
your_height:
help: What's your height?
type: float
more_json_info:
multiline: true
type: json
anything_else:
multiline: true
help: Wanna give me any more info?
# In choices, the user will always write the choice index (1, 2, 3...)
choose_list:
help: You see the value of the list items
default: first
choices:
- first
- second
- third
choose_tuple:
help: You see the 1st tuple item, but I get the 2nd item as a result
default: second
choices:
- [one, first]
- [two, second]
- [three, third]
choose_dict:
help: You see the dict key, but I get the dict value
default: third
choices:
one: first
two: second
three: third
choose_number:
help: This must be a number
default: null
type: float
choices:
- -1.1
- 0
- 1
# Simplified format is still supported
minutes_under_water: 10
optional_value: null

View File

@ -1,13 +0,0 @@
love_me: [[love_me|tojson]]
your_name: [[your_name|tojson]]
your_age: [[your_age|tojson]]
your_height: [[your_height|tojson]]
more_json_info: [[more_json_info|tojson]]
anything_else: [[anything_else|tojson]]
choose_list: [[choose_list|tojson]]
choose_tuple: [[choose_tuple|tojson]]
choose_dict: [[choose_dict|tojson]]
choose_number: [[choose_number|tojson]]
minutes_under_water: [[minutes_under_water|tojson]]
optional_value: [[optional_value|tojson]]

View File

@ -9,6 +9,3 @@ _skip_if_exists:
_tasks:
- "touch 1"
- "touch 2"
_extra_paths:
- "tests"

View File

@ -1 +0,0 @@
[% extends "parent.txt" %]

View File

@ -1 +0,0 @@
[% extends "parent.txt" %]

View File

@ -1,2 +0,0 @@
_extra_paths:
- "./tests/demo_extra_paths/parent"

View File

@ -1 +0,0 @@
PARENT_CONTENT

View File

@ -1 +0,0 @@
_min_copier_version: "10.5.1"

View File

@ -1,3 +0,0 @@
# Demo subdirectory
Generated using previous answers `_subdirectory` value.

View File

@ -1 +0,0 @@
_subdirectory: conf_project

View File

@ -1,6 +0,0 @@
# This tests two things:
# 1. That the tasks are being executed in the destiantion folder; and
# 2. That the tasks are being executed in order, one after another
_tasks:
- mkdir hello
- cd hello && touch world

View File

@ -1,17 +0,0 @@
_envops:
block_start_string: "<%"
block_end_string: "%>"
comment_start_string: "<#"
comment_end_string: "#>"
variable_start_string: "<<"
variable_end_string: ">>"
powerlevel:
type: int
default: 9000
sentence:
type: str
default:
"<% if powerlevel >= 9000 %>It's over 9000!<% else %>It's only << powerlevel >>...<%
endif %>"

View File

@ -37,6 +37,10 @@ COPIER_CMD = local.get(
)
COPIER_PATH = str(COPIER_CMD.executable)
# Helper to parse back an iso-formatted datetime; needed for py3.6, where
# datetime.datetime.fromisoformat() doesn't exist
ISOFORMAT = "%Y-%m-%d %H:%M:%S.%f"
class Keyboard(str, Enum):
ControlH = REVERSE_ANSI_SEQUENCES[Keys.ControlH]

View File

@ -3,20 +3,58 @@ from textwrap import dedent
import pytest
import copier
from copier.config.user_data import load_answersfile_data
from copier.user_data import load_answersfile_data
from .helpers import PROJECT_TEMPLATE
from .helpers import build_file_tree
SRC = f"{PROJECT_TEMPLATE}_answersfile"
@pytest.fixture(scope="module")
def template_path(tmp_path_factory) -> str:
root = tmp_path_factory.mktemp("template")
build_file_tree(
{
root
/ "[[ _copier_conf.answers_file ]].tmpl": """\
# Changes here will be overwritten by Copier
[[ _copier_answers|to_nice_yaml ]]
""",
root
/ "copier.yml": """\
_answers_file: .answers-file-changed-in-template.yml
round: 1st
# A str question without a default should default to None
str_question_without_default:
type: str
# password_1 and password_2 must not appear in answers file
_secret_questions:
- password_1
password_1: password one
password_2:
secret: yes
default: password two
""",
root
/ "round.txt.tmpl": """\
It's the [[round]] round.
password_1=[[password_1]]
password_2=[[password_2]]
""",
}
)
return str(root)
@pytest.mark.parametrize("answers_file", [None, ".changed-by-user.yaml"])
def test_answersfile(tmp_path, answers_file):
def test_answersfile(template_path, tmp_path, answers_file):
"""Test copier behaves properly when using an answersfile."""
round_file = tmp_path / "round.txt"
# Check 1st round is properly executed and remembered
copier.copy(SRC, tmp_path, answers_file=answers_file, force=True)
copier.copy(template_path, tmp_path, answers_file=answers_file, force=True)
answers_file = answers_file or ".answers-file-changed-in-template.yml"
assert (
round_file.read_text()
@ -35,7 +73,9 @@ def test_answersfile(tmp_path, answers_file):
assert "password_2" not in log
# Check 2nd round is properly executed and remembered
copier.copy(SRC, tmp_path, {"round": "2nd"}, answers_file=answers_file, force=True)
copier.copy(
template_path, tmp_path, {"round": "2nd"}, answers_file=answers_file, force=True
)
assert (
round_file.read_text()
== dedent(
@ -53,7 +93,7 @@ def test_answersfile(tmp_path, answers_file):
assert "password_2" not in log
# Check repeating 2nd is properly executed and remembered
copier.copy(SRC, tmp_path, answers_file=answers_file, force=True)
copier.copy(template_path, tmp_path, answers_file=answers_file, force=True)
assert (
round_file.read_text()
== dedent(

View File

@ -2,18 +2,98 @@ from pathlib import Path
from textwrap import dedent
import pexpect
import pytest
from copier import copy
from .helpers import COPIER_PATH, PROJECT_TEMPLATE, Keyboard
SRC = f"{PROJECT_TEMPLATE}_complex_questions"
from .helpers import COPIER_PATH, Keyboard, build_file_tree
def test_api(tmp_path):
@pytest.fixture(scope="module")
def template_path(tmp_path_factory) -> str:
root = tmp_path_factory.mktemp("template")
build_file_tree(
{
root
/ "copier.yml": """\
love_me:
help: I need to know it. Do you love me?
type: bool
your_name:
help: Please tell me your name.
type: str
your_age:
help: How old are you?
type: int
your_height:
help: What's your height?
type: float
more_json_info:
multiline: true
type: json
anything_else:
multiline: true
help: Wanna give me any more info?
# In choices, the user will always write the choice index (1, 2, 3...)
choose_list:
help: You see the value of the list items
default: first
choices:
- first
- second
- third
choose_tuple:
help: You see the 1st tuple item, but I get the 2nd item as a result
default: second
choices:
- [one, first]
- [two, second]
- [three, third]
choose_dict:
help: You see the dict key, but I get the dict value
default: third
choices:
one: first
two: second
three: third
choose_number:
help: This must be a number
default: null
type: float
choices:
- -1.1
- 0
- 1
# Simplified format is still supported
minutes_under_water: 10
optional_value: null
""",
root
/ "results.txt.tmpl": """\
love_me: [[love_me|tojson]]
your_name: [[your_name|tojson]]
your_age: [[your_age|tojson]]
your_height: [[your_height|tojson]]
more_json_info: [[more_json_info|tojson]]
anything_else: [[anything_else|tojson]]
choose_list: [[choose_list|tojson]]
choose_tuple: [[choose_tuple|tojson]]
choose_dict: [[choose_dict|tojson]]
choose_number: [[choose_number|tojson]]
minutes_under_water: [[minutes_under_water|tojson]]
optional_value: [[optional_value|tojson]]
""",
}
)
return str(root)
def test_api(tmp_path, template_path):
"""Test copier correctly processes advanced questions and answers through API."""
copy(
SRC,
template_path,
tmp_path,
{
"love_me": False,
@ -27,7 +107,7 @@ def test_api(tmp_path):
)
results_file = tmp_path / "results.txt"
assert results_file.read_text() == dedent(
"""
"""\
love_me: false
your_name: "LeChuck"
your_age: 220
@ -44,13 +124,13 @@ def test_api(tmp_path):
)
def test_cli_interactive(tmp_path, spawn):
def test_cli_interactive(tmp_path, spawn, template_path):
"""Test copier correctly processes advanced questions and answers through CLI."""
invalid = [
"Invalid value",
"please try again",
]
tui = spawn([COPIER_PATH, "copy", SRC, str(tmp_path)], timeout=10)
tui = spawn([COPIER_PATH, "copy", template_path, str(tmp_path)], timeout=10)
tui.expect_exact(["I need to know it. Do you love me?", "love_me", "Format: bool"])
tui.send("y")
tui.expect_exact(["Please tell me your name.", "your_name", "Format: str"])
@ -120,13 +200,13 @@ def test_cli_interactive(tmp_path, spawn):
tui.expect_exact(pexpect.EOF)
results_file = tmp_path / "results.txt"
assert results_file.read_text() == dedent(
r"""
"""\
love_me: true
your_name: "Guybrush Threpwood"
your_age: 22
your_height: 1.56
more_json_info: {"objective": "be a pirate"}
anything_else: ["Want some grog?", "I\u0027d love it"]
anything_else: ["Want some grog?", "I\\u0027d love it"]
choose_list: "first"
choose_tuple: "second"
choose_dict: "third"
@ -137,13 +217,13 @@ def test_cli_interactive(tmp_path, spawn):
)
def test_api_str_data(tmp_path):
def test_api_str_data(tmp_path, template_path):
"""Test copier when all data comes as a string.
This happens i.e. when using the --data CLI argument.
"""
copy(
SRC,
template_path,
tmp_path,
data={
"love_me": "false",
@ -158,24 +238,26 @@ def test_api_str_data(tmp_path):
)
results_file = tmp_path / "results.txt"
assert results_file.read_text() == dedent(
r"""
love_me: false
"""\
love_me: "false"
your_name: "LeChuck"
your_age: 220
your_height: 1.9
more_json_info: ["bad", "guy"]
anything_else: {"hates": "all"}
your_age: "220"
your_height: "1.9"
more_json_info: "[\\"bad\\", \\"guy\\"]"
anything_else: "{\\u0027hates\\u0027: \\u0027all\\u0027}"
choose_list: "first"
choose_tuple: "second"
choose_dict: "third"
choose_number: 0.0
choose_number: "0"
minutes_under_water: 10
optional_value: null
"""
)
def test_cli_interatively_with_flag_data_and_type_casts(tmp_path: Path, spawn):
def test_cli_interatively_with_flag_data_and_type_casts(
tmp_path: Path, spawn, template_path
):
"""Assert how choices work when copier is invoked with --data interactively."""
invalid = [
"Invalid value",
@ -189,7 +271,7 @@ def test_cli_interatively_with_flag_data_and_type_casts(tmp_path: Path, spawn):
"--data=choose_tuple=third",
"--data=choose_number=1",
"copy",
SRC,
template_path,
str(tmp_path),
],
timeout=10,
@ -229,17 +311,17 @@ def test_cli_interatively_with_flag_data_and_type_casts(tmp_path: Path, spawn):
tui.expect_exact(pexpect.EOF)
results_file = tmp_path / "results.txt"
assert results_file.read_text() == dedent(
r"""
"""\
love_me: true
your_name: "Guybrush Threpwood"
your_age: 22
your_height: 1.56
more_json_info: {"objective": "be a pirate"}
anything_else: ["Want some grog?", "I\u0027d love it"]
anything_else: ["Want some grog?", "I\\u0027d love it"]
choose_list: "second"
choose_tuple: "third"
choose_dict: "first"
choose_number: 1.0
choose_number: 1
minutes_under_water: 10
optional_value: null
"""

View File

@ -5,14 +5,9 @@ from plumbum import local
from pydantic import ValidationError
import copier
from copier.config.factory import make_config
from copier.config.objects import DEFAULT_EXCLUDE, ConfigData, EnvOps
from copier.config.user_data import (
InvalidConfigFileError,
MultipleConfigFilesError,
load_config_data,
load_yaml_data,
)
from copier.errors import InvalidConfigFileError, MultipleConfigFilesError
from copier.template import DEFAULT_EXCLUDE
from copier.user_data import load_config_data, load_yaml_data
from .helpers import build_file_tree
@ -34,7 +29,6 @@ def test_config_data_is_loaded_from_file():
assert config["_exclude"] == ["exclude1", "exclude2"]
assert config["_skip_if_exists"] == ["skip_if_exists1", "skip_if_exists2"]
assert config["_tasks"] == ["touch 1", "touch 2"]
assert config["_extra_paths"] == ["tests"]
@pytest.mark.parametrize("template", ["tests/demo_yaml", "tests/demo_yml"])
@ -145,150 +139,60 @@ def test_multiple_config_file_error(capsys):
{"pretend": "not_a_bool"},
{"quiet": "not_a_bool"},
{"force": "not_a_bool"},
{"skip": "not_a_bool"},
{"cleanup_on_error": "not_a_bool"},
{"force": True, "skip": True},
{"force": True, "skip_if_exists": True},
),
)
def test_flags_bad_data(data):
with pytest.raises(ValidationError):
ConfigData(**data)
copier.Worker(**data)
def test_flags_extra_ignored():
def test_flags_extra_fails():
key = "i_am_not_a_member"
conf_data = {"src_path": "..", "dst_path": ".", key: "and_i_do_not_belong_here"}
confs = ConfigData(**conf_data)
assert key not in confs.dict()
with pytest.raises(TypeError):
copier.Worker(**conf_data)
# EnvOps
@pytest.mark.parametrize(
"data",
(
{"autoescape": "not_a_bool"},
{"block_start_string": None},
{"block_end_string": None},
{"variable_start_string": None},
{"variable_end_string": None},
{"keep_trailing_newline": "not_a_bool"},
),
)
def test_envops_bad_data(data):
with pytest.raises(ValidationError):
EnvOps(**data)
def test_envops_good_data():
ops = EnvOps(**GOOD_ENV_OPS)
assert ops.dict() == GOOD_ENV_OPS
# ConfigData
def test_config_data_paths_required():
try:
ConfigData(envops=EnvOps())
except ValidationError as e:
assert len(e.errors()) == 2
for i, p in enumerate(("src_path", "dst_path")):
err = e.errors()[i]
assert err["loc"][0] == p
assert err["type"] == "value_error.missing"
else:
raise AssertionError()
def test_config_data_paths_existing(tmp_path):
try:
ConfigData(
src_path="./i_do_not_exist",
extra_paths=["./i_do_not_exist"],
dst_path=tmp_path,
envops=EnvOps(),
)
except ValidationError as e:
assert len(e.errors()) == 2
for i, p in enumerate(("src_path", "extra_paths")):
err = e.errors()[i]
assert err["loc"][0] == p
assert err["msg"] == "Project template not found."
else:
raise AssertionError()
def test_config_data_good_data(tmp_path):
tmp_path = Path(tmp_path).expanduser().resolve()
expected = {
"src_path": tmp_path,
"commit": None,
"old_commit": None,
"dst_path": tmp_path,
"extra_paths": [tmp_path],
"exclude": DEFAULT_EXCLUDE,
"original_src_path": None,
"skip_if_exists": ["skip_me"],
"tasks": ["echo python rulez"],
"templates_suffix": ".tmpl",
"cleanup_on_error": True,
"envops": EnvOps().dict(),
"force": False,
"only_diff": True,
"pretend": False,
"quiet": False,
"skip": False,
"vcs_ref": None,
"migrations": (),
"secret_questions": (),
"subdirectory": None,
}
conf = ConfigData(**expected)
conf.data["_folder_name"] = tmp_path.name
expected["answers_file"] = Path(".copier-answers.yml")
conf_dict = conf.dict()
for key, value in expected.items():
assert conf_dict[key] == value
def test_make_config_bad_data(tmp_path):
with pytest.raises(ValidationError):
make_config("./i_do_not_exist", tmp_path)
def test_missing_template(tmp_path):
with pytest.raises(ValueError):
copier.copy("./i_do_not_exist", tmp_path)
def is_subdict(small, big):
return {**big, **small} == big
def test_make_config_good_data(tmp_path):
conf = make_config("./tests/demo_data", tmp_path)
assert conf is not None
assert "_folder_name" in conf.data
assert conf.data["_folder_name"] == tmp_path.name
assert conf.exclude == ["exclude1", "exclude2"]
assert conf.skip_if_exists == ["skip_if_exists1", "skip_if_exists2"]
assert conf.tasks == ["touch 1", "touch 2"]
assert conf.extra_paths == [Path("tests").resolve()]
def test_worker_good_data(tmp_path):
# This test is probably useless, as it tests the what and not the how
conf = copier.Worker("./tests/demo_data", tmp_path)
assert conf._render_context()["_folder_name"] == tmp_path.name
assert conf.all_exclusions == ("exclude1", "exclude2")
assert conf.template.skip_if_exists == ["skip_if_exists1", "skip_if_exists2"]
assert conf.template.tasks == ["touch 1", "touch 2"]
@pytest.mark.parametrize(
"test_input, expected",
"test_input, expected_exclusions",
[
# func_args > defaults
(
{"src_path": ".", "exclude": ["aaa"]},
{"exclude": list(DEFAULT_EXCLUDE) + ["aaa"]},
tuple(DEFAULT_EXCLUDE) + ("aaa",),
),
# func_args > user_data
(
{"src_path": "tests/demo_data", "exclude": ["aaa"]},
{"exclude": ["exclude1", "exclude2", "aaa"]},
("exclude1", "exclude2", "aaa"),
),
],
)
def test_make_config_precedence(tmp_path, test_input, expected):
conf = make_config(dst_path=tmp_path, vcs_ref="HEAD", **test_input)
assert is_subdict(expected, conf.dict())
def test_worker_config_precedence(tmp_path, test_input, expected_exclusions):
conf = copier.Worker(dst_path=tmp_path, vcs_ref="HEAD", **test_input)
assert expected_exclusions == conf.all_exclusions
def test_config_data_transclusion():
config = load_config_data("tests/demo_transclude/demo")
assert config["_exclude"] == ["exclude1", "exclude2"]
config = copier.Worker("tests/demo_transclude/demo")
assert config.all_exclusions == ("exclude1", "exclude2")

View File

@ -9,14 +9,7 @@ from plumbum.cmd import git
import copier
from .helpers import (
DATA,
PROJECT_TEMPLATE,
assert_file,
build_file_tree,
filecmp,
render,
)
from .helpers import PROJECT_TEMPLATE, assert_file, build_file_tree, filecmp, render
def test_project_not_found(tmp_path):
@ -148,7 +141,7 @@ def test_skip_if_exists(tmp_path):
force=True,
)
assert (tmp_path / "a.noeof.txt").read_text() == "OVERWRITTEN"
assert (tmp_path / "a.noeof.txt").read_text() == "SKIPPED"
assert (tmp_path / "b.noeof.txt").read_text() == "SKIPPED"
assert (tmp_path / "meh" / "c.noeof.txt").read_text() == "SKIPPED"
@ -162,43 +155,17 @@ def test_skip_if_exists_rendered_patterns(tmp_path):
skip_if_exists=["[[ name ]]/c.noeof.txt"],
force=True,
)
assert (tmp_path / "a.noeof.txt").read_text() == "OVERWRITTEN"
assert (tmp_path / "a.noeof.txt").read_text() == "SKIPPED"
assert (tmp_path / "b.noeof.txt").read_text() == "OVERWRITTEN"
assert (tmp_path / "meh" / "c.noeof.txt").read_text() == "SKIPPED"
def test_config_exclude(tmp_path, monkeypatch):
def fake_data(*_args, **_kwargs):
return {"_exclude": ["*.txt"]}
monkeypatch.setattr(copier.config.factory, "load_config_data", fake_data)
copier.copy(str(PROJECT_TEMPLATE), tmp_path, data=DATA, quiet=True)
assert not (tmp_path / "aaaa.txt").exists()
def test_config_exclude_overridden(tmp_path):
def fake_data(*_args, **_kwargs):
return {"_exclude": ["*.txt"]}
copier.copy(str(PROJECT_TEMPLATE), tmp_path, data=DATA, quiet=True, exclude=[])
assert (tmp_path / "aaaa.txt").exists()
def test_config_include(tmp_path, monkeypatch):
def fake_data(*_args, **_kwargs):
return {"_exclude": ["!.svn"]}
monkeypatch.setattr(copier.config.factory, "load_config_data", fake_data)
copier.copy(str(PROJECT_TEMPLATE), tmp_path, data=DATA, quiet=True)
assert (tmp_path / ".svn").exists()
def test_skip_option(tmp_path):
render(tmp_path)
path = tmp_path / "pyproject.toml"
content = "lorem ipsum"
path.write_text(content)
render(tmp_path, skip=True)
render(tmp_path, skip_if_exists=["**"])
assert path.read_text() == content
@ -216,11 +183,3 @@ def test_pretend_option(tmp_path):
assert not (tmp_path / "doc").exists()
assert not (tmp_path / "config.py").exists()
assert not (tmp_path / "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()

View File

@ -10,9 +10,8 @@ from .helpers import PROJECT_TEMPLATE
REPO_BUNDLE_PATH = Path(f"{PROJECT_TEMPLATE}_update_tasks.bundle").absolute()
def test_update_tasks(tmpdir):
def test_update_tasks(tmp_path):
"""Test that updating a template runs tasks from the expected version."""
tmp_path = tmpdir / "tmp_path"
# Copy the 1st version
copy(
str(REPO_BUNDLE_PATH),

View File

@ -1,12 +1,17 @@
from copier.main import copy
import platform
from pathlib import Path
from .helpers import PROJECT_TEMPLATE
import pytest
from copier.main import run_auto
from .helpers import PROJECT_TEMPLATE, build_file_tree
def test_exclude_recursive(tmp_path):
"""Copy is done properly when excluding recursively."""
src = f"{PROJECT_TEMPLATE}_exclude"
copy(src, tmp_path)
run_auto(src, tmp_path)
assert not (tmp_path / "bad").exists()
assert not (tmp_path / "bad").is_dir()
@ -14,7 +19,75 @@ def test_exclude_recursive(tmp_path):
def test_exclude_recursive_negate(tmp_path):
"""Copy is done properly when copy_me.txt is the sole file copied."""
src = f"{PROJECT_TEMPLATE}_exclude_negate"
copy(src, tmp_path)
run_auto(src, tmp_path)
assert (tmp_path / "copy_me.txt").exists()
assert (tmp_path / "copy_me.txt").is_file()
assert not (tmp_path / "do_not_copy_me.txt").exists()
def test_config_exclude(tmp_path):
src, dst = tmp_path / "src", tmp_path / "dst"
build_file_tree({src / "copier.yml": "_exclude: ['*.txt']", src / "aaaa.txt": ""})
run_auto(str(src), dst, quiet=True)
assert not (dst / "aaaa.txt").exists()
assert (dst / "copier.yml").exists()
def test_config_exclude_extended(tmp_path):
src, dst = tmp_path / "src", tmp_path / "dst"
build_file_tree({src / "copier.yml": "_exclude: ['*.txt']", src / "aaaa.txt": ""})
run_auto(str(src), dst, quiet=True, exclude=["*.yml"])
assert not (dst / "aaaa.txt").exists()
assert not (dst / "copier.yml").exists()
def test_config_include(tmp_path):
src, dst = tmp_path / "src", tmp_path / "dst"
build_file_tree(
{src / "copier.yml": "_exclude: ['!.svn']", src / ".svn" / "hello": ""}
)
run_auto(str(src), dst, quiet=True)
assert (dst / ".svn" / "hello").exists()
assert (dst / "copier.yml").exists()
@pytest.mark.xfail(
condition=platform.system() in {"Darwin", "Windows"},
reason="OS without proper UTF-8 filesystem.",
strict=True,
)
def test_path_filter(tmp_path_factory):
src, dst = tmp_path_factory.mktemp("src"), tmp_path_factory.mktemp("dst")
file_excluded = {
"x.exclude": True,
"do_not.exclude!": False,
# dir patterns and their negations
Path("exclude_dir", "x"): True,
Path("exclude_dir", "please_copy_me"): False, # no mercy
Path("not_exclude_dir", "x!"): False,
# unicode patterns
"mañana.txt": True,
"mañana.txt": False,
"manana.txt": False,
}
file_tree_spec = {
src
/ "copier.yaml": """
_exclude:
# simple file patterns and their negations
- "*.exclude"
- "!do_not.exclude"
# dir patterns and their negations
- "exclude_dir/"
- "!exclude_dir/please_copy_me"
- "!not_exclude_dir/x"
# unicode patterns
- "mañana.txt"
""",
}
for key, value in file_excluded.items():
file_tree_spec[src / key] = str(value)
build_file_tree(file_tree_spec)
run_auto(str(src), dst)
for key, value in file_excluded.items():
assert (dst / key).exists() != value

View File

@ -1,38 +0,0 @@
from pathlib import Path
import pytest
from jinja2.exceptions import TemplateNotFound
import copier
CHILD_DIR = "./tests/demo_extra_paths/children"
CHILD_DIR_CONFIG = "./tests/demo_extra_paths/children_config"
PARENT_DIR = "./tests/demo_extra_paths/parent"
def test_template_not_found(tmp_path):
with pytest.raises(TemplateNotFound):
copier.copy(CHILD_DIR, tmp_path)
def test_parent_dir_not_found(tmp_path):
with pytest.raises(ValueError):
copier.copy(CHILD_DIR, tmp_path, extra_paths="foobar")
def test_copy_with_extra_paths(tmp_path):
copier.copy(CHILD_DIR, tmp_path, extra_paths=[PARENT_DIR])
gen_file = tmp_path / "child.txt"
result = gen_file.read_text()
expected = Path(PARENT_DIR + "/parent.txt").read_text()
assert result == expected
def test_copy_with_extra_paths_from_config(tmp_path):
copier.copy(CHILD_DIR_CONFIG, tmp_path)
gen_file = tmp_path / "child.txt"
result = gen_file.read_text()
expected = Path(PARENT_DIR + "/parent.txt").read_text()
assert result == expected

View File

@ -10,7 +10,7 @@ from plumbum import local
from plumbum.cmd import git
from copier import copy
from copier.config.objects import UserMessageError
from copier.errors import UserMessageError
from .helpers import PROJECT_TEMPLATE, build_file_tree

View File

@ -3,31 +3,47 @@ from plumbum import local
from plumbum.cmd import git
import copier
from copier.config.factory import make_config
from copier.config.objects import UserMessageError
from copier.errors import UnsupportedVersionError
from .helpers import build_file_tree
def test_version_less_than_required(monkeypatch):
@pytest.fixture(scope="module")
def template_path(tmp_path_factory) -> str:
root = tmp_path_factory.mktemp("template")
build_file_tree(
{
root
/ "copier.yaml": """\
_min_copier_version: "10.5.1"
""",
root / "README.md": "",
}
)
return str(root)
def test_version_less_than_required(template_path, tmp_path, monkeypatch):
monkeypatch.setattr("copier.__version__", "0.0.0a0")
with pytest.raises(UserMessageError):
make_config("./tests/demo_minimum_version")
with pytest.raises(UnsupportedVersionError):
copier.copy(template_path, tmp_path)
def test_version_equal_required(monkeypatch):
def test_version_equal_required(template_path, tmp_path, monkeypatch):
monkeypatch.setattr("copier.__version__", "10.5.1")
# assert no error
make_config("./tests/demo_minimum_version")
copier.copy(template_path, tmp_path)
def test_version_greater_than_required(monkeypatch):
def test_version_greater_than_required(template_path, tmp_path, monkeypatch):
monkeypatch.setattr("copier.__version__", "99.99.99")
# assert no error
make_config("./tests/demo_minimum_version")
copier.copy(template_path, tmp_path)
def test_minimum_version_update(tmp_path, monkeypatch):
monkeypatch.setattr("copier.__version__", "99.99.99")
copier.copy("./tests/demo_minimum_version", tmp_path)
def test_minimum_version_update(template_path, tmp_path, monkeypatch):
monkeypatch.setattr("copier.__version__", "11.0.0")
copier.copy(template_path, tmp_path)
with local.cwd(tmp_path):
git("init")
@ -37,19 +53,19 @@ def test_minimum_version_update(tmp_path, monkeypatch):
git("commit", "-m", "hello world")
monkeypatch.setattr("copier.__version__", "0.0.0.post0")
with pytest.raises(UserMessageError):
make_config("./tests/demo_minimum_version", tmp_path)
with pytest.raises(UnsupportedVersionError):
copier.copy(template_path, tmp_path)
monkeypatch.setattr("copier.__version__", "10.5.1")
# assert no error
make_config("./tests/demo_minimum_version", tmp_path)
copier.copy(template_path, tmp_path)
monkeypatch.setattr("copier.__version__", "99.99.99")
# assert no error
make_config("./tests/demo_minimum_version", tmp_path)
copier.copy(template_path, tmp_path)
def test_version_0_0_0_ignored(monkeypatch):
def test_version_0_0_0_ignored(template_path, tmp_path, monkeypatch):
monkeypatch.setattr("copier.__version__", "0.0.0")
# assert no error
make_config("./tests/demo_minimum_version")
copier.copy(template_path, tmp_path)

View File

@ -33,7 +33,7 @@ def test_output_force(capsys, tmp_path):
def test_output_skip(capsys, tmp_path):
render(tmp_path)
capsys.readouterr()
render(tmp_path, quiet=False, skip=True)
render(tmp_path, quiet=False, skip_if_exists=["config.py"])
_, err = capsys.readouterr()
assert re.search(r"conflict[^\s]* config\.py", err)
assert re.search(r"skip[^\s]* config\.py", err)

View File

@ -77,7 +77,7 @@ def test_copy_default_advertised(tmp_path_factory, spawn, name):
git("add", ".")
assert "_commit: v1" in Path(".copier-answers.yml").read_text()
git("commit", "-m", "v1")
tui = spawn([COPIER_PATH], timeout=10)
tui = spawn([COPIER_PATH], timeout=20)
# Check what was captured
tui.expect_exact(["in_love?", "Format: bool", "(Y/n)"])
tui.sendline()
@ -302,6 +302,11 @@ def test_update_choice(tmp_path_factory, spawn, choices):
/ "[[ _copier_conf.answers_file ]].tmpl": "[[ _copier_answers|to_nice_yaml ]]",
}
)
with local.cwd(template):
git("init")
git("add", ".")
git("commit", "-m one")
git("tag", "v1")
# Copy
tui = spawn([COPIER_PATH, str(template), str(subproject)], timeout=10)
tui.expect_exact(["pick_one?"])

View File

@ -1,11 +1,12 @@
import os
import pytest
from plumbum import local
from plumbum.cmd import git
import copier
from copier.config import make_config
from copier.main import update_diff
from .helpers import build_file_tree
def git_init(message="hello world"):
@ -16,28 +17,63 @@ def git_init(message="hello world"):
git("commit", "-m", message)
def test_copy_subdirectory_api_option(tmp_path):
@pytest.fixture(scope="module")
def demo_template(tmp_path_factory):
root = tmp_path_factory.mktemp("demo_subdirectory")
build_file_tree(
{
root / "api_project" / "api_readme.md": "",
root
/ "api_project"
/ "[[ _copier_conf.answers_file ]].tmpl": "[[ _copier_answers|to_nice_yaml ]]",
root
/ "conf_project"
/ "conf_readme.md": """\
# Demo subdirectory
Generated using previous answers `_subdirectory` value.
""",
root
/ "conf_project"
/ "[[ _copier_conf.answers_file ]].tmpl": "[[ _copier_answers|to_nice_yaml ]]",
root
/ "copier.yml": """\
choose_subdir:
type: str
default: conf_project
choices:
- api_project
- conf_project
_subdirectory: "[[ choose_subdir ]]"
""",
}
)
with local.cwd(root):
git_init()
return str(root)
def test_copy_subdirectory_api_option(demo_template, tmp_path):
copier.copy(
"./tests/demo_subdirectory", tmp_path, force=True, subdirectory="api_project"
demo_template, tmp_path, force=True, data={"choose_subdir": "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)
def test_copy_subdirectory_config(demo_template, tmp_path):
copier.copy(demo_template, 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)
def test_update_subdirectory(demo_template, tmp_path):
copier.copy(demo_template, 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)
copier.copy(dst_path=tmp_path, force=True)
assert not (tmp_path / "conf_project").exists()
assert not (tmp_path / "api_project").exists()
assert not (tmp_path / "api_readme.md").exists()

View File

@ -1,17 +1,40 @@
import pytest
import copier
from .helpers import DATA, render
from .helpers import build_file_tree
def test_render_tasks(tmp_path):
tasks = ["touch [[ myvar ]]/1.txt", "touch [[ myvar ]]/2.txt"]
render(tmp_path, tasks=tasks)
assert (tmp_path / DATA["myvar"] / "1.txt").exists()
assert (tmp_path / DATA["myvar"] / "2.txt").exists()
@pytest.fixture(scope="module")
def demo_template(tmp_path_factory):
root = tmp_path_factory.mktemp("demo_tasks")
build_file_tree(
{
root
/ "copier.yaml": """
other_file: bye
# This tests two things:
# 1. That the tasks are being executed in the destiantion folder; and
# 2. That the tasks are being executed in order, one after another
_tasks:
- mkdir hello
- cd hello && touch world
- touch [[ other_file ]]
"""
}
)
return str(root)
def test_copy_tasks(tmp_path):
copier.copy("tests/demo_tasks", tmp_path, quiet=True)
def test_render_tasks(tmp_path, demo_template):
copier.copy(demo_template, tmp_path, data={"other_file": "custom"})
assert (tmp_path / "custom").is_file()
def test_copy_tasks(tmp_path, demo_template):
copier.copy(demo_template, tmp_path, quiet=True, force=True)
assert (tmp_path / "hello").exists()
assert (tmp_path / "hello").is_dir()
assert (tmp_path / "hello" / "world").exists()
assert (tmp_path / "bye").is_file()

View File

@ -1,14 +1,15 @@
from datetime import datetime
import pexpect
import pytest
import yaml
from copier.config.factory import filter_config, make_config
from copier.config.objects import EnvOps
from copier.config.user_data import InvalidTypeError, query_user_data
from copier import Worker
from copier.errors import InvalidTypeError
from .helpers import COPIER_PATH, build_file_tree
from .helpers import COPIER_PATH, ISOFORMAT, build_file_tree
envops = EnvOps()
envops = {}
main_default = "copier"
main_question = {"main": {"default": main_default}}
@ -120,7 +121,7 @@ def test_templated_prompt(
tmp_path_factory.mktemp("template"),
tmp_path_factory.mktemp("subproject"),
)
questions_combined = filter_config({**main_question, **questions_data})[1]
questions_combined = {**main_question, **questions_data}
# There's always only 1 question; get its name
question_name = questions_data.copy().popitem()[0]
build_file_tree(
@ -140,71 +141,84 @@ def test_templated_prompt(
assert answers[question_name] == expected_value
def test_templated_prompt_custom_envops(tmp_path):
conf = make_config("./tests/demo_templated_prompt", tmp_path, force=True)
assert conf.data["sentence"] == "It's over 9000!"
def test_templated_prompt_custom_envops(tmp_path_factory):
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
build_file_tree(
{
src
/ "copier.yml": """
_envops:
block_start_string: "<%"
block_end_string: "%>"
comment_start_string: "<#"
comment_end_string: "#>"
variable_start_string: "<<"
variable_end_string: ">>"
conf = make_config(
"./tests/demo_templated_prompt", tmp_path, data={"powerlevel": 1}, force=True
powerlevel:
type: int
default: 9000
sentence:
type: str
default:
"<% if powerlevel >= 9000 %>It's over 9000!<% else %>It's only << powerlevel >>...<%
endif %>"
""",
src / "result.tmpl": "<<sentence>>",
}
)
assert conf.data["sentence"] == "It's only 1..."
worker1 = Worker(str(src), dst, force=True)
worker1.run_copy()
assert (dst / "result").read_text() == "It's over 9000!"
worker2 = Worker(str(src), dst, data={"powerlevel": 1}, force=True)
worker2.run_copy()
assert (dst / "result").read_text() == "It's only 1..."
def test_templated_prompt_builtins():
data = query_user_data(
{"question": {"default": "[[ now() ]]"}}, {}, {}, {}, False, envops
def test_templated_prompt_builtins(tmp_path_factory):
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
build_file_tree(
{
src
/ "copier.yaml": """
question1:
default: "[[ now() ]]"
question2:
default: "[[ make_secret() ]]"
""",
src / "now.tmpl": "[[ question1 ]]",
src / "make_secret.tmpl": "[[ question2 ]]",
}
)
Worker(str(src), dst, force=True).run_copy()
that_now = datetime.strptime((dst / "now").read_text(), ISOFORMAT)
assert that_now <= datetime.now()
assert len((dst / "make_secret").read_text()) == 128
data = query_user_data(
{"question": {"default": "[[ make_secret() ]]"}},
{},
{},
{},
False,
envops,
@pytest.mark.parametrize(
"questions, raises, returns",
(
({"question": {"default": "[[ not_valid ]]"}}, None, ""),
({"question": {"help": "[[ not_valid ]]"}}, None, "None"),
({"question": {"type": "[[ not_valid ]]"}}, InvalidTypeError, "None"),
({"question": {"choices": ["[[ not_valid ]]"]}}, None, "None"),
),
)
def test_templated_prompt_invalid(tmp_path_factory, questions, raises, returns):
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
build_file_tree(
{
src / "copier.yml": yaml.safe_dump(questions),
src / "result.tmpl": "[[question]]",
}
)
assert isinstance(data["question"], str) and len(data["question"]) == 128
def test_templated_prompt_invalid():
# assert no exception in non-strict mode
query_user_data(
{"question": {"default": "[[ not_valid ]]"}},
{},
{},
{},
False,
envops,
)
# assert no exception in non-strict mode
query_user_data(
{"question": {"help": "[[ not_valid ]]"}}, {}, {}, {}, False, envops
)
with pytest.raises(InvalidTypeError):
query_user_data(
{"question": {"type": "[[ not_valid ]]"}},
{},
{},
{},
False,
envops,
)
# assert no exception in non-strict mode
query_user_data(
{"question": {"choices": ["[[ not_valid ]]"]}},
{},
{},
{},
False,
envops,
)
# TODO: uncomment this later when EnvOps supports setting the undefined behavior
# envops.undefined = StrictUndefined
# with pytest.raises(UserMessageError):
# query_user_data(
# {"question": {"default": "[[ not_valid ]]"}}, {}, False, envops
# )
worker = Worker(str(src), dst, force=True)
if raises:
with pytest.raises(raises):
worker.run_copy()
else:
worker.run_copy()
assert (dst / "result").read_text() == returns

View File

@ -1,67 +1,7 @@
from pathlib import Path
import pytest
from poethepoet.app import PoeThePoet
from copier import tools
from copier.config.factory import ConfigData, EnvOps
from .helpers import DATA, PROJECT_TEMPLATE
def test_render(tmp_path):
envops = EnvOps().dict()
render = tools.Renderer(
ConfigData(
src_path=PROJECT_TEMPLATE,
dst_path=tmp_path,
data_from_init=DATA,
envops=envops,
)
)
assert render.string("/hello/[[ what ]]/") == "/hello/world/"
assert render.string("/hello/world/") == "/hello/world/"
sourcepath = PROJECT_TEMPLATE / "pyproject.toml.tmpl"
result = render(sourcepath)
expected = Path("./tests/reference_files/pyproject.toml").read_text()
assert result == expected
TEST_PATTERNS = (
# simple file patterns and their negations
"*.exclude",
"!do_not.exclude",
# dir patterns and their negations
"exclude_dir/",
"!exclude_dir/please_copy_me",
"!not_exclude_dir/x",
# unicode patterns
"mañana.txt",
)
path_filter = tools.create_path_filter(TEST_PATTERNS)
@pytest.mark.parametrize(
"pattern,should_match",
(
# simple file patterns and their negations
("x.exclude", True),
("do_not.exclude!", False),
# dir patterns and their negations
("exclude_dir/x", True),
("exclude_dir/please_copy_me", False), # no mercy
("not_exclude_dir/x!", False),
# unicode patterns
("mañana.txt", True),
("mañana.txt", False),
("manana.txt", False),
),
)
def test_create_path_filter(pattern, should_match):
assert path_filter(pattern) == should_match
def test_lint():
"""Ensure source code formatting"""

View File

@ -6,7 +6,7 @@ import pytest
from plumbum import local
from plumbum.cmd import git
from copier import copy
from copier import Worker, copy
from copier.cli import CopierApp
from .helpers import PROJECT_TEMPLATE, build_file_tree
@ -141,12 +141,11 @@ def test_updatediff(tmpdir):
)
commit("-m", "Subproject evolved")
# Reapply template ignoring subproject evolution
copy(
Worker(
data={"author_name": "Largo LaGrande", "project_name": "to steal a lot"},
force=True,
vcs_ref="HEAD",
only_diff=False,
)
).run_copy()
assert readme.read_text() == dedent(
"""
Let me introduce myself.