From 9cae98e321b52217f4adb06d43f4b525e31ccd55 Mon Sep 17 00:00:00 2001 From: Andrei Navrotski Date: Fri, 10 Jul 2026 15:15:26 +0300 Subject: [PATCH] feat(cli): add `copier schema` command to generate JSON Schema for copier.yml Adds a new CLI subcommand `copier schema` that outputs a JSON Schema (Draft 2020-12) describing the copier.yml template format. --- README.md | 2 + copier/_cli.py | 43 +++++- copier/_schema.py | 362 ++++++++++++++++++++++++++++++++++++++++++++ copier/_template.py | 4 +- docs/configuring.md | 15 ++ tests/test_cli.py | 23 +++ 6 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 copier/_schema.py diff --git a/README.md b/README.md index 95e3268c7..71bee8ee8 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ To generate a project from the template: copier copy path/to/project/template path/to/destination ``` + Generate a JSON Schema for your `copier.yml` with `copier schema`. + - Or in Python code, programmatically: ```python diff --git a/copier/_cli.py b/copier/_cli.py index 0365f9364..bcb13aa32 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. @@ -48,6 +48,15 @@ copier check-update ``` +- `copier schema` to generate a JSON Schema for + `copier.yml` files. + + !!! example + + ```sh + copier schema -o copier.schema.json + ``` + Below are the docs of each one of those. CLI help generated from `copier --help-all`: @@ -565,3 +574,35 @@ def inner() -> int: return 0 return _handle_exceptions(inner) + + +@CopierApp.subcommand("schema") +class CopierSchemaSubApp(cli.Application): + """The `copier schema` subcommand. + + Use this subcommand to generate a JSON Schema for copier.yml files. + The schema can be used by IDEs for autocompletion, hints, and validation. + """ + + DESCRIPTION = "Generate a JSON Schema for copier.yml files" + + output = cli.SwitchAttr( + ["-o", "--output"], + help="Write the schema to a file path instead of stdout.", + ) + + def main(self) -> int: + """Generate and output the JSON Schema.""" + + def inner() -> None: + from ._schema import generate_copier_yml_schema + + schema = generate_copier_yml_schema() + text = json.dumps(schema, indent=2) + "\n" + + if self.output: + Path(self.output).write_text(text) + else: + print(text) + + return _handle_exceptions(inner) diff --git a/copier/_schema.py b/copier/_schema.py new file mode 100644 index 000000000..6b2e2d8c1 --- /dev/null +++ b/copier/_schema.py @@ -0,0 +1,362 @@ +"""JSON Schema generation for copier.yml files.""" + +from __future__ import annotations + +from typing import Any + +_QUESTION_TYPES = ("bool", "float", "int", "json", "path", "str", "yaml") + +_BOOL_STR = {"oneOf": [{"type": "boolean"}, {"type": "string"}]} + +_SCALAR_OR_TEMPLATE = { + "oneOf": [ + {"type": "boolean"}, + {"type": "integer"}, + {"type": "number"}, + {"type": "string"}, + ] +} + +_TASK_DEFS: dict[str, Any] = { + "task": { + "oneOf": [ + { + "type": "string", + "description": "Shell command to run.", + }, + { + "type": "array", + "items": {"type": "string"}, + "description": "Command with arguments (bypasses shell).", + }, + { + "type": "object", + "description": "Task with options.", + "properties": { + "command": { + "type": "string", + "description": "The command to run.", + }, + "when": { + "type": "string", + "description": "Jinja template condition.", + }, + "working_directory": { + "type": "string", + "description": "Directory to run in (relative to project root).", + }, + }, + "required": ["command"], + "additionalProperties": False, + }, + ] + }, + "migration": { + "oneOf": [ + { + "type": "string", + "description": "Shell command to run as migration.", + }, + { + "type": "array", + "items": {"type": "string"}, + "description": "Command with arguments.", + }, + { + "type": "object", + "description": "Migration with options.", + "properties": { + "command": { + "type": "string", + "description": "The command to run.", + }, + "version": { + "type": "string", + "description": "PEP 440 version this migration applies to.", + }, + "when": { + "type": "string", + "description": "Jinja template condition.", + }, + "working_directory": { + "type": "string", + "description": "Directory to run in (relative to project root).", + }, + }, + "required": ["command"], + "additionalProperties": False, + }, + ] + }, +} + +_QUESTION_SHORTHAND: dict[str, Any] = { + "title": "Shorthand question", + "description": ( + "A question defined as just its default value. " + "The type is auto-detected from the value." + ), +} + +_QUESTION_FULL: dict[str, Any] = { + "title": "Full question", + "type": "object", + "description": "A question with explicit options.", + "properties": { + "type": { + "type": "string", + "enum": list(_QUESTION_TYPES), + "description": ("Question type. Auto-detected from default if not set."), + }, + "default": { + "description": "Default value. Can be a Jinja template.", + }, + "help": { + "type": "string", + "description": "Help text explaining the question.", + }, + "choices": { + "oneOf": [ + { + "type": "array", + "description": "List of allowed values.", + "items": {}, + }, + { + "type": "object", + "description": "Mapping of display labels to stored values.", + }, + { + "type": "string", + "description": "Jinja template that renders to a list or mapping.", + }, + ], + "description": ( + "Restrict answers to a set of choices. " + "Can be a list, a dict (label -> value), or a Jinja template." + ), + }, + "multiselect": { + "type": "boolean", + "description": "Allow multiple selections. Only meaningful with choices.", + }, + "multiline": { + "oneOf": [ + {"type": "boolean"}, + {"type": "string"}, + ], + "description": ( + "Allow multiline input. " + "Defaults to True for json/yaml types, False otherwise." + ), + }, + "placeholder": { + "type": "string", + "description": "Ghost text shown when input is empty.", + }, + "qmark": { + "type": "string", + "description": "Custom mark displayed before the question prompt.", + }, + "secret": { + "type": "boolean", + "description": ("Hide input with asterisks and exclude from answers file."), + }, + "validator": { + "type": "string", + "description": ( + "Jinja template for validation. " + "Render nothing if valid, or an error message." + ), + }, + "when": { + "oneOf": [ + {"type": "boolean"}, + {"type": "string"}, + ], + "description": ( + "Condition that skips the question if False. " + "Can be a boolean or a Jinja template." + ), + }, + }, + "additionalProperties": False, +} + + +def generate_copier_yml_schema() -> dict[str, Any]: + """Generate a JSON Schema for copier.yml files. + + Returns: + A JSON Schema (Draft 2020-12) as a dict. + """ + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://copier.readthedocs.io/en/latest/schemas/copier.schema.json", + "title": "Copier Template Configuration", + "description": ( + "Schema for copier.yml template configuration files used by Copier." + ), + "type": ["object", "string"], + "properties": { + "$schema": { + "type": "string", + "format": "uri", + "description": ("JSON Schema URL for IDE validation support."), + }, + "_answers_file": { + "type": "string", + "description": ( + "Path to the answers file, relative to the project root. " + "Default: .copier-answers.yml" + ), + }, + "_envops": { + "type": "object", + "description": "Jinja2 environment options.", + "properties": { + "autoescape": {"type": "boolean"}, + "block_start_string": {"type": "string"}, + "block_end_string": {"type": "string"}, + "variable_start_string": {"type": "string"}, + "variable_end_string": {"type": "string"}, + "comment_start_string": {"type": "string"}, + "comment_end_string": {"type": "string"}, + "keep_trailing_newline": { + "type": "boolean", + "description": "Default: true", + }, + "undefined": { + "type": "string", + "enum": [ + "jinja2.Undefined", + "jinja2.StrictUndefined", + ], + }, + "line_statement_prefix": {"type": "string"}, + "line_comment_prefix": {"type": "string"}, + "trim_blocks": {"type": "boolean"}, + "lstrip_blocks": {"type": "boolean"}, + "newline_sequence": { + "type": "string", + "enum": ["\n", "\r\n", "\r"], + }, + "optimized": {"type": "boolean"}, + }, + "additionalProperties": False, + }, + "_exclude": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Shell-style patterns for files/folders to exclude " + "from the rendered project." + ), + }, + "_external_data": { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": ( + "Map of variable names to YAML file paths (relative to " + "project root) for lazy-loaded external data." + ), + }, + "_jinja_extensions": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Additional Jinja2 extensions to load. " + "Specified as dotted Python paths." + ), + }, + "_message_after_copy": { + "type": "string", + "description": ( + "Message printed after a copy or recopy succeeds. " + "Can be a Jinja template." + ), + }, + "_message_after_update": { + "type": "string", + "description": ( + "Message printed after an update succeeds. Can be a Jinja template." + ), + }, + "_message_before_copy": { + "type": "string", + "description": ( + "Message printed before a copy or recopy. Can be a Jinja template." + ), + }, + "_message_before_update": { + "type": "string", + "description": ( + "Message printed before an update. Can be a Jinja template." + ), + }, + "_migrations": { + "type": "array", + "items": {"$ref": "#/$defs/migration"}, + "description": ( + "Migration tasks run during updates. " + "Items can be a string, a list of strings, or a dict." + ), + }, + "_min_copier_version": { + "type": "string", + "description": ("Minimum Copier version required (PEP 440 format)."), + }, + "_preserve_symlinks": { + "type": "boolean", + "description": ( + "Preserve symlinks as symlinks in the rendered project. " + "Default: false." + ), + }, + "_secret_questions": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "List of question names to treat as secret. " + "Their values are hidden from the answers file." + ), + }, + "_skip_if_exists": { + "type": "array", + "items": {"type": "string"}, + "description": ("Patterns for files to skip if they already exist."), + }, + "_subdirectory": { + "type": "string", + "description": ( + "Subdirectory within the template where the real " + "template code resides." + ), + }, + "_tasks": { + "type": "array", + "items": {"$ref": "#/$defs/task"}, + "description": ( + "Post-render tasks to execute. " + "Items can be a string, a list of strings, or a dict." + ), + }, + "_templates_suffix": { + "type": "string", + "description": ( + "File suffix that triggers Jinja rendering. Default: .jinja" + ), + }, + }, + "patternProperties": { + "^(?!_)(?!\\$schema)": { + "title": "Question", + "anyOf": [ + _QUESTION_SHORTHAND, + _QUESTION_FULL, + ], + }, + }, + "additionalProperties": False, + "$defs": _TASK_DEFS, + } diff --git a/copier/_template.py b/copier/_template.py index fbe82d563..092194b43 100644 --- a/copier/_template.py +++ b/copier/_template.py @@ -124,7 +124,9 @@ def _include(loader: yaml.Loader, node: yaml.Node) -> Any: if option in result: merged_options[option].extend(result[option]) - return dict(ChainMap(dict(merged_options), *reversed(flattened_result))) + result = dict(ChainMap(dict(merged_options), *reversed(flattened_result))) + result.pop("$schema", None) + return result def verify_copier_version(version_str: str) -> None: diff --git a/docs/configuring.md b/docs/configuring.md index a32f40123..5ad5577b7 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -40,6 +40,21 @@ for two purposes: - [Applying template settings](#available-settings) (excluding files, setting arguments defaults, etc.). +### IDE support with `$schema` + +You can add a `$schema` key to your `copier.yml` to enable autocompletion, inline +documentation, and validation in compatible IDEs (VS Code, JetBrains, etc.): + +```yaml +$schema: ./copier.schema.json +``` + +To generate the schema, run: + +```sh +copier schema -o copier.schema.json +``` + ### Questions For each key found, Copier will prompt the user to fill or confirm the values before diff --git a/tests/test_cli.py b/tests/test_cli.py index bd3566765..8f4514187 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -646,3 +646,26 @@ def test_data_with_multiselect_choice_question( ) assert run_result[1] == 0 assert load_answersfile_data(dst) == {"_src_path": str(src), "q": [1, 3]} + + +def test_schema_output() -> None: + _, status = CopierApp.run(["copier", "schema"], exit=False) + assert status == 0 + + +def test_schema_file(tmp_path: Path) -> None: + schema_path = tmp_path / "copier.schema.json" + _, status = CopierApp.run( + ["copier", "schema", "--output", str(schema_path)], exit=False + ) + assert status == 0 + assert schema_path.is_file() + import json + + schema = json.loads(schema_path.read_text()) + assert schema["$schema"].startswith("https://json-schema.org/draft/2020-12") + assert "$defs" in schema + assert "task" in schema["$defs"] + assert "migration" in schema["$defs"] + assert "$schema" in schema["properties"] + assert "_tasks" in schema["properties"]