diff --git a/copier/__init__.py b/copier/__init__.py index 59a4499af..c513938b3 100644 --- a/copier/__init__.py +++ b/copier/__init__.py @@ -22,6 +22,7 @@ def __getattr__(name: str) -> Any: if not name.startswith("_") and name not in { + "get_questions_data", "run_copy", "run_recopy", "run_update", @@ -31,6 +32,7 @@ def __getattr__(name: str) -> Any: __all__ = [ + "get_questions_data", # noqa: F405 "load_settings", "run_copy", # noqa: F405 "run_recopy", # noqa: F405 diff --git a/copier/_cli.py b/copier/_cli.py index 3d5f65a3e..d1234d9be 100644 --- a/copier/_cli.py +++ b/copier/_cli.py @@ -1,6 +1,6 @@ """Command line entrypoint. This module declares the Copier CLI applications. -Basically, there are 4 different commands you can run: +Basically, there are 5 different commands you can run: - `copier`, the main app, which is a shortcut for the `copy` and `update` subapps. @@ -39,6 +39,15 @@ copier update ``` +- `copier inspect` to inspect a template's questions + and metadata without copying. + + !!! example + + ```sh + copier inspect gh:copier-org/autopretty + ``` + - `copier check-update` to check if a preexisting project is using the latest version of its template. @@ -67,9 +76,9 @@ import yaml from plumbum import cli, colors -from ._main import get_update_data, run_copy, run_recopy, run_update -from ._tools import copier_version, try_enum -from ._types import AnyByStrDict, VcsRef +from ._main import get_questions_data, get_update_data, run_copy, run_recopy, run_update +from ._tools import cast_to_bool, copier_version, try_enum +from ._types import MISSING, AnyByStrDict, VcsRef from .errors import UnsafeTemplateError, UserMessageError @@ -437,6 +446,149 @@ def inner() -> None: return _handle_exceptions(inner) +def _is_computed(when: object) -> bool: + """Check if a ``when`` value is trivially false (computed/derived question). + + String values containing ``{{`` are Jinja2 templates that need runtime + evaluation — these are conditional, not computed. Other strings (e.g. + ``"false"``, ``"no"``) are evaluated via :func:`cast_to_bool`. + """ + if isinstance(when, str) and "{{" in when: + return False + return not cast_to_bool(when) + + +def _enrich_questions(questions: AnyByStrDict) -> AnyByStrDict: + """Add ``computed: true`` marker to questions with trivially false ``when``.""" + enriched: AnyByStrDict = {} + for name, details in questions.items(): + entry = dict(details) + if _is_computed(details.get("when", True)): + entry["computed"] = True + enriched[name] = entry + return enriched + + +def _print_questions_plain(questions: AnyByStrDict) -> None: + """Print template questions in human-readable plain text format.""" + for var_name, details in questions.items(): + when = details.get("when", True) + + # Skip computed/derived questions (when is trivially false) + if _is_computed(when): + continue + + type_name = details.get("type", "") + default = details.get("default", MISSING) + choices = details.get("choices") or [] + help_text = details.get("help", "") + secret = details.get("secret", False) + multiselect = details.get("multiselect", False) + + # Infer type from default if not specified + if not type_name and default is not MISSING: + default_type = type(default).__name__ + type_name = ( + default_type + if default_type in {"str", "int", "float", "bool"} + else "yaml" + ) + elif not type_name: + type_name = "yaml" + + # Build header line + parts = [f"{var_name} ({type_name})"] + if default is MISSING: + parts.append("REQUIRED") + else: + parts.append(f"default: {default}") + print(" ".join(parts)) + + # Detail lines + if choices: + label = "multi-choices" if multiselect else "choices" + if isinstance(choices, str): + # Jinja2 expression — print raw + print(f" {label}: {choices}") + elif isinstance(choices, dict): + print( + f" {label}: {', '.join(str(c) for c in choices.keys())}" + ) + else: + print(f" {label}: {', '.join(str(c) for c in choices)}") + if when is not True: + print(f" when: {when}") + if help_text: + print(f" help: {help_text}") + if secret: + print(" secret: true") + print() + + +@CopierApp.subcommand("inspect") +class CopierInspectSubApp(cli.Application): # type: ignore[misc] + """The ``copier inspect`` subcommand. + + Use this subcommand to inspect a template's questions without + copying or updating. Useful for discovering which ``--data`` + parameters a template expects. + + Conditional questions are shown with their raw ``when`` expressions. + The actual question set may depend on answers to earlier questions. + Questions with ``when: false`` (computed/derived values) are hidden + in plain output but included in JSON/YAML output with a + ``computed: true`` marker. + """ + + DESCRIPTION = "Inspect a template's questions and metadata" + + vcs_ref = cli.SwitchAttr( + ["-r", "--vcs-ref"], + str, + help="Git reference to checkout in `template_src`.", + ) + quiet = cli.Flag(["-q", "--quiet"], help="Suppress status output") + prereleases = cli.Flag( + ["-g", "--prereleases"], + help="Use prereleases to compare template VCS tags.", + ) + output_format = cli.SwitchAttr( + ["--output-format"], + cli.Set("plain", "json", "yaml"), + default="plain", + help="Output format: 'plain' (default), 'json', or 'yaml'.", + ) + + def main(self, template_src: str) -> int: + """Inspect a template's questions. + + Params: + template_src: + Indicate where to get the template from. + + This can be a git URL or a local path. + """ + + def inner() -> None: + questions = get_questions_data( + src_path=template_src, + vcs_ref=try_enum(VcsRef, self.vcs_ref), + use_prereleases=self.prereleases, + ) + if self.quiet: + return + if self.output_format in ("json", "yaml"): + enriched = _enrich_questions(questions) + if self.output_format == "json": + print(json.dumps(enriched, indent=2, default=str)) + else: + print(yaml.dump(enriched, default_flow_style=False, sort_keys=False), end="") + else: + _print_questions_plain(questions) + + return _handle_exceptions(inner) + + @CopierApp.subcommand("check-update") class CopierCheckUpdateSubApp(cli.Application): # type: ignore[misc] """The `copier check-update` subcommand. diff --git a/copier/_main.py b/copier/_main.py index fb9850ad2..c7e6d29f2 100644 --- a/copier/_main.py +++ b/copier/_main.py @@ -1505,6 +1505,30 @@ def _git_commit(self, message: str = "dumb commit") -> None: ) +def get_questions_data( + src_path: str, + vcs_ref: str | VcsRef | None = None, + use_prereleases: bool = False, +) -> AnyByStrDict: + """Get template questions metadata without requiring a full Worker. + + Args: + src_path: Template source (git URL or local path). + vcs_ref: Git reference to checkout in ``src_path``. + use_prereleases: Consider prereleases when detecting the latest tag. + + Returns: + A dict mapping question variable names to their metadata dicts. + """ + template = Template(url=src_path, ref=vcs_ref, use_prereleases=use_prereleases) + try: + return dict(template.questions_data) + except ValueError as e: + raise UserMessageError(str(e)) from e + finally: + template._cleanup() + + def run_copy( src_path: str, dst_path: Path | str = ".", diff --git a/tests/test_cli.py b/tests/test_cli.py index 8ed4cf5ac..bc70313ef 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -534,6 +534,36 @@ def test_check_update_help(capsys: pytest.CaptureFixture[str]) -> None: """) +def test_inspect_help(capsys: pytest.CaptureFixture[str]) -> None: + with patch("plumbum.cli.application.get_terminal_size", return_value=(80, 1)): + _, status = CopierApp.run(["copier", "inspect", "--help"], exit=False) + assert status == 0 + header, body = capsys.readouterr().out.split("\n", 1) + assert header.startswith("copier inspect") + assert body == snapshot("""\ + +Inspect a template's questions and metadata + +Usage: + copier inspect [SWITCHES] template_src + +Meta-switches: + -h, --help Prints this help message and quits + --help-all Prints help messages of all sub-commands and + quits + -v, --version Prints the program's version and quits + +Switches: + -g, --prereleases Use prereleases to compare template VCS + tags. + --output-format VALUE:{json, plain, yaml} Output format: 'plain' (default), + 'json', or 'yaml'.; the default is plain + -q, --quiet Suppress status output + -r, --vcs-ref VALUE:str Git reference to checkout in `template_src`. + +""") + + def test_python_run() -> None: cmd = [sys.executable, "-m", "copier", "--help-all"] assert subprocess.run(cmd, check=True).returncode == 0 diff --git a/tests/test_inspect.py b/tests/test_inspect.py new file mode 100644 index 000000000..51f497601 --- /dev/null +++ b/tests/test_inspect.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +import json + +import pytest +import yaml + +from copier._cli import CopierApp + +from .helpers import build_file_tree + + +@pytest.fixture() +def template_path(tmp_path_factory: pytest.TempPathFactory) -> str: + """Create a template with various question types for testing inspect.""" + root = tmp_path_factory.mktemp("inspect_template") + build_file_tree( + { + (root / "copier.yaml"): """\ + project_name: + type: str + help: The name of the project + + language: + type: str + choices: + - python + - go + - rust + + version: + type: str + default: "1.0.0" + + add_linter: + type: bool + default: false + when: "{{ language == 'python' }}" + help: Include linter configuration? + + secret_token: + type: str + secret: true + help: API token for deployment + + db_connection_string: + type: str + when: false + default: "{{ project_name }}_db" + + computed_zero: + type: int + when: 0 + default: 42 + + tags: + type: yaml + multiselect: true + choices: + - web + - cli + - library + + license: + type: str + choices: + MIT License: mit + Apache 2.0: apache2 + + dynamic_choice: + type: str + choices: "{{ available_options }}" + + count: + default: 42 + + string_when_false: + type: str + when: "false" + default: "should_be_hidden" + """, + (root / "{{ project_name }}" / "README.md.jinja"): """\ + # {{ project_name }} + """, + } + ) + return str(root) + + +class TestInspectPlainOutput: + def test_shows_questions( + self, template_path: str, capsys: pytest.CaptureFixture[str] + ) -> None: + _, status = CopierApp.run( + ["copier", "inspect", template_path], exit=False + ) + assert status == 0 + out = capsys.readouterr().out + assert "project_name (str)" in out + assert "REQUIRED" in out + assert "language (str)" in out + assert "version (str)" in out + assert "default: 1.0.0" in out + + def test_shows_choices( + self, template_path: str, capsys: pytest.CaptureFixture[str] + ) -> None: + _, status = CopierApp.run( + ["copier", "inspect", template_path], exit=False + ) + assert status == 0 + out = capsys.readouterr().out + assert "choices: python, go, rust" in out + + def test_shows_when_condition( + self, template_path: str, capsys: pytest.CaptureFixture[str] + ) -> None: + _, status = CopierApp.run( + ["copier", "inspect", template_path], exit=False + ) + assert status == 0 + out = capsys.readouterr().out + assert "when: {{ language == 'python' }}" in out + + def test_shows_help_text( + self, template_path: str, capsys: pytest.CaptureFixture[str] + ) -> None: + _, status = CopierApp.run( + ["copier", "inspect", template_path], exit=False + ) + assert status == 0 + out = capsys.readouterr().out + assert "help: The name of the project" in out + assert "help: Include linter configuration?" in out + + def test_shows_secret( + self, template_path: str, capsys: pytest.CaptureFixture[str] + ) -> None: + _, status = CopierApp.run( + ["copier", "inspect", template_path], exit=False + ) + assert status == 0 + out = capsys.readouterr().out + assert "secret: true" in out + + def test_shows_multiselect( + self, template_path: str, capsys: pytest.CaptureFixture[str] + ) -> None: + _, status = CopierApp.run( + ["copier", "inspect", template_path], exit=False + ) + assert status == 0 + out = capsys.readouterr().out + assert "multi-choices: web, cli, library" in out + + def test_shows_dict_choices( + self, template_path: str, capsys: pytest.CaptureFixture[str] + ) -> None: + _, status = CopierApp.run( + ["copier", "inspect", template_path], exit=False + ) + assert status == 0 + out = capsys.readouterr().out + assert "choices: MIT License, Apache 2.0" in out + + def test_shows_jinja_expression_choices( + self, template_path: str, capsys: pytest.CaptureFixture[str] + ) -> None: + _, status = CopierApp.run( + ["copier", "inspect", template_path], exit=False + ) + assert status == 0 + out = capsys.readouterr().out + assert "choices: {{ available_options }}" in out + + def test_infers_type_from_default( + self, template_path: str, capsys: pytest.CaptureFixture[str] + ) -> None: + """Type is inferred from default value when not specified.""" + _, status = CopierApp.run( + ["copier", "inspect", template_path], exit=False + ) + assert status == 0 + out = capsys.readouterr().out + assert "count (int)" in out + assert "default: 42" in out + + def test_hides_computed_questions( + self, template_path: str, capsys: pytest.CaptureFixture[str] + ) -> None: + """Questions with trivially false 'when' are hidden in plain output.""" + _, status = CopierApp.run( + ["copier", "inspect", template_path], exit=False + ) + assert status == 0 + out = capsys.readouterr().out + # when: false (YAML bool) + assert "db_connection_string" not in out + # when: 0 (YAML int) + assert "computed_zero" not in out + # when: "false" (YAML string) + assert "string_when_false" not in out + + +class TestInspectJsonOutput: + def test_valid_json( + self, template_path: str, capsys: pytest.CaptureFixture[str] + ) -> None: + _, status = CopierApp.run( + ["copier", "inspect", "--output-format", "json", template_path], + exit=False, + ) + assert status == 0 + data = json.loads(capsys.readouterr().out) + assert "project_name" in data + assert "language" in data + + def test_includes_computed_with_marker( + self, template_path: str, capsys: pytest.CaptureFixture[str] + ) -> None: + """Computed questions are included in JSON with computed: true.""" + _, status = CopierApp.run( + ["copier", "inspect", "--output-format", "json", template_path], + exit=False, + ) + assert status == 0 + data = json.loads(capsys.readouterr().out) + assert data["db_connection_string"]["computed"] is True + assert data["computed_zero"]["computed"] is True + assert data["string_when_false"]["computed"] is True + # Non-computed questions should not have computed marker + assert "computed" not in data["project_name"] + + +class TestInspectYamlOutput: + def test_valid_yaml( + self, template_path: str, capsys: pytest.CaptureFixture[str] + ) -> None: + _, status = CopierApp.run( + ["copier", "inspect", "--output-format", "yaml", template_path], + exit=False, + ) + assert status == 0 + data = yaml.safe_load(capsys.readouterr().out) + assert "project_name" in data + assert "language" in data + + def test_includes_computed_with_marker( + self, template_path: str, capsys: pytest.CaptureFixture[str] + ) -> None: + _, status = CopierApp.run( + ["copier", "inspect", "--output-format", "yaml", template_path], + exit=False, + ) + assert status == 0 + data = yaml.safe_load(capsys.readouterr().out) + assert data["db_connection_string"]["computed"] is True + + +class TestInspectQuiet: + def test_suppresses_output( + self, template_path: str, capsys: pytest.CaptureFixture[str] + ) -> None: + _, status = CopierApp.run( + ["copier", "inspect", "--quiet", template_path], exit=False + ) + assert status == 0 + captured = capsys.readouterr() + assert captured.out == "" + + +class TestInspectEmptyTemplate: + def test_no_questions( + self, tmp_path_factory: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Template with only config (no questions) produces empty output.""" + root = tmp_path_factory.mktemp("empty_template") + build_file_tree( + {(root / "copier.yaml"): "_templates_suffix: .jinja\n"} + ) + _, status = CopierApp.run( + ["copier", "inspect", str(root)], exit=False + ) + assert status == 0 + assert capsys.readouterr().out == "" + + def test_no_questions_json( + self, tmp_path_factory: pytest.TempPathFactory, + capsys: pytest.CaptureFixture[str], + ) -> None: + root = tmp_path_factory.mktemp("empty_template_json") + build_file_tree( + {(root / "copier.yaml"): "_templates_suffix: .jinja\n"} + ) + _, status = CopierApp.run( + ["copier", "inspect", "--output-format", "json", str(root)], + exit=False, + ) + assert status == 0 + assert json.loads(capsys.readouterr().out) == {} + + +class TestInspectErrors: + def test_nonexistent_template( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + _, status = CopierApp.run( + ["copier", "inspect", "/nonexistent/path"], exit=False + ) + assert status == 1 + assert "Local template must be a directory" in capsys.readouterr().err