Skip to content

Commit e98bb4f

Browse files
committed
feat(settings): support user-defined shortcuts
1 parent 71358ed commit e98bb4f

File tree

7 files changed

+297
-70
lines changed

7 files changed

+297
-70
lines changed

copier/main.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ class Worker:
9393
src_path:
9494
String that can be resolved to a template path, be it local or remote.
9595
96-
See [copier.vcs.get_repo][].
96+
See [copier.vcs.get_repo][] and [`shortcuts` setting][shortcuts]
9797
9898
If it is `None`, then it means that you are
9999
[updating a project][updating-a-project], and the original
@@ -872,7 +872,10 @@ def template(self) -> Template:
872872
raise TypeError("Template not found")
873873
url = str(self.subproject.template.url)
874874
result = Template(
875-
url=url, ref=self.vcs_ref, use_prereleases=self.use_prereleases
875+
url=url,
876+
ref=self.vcs_ref,
877+
use_prereleases=self.use_prereleases,
878+
settings=self.settings,
876879
)
877880
self._cleanup_hooks.append(result._cleanup)
878881
return result

copier/settings.py

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,61 @@
33
from __future__ import annotations
44

55
import os
6+
import re
67
import warnings
78
from os.path import expanduser
89
from pathlib import Path
910
from typing import Any
1011

1112
import yaml
1213
from platformdirs import user_config_path
13-
from pydantic import BaseModel, Field
14+
from pydantic import BaseModel, Field, field_validator, model_validator
1415

1516
from .errors import MissingSettingsWarning
1617

1718
ENV_VAR = "COPIER_SETTINGS_PATH"
1819

20+
RE_REF = re.compile(r"^(?P<url>.+?)@(?P<ref>[^:]+)$")
21+
22+
23+
class Shortcut(BaseModel):
24+
"""Shortcut model."""
25+
26+
url: str = Field(description="Prefix url to replace the prefix with.")
27+
suffix: bool = Field(default=True, description="Whether to add a `.git` suffix.")
28+
29+
@model_validator(mode="before")
30+
@classmethod
31+
def handle_string(cls, value: Any) -> Any:
32+
"""Allow short syntax using string only."""
33+
if isinstance(value, str):
34+
return {"url": value}
35+
return value
36+
37+
@field_validator("url", mode="after")
38+
@classmethod
39+
def inject_trailing_slash(cls, value: str) -> str:
40+
"""Always add a trailing slash."""
41+
if not value.endswith("/"):
42+
value += "/"
43+
return value
44+
45+
46+
DEFAULT_SHORTCUTS = {
47+
"gh": Shortcut(url="https://github.com/"),
48+
"gl": Shortcut(url="https://gitlab.com/"),
49+
}
50+
1951

2052
class Settings(BaseModel):
2153
"""User settings model."""
2254

2355
defaults: dict[str, Any] = Field(
2456
default_factory=dict, description="Default values for questions"
2557
)
58+
shortcuts: dict[str, Shortcut] = Field(
59+
DEFAULT_SHORTCUTS, description="URL shortcuts"
60+
)
2661
trust: set[str] = Field(
2762
default_factory=set, description="List of trusted repositories or prefixes"
2863
)
@@ -45,17 +80,40 @@ def from_file(cls, settings_path: Path | None = None) -> Settings:
4580
)
4681
return cls()
4782

83+
@field_validator("shortcuts", mode="after")
84+
@classmethod
85+
def inject_defaults(cls, value: dict[str, Shortcut]) -> dict[str, Shortcut]:
86+
"""Ensure default are always present unless overridden."""
87+
return {**DEFAULT_SHORTCUTS, **value}
88+
4889
def is_trusted(self, repository: str) -> bool:
4990
"""Check if a repository is trusted."""
5091
return any(
51-
repository.startswith(self.normalize(trusted))
92+
self.normalize(repository).startswith(self.normalize(trusted))
5293
if trusted.endswith("/")
53-
else repository == self.normalize(trusted)
94+
else self.normalize(repository) == self.normalize(trusted)
5495
for trusted in self.trust
5596
)
5697

5798
def normalize(self, url: str) -> str:
5899
"""Normalize an URL using user settings."""
100+
url, ref = url.removeprefix("git+"), None
101+
if m := RE_REF.match(url):
102+
url = m.group("url")
103+
ref = m.group("ref")
104+
for prefix, shortcut in self.shortcuts.items():
105+
prefix = f"{prefix}:"
106+
if url.startswith(prefix):
107+
# Inject shortcut
108+
url = url.replace(prefix, shortcut.url)
109+
# Remove double slash if any
110+
url = url.replace(f"{shortcut.url}/", shortcut.url)
111+
if url.startswith(shortcut.url):
112+
if not url.endswith((".git", "/")):
113+
url += ".git" if shortcut.suffix else "/"
114+
break
59115
if url.startswith("~"): # Only expand on str to avoid messing with URLs
60116
url = expanduser(url)
117+
if ref:
118+
url = f"{url}@{ref}"
61119
return url

copier/template.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
from plumbum.machines import local
2222
from pydantic.dataclasses import dataclass
2323

24+
from copier.settings import Settings
25+
2426
from .errors import (
2527
InvalidConfigFileError,
2628
MultipleConfigFilesError,
@@ -216,6 +218,7 @@ class Template:
216218
url: str
217219
ref: str | None = None
218220
use_prereleases: bool = False
221+
settings: Settings = field(default_factory=Settings)
219222

220223
def _cleanup(self) -> None:
221224
if temp_clone := self._temp_clone():
@@ -570,7 +573,7 @@ def url_expanded(self) -> str:
570573
format, which wouldn't be understood by the underlying VCS system. This
571574
property returns the expanded version, which should work properly.
572575
"""
573-
return get_repo(self.url) or self.url
576+
return self.settings.normalize(self.url)
574577

575578
@cached_property
576579
def version(self) -> Version | None:
@@ -603,6 +606,6 @@ def version(self) -> Version | None:
603606
@cached_property
604607
def vcs(self) -> VCSTypes | None:
605608
"""Get VCS system used by the template, if any."""
606-
if get_repo(self.url):
609+
if get_repo(self.url_expanded):
607610
return "git"
608611
return None

copier/vcs.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,6 @@ def get_git_version() -> Version:
4444

4545
GIT_PREFIX = ("git@", "git://", "git+", "https://github.com/", "https://gitlab.com/")
4646
GIT_POSTFIX = ".git"
47-
REPLACEMENTS = (
48-
(re.compile(r"^gh:/?(.*\.git)$"), r"https://github.com/\1"),
49-
(re.compile(r"^gh:/?(.*)$"), r"https://github.com/\1.git"),
50-
(re.compile(r"^gl:/?(.*\.git)$"), r"https://gitlab.com/\1"),
51-
(re.compile(r"^gl:/?(.*)$"), r"https://gitlab.com/\1.git"),
52-
)
5347

5448

5549
def is_git_repo_root(path: StrOrPath) -> bool:
@@ -98,18 +92,13 @@ def get_repo(url: str) -> str | None:
9892
url:
9993
Valid examples:
10094
101-
- gh:copier-org/copier
102-
- gl:copier-org/copier
10395
- [email protected]:copier-org/copier.git
10496
- git+https://mywebsiteisagitrepo.example.com/
10597
- /local/path/to/git/repo
10698
- /local/path/to/git/bundle/file.bundle
10799
- ~/path/to/git/repo
108100
- ~/path/to/git/repo.bundle
109101
"""
110-
for pattern, replacement in REPLACEMENTS:
111-
url = re.sub(pattern, replacement, url)
112-
113102
if url.endswith(GIT_POSTFIX) or url.startswith(GIT_PREFIX):
114103
if url.startswith("git+"):
115104
return url[4:]

docs/settings.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,72 @@ trust:
5454

5555
Locations ending with `/` will be matched as prefixes, trusting all templates starting with that path.
5656
Locations not ending with `/` will be matched exactly.
57+
58+
## Shortcuts
59+
60+
It is possible to define shortcuts in the `shortcuts` setting. It should be a dictionary
61+
where keys are the shortcut names and values are [copier.settings.Shortcut][] objects.
62+
63+
```yaml
64+
shortcuts:
65+
company:
66+
url: "https://git.company.org"
67+
suffix: true
68+
local:
69+
url: ~/project/templates
70+
suffix: false
71+
```
72+
73+
If suffix is `True` (default), the `.git` suffix will be appended to the URL if missing.
74+
75+
A string can be used as short syntax instead of a full Shortcut object.
76+
77+
This snippet is equivalent to the previous one:
78+
79+
```yaml
80+
shortcuts:
81+
company: "https://git.company.org"
82+
local:
83+
url: ~/project/templates
84+
suffix: false
85+
```
86+
87+
You can now write:
88+
89+
```shell
90+
copier copy company:team/repo
91+
copier copy local:template
92+
```
93+
94+
There are 2 default shortcuts always available unless you explicitely override them:
95+
96+
```yaml
97+
shortcuts:
98+
gh: "https://github.com/"
99+
gl: "https://gitlab.com/"
100+
```
101+
102+
!!! tip "Working with private repositories"
103+
104+
If you work with private GitHub or Gitlab repositories,
105+
you might want to override those to force authenticated ssh access:
106+
107+
```yaml
108+
shortcuts:
109+
110+
111+
```
112+
113+
Note the trailing `:` in the URL, it is required to make Git use the SSH protocol.
114+
(`gh:user/repo` will be expanded into `[email protected]:user/repo.git`)
115+
116+
!!! tip "Trusted locations and shortcuts"
117+
118+
Trusted locations can be defined using shortcuts.
119+
120+
```yaml
121+
trust:
122+
- gh:user/repo
123+
- gh:company/
124+
- 'local:'
125+
```

0 commit comments

Comments
 (0)