feat(settings): add user settings support with defaults values (fix #235)

This commit is contained in:
Axel H. 2025-01-19 19:35:37 +01:00 committed by Sigurd Spieckermann
parent 57439e5c18
commit 0a9644d249
12 changed files with 305 additions and 14 deletions

View File

@ -139,3 +139,7 @@ class DirtyLocalWarning(UserWarning, CopierWarning):
class ShallowCloneWarning(UserWarning, CopierWarning): class ShallowCloneWarning(UserWarning, CopierWarning):
"""The template repository is a shallow clone.""" """The template repository is a shallow clone."""
class MissingSettingsWarning(UserWarning, CopierWarning):
"""Settings path has been defined but file is missing."""

View File

@ -46,6 +46,7 @@ from .errors import (
YieldTagInFileError, YieldTagInFileError,
) )
from .jinja_ext import YieldEnvironment, YieldExtension from .jinja_ext import YieldEnvironment, YieldExtension
from .settings import Settings
from .subproject import Subproject from .subproject import Subproject
from .template import Task, Template from .template import Task, Template
from .tools import ( from .tools import (
@ -58,13 +59,7 @@ from .tools import (
scantree, scantree,
set_git_alternates, set_git_alternates,
) )
from .types import ( from .types import MISSING, AnyByStrDict, JSONSerializable, RelativePath, StrOrPath
MISSING,
AnyByStrDict,
JSONSerializable,
RelativePath,
StrOrPath,
)
from .user_data import DEFAULT_DATA, AnswersMap, Question from .user_data import DEFAULT_DATA, AnswersMap, Question
from .vcs import get_git from .vcs import get_git
@ -192,6 +187,7 @@ class Worker:
answers_file: RelativePath | None = None answers_file: RelativePath | None = None
vcs_ref: str | None = None vcs_ref: str | None = None
data: AnyByStrDict = field(default_factory=dict) data: AnyByStrDict = field(default_factory=dict)
settings: Settings = field(default_factory=Settings.from_file)
exclude: Sequence[str] = () exclude: Sequence[str] = ()
use_prereleases: bool = False use_prereleases: bool = False
skip_if_exists: Sequence[str] = () skip_if_exists: Sequence[str] = ()
@ -467,6 +463,7 @@ class Worker:
question = Question( question = Question(
answers=result, answers=result,
jinja_env=self.jinja_env, jinja_env=self.jinja_env,
settings=self.settings,
var_name=var_name, var_name=var_name,
**details, **details,
) )
@ -998,11 +995,14 @@ class Worker:
) )
subproject_subdir = self.subproject.local_abspath.relative_to(subproject_top) subproject_subdir = self.subproject.local_abspath.relative_to(subproject_top)
with TemporaryDirectory( with (
prefix=f"{__name__}.old_copy.", TemporaryDirectory(
) as old_copy, TemporaryDirectory( prefix=f"{__name__}.old_copy.",
prefix=f"{__name__}.new_copy.", ) as old_copy,
) as new_copy: TemporaryDirectory(
prefix=f"{__name__}.new_copy.",
) as new_copy,
):
# Copy old template into a temporary destination # Copy old template into a temporary destination
with replace( with replace(
self, self,

40
copier/settings.py Normal file
View File

@ -0,0 +1,40 @@
"""User settings models and helper functions."""
from __future__ import annotations
import os
import warnings
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)
@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()

View File

@ -23,6 +23,8 @@ from pydantic_core.core_schema import ValidationInfo
from pygments.lexers.data import JsonLexer, YamlLexer from pygments.lexers.data import JsonLexer, YamlLexer
from questionary.prompts.common import Choice from questionary.prompts.common import Choice
from copier.settings import Settings
from .errors import InvalidTypeError, UserMessageError from .errors import InvalidTypeError, UserMessageError
from .tools import cast_to_bool, cast_to_str, force_str_end from .tools import cast_to_bool, cast_to_str, force_str_end
from .types import MISSING, AnyByStrDict, MissingType, OptStrOrPath, StrOrPath from .types import MISSING, AnyByStrDict, MissingType, OptStrOrPath, StrOrPath
@ -178,6 +180,7 @@ class Question:
var_name: str var_name: str
answers: AnswersMap answers: AnswersMap
jinja_env: SandboxedEnvironment jinja_env: SandboxedEnvironment
settings: Settings = field(default_factory=Settings)
choices: Sequence[Any] | dict[Any, Any] | str = field(default_factory=list) choices: Sequence[Any] | dict[Any, Any] | str = field(default_factory=list)
multiselect: bool = False multiselect: bool = False
default: Any = MISSING default: Any = MISSING
@ -246,7 +249,9 @@ class Question:
except KeyError: except KeyError:
if self.default is MISSING: if self.default is MISSING:
return 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) result = self.cast_answer(result)
return result return result

View File

@ -0,0 +1 @@
::: copier.settings

39
docs/settings.md Normal file
View File

@ -0,0 +1,39 @@
# 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 |

View File

@ -11,11 +11,13 @@ nav:
- Configuring a template: "configuring.md" - Configuring a template: "configuring.md"
- Generating a project: "generating.md" - Generating a project: "generating.md"
- Updating a project: "updating.md" - Updating a project: "updating.md"
- Settings: "settings.md"
- Reference: - Reference:
- cli.py: "reference/cli.md" - cli.py: "reference/cli.md"
- errors.py: "reference/errors.md" - errors.py: "reference/errors.md"
- jinja_ext.py: "reference/jinja_ext.md" - jinja_ext.py: "reference/jinja_ext.md"
- main.py: "reference/main.md" - main.py: "reference/main.md"
- settings.py: "reference/settings.md"
- subproject.py: "reference/subproject.md" - subproject.py: "reference/subproject.md"
- template.py: "reference/template.md" - template.py: "reference/template.md"
- tools.py: "reference/tools.md" - tools.py: "reference/tools.md"

2
poetry.lock generated
View File

@ -1755,4 +1755,4 @@ type = ["pytest-mypy"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.9" python-versions = ">=3.9"
content-hash = "888edb00b45adbcefa12e5e242365976d18e2f7356d522b873e973debbb59517" content-hash = "3c9bb33fa0a43bd04125dbd879cef233ac4e3582431b3e06817f77bc9da84920"

View File

@ -41,6 +41,7 @@ pygments = ">=2.7.1"
pyyaml = ">=5.3.1" pyyaml = ">=5.3.1"
questionary = ">=1.8.1" questionary = ">=1.8.1"
eval-type-backport = { version = ">=0.1.3,<0.3.0", python = "<3.10" } eval-type-backport = { version = ">=0.1.3,<0.3.0", python = "<3.10" }
platformdirs = ">=4.3.6"
[tool.poetry.group.dev] [tool.poetry.group.dev]
optional = true optional = true

View File

@ -2,7 +2,9 @@ from __future__ import annotations
import platform import platform
import sys import sys
from pathlib import Path
from typing import Any, Iterator from typing import Any, Iterator
from unittest.mock import patch
import pytest import pytest
from coverage.tracer import CTracer from coverage.tracer import CTracer
@ -75,3 +77,18 @@ def gitconfig(gitconfig: GitConfig) -> Iterator[GitConfig]:
""" """
with local.env(GIT_CONFIG_GLOBAL=str(gitconfig)): with local.env(GIT_CONFIG_GLOBAL=str(gitconfig)):
yield 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

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path from pathlib import Path
from textwrap import dedent from textwrap import dedent
from typing import Any, Callable from typing import Any, Callable
@ -91,6 +92,59 @@ def test_read_data(
) )
def test_settings_defaults_precedence(
tmp_path_factory: pytest.TempPathFactory, settings_path: Path
) -> None:
src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
build_file_tree(
{
(src / "copier.yml"): (
f"""\
# This is a comment
_envops: {BRACKET_ENVOPS_JSON}
a_string: lorem ipsum
a_number: 12345
a_boolean: true
a_list:
- one
- two
- three
"""
),
(src / "user_data.txt.jinja"): (
"""\
A string: [[ a_string ]]
A number: [[ a_number ]]
A boolean: [[ a_boolean ]]
A list: [[ ", ".join(a_list) ]]
"""
),
}
)
settings_path.write_text(
dedent(
"""\
defaults:
a_string: whatever
a_number: 42
a_boolean: false
a_list:
- one
- two
"""
)
)
copier.run_copy(str(src), dst, defaults=True, overwrite=True)
assert (dst / "user_data.txt").read_text() == dedent(
"""\
A string: whatever
A number: 42
A boolean: False
A list: one, two
"""
)
def test_invalid_yaml(capsys: pytest.CaptureFixture[str]) -> None: def test_invalid_yaml(capsys: pytest.CaptureFixture[str]) -> None:
conf_path = Path("tests", "demo_invalid", "copier.yml") conf_path = Path("tests", "demo_invalid", "copier.yml")
with pytest.raises(InvalidConfigFileError): with pytest.raises(InvalidConfigFileError):
@ -445,6 +499,7 @@ def test_user_defaults(
"user_defaults_updated", "user_defaults_updated",
"data_initial", "data_initial",
"data_updated", "data_updated",
"settings_defaults",
"expected_initial", "expected_initial",
"expected_updated", "expected_updated",
), ),
@ -460,6 +515,53 @@ def test_user_defaults(
}, },
{}, {},
{}, {},
{},
dedent(
"""\
A string: foo
"""
),
dedent(
"""\
A string: foo
"""
),
),
# Settings defaults takes precedence over initial defaults.
# The output should remain unchanged following the update operation.
(
{},
{},
{},
{},
{
"a_string": "bar",
},
dedent(
"""\
A string: bar
"""
),
dedent(
"""\
A string: bar
"""
),
),
# User provided defaults takes precedence over initial defaults and settings defaults.
# The output should remain unchanged following the update operation.
(
{
"a_string": "foo",
},
{
"a_string": "foobar",
},
{},
{},
{
"a_string": "bar",
},
dedent( dedent(
"""\ """\
A string: foo A string: foo
@ -484,6 +586,9 @@ def test_user_defaults(
"a_string": "yosemite", "a_string": "yosemite",
}, },
{}, {},
{
"a_string": "bar",
},
dedent( dedent(
"""\ """\
A string: yosemite A string: yosemite
@ -510,6 +615,9 @@ def test_user_defaults(
{ {
"a_string": "red rocks", "a_string": "red rocks",
}, },
{
"a_string": "bar",
},
dedent( dedent(
"""\ """\
A string: yosemite A string: yosemite
@ -529,8 +637,10 @@ def test_user_defaults_updated(
user_defaults_updated: AnyByStrDict, user_defaults_updated: AnyByStrDict,
data_initial: AnyByStrDict, data_initial: AnyByStrDict,
data_updated: AnyByStrDict, data_updated: AnyByStrDict,
settings_defaults: AnyByStrDict,
expected_initial: str, expected_initial: str,
expected_updated: str, expected_updated: str,
settings_path: Path,
) -> None: ) -> None:
src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) src, dst = map(tmp_path_factory.mktemp, ("src", "dst"))
with local.cwd(src): with local.cwd(src):
@ -557,6 +667,7 @@ def test_user_defaults_updated(
} }
) )
git_init() git_init()
settings_path.write_text(f"defaults: {json.dumps(settings_defaults)}")
copier.run_copy( copier.run_copy(
str(src), str(src),

71
tests/test_settings.py Normal file
View File

@ -0,0 +1,71 @@
from __future__ import annotations
from pathlib import Path
import pytest
from copier.errors import MissingSettingsWarning
from copier.settings import Settings
def test_default_settings() -> None:
settings = Settings()
assert settings.defaults == {}
def test_settings_from_default_location(settings_path: Path) -> None:
settings_path.write_text("defaults:\n foo: bar")
settings = Settings.from_file()
assert settings.defaults == {"foo": "bar"}
@pytest.mark.usefixtures("config_path")
def test_settings_from_default_location_dont_exists() -> None:
settings = Settings.from_file()
assert settings.defaults == {}
def test_settings_from_env_location(
settings_path: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
settings_path.write_text("defaults:\n foo: bar")
settings_from_env_path = tmp_path / "settings.yml"
settings_from_env_path.write_text("defaults:\n foo: baz")
monkeypatch.setenv("COPIER_SETTINGS_PATH", str(settings_from_env_path))
settings = Settings.from_file()
assert settings.defaults == {"foo": "baz"}
def test_settings_from_param(
settings_path: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
settings_path.write_text("defaults:\n foo: bar")
settings_from_env_path = tmp_path / "settings.yml"
settings_from_env_path.write_text("defaults:\n foo: baz")
monkeypatch.setenv("COPIER_SETTINGS_PATH", str(settings_from_env_path))
file_path = tmp_path / "file.yml"
file_path.write_text("defaults:\n from: file")
settings = Settings.from_file(file_path)
assert settings.defaults == {"from": "file"}
def test_settings_defined_but_missing(
settings_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("COPIER_SETTINGS_PATH", str(settings_path))
with pytest.warns(MissingSettingsWarning):
Settings.from_file()