Skip to content
Merged
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
31 changes: 17 additions & 14 deletions constructor/construct.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -92,32 +96,31 @@ 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)
try:
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())

Expand Down
14 changes: 13 additions & 1 deletion constructor/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions news/1263-render-cli
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
### Enhancements

* Add `constructor --render` CLI option to render `construct.yaml` files. (#1263)

### Bug fixes

* <news item>

### Deprecations

* <news item>

### Docs

* <news item>

### Other

* <news item>
112 changes: 112 additions & 0 deletions tests/test_construct.py
Original file line number Diff line number Diff line change
@@ -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())
Loading