Skip to content
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
2 changes: 2 additions & 0 deletions copier/__init__.py
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'd prefer keeping this function internal until there is real demand for performing this via Python API to minimize the public API surface.

Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
160 changes: 156 additions & 4 deletions copier/_cli.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
Comment on lines +456 to +457
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The Jinja variable start marker {{ can be configured in the template via

_envops:
  variable_start_string: "[["

so it is incorrect to hardcode it here.

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")
Comment on lines +501 to +502
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Question can be conditionally required by rendering the special UNSET variable. See the example of the default field at https://copier.readthedocs.io/en/stable/configuring/#advanced-prompt-formatting:

database_engine:
    type: str
    help: Database engine
    choices:
        - postgres
        - mysql
        - other
    default: postgres

database_url:
    type: str
    help: Database URL
    default: >-
        {%- if database_engine == 'postgres' -%}
        postgresql://user:pass@localhost:5432/dbname
        {%- elif database_engine == 'mysql' -%}
        mysql://user:pass@localhost:3306/dbname
        {%- else -%}
        {{ UNSET }}
        {%- endif -%}
    # Simplified for illustration purposes
    validator: "{% if '://' not in database_url %}Invalid{% endif %}"

This means, printing REQUIRED according to this logic isn't guaranteed to be accurate.

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())}"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Listing the choice keys when choices is a dict means that the "labels" are listed but not the values/answers. When passing answers via -d, --data or --data-file flag, the dict value must be passed.

This leads to a more fundamental question: How do you intend to print choice values for JSON/YAML questions when they are complex (multiline) objects?

)
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")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What is the point of this subcommand when its output is suppressed? I think this flag makes no sense here and should be removed.

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.
Expand Down
24 changes: 24 additions & 0 deletions copier/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ".",
Expand Down
30 changes: 30 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading