Skip to content
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

feat(settings): add user settings support with defaults values and trusts #1940

Open
wants to merge 2 commits 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
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 setttings 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"
)
pawamoy marked this conversation as resolved.
Show resolved Hide resolved
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()
noirbizarre marked this conversation as resolved.
Show resolved Hide resolved

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
Comment on lines +50 to +61
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether we should use copier.vcs.get_repo() to normalize trusted repository URLs to be more robust against non-canonical URLs. From the tests below, a (normalized) repository https://github.com/user/repo.git wouldn't be considered trusted despite the trust list entry https://github.com/user/repo. Won't this lead to confusion?

I realize that copier.vcs.get_repo() currently doesn't handle URLs with a trailing slash properly:

>>> get_repo("https://github.com/user/repo/")
'https://github.com/user/repo/.git'

But this could be fixed. WDYT?

Copy link
Contributor Author

@noirbizarre noirbizarre Jan 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I noticed that when I started #1941. That's one of the reasons I separated the shortcuts into their own PR.
If this is OK for you, I would prefer handling get_repo and URL normalization in #1941 as it is its main point. It would allow having the full picture and craft a solution that handles more cases.

I think we have the opportunity to properly review/complete/fix normalization rules in #1941. (I'll properly rebase it when this one is merged so we can have a clear view)

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
noirbizarre marked this conversation as resolved.
Show resolved Hide resolved
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: [email protected]
```

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
Loading
Loading