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

refactor: use default BuildPlanner #567

Merged
merged 5 commits into from
Jul 11, 2024
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
2 changes: 1 addition & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ click==8.1.7
codespell==2.2.6
colorama==0.4.6
coverage==7.5.1
craft-application==2.9.0
craft-application==3.1.0
craft-archives==1.1.3
craft-cli==2.6.0
craft-grammar==1.2.0
Expand Down
2 changes: 1 addition & 1 deletion requirements-doc.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ cffi==1.16.0
charset-normalizer==3.3.2
click==8.1.7
colorama==0.4.6
craft-application==2.9.0
craft-application==3.1.0
craft-archives==1.1.3
craft-cli==2.6.0
craft-grammar==1.2.0
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
certifi==2024.2.2
cffi==1.16.0
charset-normalizer==3.3.2
craft-application==2.9.0
craft-application==3.1.0
craft-archives==1.1.3
craft-cli==2.6.0
craft-grammar==1.2.0
Expand Down
70 changes: 22 additions & 48 deletions rockcraft/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,20 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Project definition and helpers."""
import copy
import re
import shlex
from collections.abc import Callable, Mapping
from collections.abc import Mapping
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, cast

import craft_application.models
import craft_cli
import pydantic
import spdx_lookup # type: ignore
import yaml
from craft_application.errors import CraftValidationError
from craft_application.models import BuildInfo, CraftBaseConfig
from craft_application.models import BuildPlanner as BaseBuildPlanner
from craft_application.models import BuildPlanner as BaseBuildPlanner, CraftBaseConfig
from craft_application.models import Project as BaseProject
from craft_providers import bases
from craft_providers.errors import BaseConfigurationError
Expand All @@ -48,22 +49,14 @@
_RunUser = Literal[tuple(SUPPORTED_GLOBAL_USERNAMES)] | None


class Platform(pydantic.BaseModel):
class Platform(craft_application.models.Platform):
"""Rockcraft project platform definition."""

build_on: pydantic.conlist(str, unique_items=True, min_items=1) | None # type: ignore[valid-type]
build_for: ( # type: ignore[valid-type]
pydantic.conlist(str, unique_items=True, min_items=1) | None # type: ignore[reportInvalidTypeForm]
)

class Config: # pylint: disable=too-few-public-methods
"""Pydantic model configuration."""

allow_population_by_field_name = True
alias_generator: Callable[[str], str] = lambda s: s.replace( # noqa: E731
"_", "-"
)

@pydantic.validator("build_for", pre=True)
@classmethod
def _vectorise_build_for(cls, val: str | list[str]) -> list[str]:
Expand Down Expand Up @@ -126,9 +119,9 @@ class NameStr(pydantic.ConstrainedStr):
class BuildPlanner(BaseBuildPlanner):
"""BuildPlanner for Rockcraft projects."""

platforms: dict[str, Any] # type: ignore[reportIncompatibleVariableOverride]
base: Literal["bare", "[email protected]", "[email protected]", "[email protected]"]
build_base: Literal["[email protected]", "[email protected]", "[email protected]", "devel"] | None
platforms: dict[str, Platform] # type: ignore[assignment]
base: Literal["bare", "[email protected]", "[email protected]", "[email protected]"] # type: ignore[reportIncompatibleVariableOverride]
build_base: Literal["[email protected]", "[email protected]", "[email protected]", "devel"] | None # type: ignore[reportIncompatibleVariableOverride]

@pydantic.validator("build_base", always=True)
@classmethod
Expand Down Expand Up @@ -174,30 +167,29 @@ def _check_deprecated_base(base_value: str | None, field_name: str) -> str | Non
def _validate_all_platforms(cls, platforms: dict[str, Any]) -> dict[str, Any]:
"""Make sure all provided platforms are tangible and sane."""
for platform_label in platforms:
platform: dict[str, Any] = (
platform: Platform | dict[str, Any] = (
platforms[platform_label] if platforms[platform_label] else {}
)
error_prefix = f"Error for platform entry '{platform_label}'"

# Make sure the provided platform_set is valid
try:
platform = Platform(**platform).dict()
except pydantic.ValidationError as err:
errors = [err_dict["msg"] for err_dict in err.errors()]
full_errors = ",".join(errors)
raise ValueError(f"{error_prefix}: {full_errors}") from None
if not isinstance(platform, Platform):
try:
platform = Platform(**platform)
except pydantic.ValidationError as err:
errors = [err_dict["msg"] for err_dict in err.errors()]
full_errors = ",".join(errors)
raise ValueError(f"{error_prefix}: {full_errors}") from None

# build_on and build_for are validated
# let's also validate the platform label
build_on_one_of = (
platform["build_on"] if platform["build_on"] else [platform_label]
)
build_on_one_of = platform.build_on or [platform_label]

# If the label maps to a valid architecture and
# `build-for` is present, then both need to have the same value,
# otherwise the project is invalid.
if platform["build_for"]:
build_target = platform["build_for"][0]
if platform.build_for:
build_target = platform.build_for[0]
if platform_label in SUPPORTED_ARCHS and platform_label != build_target:
raise ValueError(
str(
Expand Down Expand Up @@ -244,25 +236,6 @@ def effective_base(self) -> bases.BaseName:

return bases.BaseName(name, channel)

def get_build_plan(self) -> list[BuildInfo]:
"""Obtain the list of architectures and bases from the project file."""
build_infos: list[BuildInfo] = []
base = self.effective_base

for platform_entry, platform in self.platforms.items():
for build_for in platform.get("build_for") or [platform_entry]:
for build_on in platform.get("build_on") or [platform_entry]:
build_infos.append(
BuildInfo(
platform=platform_entry,
build_on=build_on,
build_for=build_for,
base=base,
)
)

return build_infos

@override
@classmethod
def model_reference_slug(cls) -> str | None:
Expand All @@ -282,6 +255,7 @@ class Project(YamlModelMixin, BuildPlanner, BaseProject): # type: ignore[misc]
services: dict[str, Service] | None
checks: dict[str, Check] | None
entrypoint_service: str | None
platforms: dict[str, Platform | None] # type: ignore[assignment]
mr-cal marked this conversation as resolved.
Show resolved Hide resolved

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

Expand All @@ -301,7 +275,6 @@ def _providers_base(cls, base: str) -> bases.BaseAlias | None:
:param base: The base name.

:returns: The BaseAlias for the base or None for bare bases.

:raises ValueError: If the project's base cannot be determined.
"""
if base == "bare":
Expand Down Expand Up @@ -561,7 +534,8 @@ def _add_pebble_data(yaml_data: dict[str, Any]) -> None:
# Project already has a pebble part: this is not supported.
raise CraftValidationError('Cannot override the default "pebble" part')

model = BuildPlanner.unmarshal(yaml_data)
# do not modify the original data with pre-validators
model = BuildPlanner.unmarshal(copy.deepcopy(yaml_data))
build_base = model.build_base if model.build_base else model.base

parts["pebble"] = Pebble.get_part_spec(build_base)
46 changes: 45 additions & 1 deletion schema/rockcraft.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,17 @@
},
"platforms": {
"title": "Platforms",
"type": "object"
"type": "object",
"additionalProperties": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/definitions/Platform"
}
]
}
},
"contact": {
"title": "Contact",
Expand Down Expand Up @@ -156,6 +166,40 @@
],
"additionalProperties": false,
"definitions": {
"Platform": {
"title": "Platform",
"description": "Rockcraft project platform definition.",
"type": "object",
"properties": {
"build-on": {
"title": "Build-On",
"minItems": 1,
"uniqueItems": true,
"type": "array",
"items": {
"type": "string"
}
},
"build-for": {
"oneOf": [
{
"title": "Build-For",
"minItems": 1,
"uniqueItems": true,
"type": "array",
"items": {
"type": "string"
}
},
{
"title": "Build-For",
"type": "string"
}
]
}
},
"additionalProperties": false
},
"Service": {
"title": "Service",
"description": "Lightweight schema validation for a Pebble service.\n\nBased on\nhttps://github.com/canonical/pebble#layer-specification",
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ include_package_data = True
packages = find:
zip_safe = False
install_requires =
craft-application>=1.0.0
craft-application>=3.1.0
craft-archives>=1.1.0
craft-cli
craft-parts
Expand Down
5 changes: 3 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ def extra_project_params():
@pytest.fixture()
def default_project(extra_project_params):
from craft_application.models import VersionStr
from rockcraft.models.project import NameStr, Project

from rockcraft.models.project import NameStr, Project, Platform

parts = extra_project_params.pop("parts", {})

Expand All @@ -124,7 +125,7 @@ def default_project(extra_project_params):
base="[email protected]",
parts=parts,
license="MIT",
platforms={"amd64": None},
platforms={"amd64": Platform(build_on=["amd64"], build_for=["amd64"])},
**extra_project_params,
)

Expand Down
6 changes: 4 additions & 2 deletions tests/unit/commands/test_expand_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@
build-base: [email protected]
platforms:
amd64:
build_on: null
build_for: null
build-on:
- amd64
build-for:
- amd64
license: Apache-2.0
parts:
foo:
Expand Down
39 changes: 22 additions & 17 deletions tests/unit/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from craft_application.errors import CraftValidationError
from craft_application.models import BuildInfo
from craft_providers.bases import BaseName, ubuntu

from rockcraft.errors import ProjectLoadError
from rockcraft.models import Project
from rockcraft.models.project import INVALID_NAME_MESSAGE, Platform, load_project
Expand Down Expand Up @@ -137,10 +138,12 @@ def test_project_unmarshal(check, yaml_loaded_data):
# platforms get mutated at validation time
assert getattr(project, attr).keys() == v.keys()
assert all(
"build_on" in platform for platform in getattr(project, attr).values()
hasattr(platform, "build_on")
for platform in getattr(project, attr).values()
)
assert all(
"build_for" in platform for platform in getattr(project, attr).values()
hasattr(platform, "build_for")
for platform in getattr(project, attr).values()
)
continue
if attr == "services":
Expand Down Expand Up @@ -419,8 +422,8 @@ def reload_project_platforms(new_platforms=None):

expected = (
"Bad rockcraft.yaml content:\n"
"- error for platform entry 'foo': 'build-for' expects 'build-on' "
"to also be provided. (in field 'platforms')"
"- 'build-for' expects 'build-on' to also be provided."
" (in field 'platforms.foo')"
)

assert reload_project_platforms(mock_platforms) == expected
Expand All @@ -434,15 +437,15 @@ def reload_project_platforms(new_platforms=None):
mock_platforms = {
"mock": {"build-on": ["arm64a", "noarch"], "build-for": ["amd64"]}
}
assert "none of these build architectures is supported" in reload_project_platforms(
mock_platforms
assert (
"Invalid architecture: 'arm64a' must be a valid debian architecture."
in reload_project_platforms(mock_platforms)
)

mock_platforms = {
"mock": {"build-on": ["arm64a", "arm64"], "build-for": ["noarch"]}
}
assert "build rock for target architecture noarch" in reload_project_platforms(
mock_platforms
mock_platforms = {"mock": {"build-on": ["arm64", "arm64"], "build-for": ["noarch"]}}
assert (
"Invalid architecture: 'noarch' must be a valid debian architecture."
in reload_project_platforms(mock_platforms)
)


Expand Down Expand Up @@ -652,17 +655,19 @@ def test_metadata_base_devel(yaml_loaded_data):
build-base: [email protected]
platforms:
{BUILD_ON_ARCH}:
build_on: null
build_for: null
build-on:
- {BUILD_ON_ARCH}
build-for:
- {BUILD_ON_ARCH}
some-text:
build_on:
build-on:
- {BUILD_ON_ARCH}
build_for:
build-for:
- {BUILD_ON_ARCH}
same-with-different-syntax:
build_on:
build-on:
- {BUILD_ON_ARCH}
build_for:
build-for:
- {BUILD_ON_ARCH}
license: Apache-2.0
parts:
Expand Down
19 changes: 19 additions & 0 deletions tools/schema/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,25 @@ def generate_project_schema() -> str:
# combine both schemas
project_schema = {**initial_schema, **project_schema}

# tweak the platforms definition on the Project (each value can be empty)
project_schema["properties"]["platforms"]["additionalProperties"] = {
"oneOf": [{"type": "null"}, {"$ref": "#/definitions/Platform"}]
}

# tweak the Platform (build-for can be a single string)
project_schema["definitions"]["Platform"]["properties"]["build-for"] = {
"oneOf": [
{
"title": "Build-For",
"minItems": 1,
"uniqueItems": True,
"type": "array",
"items": {"type": "string"},
},
{"title": "Build-For", "type": "string"},
]
}

# project.schema() will define the `parts` field as an `object`
# so we need to manually add the schema for parts by running
# schema() on part.spec and add the outcome project schema's definitions
Expand Down
Loading