Skip to content

Commit 0a9644d

Browse files
noirbizarresisp
authored andcommitted
feat(settings): add user settings support with defaults values (fix #235)
1 parent 57439e5 commit 0a9644d

File tree

12 files changed

+305
-14
lines changed

12 files changed

+305
-14
lines changed

copier/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,7 @@ class DirtyLocalWarning(UserWarning, CopierWarning):
139139

140140
class ShallowCloneWarning(UserWarning, CopierWarning):
141141
"""The template repository is a shallow clone."""
142+
143+
144+
class MissingSettingsWarning(UserWarning, CopierWarning):
145+
"""Settings path has been defined but file is missing."""

copier/main.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
YieldTagInFileError,
4747
)
4848
from .jinja_ext import YieldEnvironment, YieldExtension
49+
from .settings import Settings
4950
from .subproject import Subproject
5051
from .template import Task, Template
5152
from .tools import (
@@ -58,13 +59,7 @@
5859
scantree,
5960
set_git_alternates,
6061
)
61-
from .types import (
62-
MISSING,
63-
AnyByStrDict,
64-
JSONSerializable,
65-
RelativePath,
66-
StrOrPath,
67-
)
62+
from .types import MISSING, AnyByStrDict, JSONSerializable, RelativePath, StrOrPath
6863
from .user_data import DEFAULT_DATA, AnswersMap, Question
6964
from .vcs import get_git
7065

@@ -192,6 +187,7 @@ class Worker:
192187
answers_file: RelativePath | None = None
193188
vcs_ref: str | None = None
194189
data: AnyByStrDict = field(default_factory=dict)
190+
settings: Settings = field(default_factory=Settings.from_file)
195191
exclude: Sequence[str] = ()
196192
use_prereleases: bool = False
197193
skip_if_exists: Sequence[str] = ()
@@ -467,6 +463,7 @@ def _ask(self) -> None: # noqa: C901
467463
question = Question(
468464
answers=result,
469465
jinja_env=self.jinja_env,
466+
settings=self.settings,
470467
var_name=var_name,
471468
**details,
472469
)
@@ -998,11 +995,14 @@ def _apply_update(self) -> None: # noqa: C901
998995
)
999996
subproject_subdir = self.subproject.local_abspath.relative_to(subproject_top)
1000997

1001-
with TemporaryDirectory(
1002-
prefix=f"{__name__}.old_copy.",
1003-
) as old_copy, TemporaryDirectory(
1004-
prefix=f"{__name__}.new_copy.",
1005-
) as new_copy:
998+
with (
999+
TemporaryDirectory(
1000+
prefix=f"{__name__}.old_copy.",
1001+
) as old_copy,
1002+
TemporaryDirectory(
1003+
prefix=f"{__name__}.new_copy.",
1004+
) as new_copy,
1005+
):
10061006
# Copy old template into a temporary destination
10071007
with replace(
10081008
self,

copier/settings.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""User settings models and helper functions."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import warnings
7+
from pathlib import Path
8+
from typing import Any
9+
10+
import yaml
11+
from platformdirs import user_config_path
12+
from pydantic import BaseModel, Field
13+
14+
from .errors import MissingSettingsWarning
15+
16+
ENV_VAR = "COPIER_SETTINGS_PATH"
17+
18+
19+
class Settings(BaseModel):
20+
"""User settings model."""
21+
22+
defaults: dict[str, Any] = Field(default_factory=dict)
23+
24+
@classmethod
25+
def from_file(cls, settings_path: Path | None = None) -> Settings:
26+
"""Load settings from a file."""
27+
env_path = os.getenv(ENV_VAR)
28+
if settings_path is None:
29+
if env_path:
30+
settings_path = Path(env_path)
31+
else:
32+
settings_path = user_config_path("copier") / "settings.yml"
33+
if settings_path.is_file():
34+
data = yaml.safe_load(settings_path.read_text())
35+
return cls.model_validate(data)
36+
elif env_path:
37+
warnings.warn(
38+
f"Settings file not found at {env_path}", MissingSettingsWarning
39+
)
40+
return cls()

copier/user_data.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
from pygments.lexers.data import JsonLexer, YamlLexer
2424
from questionary.prompts.common import Choice
2525

26+
from copier.settings import Settings
27+
2628
from .errors import InvalidTypeError, UserMessageError
2729
from .tools import cast_to_bool, cast_to_str, force_str_end
2830
from .types import MISSING, AnyByStrDict, MissingType, OptStrOrPath, StrOrPath
@@ -178,6 +180,7 @@ class Question:
178180
var_name: str
179181
answers: AnswersMap
180182
jinja_env: SandboxedEnvironment
183+
settings: Settings = field(default_factory=Settings)
181184
choices: Sequence[Any] | dict[Any, Any] | str = field(default_factory=list)
182185
multiselect: bool = False
183186
default: Any = MISSING
@@ -246,7 +249,9 @@ def get_default(self) -> Any:
246249
except KeyError:
247250
if self.default is MISSING:
248251
return MISSING
249-
result = self.render_value(self.default)
252+
result = self.render_value(
253+
self.settings.defaults.get(self.var_name, self.default)
254+
)
250255
result = self.cast_answer(result)
251256
return result
252257

docs/reference/settings.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
::: copier.settings

docs/settings.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Settings
2+
3+
Copier settings are stored in `<CONFIG_ROOT>/settings.yml` where `<CONFIG_ROOT>` is the
4+
standard configuration directory for your platform:
5+
6+
- `$XDG_CONFIG_HOME/copier` (`~/.config/copier ` in most cases) on Linux as defined by
7+
[XDG Base Directory Specifications](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html)
8+
- `~/Library/Application Support/copier` on macOS as defined by
9+
[Apple File System Basics](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html)
10+
- `%USERPROFILE%\AppData\Local\copier` on Windows as defined in
11+
[Known folders](https://docs.microsoft.com/en-us/windows/win32/shell/known-folders)
12+
13+
This location can be overridden by setting the `COPIER_SETTINGS_PATH` environment
14+
variable.
15+
16+
## User defaults
17+
18+
Users may define some reusable default variables in the `defaults` section of the
19+
configuration file.
20+
21+
```yaml title="<CONFIG_ROOT>/settings.yml"
22+
defaults:
23+
user_name: "John Doe"
24+
user_email: [email protected]
25+
```
26+
27+
This user data will replace the default value of fields of the same name.
28+
29+
### Well-known variables
30+
31+
To ensure templates efficiently reuse user-defined variables, we invite template authors
32+
to use the following well-known variables:
33+
34+
| Variable name | Type | Description |
35+
| ------------- | ----- | ---------------------- |
36+
| `user_name` | `str` | User's full name |
37+
| `user_email` | `str` | User's email address |
38+
| `github_user` | `str` | User's GitHub username |
39+
| `gitlab_user` | `str` | User's GitLab username |

mkdocs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ nav:
1111
- Configuring a template: "configuring.md"
1212
- Generating a project: "generating.md"
1313
- Updating a project: "updating.md"
14+
- Settings: "settings.md"
1415
- Reference:
1516
- cli.py: "reference/cli.md"
1617
- errors.py: "reference/errors.md"
1718
- jinja_ext.py: "reference/jinja_ext.md"
1819
- main.py: "reference/main.md"
20+
- settings.py: "reference/settings.md"
1921
- subproject.py: "reference/subproject.md"
2022
- template.py: "reference/template.md"
2123
- tools.py: "reference/tools.md"

poetry.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ pygments = ">=2.7.1"
4141
pyyaml = ">=5.3.1"
4242
questionary = ">=1.8.1"
4343
eval-type-backport = { version = ">=0.1.3,<0.3.0", python = "<3.10" }
44+
platformdirs = ">=4.3.6"
4445

4546
[tool.poetry.group.dev]
4647
optional = true

tests/conftest.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import platform
44
import sys
5+
from pathlib import Path
56
from typing import Any, Iterator
7+
from unittest.mock import patch
68

79
import pytest
810
from coverage.tracer import CTracer
@@ -75,3 +77,18 @@ def gitconfig(gitconfig: GitConfig) -> Iterator[GitConfig]:
7577
"""
7678
with local.env(GIT_CONFIG_GLOBAL=str(gitconfig)):
7779
yield gitconfig
80+
81+
82+
@pytest.fixture
83+
def config_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
84+
config_path = tmp_path / "config"
85+
monkeypatch.delenv("COPIER_SETTINGS_PATH", raising=False)
86+
with patch("copier.settings.user_config_path", return_value=config_path):
87+
yield config_path
88+
89+
90+
@pytest.fixture
91+
def settings_path(config_path: Path) -> Path:
92+
config_path.mkdir()
93+
settings_path = config_path / "settings.yml"
94+
return settings_path

0 commit comments

Comments
 (0)