Skip to content

Commit

Permalink
feat: do variable expansion when loading project (#101)
Browse files Browse the repository at this point in the history
Perform environment expansion on the yaml dict that is used to create
the Project. This expands variables like CRAFT_PROJECT_VERSION (and the
many more provided by craft-parts).

Also add a "hook" for app-specific modifications to the yaml dict in the
form of the `_extra_yaml_transform()` method (a no-op in the base
Application).
  • Loading branch information
tigarmo authored Oct 12, 2023
1 parent ea5ec44 commit 4c3162b
Show file tree
Hide file tree
Showing 7 changed files with 169 additions and 2 deletions.
57 changes: 56 additions & 1 deletion craft_application/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,13 @@ def project(self) -> models.Project:
# Current working directory contains the project file
project_file = pathlib.Path(f"{self.app.name}.yaml").resolve()
craft_cli.emit.debug(f"Loading project file '{project_file!s}'")
return self.app.ProjectClass.from_yaml_file(project_file)

with project_file.open() as file:
yaml_data = util.safe_yaml_load(file)

yaml_data = self._transform_project_yaml(yaml_data)

return self.app.ProjectClass.from_yaml_data(yaml_data, project_file)

def run_managed(self, platform: str | None, build_for: str | None) -> None:
"""Run the application in a managed instance."""
Expand Down Expand Up @@ -364,6 +370,55 @@ def _emit_error(

craft_cli.emit.error(error)

def _transform_project_yaml(self, yaml_data: dict[str, Any]) -> dict[str, Any]:
"""Update the project's yaml data with runtime properties.
Performs task such as environment expansion. Note that this transforms
``yaml_data`` in-place.
"""
# Perform variable expansion.
self._expand_environment(yaml_data)

# Perform extra, application-specific transformations.
yaml_data = self._extra_yaml_transform(yaml_data)

return yaml_data

def _expand_environment(self, yaml_data: dict[str, Any]) -> None:
"""Perform expansion of project environment variables."""
project_vars = self._project_vars(yaml_data)

info = craft_parts.ProjectInfo(
application_name=self.app.name, # not used in environment expansion
cache_dir=pathlib.Path(), # not used in environment expansion
project_name=yaml_data.get("name", ""),
project_dirs=craft_parts.ProjectDirs(work_dir=self._work_dir),
project_vars=project_vars,
)

self._set_global_environment(info)

craft_parts.expand_environment(yaml_data, info=info)

def _project_vars(self, yaml_data: dict[str, Any]) -> dict[str, str]:
"""Return a dict with project-specific variables, for a craft_part.ProjectInfo."""
return {"version": cast(str, yaml_data["version"])}

def _set_global_environment(self, info: craft_parts.ProjectInfo) -> None:
"""Populate the ProjectInfo's global environment."""
info.global_environment.update(
{
"CRAFT_PROJECT_VERSION": info.get_project_var("version", raw_read=True),
}
)

def _extra_yaml_transform(self, yaml_data: dict[str, Any]) -> dict[str, Any]:
"""Perform additional transformations on a project's yaml data.
Note: subclasses should return a new dict and keep the parameter unmodified.
"""
return yaml_data


def _filter_plan(
build_plan: list[BuildInfo], platform: str | None, build_for: str | None
Expand Down
11 changes: 10 additions & 1 deletion craft_application/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,20 @@ def from_yaml_file(cls, path: pathlib.Path) -> Self:
"""Instantiate this model from a YAML file."""
with path.open() as file:
data = safe_yaml_load(file)
return cls.from_yaml_data(data, path)

@classmethod
def from_yaml_data(cls, data: dict[str, Any], filepath: pathlib.Path) -> Self:
"""Instantiate this model from already-loaded YAML data.
:param data: The dict of model properties.
:param filepath: The filepath corresponding to ``data``, for error reporting.
"""
try:
return cls.unmarshal(data)
except pydantic.ValidationError as err:
raise errors.CraftValidationError.from_pydantic(
err, file_name=path.name
err, file_name=filepath.name
) from None

def to_yaml_file(self, path: pathlib.Path) -> None:
Expand Down
1 change: 1 addition & 0 deletions tests/integration/data/valid_projects/environment/stdout
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Packed package.tar.zst
14 changes: 14 additions & 0 deletions tests/integration/data/valid_projects/environment/testcraft.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: environment-project
summary: A project with environment variables
version: 1.2.3
base: ["ubuntu", "22.04"]

parts:
my-part:
plugin: nil
override-build: |
target_file=${CRAFT_PART_INSTALL}/variables.yaml
touch $target_file
echo "project_name: \"${CRAFT_PROJECT_NAME}\"" >> $target_file
echo "project_dir: \"${CRAFT_PROJECT_DIR}\"" >> $target_file
echo "project_version: \"${CRAFT_PROJECT_VERSION}\"" >> $target_file
27 changes: 27 additions & 0 deletions tests/integration/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import craft_cli
import pytest
import pytest_check
from craft_application.util import yaml
from typing_extensions import override


Expand Down Expand Up @@ -230,3 +231,29 @@ def test_invalid_command_argument(monkeypatch, capsys, app):

stdout, stderr = capsys.readouterr()
assert stderr == expected_stderr


def test_global_environment(
create_app,
monkeypatch,
tmp_path,
):
"""Test that the global environment is correctly populated during the build process."""
monkeypatch.chdir(tmp_path)
shutil.copytree(VALID_PROJECTS_DIR / "environment", tmp_path, dirs_exist_ok=True)

# Run in destructive mode
monkeypatch.setattr("sys.argv", ["testcraft", "prime", "--destructive-mode"])
app = create_app()
app.run()

# The project's build step stages a "variables.yaml" file containing the values of
# variables taken from the global environment.
variables_yaml = tmp_path / "stage/variables.yaml"
assert variables_yaml.is_file()
with variables_yaml.open() as file:
variables = yaml.safe_yaml_load(file)

assert variables["project_name"] == "environment-project"
assert variables["project_dir"] == str(tmp_path)
assert variables["project_version"] == "1.2.3"
31 changes: 31 additions & 0 deletions tests/unit/models/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from typing import Optional

import pytest
from craft_application import util
from craft_application.errors import CraftValidationError
from craft_application.models import Project

Expand Down Expand Up @@ -133,6 +134,36 @@ def test_from_yaml_file_failure(project_file, error_class):
Project.from_yaml_file(project_file)


@pytest.mark.parametrize(
("project_file", "expected"),
[
(PROJECTS_DIR / "basic_project.yaml", BASIC_PROJECT),
(PROJECTS_DIR / "full_project.yaml", FULL_PROJECT),
],
)
def test_from_yaml_data_success(project_file, expected):
with project_file.open() as file:
data = util.safe_yaml_load(file)

actual = Project.from_yaml_data(data, project_file)

assert expected == actual


@pytest.mark.parametrize(
("project_file", "error_class"),
[
(PROJECTS_DIR / "invalid_project.yaml", CraftValidationError),
],
)
def test_from_yaml_data_failure(project_file, error_class):
with project_file.open() as file:
data = util.safe_yaml_load(file)

with pytest.raises(error_class):
Project.from_yaml_data(data, project_file)


@pytest.mark.parametrize(
("project", "expected_file"),
[
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,3 +483,33 @@ def test_work_dir_project_managed(monkeypatch, app_metadata, fake_services):
# Make sure the project is loaded correctly (from the cwd)
assert app.project.name == "myproject"
assert app.project.version == "1.0"


@pytest.fixture()
def environment_project(monkeypatch, tmp_path):
project_dir = tmp_path / "project"
project_dir.mkdir()
project_path = project_dir / "testcraft.yaml"
project_path.write_text(
dedent(
"""
name: myproject
version: 1.2.3
parts:
mypart:
plugin: nil
source-tag: v$CRAFT_PROJECT_VERSION
"""
)
)
monkeypatch.chdir(project_dir)

return project_path


@pytest.mark.usefixtures("environment_project")
def test_application_expand_environment(app_metadata, fake_services):
app = application.Application(app_metadata, fake_services)
project = app.project

assert project.parts["mypart"]["source-tag"] == "v1.2.3"

0 comments on commit 4c3162b

Please sign in to comment.