-
-
Notifications
You must be signed in to change notification settings - Fork 250
feat: add copier inspect subcommand #2575
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
|
|
@@ -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 | ||
|
Comment on lines
+456
to
+457
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Jinja variable start marker _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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question can be conditionally required by rendering the special 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 |
||
| 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())}" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Listing the choice keys when 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") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
|
||
There was a problem hiding this comment.
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.