Skip to content
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

feat: implement adoptable project variables #217

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
16 changes: 10 additions & 6 deletions craft_application/application.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# This file is part of craft_application.
#
# Copyright 2023 Canonical Ltd.
# Copyright 2023-2024 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License version 3, as
Expand Down Expand Up @@ -75,6 +75,7 @@ class AppMetadata:
source_ignore_patterns: list[str] = field(default_factory=lambda: [])
managed_instance_project_path = pathlib.PurePosixPath("/root/project")
features: AppFeatures = AppFeatures()
project_variables: list[str] = field(default_factory=lambda: ["version"])

ProjectClass: type[models.Project] = models.Project
BuildPlannerClass: type[models.BuildPlanner] = field(
Expand Down Expand Up @@ -570,22 +571,25 @@ def _transform_project_yaml(

def _expand_environment(self, yaml_data: dict[str, Any]) -> None:
"""Perform expansion of project environment variables."""
project_vars = self._project_vars(yaml_data)
environment_vars = self._get_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,
project_vars=environment_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 _get_project_vars(self, yaml_data: dict[str, Any]) -> dict[str, str]:
"""Return a dict with project variables to be expanded."""
pvars: dict[str, str] = {}
for var in self.app.project_variables:
pvars[var] = yaml_data.get(var, "")
return pvars

def _set_global_environment(self, info: craft_parts.ProjectInfo) -> None:
"""Populate the ProjectInfo's global environment."""
Expand Down
4 changes: 4 additions & 0 deletions craft_application/launchpad/launchpad.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ def get_recipe(
name: str,
owner: str | None,
) -> models.SnapRecipe: ...

@overload
def get_recipe(
self,
Expand All @@ -114,6 +115,7 @@ def get_recipe(
owner: str | None,
project: str,
) -> models.CharmRecipe: ...

def get_recipe(
self,
type_: models.RecipeType | str,
Expand Down Expand Up @@ -171,10 +173,12 @@ def new_project(

@overload
def get_repository(self, *, path: str) -> models.GitRepository: ...

@overload
def get_repository(
self, *, name: str, owner: str | None = None, project: str | None = None
) -> models.GitRepository: ...

def get_repository(
self,
*,
Expand Down
22 changes: 20 additions & 2 deletions craft_application/models/project.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# This file is part of craft-application.
#
# Copyright 2023 Canonical Ltd.
# Copyright 2023-2024 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License version 3, as
Expand Down Expand Up @@ -78,7 +78,7 @@ class Project(CraftBaseModel):

name: ProjectName
title: ProjectTitle | None
version: VersionStr
version: VersionStr | None
summary: SummaryStr | None
description: str | None

Expand All @@ -91,10 +91,28 @@ class Project(CraftBaseModel):
source_code: AnyUrl | None
license: str | None

adopt_info: str | None

parts: dict[str, dict[str, Any]] # parts are handled by craft-parts

package_repositories: list[dict[str, Any]] | None

@pydantic.root_validator( # pyright: ignore[reportUnknownMemberType,reportUntypedFunctionDecorator]
pre=True
)
@classmethod
def _validate_adopt_info(cls, values: dict[str, Any]) -> dict[str, Any]:
if values.get("version") is None and values.get("adopt-info") is None:
raise ValueError(
"Required field 'version' is not set and 'adopt-info' not used."
)

adopted_part = values.get("adopt-info")
if adopted_part is not None and adopted_part not in values.get("parts", {}):
raise ValueError("'adopt-info' does not reference a valid part.")

return values

@pydantic.validator( # pyright: ignore[reportUnknownMemberType,reportUntypedFunctionDecorator]
"parts", each_item=True
)
Expand Down
32 changes: 31 additions & 1 deletion craft_application/services/lifecycle.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# This file is part of craft-application.
#
# Copyright 2023 Canonical Ltd.
# Copyright 2023-2024 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License version 3, as
Expand Down Expand Up @@ -162,6 +162,14 @@ def _init_lifecycle_manager(self) -> LifecycleManager:
self._project.package_repositories
)

pvars: dict[str, str] = {}
for var in self._app.project_variables:
pvars[var] = getattr(self._project, var) or ""
self._project_vars = pvars

emit.debug(f"Project vars: {self._project_vars}")
emit.debug(f"Adopting part: {self._project.adopt_info}")

try:
return LifecycleManager(
{"parts": self._project.parts},
Expand All @@ -171,6 +179,8 @@ def _init_lifecycle_manager(self) -> LifecycleManager:
work_dir=self._work_dir,
ignore_local_sources=self._app.source_ignore_patterns,
parallel_build_count=self._get_parallel_build_count(),
project_vars_part_name=self._project.adopt_info,
project_vars=self._project_vars,
**self._manager_kwargs,
)
except PartsError as err:
Expand Down Expand Up @@ -213,6 +223,7 @@ def run(self, step_name: str | None, part_names: list[str] | None = None) -> Non
emit.progress(message)
with emit.open_stream() as stream:
aex.execute(action, stdout=stream, stderr=stream)

except PartsError as err:
raise errors.PartsLifecycleError.from_parts_error(err) from err
except RuntimeError as err:
Expand All @@ -222,6 +233,9 @@ def run(self, step_name: str | None, part_names: list[str] | None = None) -> Non
except Exception as err: # noqa: BLE001 - Converting general error.
raise errors.PartsLifecycleError(f"Unknown error: {str(err)}") from err

emit.progress("Updating project metadata")
self._update_project_metadata()

def post_prime(self, step_info: StepInfo) -> bool:
"""Perform any necessary post-lifecycle modifications to the prime directory.

Expand Down Expand Up @@ -268,6 +282,22 @@ def __repr__(self) -> str:
f"{work_dir=}, {cache_dir=}, {build_for=}, **{self._manager_kwargs!r})"
)

def _update_project_metadata(self) -> None:
"""Replace project fields with values adopted during the lifecycle."""
self._update_project_variables()

def _update_project_variables(self) -> None:
"""Replace project fields with values set using craftctl."""
update_vars: dict[str, str] = {}
for var in self._app.project_variables:
value = self.project_info.get_project_var(var)
if not value:
raise errors.PartsLifecycleError(f"Project field '{var}' was not set.")
update_vars[var] = value

emit.debug(f"Update project variables: {update_vars}")
self._project.__dict__.update(update_vars)

def _verify_parallel_build_count(
self, env_name: str, parallel_build_count: int | str
) -> int:
Expand Down
1 change: 0 additions & 1 deletion craft_application/services/remotebuild.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
from craft_application.services import base

if TYPE_CHECKING: # pragma: no cover

from craft_application import AppMetadata, ServiceFactory

DEFAULT_POLL_INTERVAL = 30
Expand Down
3 changes: 2 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ def fake_project() -> models.Project:
parts={"my-part": {"plugin": "nil"}},
platforms={"foo": Platform(build_on=arch, build_for=arch)},
package_repositories=None,
adopt_info=None,
)


Expand Down Expand Up @@ -185,7 +186,7 @@ def pack(
self, prime_dir: pathlib.Path, dest: pathlib.Path
) -> list[pathlib.Path]:
assert prime_dir.exists()
pkg = dest / "package.tar.zst"
pkg = dest / f"package_{self._project.version}.tar.zst"
pkg.touch()
return [pkg]

Expand Down
1 change: 1 addition & 0 deletions tests/integration/data/valid_projects/adoption/stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Packed package_1.0.tar.zst
12 changes: 12 additions & 0 deletions tests/integration/data/valid_projects/adoption/testcraft.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: empty
title: A version adoption test project
base: ["ubuntu", "22.04"]

adopt-info: my-part

parts:
my-part:
plugin: nil
override-build: |
craftctl default
craftctl set version=1.0
2 changes: 1 addition & 1 deletion tests/integration/data/valid_projects/basic/stderr
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Packed package.tar.zst
Packed package_1.0.tar.zst
2 changes: 1 addition & 1 deletion tests/integration/data/valid_projects/basic/testcraft.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: empty
title: A most basic project
version: git
version: 1.0
base: ["ubuntu", "22.04"]

parts:
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/data/valid_projects/environment/stderr
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Packed package.tar.zst
Packed package_1.0.tar.zst
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: environment-project
summary: A project with environment variables
version: 1.2.3
version: 1.0
base: ["ubuntu", "22.04"]

parts:
Expand Down
1 change: 0 additions & 1 deletion tests/integration/launchpad/test_anonymous_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@


def test_get_basic_items(anonymous_lp):

snapstore_server = anonymous_lp.get_project("snapstore-server")
assert snapstore_server.name == "snapstore-server"
assert snapstore_server.title == "Snap Store Server"
Expand Down
8 changes: 4 additions & 4 deletions tests/integration/test_application.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2023 Canonical Ltd.
# Copyright 2023-2024 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License version 3, as
Expand Down Expand Up @@ -139,7 +139,7 @@ def test_project_managed(

app.run()

assert (tmp_path / "package.tar.zst").exists()
assert (tmp_path / "package_1.0.tar.zst").exists()
captured = capsys.readouterr()
assert (
captured.err.splitlines()[-1]
Expand All @@ -160,7 +160,7 @@ def test_project_destructive(
app._build_plan = fake_build_plan
app.run()

assert (tmp_path / "package.tar.zst").exists()
assert (tmp_path / "package_1.0.tar.zst").exists()
captured = capsys.readouterr()
assert (
captured.err.splitlines()[-1]
Expand Down Expand Up @@ -282,7 +282,7 @@ def test_global_environment(

assert variables["project_name"] == "environment-project"
assert variables["project_dir"] == str(tmp_path)
assert variables["project_version"] == "1.2.3"
assert variables["project_version"] == "1.0"


@pytest.fixture()
Expand Down
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 @@
import textwrap
from textwrap import dedent

import pydantic
import pytest
from craft_application import util
from craft_application.errors import CraftValidationError
Expand Down Expand Up @@ -224,6 +225,36 @@ def test_effective_base_unknown():
assert exc_info.match("Could not determine effective base")


@pytest.mark.parametrize(
("version", "adopter_part", "error"),
[
("1", None, None),
("1", "foo", None),
("1", "bar", "'adopt-info' does not reference a valid part."),
(None, None, "Required field 'version' is not set and 'adopt-info' not used."),
(None, "bar", "'adopt-info' does not reference a valid part."),
(None, "foo", None),
],
)
def test_adoptable_version(version, adopter_part, error):
def _project(version, adopter_part) -> Project:
return Project( # pyright: ignore[reportCallIssue]
**{
"name": "project-name", # pyright: ignore[reportGeneralTypeIssues]
"version": version,
"adopt-info": adopter_part,
"parts": {"foo": {"plugin": "nil"}},
}
)

if error:
with pytest.raises(pydantic.ValidationError) as exc_info:
_project(version, adopter_part)
assert exc_info.match(error)
else:
_project(version, adopter_part)


@pytest.mark.parametrize(
("field_name", "invalid_value", "expected_message"),
[
Expand Down
Loading
Loading