Skip to content

feat(settings): custom shortcuts #1941

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions copier/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ class Worker:
src_path:
String that can be resolved to a template path, be it local or remote.

See [copier.vcs.get_repo][].
See [copier.vcs.get_repo][] and [`shortcuts` setting][shortcuts]

If it is `None`, then it means that you are
[updating a project][updating-a-project], and the original
Expand Down Expand Up @@ -913,7 +913,10 @@ def template(self) -> Template:
raise TypeError("Template not found")
url = str(self.subproject.template.url)
result = Template(
url=url, ref=self.vcs_ref, use_prereleases=self.use_prereleases
url=url,
ref=self.vcs_ref,
use_prereleases=self.use_prereleases,
settings=self.settings,
)
self._cleanup_hooks.append(result._cleanup)
return result
Expand Down
64 changes: 61 additions & 3 deletions copier/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,61 @@
from __future__ import annotations

import os
import re
import warnings
from os.path import expanduser
from pathlib import Path
from typing import Any

import yaml
from platformdirs import user_config_path
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator, model_validator

from .errors import MissingSettingsWarning

ENV_VAR = "COPIER_SETTINGS_PATH"

RE_REF = re.compile(r"^(?P<url>.+?)@(?P<ref>[^:]+)$")


class Shortcut(BaseModel):
"""Shortcut model."""

url: str = Field(description="Prefix url to replace the prefix with.")
suffix: bool = Field(default=True, description="Whether to add a `.git` suffix.")

@model_validator(mode="before")
@classmethod
def handle_string(cls, value: Any) -> Any:
"""Allow short syntax using string only."""
if isinstance(value, str):
return {"url": value}
return value

@field_validator("url", mode="after")
@classmethod
def inject_trailing_slash(cls, value: str) -> str:
"""Always add a trailing slash."""
if not value.endswith("/"):
value += "/"
return value


DEFAULT_SHORTCUTS = {
"gh": Shortcut(url="https://github.com/"),
"gl": Shortcut(url="https://gitlab.com/"),
}


class Settings(BaseModel):
"""User settings model."""

defaults: dict[str, Any] = Field(
default_factory=dict, description="Default values for questions"
)
shortcuts: dict[str, Shortcut] = Field(
DEFAULT_SHORTCUTS, description="URL shortcuts"
)
trust: set[str] = Field(
default_factory=set, description="List of trusted repositories or prefixes"
)
Expand All @@ -45,17 +80,40 @@ def from_file(cls, settings_path: Path | None = None) -> Settings:
)
return cls()

@field_validator("shortcuts", mode="after")
@classmethod
def inject_defaults(cls, value: dict[str, Shortcut]) -> dict[str, Shortcut]:
"""Ensure default are always present unless overridden."""
return {**DEFAULT_SHORTCUTS, **value}

def is_trusted(self, repository: str) -> bool:
"""Check if a repository is trusted."""
return any(
repository.startswith(self.normalize(trusted))
self.normalize(repository).startswith(self.normalize(trusted))
if trusted.endswith("/")
else repository == self.normalize(trusted)
else self.normalize(repository) == self.normalize(trusted)
for trusted in self.trust
)

def normalize(self, url: str) -> str:
"""Normalize an URL using user settings."""
url, ref = url.removeprefix("git+"), None
if m := RE_REF.match(url):
url = m.group("url")
ref = m.group("ref")
for prefix, shortcut in self.shortcuts.items():
prefix = f"{prefix}:"
if url.startswith(prefix):
# Inject shortcut
url = url.replace(prefix, shortcut.url)
# Remove double slash if any
url = url.replace(f"{shortcut.url}/", shortcut.url)
if url.startswith(shortcut.url):
if not url.endswith((".git", "/")):
url += ".git" if shortcut.suffix else "/"
break
if url.startswith("~"): # Only expand on str to avoid messing with URLs
url = expanduser(url) # noqa: PTH111
if ref:
url = f"{url}@{ref}"
return url
7 changes: 5 additions & 2 deletions copier/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from plumbum.machines import local
from pydantic.dataclasses import dataclass

from copier.settings import Settings

from .errors import (
InvalidConfigFileError,
MultipleConfigFilesError,
Expand Down Expand Up @@ -216,6 +218,7 @@ class Template:
url: str
ref: str | None = None
use_prereleases: bool = False
settings: Settings = field(default_factory=Settings)

def _cleanup(self) -> None:
if temp_clone := self._temp_clone():
Expand Down Expand Up @@ -578,7 +581,7 @@ def url_expanded(self) -> str:
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
return self.settings.normalize(self.url)

@cached_property
def version(self) -> Version | None:
Expand Down Expand Up @@ -611,6 +614,6 @@ def version(self) -> Version | None:
@cached_property
def vcs(self) -> VCSTypes | None:
"""Get VCS system used by the template, if any."""
if get_repo(self.url):
if get_repo(self.url_expanded):
return "git"
return None
11 changes: 0 additions & 11 deletions copier/vcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,6 @@ def get_git_version() -> Version:

GIT_PREFIX = ("git@", "git://", "git+", "https://github.com/", "https://gitlab.com/")
GIT_POSTFIX = ".git"
REPLACEMENTS = (
(re.compile(r"^gh:/?(.*\.git)$"), r"https://github.com/\1"),
(re.compile(r"^gh:/?(.*)$"), r"https://github.com/\1.git"),
(re.compile(r"^gl:/?(.*\.git)$"), r"https://gitlab.com/\1"),
(re.compile(r"^gl:/?(.*)$"), r"https://gitlab.com/\1.git"),
)


def is_git_repo_root(path: StrOrPath) -> bool:
Expand Down Expand Up @@ -97,18 +91,13 @@ def get_repo(url: str) -> str | None:
url:
Valid examples:

- gh:copier-org/copier
- gl:copier-org/copier
- [email protected]:copier-org/copier.git
- git+https://mywebsiteisagitrepo.example.com/
- /local/path/to/git/repo
- /local/path/to/git/bundle/file.bundle
- ~/path/to/git/repo
- ~/path/to/git/repo.bundle
"""
for pattern, replacement in REPLACEMENTS:
url = re.sub(pattern, replacement, url)

if url.endswith(GIT_POSTFIX) or url.startswith(GIT_PREFIX):
if url.startswith("git+"):
return url[4:]
Expand Down
69 changes: 69 additions & 0 deletions docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,72 @@ trust:

Locations ending with `/` will be matched as prefixes, trusting all templates starting with that path.
Locations not ending with `/` will be matched exactly.

## Shortcuts

It is possible to define shortcuts in the `shortcuts` setting. It should be a dictionary
where keys are the shortcut names and values are [copier.settings.Shortcut][] objects.

```yaml
shortcuts:
company:
url: "https://git.company.org"
suffix: true
local:
url: ~/project/templates
suffix: false
```

If suffix is `True` (default), the `.git` suffix will be appended to the URL if missing.

A string can be used as short syntax instead of a full Shortcut object.

This snippet is equivalent to the previous one:

```yaml
shortcuts:
company: "https://git.company.org"
local:
url: ~/project/templates
suffix: false
```

You can now write:

```shell
copier copy company:team/repo
copier copy local:template
```

There are 2 default shortcuts always available unless you explicitely override them:

```yaml
shortcuts:
gh: "https://github.com/"
gl: "https://gitlab.com/"
```

!!! tip "Working with private repositories"

If you work with private GitHub or Gitlab repositories,
you might want to override those to force authenticated ssh access:

```yaml
shortcuts:
gh: '[email protected]:'
gl: '[email protected]:'
```

Note the trailing `:` in the URL, it is required to make Git use the SSH protocol.
(`gh:user/repo` will be expanded into `[email protected]:user/repo.git`)

!!! tip "Trusted locations and shortcuts"

Trusted locations can be defined using shortcuts.

```yaml
trust:
- gh:user/repo
- gh:company/
- 'local:'
```
Loading
Loading