From 78a58a2a53203b833747669af053001cbb806cfa Mon Sep 17 00:00:00 2001 From: Marco Esters Date: Mon, 15 Jun 2026 09:39:02 -0700 Subject: [PATCH 1/3] Add recipe render CLI --- constructor/construct.py | 31 +++++++++++++++++-------------- constructor/main.py | 14 +++++++++++++- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/constructor/construct.py b/constructor/construct.py index 1bb411ec6..bca76e357 100644 --- a/constructor/construct.py +++ b/constructor/construct.py @@ -16,6 +16,7 @@ from functools import partial from os.path import dirname from pathlib import Path +from typing import TYPE_CHECKING from jsonschema import Draft202012Validator, validators from jsonschema.exceptions import ValidationError @@ -24,6 +25,9 @@ from constructor.exceptions import UnableToParse, UnableToParseMissingJinja2, YamlParsingError from constructor.utils import yaml +if TYPE_CHECKING: + import os + logger = logging.getLogger(__name__) HERE = Path(__file__).parent @@ -92,11 +96,18 @@ def select_lines(data, namespace): return "\n".join(lines) + "\n" -# adapted from conda-build -def yamlize(data, directory, content_filter): +def render(path: os.PathLike, platform: str) -> str: + try: + with open(path) as fi: + data = fi.read() + except OSError: + sys.exit("Error: could not open '%s' for reading" % path) + directory = dirname(path) + content_filter = partial(select_lines, namespace=ns_platform(platform)) data = content_filter(data) try: - return yaml.load(data) + yaml.load(data) + return data except YAMLError as e: if ("{{" not in data) and ("{%" not in data): raise UnableToParse(original=e) @@ -104,20 +115,12 @@ def yamlize(data, directory, content_filter): from constructor.jinja import render_jinja_for_input_file except ImportError as ex: raise UnableToParseMissingJinja2(original=ex) - data = render_jinja_for_input_file(data, directory, content_filter) - return yaml.load(data) + return render_jinja_for_input_file(data, directory, content_filter) -def parse(path, platform): - try: - with open(path) as fi: - data = fi.read() - except OSError: - sys.exit("Error: could not open '%s' for reading" % path) - directory = dirname(path) - content_filter = partial(select_lines, namespace=ns_platform(platform)) +def parse(path: os.PathLike, platform: str): try: - res = yamlize(data, directory, content_filter) + res = yaml.load(render(path, platform)) except YamlParsingError as e: sys.exit(e.error_msg()) diff --git a/constructor/main.py b/constructor/main.py index 6c07fe2e7..23ab3afa3 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -28,6 +28,7 @@ from .conda_interface import VersionOrder as Version from .construct import SCHEMA_PATH, ns_platform from .construct import parse as construct_parse +from .construct import render as construct_render from .construct import verify as construct_verify from .fcp import main as fcp_main from .utils import StandaloneExe, check_version, identify_conda_exe, normalize_path, yield_lines @@ -544,6 +545,11 @@ def main(argv=None): default=False, action="store_true", ) + p.add_argument( + "--render", + help="Parse and render the construct.yaml file", + action="store_true", + ) p.add_argument("-v", "--verbose", action="store_true") @@ -581,7 +587,8 @@ def main(argv=None): ) args = p.parse_args(argv) - logger.info("Got the following cli arguments: '%s'", args) + if not args.render: + logger.info("Got the following cli arguments: '%s'", args) if args.verbose or args.debug: logging.getLogger("constructor").setLevel(logging.DEBUG) @@ -604,6 +611,11 @@ def main(argv=None): if not os.path.isfile(full_config_path): p.error("no such file: %s" % full_config_path) + if args.render: + platform = args.platform or cc_platform + print(construct_render(full_config_path, platform=platform)) + return + conda_exe = args.conda_exe conda_exe_default_path = os.path.join(sys.prefix, "standalone_conda", "conda.exe") conda_exe_default_path = normalize_path(conda_exe_default_path) From 1d212ea148f4a479f90fae5dfd67e27f9aff6972 Mon Sep 17 00:00:00 2001 From: Marco Esters Date: Mon, 15 Jun 2026 09:40:41 -0700 Subject: [PATCH 2/3] Add unit tests --- tests/test_construct.py | 112 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/test_construct.py diff --git a/tests/test_construct.py b/tests/test_construct.py new file mode 100644 index 000000000..6d3274c16 --- /dev/null +++ b/tests/test_construct.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from constructor.conda_interface import cc_platform +from constructor.construct import parse as construct_parse +from constructor.construct import render as construct_render + +if TYPE_CHECKING: + from pathlib import Path + + +CONSTRUCT_YAML = """\ +name: Installer +version: 1.0.0 +specs: + - python + - miniforge_console_shortcut # [win] +""" + +CONSTRUCT_YAML_JINJA = """\ +name: Installer +version: 1.0.0 +specs: + - python +{%- if os.environ.get("__CONSTRUCTOR_INCLUDE_CONDA__") %} + - conda +{%- endif %} +""" + +CONSTRUCY_YAML_BROKEN = """\ +name: Installer +version: 1.0.0 +specs +""" + + +@pytest.fixture +def construct_yaml_file(tmp_path: Path) -> str: + file_path = tmp_path / "construct.yaml" + file_path.write_text(CONSTRUCT_YAML) + return str(file_path) + + +@pytest.fixture +def construct_yaml_file_jinja(tmp_path: Path) -> str: + file_path = tmp_path / "construct.yaml" + file_path.write_text(CONSTRUCT_YAML_JINJA) + return str(file_path) + + +@pytest.mark.parametrize("platform", ("linux-64", "win-64")) +def test_render(platform: str, construct_yaml_file: Path): + rendered = construct_render(construct_yaml_file, platform) + rendered_lines = rendered.splitlines() + expected = CONSTRUCT_YAML.splitlines()[:-1] + if platform == "win-64": + expected.append(" - miniforge_console_shortcut") + assert rendered_lines == expected + + +@pytest.mark.parametrize("platform", ("linux-64", "win-64")) +def test_parse(platform: str, construct_yaml_file: Path): + parsed = construct_parse(construct_yaml_file, platform) + assert parsed["name"] == "Installer" + assert parsed["version"] == "1.0.0" + expected_specs = [ + "python", + *(("miniforge_console_shortcut",) if platform == "win-64" else ()), + ] + assert parsed["specs"] == expected_specs + + +@pytest.mark.parametrize("include_conda", (True, False)) +def test_render_jinja( + include_conda: bool, construct_yaml_file_jinja: Path, monkeypatch: pytest.MonkeyPatch +): + if include_conda: + monkeypatch.setenv("__CONSTRUCTOR_INCLUDE_CONDA__", "1") + rendered = construct_render(construct_yaml_file_jinja, cc_platform) + rendered_lines = rendered.splitlines() + expected = CONSTRUCT_YAML_JINJA.splitlines()[:-3] + if include_conda: + expected.append(" - conda") + assert rendered_lines == expected + + +@pytest.mark.parametrize("include_conda", (True, False)) +def test_parse_jinja( + include_conda: bool, construct_yaml_file_jinja: Path, monkeypatch: pytest.MonkeyPatch +): + if include_conda: + monkeypatch.setenv("__CONSTRUCTOR_INCLUDE_CONDA__", "1") + parsed = construct_parse(construct_yaml_file_jinja, cc_platform) + assert parsed["name"] == "Installer" + assert parsed["version"] == "1.0.0" + expected_specs = [ + "python", + *(("conda",) if include_conda else ()), + ] + assert parsed["specs"] == expected_specs + + +def test_parse_error(tmp_path): + construct_yaml_file = tmp_path / "construct.yaml" + construct_yaml_file.write_text(CONSTRUCY_YAML_BROKEN) + with pytest.raises(SystemExit) as exc: + construct_parse(construct_yaml_file, cc_platform) + assert exc.value.code != 0 + assert "Unable to parse" in str(exc.getrepr()) From ddc2de1c6508a8830b7799f30cf16fb3cf961995 Mon Sep 17 00:00:00 2001 From: Marco Esters Date: Mon, 15 Jun 2026 09:42:26 -0700 Subject: [PATCH 3/3] Add news --- news/1263-render-cli | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 news/1263-render-cli diff --git a/news/1263-render-cli b/news/1263-render-cli new file mode 100644 index 000000000..22ce21ea8 --- /dev/null +++ b/news/1263-render-cli @@ -0,0 +1,19 @@ +### Enhancements + +* Add `constructor --render` CLI option to render `construct.yaml` files. (#1263) + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* + +### Other + +*