Merge pull request #54 from pykong/cleanup

Cleanup and move to Python 3.6
This commit is contained in:
Juan-Pablo Scaletti 2019-08-20 13:26:24 -05:00 committed by GitHub
commit 9b8ce8ad17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 75 additions and 147 deletions

View File

@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
### Version 3.x (2019-xx)
- Dropped support for Python 3.5.
- Dropped support for deprecated `voodoo.json`.
- Type annotated entire code base.
### Version 2.5 (2019-06)
- Expanduser on all paths (so "~/foo/bar" is expanded to "<YOUR_HOME_FOLDER>/foo/bar").
- Improve the output when running tasks.

View File

@ -1,8 +1,5 @@
#!/usr/bin/env python
import argparse
import sys
from hashlib import sha512
from os import urandom
try:
from .main import copy
@ -73,20 +70,9 @@ parser.add_argument(
def run() -> None: # pragma:no cover
if len(sys.argv) == 1 or sys.argv[1] == "help":
parser.print_help(sys.stderr)
print()
sys.exit(1)
if sys.argv[1] == "version":
sys.stdout.write(__version__)
print()
sys.exit(1)
args = parser.parse_args()
kwargs = vars(args)
data = {"make_secret": lambda: sha512(urandom(48)).hexdigest()}
copy(kwargs.pop("source"), kwargs.pop("dest"), data=data, **kwargs)
copy(kwargs.pop("source"), kwargs.pop("dest"), **kwargs)
if __name__ == "__main__":

View File

@ -4,15 +4,26 @@ import os
import re
import shutil
import subprocess
from hashlib import sha512
from os import urandom
from pathlib import Path
from typing import Callable, Dict, List, Optional, Tuple
from . import vcs
from .tools import (STYLE_DANGER, STYLE_IGNORE, STYLE_OK, STYLE_WARNING,
Renderer, copy_file, get_jinja_renderer, get_name_filters,
make_folder, printf, prompt_bool)
from .types import (AnyByStrDict, CheckPathFunc, OptStrOrPathSeq, OptStrSeq,
StrOrPath)
from .tools import (
STYLE_DANGER,
STYLE_IGNORE,
STYLE_OK,
STYLE_WARNING,
Renderer,
copy_file,
get_jinja_renderer,
get_name_filters,
make_folder,
printf,
prompt_bool,
)
from .types import AnyByStrDict, CheckPathFunc, OptStrOrPathSeq, OptStrSeq, StrOrPath
from .user_data import load_config_data, query_user_data
__all__ = ("copy", "copy_local")
@ -24,7 +35,6 @@ DEFAULT_EXCLUDE: Tuple[str, ...] = (
"copier.yml",
"copier.toml",
"copier.json",
"voodoo.json",
"~*",
"*.py[co]",
"__pycache__",
@ -36,7 +46,10 @@ DEFAULT_EXCLUDE: Tuple[str, ...] = (
)
DEFAULT_INCLUDE: Tuple[str, ...] = ()
DEFAULT_DATA: AnyByStrDict = {"now": datetime.datetime.utcnow}
DEFAULT_DATA: AnyByStrDict = {
"now": datetime.datetime.utcnow,
"make_secret": lambda: sha512(urandom(48)).hexdigest(),
}
def copy(
@ -54,7 +67,7 @@ def copy(
force: bool = False,
skip: bool = False,
quiet: bool = False,
cleanup_on_error: bool = True
cleanup_on_error: bool = True,
) -> None:
"""
Uses the template in src_path to generate a new project at dst_path.
@ -144,12 +157,11 @@ def copy(
except Exception:
if cleanup_on_error:
print("Something went wrong. Removing destination folder.")
# Python3.5 shutil methods doesn't wok with `pathlib.Path`
shutil.rmtree(str(dst_path), ignore_errors=True)
shutil.rmtree(dst_path, ignore_errors=True)
raise
finally:
if repo:
shutil.rmtree(str(src_path))
shutil.rmtree(src_path)
RE_TMPL = re.compile(r"\.tmpl$", re.IGNORECASE)
@ -190,7 +202,7 @@ def copy_local(
skip_if_exists: OptStrOrPathSeq = None,
tasks: OptStrSeq = None,
envops: Optional[AnyByStrDict] = None,
**flags: bool
**flags: bool,
) -> None:
src_path, dst_path, extra_paths = resolve_paths(src_path, dst_path, extra_paths)
config_data = load_config_data(src_path, quiet=flags["quiet"])
@ -347,7 +359,6 @@ def file_is_identical(
) -> bool:
if content is None:
return files_are_identical(source_path, final_path)
return file_has_this_content(final_path, content)
@ -369,7 +380,7 @@ def overwrite_file(
if flags["skip"]:
return False
msg = " Overwrite {}?".format(final_path) # pragma:no cover
msg = f" Overwrite {final_path}?" # pragma:no cover
return prompt_bool(msg, default=True) # pragma:no cover
@ -377,7 +388,5 @@ def run_tasks(dst_path: StrOrPath, engine: Renderer, tasks) -> None:
dst_path = str(dst_path)
for i, task in enumerate(tasks):
task = engine.string(task)
printf(
" > Running task {} of {}".format(i + 1, len(tasks)), task, style=STYLE_OK
)
printf(f" > Running task {i + 1} of {len(tasks)}", task, style=STYLE_OK)
subprocess.run(task, shell=True, check=True, cwd=dst_path)

View File

@ -31,6 +31,9 @@ STYLE_WARNING: List[int] = [Fore.YELLOW, Style.BRIGHT]
STYLE_IGNORE: List[int] = [Fore.CYAN]
STYLE_DANGER: List[int] = [Fore.RED, Style.BRIGHT]
INDENT = " " * 2
HLINE = "-" * 42
def printf(
action: str, msg: str = "", style: Optional[List[int]] = None, indent: int = 10
@ -39,7 +42,7 @@ def printf(
if not style:
return action + msg
out = style + [action, Fore.RESET, Style.RESET_ALL, " ", msg] # type: ignore
out = style + [action, Fore.RESET, Style.RESET_ALL, INDENT, msg] # type: ignore
print(*out, sep="")
return None # HACK: Satisfy MyPy
@ -55,9 +58,9 @@ def printf_block(
if not quiet:
print("")
printf(action, msg=msg, style=style, indent=indent)
print("-" * 42)
print(HLINE)
print(e)
print("-" * 42)
print(HLINE)
no_value: object = object()
@ -74,7 +77,7 @@ def prompt(
default: Optional[Any] = no_value,
default_show: Optional[Any] = None,
validator: Callable = required,
**kwargs: AnyByStrDict
**kwargs: AnyByStrDict,
) -> Optional[Any]:
"""
Prompt for a value from the command line. A default value can be provided,
@ -85,9 +88,9 @@ def prompt(
printed and the user asked to supply another value.
"""
if default_show:
question += " [{}] ".format(default_show)
question += f" [{default_show}] "
elif default and default is not no_value:
question += " [{}] ".format(default)
question += f" [{default}] "
else:
question += " "
@ -114,13 +117,13 @@ def prompt_bool(
yes_choices: Optional[List[str]] = None,
no_choices: Optional[List[str]] = None,
) -> Optional[bool]:
# Backwards compatibility. Remove for version 3.0
# TODO: Backwards compatibility. Remove for version 3.0
if yes_choices:
yes = yes_choices[0]
if no_choices:
no = no_choices[0]
please_answer = ' Please answer "{}" or "{}"'.format(yes, no)
please_answer = f' Please answer "{yes}" or "{no}"'
def validator(value: Union[str, bool], **kwargs) -> Union[str, bool]:
if value:
@ -134,13 +137,13 @@ def prompt_bool(
if default is None:
default = no_value
default_show = yes + "/" + no
default_show = f"{yes}/{no}"
elif default:
default = yes
default_show = yes.upper() + "/" + no
default_show = f"{yes.upper()}/{no}"
else:
default = no
default_show = yes + "/" + no.upper()
default_show = f"{yes}/{no.upper()}"
return prompt(
question, default=default, default_show=default_show, validator=validator
@ -173,14 +176,14 @@ DEFAULT_ENV_OPTIONS: AnyByStrDict = {
class Renderer:
def __init__(
self, env: SandboxedEnvironment, src_path: str, data: AnyByStrDict
self, env: SandboxedEnvironment, src_path: Path, data: AnyByStrDict
) -> None:
self.env = env
self.src_path = src_path
self.data = data
def __call__(self, fullpath: StrOrPath) -> str:
relpath = str(fullpath).replace(self.src_path, "", 1).lstrip(os.path.sep)
relpath = str(fullpath).replace(str(self.src_path), "", 1).lstrip(os.path.sep)
tmpl = self.env.get_template(relpath)
return tmpl.render(**self.data)
@ -198,11 +201,10 @@ def get_jinja_renderer(
"""Returns a function that can render a Jinja template.
"""
# Jinja <= 2.10 does not work with `pathlib.Path`s
_src_path: str = str(src_path)
_envops = DEFAULT_ENV_OPTIONS.copy()
_envops.update(envops or {})
paths = [_src_path] + [str(p) for p in extra_paths or []]
paths = [src_path] + [Path(p) for p in extra_paths or []]
_envops.setdefault("loader", FileSystemLoader(paths)) # type: ignore
# We want to minimize the risk of hidden malware in the templates
@ -210,7 +212,7 @@ def get_jinja_renderer(
# 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(**_envops)
return Renderer(env=env, src_path=_src_path, data=data)
return Renderer(env=env, src_path=src_path, data=data)
def normalize_str(text: StrOrPath, form: str = "NFD") -> str:

View File

@ -1,7 +1,6 @@
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Sequence, TypeVar, Union
IntOrStr = Union[int, str]
StrOrPath = Union[str, Path]
AnyByStrDict = Dict[str, Any]

View File

@ -1,12 +1,10 @@
from pathlib import Path
from .tools import STYLE_WARNING, printf, printf_block, prompt
from .tools import HLINE, INDENT, printf_block, prompt
from .types import AnyByStrDict, StrOrPath
__all__ = ("load_config_data", "query_user_data")
INDENT = " "
def load_toml_data(src_path: StrOrPath, quiet: bool = False) -> AnyByStrDict:
toml_path = Path(src_path) / "copier.toml"
@ -46,36 +44,8 @@ def load_json_data(
) -> AnyByStrDict:
json_path = Path(src_path) / "copier.json"
if not json_path.exists():
return load_old_json_data(src_path, quiet=quiet, _warning=_warning)
import json
json_src = json_path.read_text()
try:
return json.loads(json_src)
except ValueError as e:
printf_block(e, "INVALID", msg=str(json_path), quiet=quiet)
return {}
def load_old_json_data(
src_path: StrOrPath, quiet: bool = False, _warning: bool = True
) -> AnyByStrDict:
# TODO: Remove on version 3.0
json_path = Path(src_path) / "voodoo.json"
if not json_path.exists():
return {}
if _warning and not quiet:
print("")
printf(
"WARNING",
msg="`voodoo.json` is deprecated. "
+ "Replace it with a `copier.yaml`, `copier.toml`, or `copier.json`.",
style=STYLE_WARNING,
indent=10,
)
import json
json_src = json_path.read_text()
@ -110,7 +80,7 @@ def query_user_data(default_user_data: AnyByStrDict) -> AnyByStrDict: # pragma:
user_data = {}
for key in default_user_data:
default = default_user_data[key]
user_data[key] = prompt(INDENT + " {0}?".format(key), default)
user_data[key] = prompt(INDENT + f" {key}?", default)
print("\n" + INDENT + "-" * 42)
print(f"\n {INDENT} {HLINE}")
return user_data

View File

@ -1,4 +0,0 @@
A string: [[ a_string ]]
A number: [[ a_number ]]
A boolean: [[ a_boolean ]]
A list: [[ ", ".join(a_list) ]]

View File

@ -1,6 +0,0 @@
{
"a_string": "lorem ipsum",
"a_number": 12345,
"a_boolean": true,
"a_list": ["one", "two", "three"]
}

View File

@ -8,7 +8,6 @@ from copier.user_data import (
load_yaml_data,
load_toml_data,
load_json_data,
load_old_json_data,
load_config_data,
)
@ -24,20 +23,13 @@ def test_config_data_is_loaded_from_file():
@pytest.mark.parametrize(
"template",
[
"tests/demo_toml",
"tests/demo_yaml",
"tests/demo_yml",
"tests/demo_json",
"tests/demo_json_old",
],
["tests/demo_toml", "tests/demo_yaml", "tests/demo_yml", "tests/demo_json"],
)
def test_read_data(dst, template):
copier.copy(template, dst, force=True)
gen_file = dst / "user_data.txt"
result = gen_file.read_text()
print(result)
expected = Path("tests/user_data.ref.txt").read_text()
assert result == expected
@ -48,38 +40,22 @@ def test_bad_toml(capsys):
def test_invalid_toml(capsys):
assert {} == load_yaml_data("tests/demo_invalid")
out, err = capsys.readouterr()
out, _ = capsys.readouterr()
assert re.search(r"INVALID.*tests/demo_invalid/copier\.yml", out)
assert {} == load_toml_data("tests/demo_invalid")
out, err = capsys.readouterr()
out, _ = capsys.readouterr()
assert re.search(r"INVALID.*tests/demo_invalid/copier\.toml", out)
assert {} == load_json_data("tests/demo_invalid")
out, err = capsys.readouterr()
out, _ = capsys.readouterr()
assert re.search(r"INVALID.*tests/demo_invalid/copier\.json", out)
# TODO: Remove on version 3.0
assert {} == load_old_json_data("tests/demo_invalid", _warning=False)
out, err = capsys.readouterr()
assert re.search(r"INVALID.*tests/demo_invalid/voodoo\.json", out)
assert {} == load_config_data("tests/demo_invalid", _warning=False)
assert re.search(r"INVALID", out)
def test_invalid_quiet(capsys):
assert {} == load_config_data("tests/demo_invalid", quiet=True)
out, err = capsys.readouterr()
out, _ = capsys.readouterr()
assert out == ""
assert {} == load_old_json_data("tests/demo_invalid", quiet=True)
out, err = capsys.readouterr()
assert out == ""
def test_deprecated_msg(capsys):
# TODO: Remove on version 3.0
load_old_json_data("tests/demo_json_old")
out, err = capsys.readouterr()
assert re.search(r"`voodoo\.json` is deprecated", out)

View File

@ -26,7 +26,6 @@ def test_copy_with_extra_paths(dst):
gen_file = dst / "child.txt"
result = gen_file.read_text()
print(result)
expected = Path(PARENT_DIR + "/parent.txt").read_text()
assert result == expected
@ -36,6 +35,5 @@ def test_copy_with_extra_paths_from_config(dst):
gen_file = dst / "child.txt"
result = gen_file.read_text()
print(result)
expected = Path(PARENT_DIR + "/parent.txt").read_text()
assert result == expected

View File

@ -5,8 +5,7 @@ from .helpers import render
def test_output(capsys, dst):
render(dst, quiet=False)
out, err = capsys.readouterr()
print(out)
out, _ = capsys.readouterr()
assert re.search(r"create[^\s]* config\.py", out)
assert re.search(r"create[^\s]* pyproject\.toml", out)
assert re.search(r"create[^\s]* doc/images/nslogo\.gif", out)
@ -14,8 +13,7 @@ def test_output(capsys, dst):
def test_output_pretend(capsys, dst):
render(dst, quiet=False, pretend=True)
out, err = capsys.readouterr()
out, _ = capsys.readouterr()
assert re.search(r"create[^\s]* config\.py", out)
assert re.search(r"create[^\s]* pyproject\.toml", out)
assert re.search(r"create[^\s]* doc/images/nslogo\.gif", out)
@ -23,11 +21,9 @@ def test_output_pretend(capsys, dst):
def test_output_force(capsys, dst):
render(dst)
out, err = capsys.readouterr()
out, _ = capsys.readouterr()
render(dst, quiet=False, force=True)
out, err = capsys.readouterr()
print(out)
out, _ = capsys.readouterr()
assert re.search(r"conflict[^\s]* config\.py", out)
assert re.search(r"force[^\s]* config\.py", out)
assert re.search(r"identical[^\s]* pyproject\.toml", out)
@ -36,11 +32,9 @@ def test_output_force(capsys, dst):
def test_output_skip(capsys, dst):
render(dst)
out, err = capsys.readouterr()
out, _ = capsys.readouterr()
render(dst, quiet=False, skip=True)
out, err = capsys.readouterr()
print(out)
out, _ = capsys.readouterr()
assert re.search(r"conflict[^\s]* config\.py", out)
assert re.search(r"skip[^\s]* config\.py", out)
assert re.search(r"identical[^\s]* pyproject\.toml", out)
@ -49,5 +43,5 @@ def test_output_skip(capsys, dst):
def test_output_quiet(capsys, dst):
render(dst, quiet=True)
out, err = capsys.readouterr()
out, _ = capsys.readouterr()
assert out == ""

View File

@ -50,7 +50,7 @@ def test_prompt_default_no_input(stdin, capsys):
out, _ = capsys.readouterr()
assert response == default
assert out == "{} [{}] ".format(question, default)
assert out == f"{question} [{default}] "
def test_prompt_default_overridden(stdin, capsys):
@ -63,7 +63,7 @@ def test_prompt_default_overridden(stdin, capsys):
out, _ = capsys.readouterr()
assert response == name
assert out == "{} [{}] ".format(question, default)
assert out == f"{question} [{default}] "
def test_prompt_error_message(stdin, capsys):
@ -79,9 +79,8 @@ def test_prompt_error_message(stdin, capsys):
stdin.append("yes\n")
response = prompt(question, validator=validator)
out, _ = capsys.readouterr()
print(out)
assert response is True
assert out == "{0} {1}\n{0} ".format(question, error)
assert out == f"{question} {error}\n{question} "
def test_prompt_bool(stdin, capsys):
@ -90,7 +89,7 @@ def test_prompt_bool(stdin, capsys):
response = prompt_bool(question)
stdout, _ = capsys.readouterr()
assert response is True
assert stdout == "{} [y/N] ".format(question)
assert stdout == f"{question} [y/N] "
def test_prompt_bool_false(stdin, capsys):
@ -99,7 +98,7 @@ def test_prompt_bool_false(stdin, capsys):
response = prompt_bool(question)
stdout, _ = capsys.readouterr()
assert response is False
assert stdout == "{} [y/N] ".format(question)
assert stdout == f"{question} [y/N] "
def test_prompt_bool_default_true(stdin, capsys):
@ -108,7 +107,7 @@ def test_prompt_bool_default_true(stdin, capsys):
response = prompt_bool(question, default=True)
stdout, _ = capsys.readouterr()
assert response is True
assert stdout == "{} [Y/n] ".format(question)
assert stdout == f"{question} [Y/n] "
def test_prompt_bool_default_false(stdin, capsys):
@ -117,7 +116,7 @@ def test_prompt_bool_default_false(stdin, capsys):
response = prompt_bool(question, default=False)
stdout, _ = capsys.readouterr()
assert response is False
assert stdout == "{} [y/N] ".format(question)
assert stdout == f"{question} [y/N] "
def test_prompt_bool_no_default(stdin, capsys):
@ -125,7 +124,7 @@ def test_prompt_bool_no_default(stdin, capsys):
stdin.append("\ny\n")
prompt_bool(question, default=None)
stdout, _ = capsys.readouterr()
assert "{} [y/n] ".format(question) in stdout
assert f"{question} [y/n] " in stdout
assert 'Please answer "y" or "n"' in stdout

View File

@ -14,5 +14,4 @@ def test_render(dst):
sourcepath = PROJECT_TEMPLATE / "pyproject.toml.tmpl"
result = render(sourcepath)
expected = Path("./tests/pyproject.toml.ref").read_text()
print(result)
assert result == expected

View File

@ -45,4 +45,4 @@ def test_clone():
tmp = vcs.clone("https://github.com/jpscaletti/siht.git")
assert tmp
assert exists(join(tmp, "setup.py"))
shutil.rmtree(str(tmp))
shutil.rmtree(tmp)