diff --git a/copier/errors.py b/copier/errors.py index d74bd41..f1bbf8a 100644 --- a/copier/errors.py +++ b/copier/errors.py @@ -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.""" diff --git a/copier/main.py b/copier/main.py index f2b1db1..27307b6 100644 --- a/copier/main.py +++ b/copier/main.py @@ -46,6 +46,7 @@ from .errors import ( YieldTagInFileError, ) from .jinja_ext import YieldEnvironment, YieldExtension +from .settings import Settings from .subproject import Subproject from .template import Task, Template from .tools import ( @@ -58,13 +59,7 @@ from .tools import ( 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 @@ -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] = () @@ -467,6 +463,7 @@ class Worker: question = Question( answers=result, jinja_env=self.jinja_env, + settings=self.settings, var_name=var_name, **details, ) @@ -998,11 +995,14 @@ class Worker: ) 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, diff --git a/copier/settings.py b/copier/settings.py new file mode 100644 index 0000000..4831ced --- /dev/null +++ b/copier/settings.py @@ -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() diff --git a/copier/user_data.py b/copier/user_data.py index 6672128..3cb870a 100644 --- a/copier/user_data.py +++ b/copier/user_data.py @@ -23,6 +23,8 @@ from pydantic_core.core_schema import ValidationInfo 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 @@ -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 @@ -246,7 +249,9 @@ class Question: 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 diff --git a/docs/reference/settings.md b/docs/reference/settings.md new file mode 100644 index 0000000..2bdf0d3 --- /dev/null +++ b/docs/reference/settings.md @@ -0,0 +1 @@ +::: copier.settings diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 0000000..b5a296d --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,39 @@ +# Settings + +Copier settings are stored in `/settings.yml` where `` 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="/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 | diff --git a/mkdocs.yml b/mkdocs.yml index 4eeed47..2d3761c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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" diff --git a/poetry.lock b/poetry.lock index efd825f..fff2c6d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1755,4 +1755,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.9" -content-hash = "888edb00b45adbcefa12e5e242365976d18e2f7356d522b873e973debbb59517" +content-hash = "3c9bb33fa0a43bd04125dbd879cef233ac4e3582431b3e06817f77bc9da84920" diff --git a/pyproject.toml b/pyproject.toml index b21f28c..a082b29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index c5d5ba8..2d3d3e7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,9 @@ from __future__ import annotations 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 @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py index 1c13dd1..03b66b3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from pathlib import Path from textwrap import dedent 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: conf_path = Path("tests", "demo_invalid", "copier.yml") with pytest.raises(InvalidConfigFileError): @@ -445,6 +499,7 @@ def test_user_defaults( "user_defaults_updated", "data_initial", "data_updated", + "settings_defaults", "expected_initial", "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( """\ A string: foo @@ -484,6 +586,9 @@ def test_user_defaults( "a_string": "yosemite", }, {}, + { + "a_string": "bar", + }, dedent( """\ A string: yosemite @@ -510,6 +615,9 @@ def test_user_defaults( { "a_string": "red rocks", }, + { + "a_string": "bar", + }, dedent( """\ A string: yosemite @@ -529,8 +637,10 @@ def test_user_defaults_updated( user_defaults_updated: AnyByStrDict, data_initial: AnyByStrDict, data_updated: AnyByStrDict, + settings_defaults: AnyByStrDict, expected_initial: str, expected_updated: str, + settings_path: Path, ) -> None: src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) with local.cwd(src): @@ -557,6 +667,7 @@ def test_user_defaults_updated( } ) git_init() + settings_path.write_text(f"defaults: {json.dumps(settings_defaults)}") copier.run_copy( str(src), diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..2f57a5f --- /dev/null +++ b/tests/test_settings.py @@ -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()