Skip to content
Merged
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
4 changes: 4 additions & 0 deletions copier/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,7 @@ class DirtyLocalWarning(UserWarning, CopierWarning):

class ShallowCloneWarning(UserWarning, CopierWarning):
"""The template repository is a shallow clone."""


class MissingSettingsWarning(UserWarning, CopierWarning):
"""Settings path has been defined but file is missing."""
26 changes: 13 additions & 13 deletions copier/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
YieldTagInFileError,
)
from .jinja_ext import YieldEnvironment, YieldExtension
from .settings import Settings
from .subproject import Subproject
from .template import Task, Template
from .tools import (
Expand All @@ -58,13 +59,7 @@
scantree,
set_git_alternates,
)
from .types import (
MISSING,
AnyByStrDict,
JSONSerializable,
RelativePath,
StrOrPath,
)
from .types import MISSING, AnyByStrDict, JSONSerializable, RelativePath, StrOrPath
from .user_data import DEFAULT_DATA, AnswersMap, Question
from .vcs import get_git

Expand Down Expand Up @@ -192,6 +187,7 @@ class Worker:
answers_file: RelativePath | None = None
vcs_ref: str | None = None
data: AnyByStrDict = field(default_factory=dict)
settings: Settings = field(default_factory=Settings.from_file)
exclude: Sequence[str] = ()
use_prereleases: bool = False
skip_if_exists: Sequence[str] = ()
Expand Down Expand Up @@ -245,7 +241,7 @@ def _cleanup(self) -> None:

def _check_unsafe(self, mode: Literal["copy", "update"]) -> None:
"""Check whether a template uses unsafe features."""
if self.unsafe:
if self.unsafe or self.settings.is_trusted(self.template.url):
return
features: set[str] = set()
if self.template.jinja_extensions:
Expand Down Expand Up @@ -467,6 +463,7 @@ def _ask(self) -> None: # noqa: C901
question = Question(
answers=result,
jinja_env=self.jinja_env,
settings=self.settings,
var_name=var_name,
**details,
)
Expand Down Expand Up @@ -998,11 +995,14 @@ def _apply_update(self) -> None: # noqa: C901
)
subproject_subdir = self.subproject.local_abspath.relative_to(subproject_top)

with TemporaryDirectory(
prefix=f"{__name__}.old_copy.",
) as old_copy, TemporaryDirectory(
prefix=f"{__name__}.new_copy.",
) as new_copy:
with (
TemporaryDirectory(
prefix=f"{__name__}.old_copy.",
) as old_copy,
TemporaryDirectory(
prefix=f"{__name__}.new_copy.",
) as new_copy,
):
# Copy old template into a temporary destination
with replace(
self,
Expand Down
61 changes: 61 additions & 0 deletions copier/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""User settings models and helper functions."""

from __future__ import annotations

import os
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 .errors import MissingSettingsWarning

ENV_VAR = "COPIER_SETTINGS_PATH"


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

defaults: dict[str, Any] = Field(
default_factory=dict, description="Default values for questions"
)
trust: set[str] = Field(
default_factory=set, description="List of trusted repositories or prefixes"
)

@classmethod
def from_file(cls, settings_path: Path | None = None) -> Settings:
"""Load settings from a file."""
env_path = os.getenv(ENV_VAR)
if settings_path is None:
if env_path:
settings_path = Path(env_path)
else:
settings_path = user_config_path("copier") / "settings.yml"
if settings_path.is_file():
data = yaml.safe_load(settings_path.read_text())
return cls.model_validate(data)
elif env_path:
warnings.warn(
f"Settings file not found at {env_path}", MissingSettingsWarning
)
return cls()

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

def normalize(self, url: str) -> str:
"""Normalize an URL using user settings."""
if url.startswith("~"): # Only expand on str to avoid messing with URLs
url = expanduser(url)
return url
7 changes: 6 additions & 1 deletion copier/user_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
from pygments.lexers.data import JsonLexer, YamlLexer
from questionary.prompts.common import Choice

from copier.settings import Settings

from .errors import InvalidTypeError, UserMessageError
from .tools import cast_to_bool, cast_to_str, force_str_end
from .types import MISSING, AnyByStrDict, MissingType, OptStrOrPath, StrOrPath
Expand Down Expand Up @@ -178,6 +180,7 @@ class Question:
var_name: str
answers: AnswersMap
jinja_env: SandboxedEnvironment
settings: Settings = field(default_factory=Settings)
choices: Sequence[Any] | dict[Any, Any] | str = field(default_factory=list)
multiselect: bool = False
default: Any = MISSING
Expand Down Expand Up @@ -246,7 +249,9 @@ def get_default(self) -> Any:
except KeyError:
if self.default is MISSING:
return MISSING
result = self.render_value(self.default)
result = self.render_value(
self.settings.defaults.get(self.var_name, self.default)
)
result = self.cast_answer(result)
return result

Expand Down
4 changes: 4 additions & 0 deletions docs/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -1588,6 +1588,10 @@ switch `--UNSAFE` or `--trust`.

Not supported in `copier.yml`.

!!! tip

See the [`trust` setting][trusted-locations] to mark some repositories as always trusted.

### `use_prereleases`

- Format: `bool`
Expand Down
1 change: 1 addition & 0 deletions docs/reference/settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: copier.settings
56 changes: 56 additions & 0 deletions docs/settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Settings

Copier settings are stored in `<CONFIG_ROOT>/settings.yml` where `<CONFIG_ROOT>` is the
standard configuration directory for your platform:

- `$XDG_CONFIG_HOME/copier` (`~/.config/copier ` in most cases) on Linux as defined by
[XDG Base Directory Specifications](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html)
- `~/Library/Application Support/copier` on macOS as defined by
[Apple File System Basics](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html)
- `%USERPROFILE%\AppData\Local\copier` on Windows as defined in
[Known folders](https://docs.microsoft.com/en-us/windows/win32/shell/known-folders)

This location can be overridden by setting the `COPIER_SETTINGS_PATH` environment
variable.

## User defaults

Users may define some reusable default variables in the `defaults` section of the
configuration file.

```yaml title="<CONFIG_ROOT>/settings.yml"
defaults:
user_name: "John Doe"
user_email: john.doe@acme.com
```

This user data will replace the default value of fields of the same name.

### Well-known variables

To ensure templates efficiently reuse user-defined variables, we invite template authors
to use the following well-known variables:

| Variable name | Type | Description |
| ------------- | ----- | ---------------------- |
| `user_name` | `str` | User's full name |
| `user_email` | `str` | User's email address |
| `github_user` | `str` | User's GitHub username |
| `gitlab_user` | `str` | User's GitLab username |

## Trusted locations

Users may define trusted locations in the `trust` setting. It should be a list of Copier
template repositories, or repositories prefix.

```yaml
trust:
- https://github.com/your_account/your_template.git
- https://github.com/your_account/
- ~/templates/
```

!!! warning "Security considerations"

Locations ending with `/` will be matched as prefixes, trusting all templates starting with that path.
Locations not ending with `/` will be matched exactly.
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ nav:
- Configuring a template: "configuring.md"
- Generating a project: "generating.md"
- Updating a project: "updating.md"
- Settings: "settings.md"
- Reference:
- cli.py: "reference/cli.md"
- errors.py: "reference/errors.md"
- jinja_ext.py: "reference/jinja_ext.md"
- main.py: "reference/main.md"
- settings.py: "reference/settings.md"
- subproject.py: "reference/subproject.md"
- template.py: "reference/template.md"
- tools.py: "reference/tools.md"
Expand Down
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pygments = ">=2.7.1"
pyyaml = ">=5.3.1"
questionary = ">=1.8.1"
eval-type-backport = { version = ">=0.1.3,<0.3.0", python = "<3.10" }
platformdirs = ">=4.3.6"

[tool.poetry.group.dev]
optional = true
Expand Down
17 changes: 17 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import platform
import sys
from pathlib import Path
from typing import Any, Iterator
from unittest.mock import patch

import pytest
from coverage.tracer import CTracer
Expand Down Expand Up @@ -75,3 +77,18 @@ def gitconfig(gitconfig: GitConfig) -> Iterator[GitConfig]:
"""
with local.env(GIT_CONFIG_GLOBAL=str(gitconfig)):
yield gitconfig


@pytest.fixture
def config_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
config_path = tmp_path / "config"
monkeypatch.delenv("COPIER_SETTINGS_PATH", raising=False)
with patch("copier.settings.user_config_path", return_value=config_path):
yield config_path


@pytest.fixture
def settings_path(config_path: Path) -> Path:
config_path.mkdir()
settings_path = config_path / "settings.yml"
return settings_path
Loading
Loading