Skip to content

tools.py

Some utility functions.

Style

Common color styles.

Source code in copier/tools.py
26
27
28
29
30
31
32
33
class Style:
    """Common color styles."""

    OK = [colorama.Fore.GREEN, colorama.Style.BRIGHT]
    WARNING = [colorama.Fore.YELLOW, colorama.Style.BRIGHT]
    IGNORE = [colorama.Fore.CYAN]
    DANGER = [colorama.Fore.RED, colorama.Style.BRIGHT]
    RESET = [colorama.Fore.RESET, colorama.Style.RESET_ALL]

cast_to_bool(value)

Parse anything to bool.

Parameters:

Name Type Description Default
value Any

Anything to be casted to a bool. Tries to be as smart as possible.

  1. Cast to number. Then: 0 = False; anything else = True.
  2. Find YAML booleans, YAML nulls or none in it and use it appropriately.
  3. Cast to boolean using standard python bool(value).
required
Source code in copier/tools.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def cast_to_bool(value: Any) -> bool:
    """Parse anything to bool.

    Params:
        value:
            Anything to be casted to a bool. Tries to be as smart as possible.

            1.  Cast to number. Then: 0 = False; anything else = True.
            1.  Find [YAML booleans](https://yaml.org/type/bool.html),
                [YAML nulls](https://yaml.org/type/null.html) or `none` in it
                and use it appropriately.
            1.  Cast to boolean using standard python `bool(value)`.
    """
    # Assume it's a number
    with suppress(TypeError, ValueError):
        return bool(float(value))
    # Assume it's a string
    with suppress(AttributeError):
        lower = value.lower()
        if lower in {"y", "yes", "t", "true", "on"}:
            return True
        elif lower in {"n", "no", "f", "false", "off", "~", "null", "none"}:
            return False
    # Assume nothing
    return bool(value)

cast_to_str(value)

Parse anything to str.

Parameters:

Name Type Description Default
value Any

Anything to be casted to a str.

required
Source code in copier/tools.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def cast_to_str(value: Any) -> str:
    """Parse anything to str.

    Params:
        value:
            Anything to be casted to a str.
    """
    if isinstance(value, str):
        return value.value if isinstance(value, Enum) else value
    if isinstance(value, (float, int, Decimal)):
        return str(value)
    if isinstance(value, (bytes, bytearray)):
        return value.decode()
    raise ValueError(f"Could not convert {value} to string")

copier_version()

Get closest match for the installed copier version.

Source code in copier/tools.py
49
50
51
52
53
54
55
56
57
58
59
60
def copier_version() -> Version:
    """Get closest match for the installed copier 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__

    if __version__ != "0.0.0":
        return Version(__version__)

    # Get the installed package version otherwise, which is sometimes more specific
    return Version(version("copier"))

escape_git_path(path)

Escape paths that will be used as literal gitwildmatch patterns.

If the path was returned by a Git command, it should be unescaped completely. normalize_git_path can be used for this purpose.

Parameters:

Name Type Description Default
path str

The Git path to escape.

required

Returns:

Name Type Description
str str

The escaped Git path.

Source code in copier/tools.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
def escape_git_path(path: str) -> str:
    """Escape paths that will be used as literal gitwildmatch patterns.

    If the path was returned by a Git command, it should be unescaped completely.
    ``normalize_git_path`` can be used for this purpose.

    Args:
        path: The Git path to escape.

    Returns:
        str: The escaped Git path.
    """
    # GitWildMatchPattern.escape does not escape backslashes
    # or trailing whitespace.
    path = path.replace("\\", "\\\\")
    path = GitWildMatchPattern.escape(path)
    return _re_whitespace.sub(
        lambda match: "".join(f"\\{whitespace}" for whitespace in match.group()),
        path,
    )

force_str_end(original_str, end='\n')

Make sure a original_str ends with end.

Parameters:

Name Type Description Default
original_str str

String that you want to ensure ending.

required
end str

String that must exist at the end of original_str

'\n'
Source code in copier/tools.py
139
140
141
142
143
144
145
146
147
148
def force_str_end(original_str: str, end: str = "\n") -> str:
    """Make sure a `original_str` ends with `end`.

    Params:
        original_str: String that you want to ensure ending.
        end: String that must exist at the end of `original_str`
    """
    if not original_str.endswith(end):
        return original_str + end
    return original_str

get_git_objects_dir(path)

Get the absolute path of a Git repository's objects directory.

Source code in copier/tools.py
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def get_git_objects_dir(path: Path) -> Path:
    """Get the absolute path of a Git repository's objects directory."""
    # FIXME: A lazy import is currently necessary to avoid circular imports with
    # `errors.py`.
    from .vcs import get_git

    git = get_git()
    return Path(
        git(
            "-C",
            path,
            "rev-parse",
            "--path-format=absolute",
            "--git-path",
            "objects",
        ).strip()
    )

handle_remove_readonly(func, path, exc)

Handle errors when trying to remove read-only files through shutil.rmtree.

On Windows, shutil.rmtree does not handle read-only files very well. This handler makes sure the given file is writable, then re-execute the given removal function.

Parameters:

Name Type Description Default
func Callable[[str], None]

An OS-dependant function used to remove a file.

required
path str

The path to the file to remove.

required
exc BaseException | tuple[type[BaseException], BaseException, TracebackType]

An exception (Python >= 3.12) or sys.exc_info() object.

required
Source code in copier/tools.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def handle_remove_readonly(
    func: Callable[[str], None],
    path: str,
    # TODO: Change this union to simply `BaseException` when Python 3.11 support is dropped
    exc: BaseException | tuple[type[BaseException], BaseException, TracebackType],
) -> None:
    """Handle errors when trying to remove read-only files through `shutil.rmtree`.

    On Windows, `shutil.rmtree` does not handle read-only files very well. This handler
    makes sure the given file is writable, then re-execute the given removal function.

    Arguments:
        func: An OS-dependant function used to remove a file.
        path: The path to the file to remove.
        exc: An exception (Python >= 3.12) or `sys.exc_info()` object.
    """
    # TODO: Change to `excvalue = exc` when Python 3.11 support is dropped
    excvalue = cast(OSError, exc if isinstance(exc, BaseException) else exc[1])

    if func in (os.rmdir, os.remove, os.unlink) and excvalue.errno == errno.EACCES:
        os.chmod(path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)  # 0777
        func(path)
    else:
        raise

normalize_git_path(path)

Convert weird characters returned by Git to normal UTF-8 path strings.

A filename like âñ will be reported by Git as "\303\242\303\261" (octal notation). Similarly, a filename like "foo\bar" will be reported as "\tfoo\b\nar". This can be disabled with git config core.quotepath off.

Parameters:

Name Type Description Default
path str

The Git path to normalize.

required

Returns:

Name Type Description
str str

The normalized Git path.

Source code in copier/tools.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
def normalize_git_path(path: str) -> str:
    r"""Convert weird characters returned by Git to normal UTF-8 path strings.

    A filename like âñ will be reported by Git as "\\303\\242\\303\\261" (octal notation).
    Similarly, a filename like "<tab>foo\b<lf>ar" will be reported as "\tfoo\\b\nar".
    This can be disabled with `git config core.quotepath off`.

    Args:
        path: The Git path to normalize.

    Returns:
        str: The normalized Git path.
    """
    # Remove surrounding quotes
    if path[0] == path[-1] == '"':
        path = path[1:-1]
    # Repair double-quotes
    path = path.replace('\\"', '"')
    # Unescape escape characters
    path = path.encode("latin-1", "backslashreplace").decode("unicode-escape")
    # Convert octal to utf8
    return path.encode("latin-1", "backslashreplace").decode("utf-8")

printf(action, msg='', style=None, indent=10, quiet=False, file_=sys.stdout)

Print string with common format.

Source code in copier/tools.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def printf(
    action: str,
    msg: Any = "",
    style: list[str] | None = None,
    indent: int = 10,
    quiet: bool | StrictBool = False,
    file_: TextIO = sys.stdout,
) -> str | None:
    """Print string with common format."""
    if quiet:
        return None
    _msg = str(msg)
    action = action.rjust(indent, " ")
    if not style:
        return action + _msg

    out = style + [action] + Style.RESET + [INDENT, _msg]
    print(*out, sep="", file=file_)
    return None

printf_exception(e, action, msg='', indent=0, quiet=False)

Print exception with common format.

Source code in copier/tools.py
84
85
86
87
88
89
90
91
92
93
def printf_exception(
    e: Exception, action: str, msg: str = "", indent: int = 0, quiet: bool = False
) -> None:
    """Print exception with common format."""
    if not quiet:
        print("", file=sys.stderr)
        printf(action, msg=msg, style=Style.DANGER, indent=indent, file_=sys.stderr)
        print(HLINE, file=sys.stderr)
        print(e, file=sys.stderr)
        print(HLINE, file=sys.stderr)

A custom version of os.readlink/pathlib.Path.readlink.

pathlib.Path.readlink is what we ideally would want to use, but it is only available on python>=3.9.

Source code in copier/tools.py
177
178
179
180
181
182
183
184
185
def readlink(link: Path) -> Path:
    """A custom version of os.readlink/pathlib.Path.readlink.

    pathlib.Path.readlink is what we ideally would want to use, but it is only available on python>=3.9.
    """
    if sys.version_info >= (3, 9):
        return link.readlink()
    else:
        return Path(os.readlink(link))

set_git_alternates(*repos, path=Path('.'))

Set Git alternates to borrow Git objects from other repositories.

Alternates are paths of other repositories' object directories written to $GIT_DIR/objects/info/alternates and delimited by the newline character.

Parameters:

Name Type Description Default
*repos Path

The paths of repositories from which to borrow Git objects.

()
path Path

The path of the repository where to set Git alternates. Defaults to the current working directory.

Path('.')
Source code in copier/tools.py
256
257
258
259
260
261
262
263
264
265
266
267
268
269
def set_git_alternates(*repos: Path, path: Path = Path(".")) -> None:
    """Set Git alternates to borrow Git objects from other repositories.

    Alternates are paths of other repositories' object directories written to
    `$GIT_DIR/objects/info/alternates` and delimited by the newline character.

    Args:
        *repos: The paths of repositories from which to borrow Git objects.
        path: The path of the repository where to set Git alternates. Defaults
            to the current working directory.
    """
    alternates_file = get_git_objects_dir(path) / "info" / "alternates"
    alternates_file.parent.mkdir(parents=True, exist_ok=True)
    alternates_file.write_bytes(b"\n".join(map(bytes, map(get_git_objects_dir, repos))))