diff --git a/craft_application/application.py b/craft_application/application.py
index 31c9b0d3..b4eeb044 100644
--- a/craft_application/application.py
+++ b/craft_application/application.py
@@ -25,6 +25,7 @@
import subprocess
import sys
import traceback
+import warnings
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field
from functools import cached_property
@@ -37,9 +38,9 @@
from craft_parts.plugins.plugins import PluginType
from platformdirs import user_cache_path
-from craft_application import _config, commands, errors, grammar, models, util
+from craft_application import _config, commands, errors, models, util
from craft_application.errors import PathInvalidError
-from craft_application.models import BuildInfo, GrammarAwareProject
+from craft_application.models import BuildInfo
if TYPE_CHECKING:
from craft_application.services import service_factory
@@ -300,6 +301,17 @@ def cache_dir(self) -> pathlib.Path:
f"Unable to create/access cache directory: {err.strerror}"
) from err
+ def _configure_early_services(self) -> None:
+ """Configure early-starting services.
+
+ This should only contain configuration for services that are needed during
+ application startup. All other configuration belongs in ``_configure_services``
+ """
+ self.services.update_kwargs(
+ "project",
+ project_dir=self.project_dir,
+ )
+
def _configure_services(self, provider_name: str | None) -> None:
"""Configure additional keyword arguments for any service classes.
@@ -325,18 +337,6 @@ def _configure_services(self, provider_name: str | None) -> None:
session_policy=self._fetch_service_policy,
)
- def _resolve_project_path(self, project_dir: pathlib.Path | None) -> pathlib.Path:
- """Find the project file for the current project.
-
- The default implementation simply looks for the project file in the project
- directory. Applications may wish to override this if the project file could be
- in multiple places within the project directory.
- """
- if project_dir is None:
- project_dir = self.project_dir
-
- return (project_dir / f"{self.app.name}.yaml").resolve(strict=True)
-
def get_project(
self,
*,
@@ -352,66 +352,17 @@ def get_project(
:param build_for: the architecture to build this project for.
:returns: A transformed, loaded project model.
"""
- if self.__project is not None:
- return self.__project
-
- try:
- project_path = self._resolve_project_path(self.project_dir)
- except FileNotFoundError as err:
- raise errors.ProjectFileMissingError(
- f"Project file '{self.app.name}.yaml' not found in '{self.project_dir}'.",
- details="The project file could not be found.",
- resolution="Ensure the project file exists.",
- retcode=os.EX_NOINPUT,
- ) from err
- craft_cli.emit.debug(f"Loading project file '{project_path!s}'")
-
- with project_path.open() as file:
- yaml_data = util.safe_yaml_load(file)
-
- host_arch = util.get_host_architecture()
- build_planner = self.app.BuildPlannerClass.from_yaml_data(
- yaml_data, project_path
- )
- self._full_build_plan = build_planner.get_build_plan()
- self._build_plan = filter_plan(
- self._full_build_plan, platform, build_for, host_arch
+ warnings.warn(
+ DeprecationWarning(
+ "Do not get the project directly from the Application. "
+ "Get it from the project service."
+ ),
+ stacklevel=2,
)
-
- if not build_for:
- # get the build-for arch from the platform
- if platform:
- all_platforms = {b.platform: b for b in self._full_build_plan}
- if platform not in all_platforms:
- raise errors.InvalidPlatformError(
- platform, list(all_platforms.keys())
- )
- build_for = all_platforms[platform].build_for
- # otherwise get the build-for arch from the build plan
- elif self._build_plan:
- build_for = self._build_plan[0].build_for
-
- # validate project grammar
- GrammarAwareProject.validate_grammar(yaml_data)
-
- build_on = host_arch
-
- # Setup partitions, some projects require the yaml data, most will not
- self._partitions = self._setup_partitions(yaml_data)
- yaml_data = self._transform_project_yaml(yaml_data, build_on, build_for)
- self.__project = self.app.ProjectClass.from_yaml_data(yaml_data, project_path)
-
- # check if mandatory adoptable fields exist if adopt-info not used
- for name in self.app.mandatory_adoptable_fields:
- if (
- not getattr(self.__project, name, None)
- and not self.__project.adopt_info
- ):
- raise errors.CraftValidationError(
- f"Required field '{name}' is not set and 'adopt-info' not used."
- )
-
- return self.__project
+ project_service = self.services.get("project")
+ if project_service.is_rendered:
+ return project_service.get()
+ return project_service.render_once(platform=platform, build_for=build_for)
@cached_property
def project(self) -> models.Project:
@@ -609,7 +560,7 @@ def get_arg_or_config(self, parsed_args: argparse.Namespace, item: str) -> Any:
arg_value = getattr(parsed_args, item, None)
if arg_value is not None:
return arg_value
- return self.services.config.get(item)
+ return self.services.get("config").get(item)
def _run_inner(self) -> int:
"""Actual run implementation."""
@@ -629,6 +580,11 @@ def _run_inner(self) -> int:
platform = platform.split(",", maxsplit=1)[0]
if build_for and "," in build_for:
build_for = build_for.split(",", maxsplit=1)[0]
+ if command.needs_project(dispatcher.parsed_args()):
+ project_service = self.services.get("project")
+ # This branch always runs, except during testing.
+ if not project_service.is_rendered:
+ project_service.render_once()
provider_name = command.provider_name(dispatcher.parsed_args())
@@ -636,11 +592,6 @@ def _run_inner(self) -> int:
self._pre_run(dispatcher)
managed_mode = command.run_managed(dispatcher.parsed_args())
- if managed_mode or command.needs_project(dispatcher.parsed_args()):
- self.services.project = self.get_project(
- platform=platform, build_for=build_for
- )
-
self._configure_services(provider_name)
return_code = 1 # General error
@@ -661,6 +612,7 @@ def _run_inner(self) -> int:
def run(self) -> int:
"""Bootstrap and run the application."""
self._setup_logging()
+ self._configure_early_services()
self._load_plugins()
self._initialize_craft_parts()
@@ -719,78 +671,6 @@ def _emit_error(
craft_cli.emit.error(error)
- def _transform_project_yaml(
- self, yaml_data: dict[str, Any], build_on: str, build_for: str | None
- ) -> 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.
- """
- # apply application-specific transformations first because an application may
- # add advanced grammar, project variables, or secrets to the yaml
- yaml_data = self._extra_yaml_transform(
- yaml_data, build_on=build_on, build_for=build_for
- )
-
- # At the moment there is no perfect solution for what do to do
- # expand project variables or to resolve the grammar if there's
- # no explicitly-provided target arch. However, we must resolve
- # it with *something* otherwise we might have an invalid parts
- # definition full of grammar declarations and incorrect build_for
- # architectures.
- build_for = build_for or build_on
-
- # Perform variable expansion.
- self._expand_environment(yaml_data=yaml_data, build_for=build_for)
-
- # Expand grammar.
- if "parts" in yaml_data:
- craft_cli.emit.debug(f"Processing grammar (on {build_on} for {build_for})")
- yaml_data["parts"] = grammar.process_parts(
- parts_yaml_data=yaml_data["parts"],
- arch=build_on,
- target_arch=build_for,
- )
-
- return yaml_data
-
- def _expand_environment(self, yaml_data: dict[str, Any], build_for: str) -> None:
- """Perform expansion of project environment variables.
-
- :param yaml_data: The project's yaml data.
- :param build_for: The architecture to build for.
- """
- if build_for == "all":
- build_for_arch = util.get_host_architecture()
- craft_cli.emit.debug(
- "Expanding environment variables with the host architecture "
- f"{build_for_arch!r} as the build-for architecture because 'all' was "
- "specified."
- )
- else:
- build_for_arch = build_for
-
- environment_vars = self._get_project_vars(yaml_data)
- project_dirs = craft_parts.ProjectDirs(
- work_dir=self._work_dir, partitions=self._partitions
- )
-
- info = craft_parts.ProjectInfo(
- application_name=self.app.name, # not used in environment expansion
- cache_dir=pathlib.Path(), # not used in environment expansion
- arch=build_for_arch,
- parallel_build_count=util.get_parallel_build_count(self.app.name),
- project_name=yaml_data.get("name", ""),
- project_dirs=project_dirs,
- project_vars=environment_vars,
- partitions=self._partitions,
- )
-
- self._set_global_environment(info)
-
- craft_parts.expand_environment(yaml_data, info=info)
-
def _setup_partitions(self, yaml_data: dict[str, Any]) -> list[str] | None:
"""Return partitions to be used.
@@ -815,19 +695,6 @@ def _set_global_environment(self, info: craft_parts.ProjectInfo) -> None:
}
)
- def _extra_yaml_transform(
- self,
- yaml_data: dict[str, Any],
- *,
- build_on: str, # noqa: ARG002 (Unused method argument)
- build_for: str | None, # noqa: ARG002 (Unused method argument)
- ) -> 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 _setup_logging(self) -> None:
"""Initialize the logging system."""
# Set the logging level to DEBUG for all craft-libraries. This is OK even if
diff --git a/craft_application/commands/remote.py b/craft_application/commands/remote.py
index ab12510d..9780b220 100644
--- a/craft_application/commands/remote.py
+++ b/craft_application/commands/remote.py
@@ -23,7 +23,7 @@
from craft_cli import emit
from overrides import override # pyright: ignore[reportUnknownVariableType]
-from craft_application import errors, models
+from craft_application import errors
from craft_application.commands import ExtensibleCommand
from craft_application.launchpad.models import Build, BuildState
from craft_application.remote.utils import get_build_id
@@ -139,7 +139,7 @@ def _run(
build_args = self._get_build_args(parsed_args)
builder = self._services.remote_build
- project = cast(models.Project, self._services.project)
+ project = self._services.get("project").get()
config = cast(dict[str, Any], self.config)
project_dir = (
pathlib.Path(config.get("global_args", {}).get("project_dir") or ".")
diff --git a/craft_application/errors.py b/craft_application/errors.py
index 7a637a9e..db392a41 100644
--- a/craft_application/errors.py
+++ b/craft_application/errors.py
@@ -21,8 +21,9 @@
from __future__ import annotations
import os
-from collections.abc import Sequence
-from typing import TYPE_CHECKING
+import pathlib
+from collections.abc import Collection, Sequence
+from typing import TYPE_CHECKING, Literal
import yaml
from craft_cli import CraftError
@@ -38,12 +39,87 @@
from typing_extensions import Self
-class ProjectFileMissingError(CraftError, FileNotFoundError):
+class PathInvalidError(CraftError, OSError):
+ """Error that the given path is not usable."""
+
+
+class ProjectFileError(CraftError):
+ """Errors to do with the project file or directory."""
+
+
+class ProjectFileMissingError(ProjectFileError, FileNotFoundError):
"""Error finding project file."""
-class PathInvalidError(CraftError, OSError):
- """Error that the given path is not usable."""
+class ProjectDirectoryMissingError(ProjectFileError, FileNotFoundError):
+ """The project directory doesn't exist."""
+
+ def __init__(
+ self,
+ directory: pathlib.Path,
+ *,
+ details: str | None = None,
+ resolution: str | None = None,
+ docs_url: str | None = None,
+ doc_slug: str | None = None,
+ ) -> None:
+ super().__init__(
+ f"Project directory missing: {directory}",
+ details=details,
+ resolution=resolution,
+ docs_url=docs_url,
+ logpath_report=False,
+ reportable=False,
+ retcode=os.EX_NOINPUT,
+ doc_slug=doc_slug,
+ )
+
+
+class ProjectDirectoryTypeError(ProjectFileError, FileNotFoundError):
+ """The project directory is not a directory."""
+
+ def __init__(
+ self,
+ directory: pathlib.Path,
+ *,
+ details: str | None = None,
+ resolution: str | None = None,
+ docs_url: str | None = None,
+ doc_slug: str | None = None,
+ ) -> None:
+ super().__init__(
+ f"Given project directory path is not a directory: {directory}",
+ details=details,
+ resolution=resolution,
+ docs_url=docs_url,
+ logpath_report=False,
+ reportable=False,
+ retcode=os.EX_NOINPUT,
+ doc_slug=doc_slug,
+ )
+
+
+class ProjectFileInvalidError(ProjectFileError):
+ """Error that the project file is valid YAML, but not a valid project file."""
+
+ def __init__(
+ self,
+ project_data: object,
+ *,
+ resolution: str | None = None,
+ docs_url: str | None = None,
+ doc_slug: str | None = None,
+ ) -> None:
+ super().__init__(
+ "Invalid project file.",
+ details=f"Project file should be a YAML mapping, not {type(project_data).__name__!r}",
+ resolution=resolution,
+ docs_url=docs_url,
+ logpath_report=False,
+ reportable=False,
+ retcode=os.EX_NOINPUT,
+ doc_slug=doc_slug,
+ )
class YamlError(CraftError, yaml.YAMLError):
@@ -141,16 +217,43 @@ def __init__(self, host_directive: str) -> None:
super().__init__(message=message)
-class InvalidPlatformError(CraftError):
+class PlatformDefinitionError(CraftError):
+ """Errors with the platform definitions."""
+
+
+class InvalidPlatformError(PlatformDefinitionError):
"""The selected build plan platform is invalid."""
def __init__(self, platform: str, all_platforms: Sequence[str]) -> None:
message = f"Platform {platform!r} not found in the project definition."
- details = f"Valid platforms are: {', '.join(all_platforms)}."
+ platforms_str = ", ".join(repr(platform) for platform in all_platforms)
+ details = f"Valid platforms are: {platforms_str}."
super().__init__(message=message, details=details)
+class ArchitectureNotInPlatformError(PlatformDefinitionError):
+ """The selected build-for is not in the selected platform."""
+
+ def __init__(
+ self,
+ build_key: Literal["build-on", "build-for"],
+ build_for: str,
+ platform: str,
+ build_fors: Collection[str],
+ *,
+ docs_url: str | None = None,
+ doc_slug: str | None = None,
+ ) -> None:
+ super().__init__(
+ f"Platform {platform!r} does not contain {build_key!r} {build_for}.",
+ details=f"Valid {build_key!r} values: {humanize_list(build_fors, 'and')}",
+ reportable=False,
+ docs_url=docs_url,
+ doc_slug=doc_slug,
+ )
+
+
class EmptyBuildPlanError(CraftError):
"""The build plan filtered out all possible builds."""
diff --git a/craft_application/services/__init__.py b/craft_application/services/__init__.py
index e9c066c2..56e88635 100644
--- a/craft_application/services/__init__.py
+++ b/craft_application/services/__init__.py
@@ -15,12 +15,13 @@
# along with this program. If not, see .
"""Service classes for the business logic of various categories of command."""
-from craft_application.services.base import AppService, ProjectService
+from craft_application.services.base import AppService
from craft_application.services.config import ConfigService
from craft_application.services.fetch import FetchService
from craft_application.services.lifecycle import LifecycleService
from craft_application.services.init import InitService
from craft_application.services.package import PackageService
+from craft_application.services.project import ProjectService
from craft_application.services.provider import ProviderService
from craft_application.services.remotebuild import RemoteBuildService
from craft_application.services.request import RequestService
diff --git a/craft_application/services/base.py b/craft_application/services/base.py
index b9dace95..77b4bff9 100644
--- a/craft_application/services/base.py
+++ b/craft_application/services/base.py
@@ -23,7 +23,6 @@
from craft_cli import emit
if typing.TYPE_CHECKING:
- from craft_application import models
from craft_application.application import AppMetadata
from craft_application.services import ServiceFactory
@@ -44,21 +43,3 @@ def __init__(self, app: AppMetadata, services: ServiceFactory) -> None:
def setup(self) -> None:
"""Application-specific service setup."""
emit.debug(f"Setting up {self.__class__.__name__}")
-
-
-class ProjectService(AppService, metaclass=abc.ABCMeta):
- """A service that requires access to a project.
-
- The ServiceFactory will refuse to instantiate a subclass of this service if
- no project can be created or the project is invalid.
- """
-
- def __init__(
- self,
- app: AppMetadata,
- services: ServiceFactory,
- *,
- project: models.Project,
- ) -> None:
- super().__init__(app=app, services=services)
- self._project = project
diff --git a/craft_application/services/fetch.py b/craft_application/services/fetch.py
index c1fb6d5b..6e817653 100644
--- a/craft_application/services/fetch.py
+++ b/craft_application/services/fetch.py
@@ -27,11 +27,13 @@
from craft_cli import emit
from typing_extensions import override
-from craft_application import fetch, models, services, util
+from craft_application import fetch, models, util
from craft_application.models.manifest import CraftManifest, ProjectManifest
+from craft_application.services import base
if typing.TYPE_CHECKING:
from craft_application.application import AppMetadata
+ from craft_application.services import service_factory
_PROJECT_MANIFEST_MANAGED_PATH = pathlib.Path(
@@ -39,7 +41,7 @@
)
-class FetchService(services.ProjectService):
+class FetchService(base.AppService):
"""Service class that handles communication with the fetch-service.
This Service is able to spawn a fetch-service instance and create sessions
@@ -61,9 +63,8 @@ class FetchService(services.ProjectService):
def __init__(
self,
app: AppMetadata,
- services: services.ServiceFactory,
+ services: service_factory.ServiceFactory,
*,
- project: models.Project,
build_plan: list[models.BuildInfo],
session_policy: str,
) -> None:
@@ -72,7 +73,7 @@ def __init__(
:param session_policy: Whether the created fetch-service sessions should
be "strict" or "permissive".
"""
- super().__init__(app, services, project=project)
+ super().__init__(app, services)
self._fetch_process = None
self._session_data = None
self._build_plan = build_plan
@@ -159,16 +160,18 @@ def create_project_manifest(self, artifacts: list[pathlib.Path]) -> None:
return
emit.debug(f"Generating project manifest at {_PROJECT_MANIFEST_MANAGED_PATH}")
+ project = self._services.get("project").get()
project_manifest = ProjectManifest.from_packed_artifact(
- self._project, self._build_plan[0], artifacts[0]
+ project, self._build_plan[0], artifacts[0]
)
project_manifest.to_yaml_file(_PROJECT_MANIFEST_MANAGED_PATH)
def _create_craft_manifest(
self, project_manifest: pathlib.Path, session_report: dict[str, typing.Any]
) -> None:
- name = self._project.name
- version = self._project.version
+ project = self._services.get("project").get()
+ name = project.name
+ version = project.version
platform = self._build_plan[0].platform
manifest_path = pathlib.Path(f"{name}_{version}_{platform}.json")
diff --git a/craft_application/services/lifecycle.py b/craft_application/services/lifecycle.py
index 53c8e5ef..c8cc70e0 100644
--- a/craft_application/services/lifecycle.py
+++ b/craft_application/services/lifecycle.py
@@ -44,7 +44,6 @@
from pathlib import Path
from craft_application.application import AppMetadata
- from craft_application.models import Project
from craft_application.services import ServiceFactory
@@ -112,7 +111,7 @@ def _get_step(step_name: str) -> Step:
raise RuntimeError(f"Invalid target step {step_name!r}") from None
-class LifecycleService(base.ProjectService):
+class LifecycleService(base.AppService):
"""Create and manage the parts lifecycle.
:param app: An AppMetadata object containing metadata about the application.
@@ -125,19 +124,20 @@ class LifecycleService(base.ProjectService):
LifecycleManager on initialisation.
"""
+ _project: models.Project
+
def __init__(
self,
app: AppMetadata,
services: ServiceFactory,
*,
- project: Project,
work_dir: Path | str,
cache_dir: Path | str,
build_plan: list[models.BuildInfo],
partitions: list[str] | None = None,
**lifecycle_kwargs: Any,
) -> None:
- super().__init__(app, services, project=project)
+ super().__init__(app, services)
self._work_dir = work_dir
self._cache_dir = cache_dir
self._build_plan = build_plan
@@ -148,6 +148,7 @@ def __init__(
@override
def setup(self) -> None:
"""Initialize the LifecycleManager with previously-set arguments."""
+ self._project = self._services.get("project").get()
self._lcm = self._init_lifecycle_manager()
callbacks.register_post_step(self.post_prime, step_list=[Step.PRIME])
@@ -320,7 +321,7 @@ def __repr__(self) -> str:
cache_dir = self._cache_dir
plan = self._build_plan
return (
- f"{self.__class__.__name__}({self._app!r}, {self._project!r}, "
+ f"{self.__class__.__name__}({self._app!r}, "
f"{work_dir=}, {cache_dir=}, {plan=}, **{self._manager_kwargs!r})"
)
diff --git a/craft_application/services/package.py b/craft_application/services/package.py
index d7b5f3d9..94421cc2 100644
--- a/craft_application/services/package.py
+++ b/craft_application/services/package.py
@@ -31,7 +31,7 @@
from craft_application import models
-class PackageService(base.ProjectService):
+class PackageService(base.AppService):
"""Business logic for creating packages."""
@abc.abstractmethod
@@ -56,7 +56,8 @@ def update_project(self) -> None:
update_vars[var] = project_info.get_project_var(var)
emit.debug(f"Update project variables: {update_vars}")
- self._project.__dict__.update(update_vars)
+ project = self._services.get("project").get()
+ project.__dict__.update(update_vars)
# Give subclasses a chance to update the project with their own logic
self._extra_project_updates()
@@ -64,7 +65,7 @@ def update_project(self) -> None:
unset_fields = [
field
for field in self._app.mandatory_adoptable_fields
- if not getattr(self._project, field)
+ if not getattr(project, field)
]
if unset_fields:
diff --git a/craft_application/services/project.py b/craft_application/services/project.py
new file mode 100644
index 00000000..b2a3c57e
--- /dev/null
+++ b/craft_application/services/project.py
@@ -0,0 +1,362 @@
+# Copyright 2025 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
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE.
+# See the GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with this program. If not, see .
+"""A service for handling access to the project."""
+
+from __future__ import annotations
+
+import copy
+import os
+import pathlib
+from typing import TYPE_CHECKING, Any, cast, final
+
+import craft_parts
+import craft_platforms
+from craft_cli import emit
+
+from craft_application import errors, grammar, util
+from craft_application.models.grammar import GrammarAwareProject
+
+from . import base
+
+if TYPE_CHECKING:
+ from craft_application import models
+ from craft_application.application import AppMetadata
+
+ from .service_factory import ServiceFactory
+
+
+class ProjectService(base.AppService):
+ """A service for handling access to the project."""
+
+ __platforms: dict[str, craft_platforms.PlatformDict] | None
+ __project_file_path: pathlib.Path | None
+ __raw_project: dict[str, Any] | None
+ _project_dir: pathlib.Path
+ _project_model: models.Project | None
+
+ def __init__(
+ self, app: AppMetadata, services: ServiceFactory, *, project_dir: pathlib.Path
+ ) -> None:
+ super().__init__(app, services)
+ self.__platforms = None
+ self.__project_file_path = None
+ self.__raw_project: dict[str, Any] | None = None
+ self._project_dir = project_dir
+ self._project_model = None
+
+ def resolve_project_file_path(self) -> pathlib.Path:
+ """Get the path to the project file from the root project directory.
+
+ The default behaviour is to find the project file directly in the directory
+ based on the app name or raise an exception. However, an application may
+ override this if necessary. For example, Snapcraft needs to override this to
+ check other possible directories.
+
+ :param project_dir: The base project directory to search.
+ :returns: The path to the extant project file
+ :raises: ProjectFileMissingError if the project file could not be found.
+ """
+ if self.__project_file_path:
+ return self.__project_file_path
+ if not self._project_dir.is_dir():
+ if not self._project_dir.exists():
+ raise errors.ProjectDirectoryMissingError(self._project_dir)
+ raise errors.ProjectDirectoryTypeError(self._project_dir)
+ try:
+ path = (self._project_dir / f"{self._app.name}.yaml").resolve(strict=True)
+ except FileNotFoundError as err:
+ raise errors.ProjectFileMissingError(
+ f"Project file '{self._app.name}.yaml' not found in '{self._project_dir}'.",
+ details="The project file could not be found.",
+ resolution="Ensure the project file exists.",
+ retcode=os.EX_NOINPUT,
+ ) from err
+ emit.trace(f"Project file found at {path}")
+ return path
+
+ @final
+ def _load_raw_project(self) -> dict[str, Any]:
+ """Get the raw project data structure.
+
+ This loads the project file from the given path, parses the YAML, and returns
+ that raw data structure. This method should be used with care, as the project
+ does not have any preprocessors applied.
+ """
+ if self.__raw_project:
+ return self.__raw_project
+ project_path = self.resolve_project_file_path()
+ with project_path.open() as project_file:
+ emit.debug(f"Loading project file '{project_path!s}")
+ raw_yaml = util.safe_yaml_load(project_file)
+ if not isinstance(raw_yaml, dict):
+ raise errors.ProjectFileInvalidError(raw_yaml)
+ self.__raw_project = cast(dict[str, Any], raw_yaml)
+ return self.__raw_project
+
+ def _app_preprocess_project(
+ self,
+ project: dict[str, Any],
+ *,
+ build_on: str,
+ build_for: str,
+ platform: str,
+ ) -> None:
+ """Run any application-specific pre-processing on the project, in-place.
+
+ This includes any application-specific transformations on a project's raw data
+ structure before it gets rendered as a pydantic model. Some examples of
+ processing to do here include:
+
+ - Applying extensions
+ - Adding "hidden" app-specific parts
+ - Processing grammar for keys other than parts
+ """
+
+ def _app_render_legacy_platforms(self) -> dict[str, craft_platforms.PlatformDict]:
+ """Application-specific rendering function if no platforms are declared.
+
+ This method is intended to be overridden by an application-specific service
+ if the root project has no ``platforms`` key. In this case, it should return
+ a dictionary structured the same way as the ``platforms`` key in a project
+ for use in that project. This dictionary is less strict than what is expected
+ in the project file's ``platforms`` key. For example, the ``build-for``
+ key for each platform **MAY** contain multiple targets.
+ """
+ raise errors.CraftValidationError(
+ f"{self._app.name}.yaml must contain a 'platforms' key."
+ )
+
+ @final
+ def get_platforms(self) -> dict[str, craft_platforms.PlatformDict]:
+ """Get the platforms definition for the project."""
+ if self.__platforms:
+ return self.__platforms.copy()
+ raw_project = self._load_raw_project()
+ if "platforms" not in raw_project:
+ return self._app_render_legacy_platforms()
+
+ platforms: dict[str, craft_platforms.PlatformDict] = raw_project["platforms"]
+ for name, data in platforms.items():
+ if data is None:
+ platforms[name] = {"build-on": [name], "build-for": [name]}
+
+ return platforms
+
+ def _get_project_vars(self, yaml_data: dict[str, Any]) -> dict[str, str]:
+ """Return a dict with project variables to be expanded."""
+ return {var: str(yaml_data.get(var, "")) for var in self._app.project_variables}
+
+ def get_partitions(self) -> list[str] | None:
+ """Get the partitions this application needs for this project.
+
+ Applications should override this method depending on how they determine
+ partitions.
+ """
+ if craft_parts.Features().enable_partitions:
+ return ["default"]
+ return None
+
+ @final
+ def _expand_environment(
+ self,
+ project_data: dict[str, Any],
+ build_for: str,
+ ) -> None:
+ """Perform expansion of project environment variables.
+
+ :param project_data: The project's yaml data.
+ :param build_for: The architecture to build for.
+ """
+ # We can use "all" directly after resolving:
+ # https://github.com/canonical/craft-parts/issues/1019
+ if build_for == "all":
+ host_arch = craft_platforms.DebianArchitecture.from_host()
+ for target in craft_platforms.DebianArchitecture:
+ if target != host_arch:
+ build_for = target.value
+ break
+ else:
+ raise ValueError(
+ "Could not find an architecture other than the host architecture "
+ "to set as the build-for architecture. This is a bug in "
+ f"{self._app.name} or craft-application."
+ )
+ emit.debug(
+ "Expanding environment variables with the architecture "
+ f"{build_for!r} as the build-for architecture because 'all' was "
+ "specified."
+ )
+
+ environment_vars = self._get_project_vars(project_data)
+ partitions = self.get_partitions()
+ project_dirs = craft_parts.ProjectDirs(
+ work_dir=self._project_dir, partitions=partitions
+ )
+ info = craft_parts.ProjectInfo(
+ application_name=self._app.name, # not used in environment expansion
+ cache_dir=pathlib.Path(), # not used in environment expansion
+ arch=build_for,
+ parallel_build_count=util.get_parallel_build_count(self._app.name),
+ project_name=project_data.get("name", ""),
+ project_dirs=project_dirs,
+ project_vars=environment_vars,
+ partitions=partitions,
+ )
+
+ self.update_project_environment(info)
+ craft_parts.expand_environment(project_data, info=info)
+
+ @final
+ def render_for(
+ self,
+ *,
+ build_for: str,
+ build_on: str,
+ platform: str,
+ ) -> models.Project:
+ """Render the project for a specific combination of archs/platforms..
+
+ This method does not guarantee that the project will be buildable with the
+ given parameters or that the parameters even correspond to something a build
+ plan would generate.
+
+ :param build_for: The target architecture of the build.
+ :param platform: The name of the target platform.
+ :param build_on: The host architecture the build happens on.
+ :returns: A Project model containing the project rendered as above.
+ """
+ platforms = self.get_platforms()
+ if platform not in platforms:
+ raise errors.InvalidPlatformError(platform, sorted(platforms.keys()))
+
+ project = copy.deepcopy(self._load_raw_project())
+
+ GrammarAwareProject.validate_grammar(project)
+ self._app_preprocess_project(
+ project, build_on=build_on, build_for=build_for, platform=platform
+ )
+ self._expand_environment(project, build_for=build_for)
+
+ # Process grammar.
+ if "parts" in project:
+ emit.debug(f"Processing grammar (on {build_on} for {build_for})")
+ project["parts"] = grammar.process_parts(
+ parts_yaml_data=project["parts"],
+ arch=build_on,
+ target_arch=build_for,
+ )
+ project_model = self._app.ProjectClass.from_yaml_data(
+ project, self.resolve_project_file_path()
+ )
+
+ if not project_model.adopt_info:
+ missing_fields: set[str] = set()
+ for field in self._app.mandatory_adoptable_fields:
+ if not getattr(project_model, field, None):
+ missing_fields.add(field)
+ if missing_fields:
+ missing = ", ".join(repr(field) for field in sorted(missing_fields))
+ raise errors.CraftValidationError(
+ f"'adopt-info' not set and required fields are missing: {missing}"
+ )
+
+ return project_model
+
+ def update_project_environment(self, info: craft_parts.ProjectInfo) -> None:
+ """Update a ProjectInfo's global environment."""
+ info.global_environment.update(
+ {
+ "CRAFT_PROJECT_VERSION": info.get_project_var("version", raw_read=True),
+ }
+ )
+
+ @property
+ @final
+ def is_rendered(self) -> bool:
+ """Whether the project has already been rendered."""
+ return self._project_model is not None
+
+ @final
+ def get(self) -> models.Project:
+ """Get the rendered project.
+
+ :returns: The project model.
+ :raises: RuntimeError if the project has not been rendered.
+ """
+ if not self._project_model:
+ raise RuntimeError("Project not rendered yet.")
+ return self._project_model
+
+ @final
+ def render_once(
+ self,
+ *,
+ platform: str | None = None,
+ build_for: str | None = None,
+ ) -> models.Project:
+ """Render the project model for this run.
+
+ This should only be called by the Application for initial setup of the project.
+ Everything else should use :py:meth:`get`.
+
+ If build_for or platform is not set, it tries to select the appropriate one.
+ Which value is selected is not guaranteed unless both a build_for and platform
+ are passed. If an application requires that each platform only build for
+ exactly one target, passing only platform will guarantee a repeatable output.
+
+ :param platform: The platform build name.
+ :param build_for: The destination architecture (or base:arch)
+ :returns: The rendered project
+ :raises: RuntimeError if the project has already been rendered.
+ """
+ if self._project_model:
+ raise RuntimeError("Project should only be rendered once.")
+
+ build_on = craft_platforms.DebianArchitecture.from_host()
+ if not platform or not build_for:
+ platforms = self.get_platforms()
+ if not platform:
+ # If we don't have a platform, select the first platform that matches
+ # our build-on and build-for. If we don't have a build-for, select the
+ # first platform that matches our build-on.
+ for name, data in platforms.items():
+ if build_on.value not in data["build-on"]:
+ continue
+ if build_for and build_for not in data["build-for"]:
+ continue
+ platform = name
+ break
+ else:
+ if build_for:
+ raise RuntimeError(
+ f"Cannot generate a project that builds on {build_on} and "
+ f"builds for {build_for}"
+ )
+ # We won't be able to build in this case, but the project is
+ # still valid for non-lifecycle commands. Render for anything.
+ platform = next(iter(platforms))
+ self._project_model = self.render_for(
+ platform=platform,
+ build_for=platforms[platform]["build-for"][0],
+ build_on=platforms[platform]["build-on"][0],
+ )
+ return self._project_model
+ # Any build-for in the platform is fine. For most crafts this is the
+ # only build-for in the platform.
+ build_for = platforms[platform]["build-for"][0]
+
+ self._project_model = self.render_for(
+ build_for=build_for, build_on=build_on, platform=platform
+ )
+ return self._project_model
diff --git a/craft_application/services/provider.py b/craft_application/services/provider.py
index 4405bc3f..5ca3d940 100644
--- a/craft_application/services/provider.py
+++ b/craft_application/services/provider.py
@@ -49,7 +49,7 @@
DEFAULT_FORWARD_ENVIRONMENT_VARIABLES: Iterable[str] = ()
-class ProviderService(base.ProjectService):
+class ProviderService(base.AppService):
"""Manager for craft_providers in an application.
:param app: Metadata about this application.
@@ -64,13 +64,12 @@ def __init__(
app: AppMetadata,
services: ServiceFactory,
*,
- project: models.Project,
work_dir: pathlib.Path,
build_plan: list[models.BuildInfo],
provider_name: str | None = None,
install_snap: bool = True,
) -> None:
- super().__init__(app, services, project=project)
+ super().__init__(app, services)
self._provider: craft_providers.Provider | None = None
self._work_dir = work_dir
self._build_plan = build_plan
@@ -128,6 +127,7 @@ def instance(
work_dir: pathlib.Path,
allow_unstable: bool = True,
clean_existing: bool = False,
+ project_name: str | None = None,
**kwargs: bool | str | None,
) -> Generator[craft_providers.Executor, None, None]:
"""Context manager for getting a provider instance.
@@ -139,7 +139,9 @@ def instance(
and re-created.
:returns: a context manager of the provider instance.
"""
- instance_name = self._get_instance_name(work_dir, build_info)
+ if not project_name:
+ project_name = self._services.get("project").get().name
+ instance_name = self._get_instance_name(work_dir, build_info, project_name)
emit.debug(f"Preparing managed instance {instance_name!r}")
base_name = build_info.base
base = self.get_base(base_name, instance_name=instance_name, **kwargs)
@@ -148,11 +150,11 @@ def instance(
provider.ensure_provider_is_available()
if clean_existing:
- self._clean_instance(provider, work_dir, build_info)
+ self._clean_instance(provider, work_dir, build_info, project_name)
emit.progress(f"Launching managed {base_name[0]} {base_name[1]} instance...")
with provider.launched_environment(
- project_name=self._project.name,
+ project_name=project_name,
project_path=work_dir,
instance_name=instance_name,
base_configuration=base,
@@ -283,18 +285,21 @@ def clean_instances(self) -> None:
target = "environments" if len(build_plan) > 1 else "environment"
emit.progress(f"Cleaning build {target}")
+ project_name = self._services.get("project").get().name
+
for info in build_plan:
- self._clean_instance(provider, self._work_dir, info)
+ self._clean_instance(provider, self._work_dir, info, project_name)
def _get_instance_name(
- self, work_dir: pathlib.Path, build_info: models.BuildInfo
+ self, work_dir: pathlib.Path, build_info: models.BuildInfo, project_name: str
) -> str:
work_dir_inode = work_dir.stat().st_ino
# craft-providers will remove invalid characters from the name but replacing
# characters improves readability for multi-base platforms like "ubuntu@24.04:amd64"
platform = build_info.platform.replace(":", "-").replace("@", "-")
- return f"{self._app.name}-{self._project.name}-{platform}-{work_dir_inode}"
+
+ return f"{self._app.name}-{project_name}-{platform}-{work_dir_inode}"
def _get_provider_by_name(self, name: str) -> craft_providers.Provider:
"""Get a provider by its name."""
@@ -354,8 +359,9 @@ def _clean_instance(
provider: craft_providers.Provider,
work_dir: pathlib.Path,
info: models.BuildInfo,
+ project_name: str,
) -> None:
"""Clean an instance, if it exists."""
- instance_name = self._get_instance_name(work_dir, info)
+ instance_name = self._get_instance_name(work_dir, info, project_name)
emit.debug(f"Cleaning instance {instance_name}")
provider.clean_project_environments(instance_name=instance_name)
diff --git a/craft_application/services/remotebuild.py b/craft_application/services/remotebuild.py
index 6bfb2e34..24cbe19a 100644
--- a/craft_application/services/remotebuild.py
+++ b/craft_application/services/remotebuild.py
@@ -32,7 +32,7 @@
import launchpadlib.errors # type: ignore[import-untyped]
import platformdirs
-from craft_application import errors, launchpad, models
+from craft_application import errors, launchpad
from craft_application.git import GitError, GitRepo
from craft_application.remote import (
RemoteBuildGitError,
@@ -123,7 +123,7 @@ def start_builds(
if self._builds:
raise ValueError("Cannot start builds if already running builds")
- project = cast(models.Project, self._services.project)
+ project = self._services.get("project").get()
check_git_repo_for_remote_build(project_dir)
diff --git a/craft_application/services/service_factory.py b/craft_application/services/service_factory.py
index 7abf1662..4f3a2b99 100644
--- a/craft_application/services/service_factory.py
+++ b/craft_application/services/service_factory.py
@@ -31,16 +31,18 @@
import annotated_types
-from craft_application import models, services
+from craft_application import services
if TYPE_CHECKING:
from craft_application.application import AppMetadata
+ from craft_application.services.project import ProjectService
_DEFAULT_SERVICES = {
"config": "ConfigService",
"fetch": "FetchService",
"init": "InitService",
"lifecycle": "LifecycleService",
+ "project": "ProjectService",
"provider": "ProviderService",
"remote_build": "RemoteBuildService",
"request": "RequestService",
@@ -81,12 +83,9 @@ class ServiceFactory:
def __init__(
self,
app: AppMetadata,
- *,
- project: models.Project | None = None,
**kwargs: type[services.AppService] | None,
) -> None:
self.app = app
- self.project = project
self._service_kwargs: dict[str, dict[str, Any]] = {}
self._services: dict[str, services.AppService] = {}
@@ -257,6 +256,8 @@ def get(self, service: Literal["package"]) -> services.PackageService: ...
@overload
def get(self, service: Literal["lifecycle"]) -> services.LifecycleService: ...
@overload
+ def get(self, service: Literal["project"]) -> ProjectService: ...
+ @overload
def get(self, service: Literal["provider"]) -> services.ProviderService: ...
@overload
def get(self, service: Literal["remote_build"]) -> services.RemoteBuildService: ...
@@ -277,13 +278,6 @@ def get(self, service: str) -> services.AppService:
return self._services[service]
cls = self.get_class(service)
kwargs = self._service_kwargs.get(service, {})
- if issubclass(cls, services.ProjectService):
- if not self.project:
- raise ValueError(
- f"{cls.__name__} requires a project to be available before creation."
- )
- kwargs.setdefault("project", self.project)
-
instance = cls(app=self.app, services=self, **kwargs)
instance.setup()
self._services[service] = instance
diff --git a/docs/conf.py b/docs/conf.py
index aac870ac..00243343 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -28,7 +28,14 @@
"https://github.com/canonical/[a-z]*craft[a-z-]*/releases/.*",
]
-# region Configuration for canonical-sphinx
+extensions = ["canonical_sphinx", "sphinx.ext.autodoc", "sphinx.ext.intersphinx"]
+
+rst_epilog = """
+.. include:: /reuse/links.txt
+"""
+
+
+# Canonical-sphinx
ogp_site_url = "https://canonical-craft-application.readthedocs-hosted.com/"
ogp_site_name = project
@@ -37,14 +44,14 @@
"github_url": "https://github.com/canonical/craft-application",
}
-extensions = [
- "canonical_sphinx",
- "sphinx.ext.autodoc",
-]
-# endregion
-
-# region Options for extensions
# Github config
github_username = "canonical"
github_repository = "craft-application"
-# endregion
+
+intersphinx_mapping = {
+ "craft-grammar": ("https://craft-grammar.readthedocs.io/en/latest", None),
+ "craft-parts": (
+ "https://canonical-craft-parts.readthedocs-hosted.com/en/latest",
+ None,
+ ),
+}
diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst
index e566d67b..3087ced1 100644
--- a/docs/reference/changelog.rst
+++ b/docs/reference/changelog.rst
@@ -10,6 +10,8 @@ Changelog
Services
========
+- A new :doc:`services/project` now handles the creation and management of the project
+ for this run of the application.
- Setting the arguments for a service using the service factory's ``set_kwargs`` is
deprecated. Use ``update_kwargs`` instead or file `an issue
`_
@@ -27,6 +29,10 @@ Breaking changes
- The pytest plugin includes an auto-used fixture that puts the app into debug mode
by default for tests.
- Support for secrets has been removed.
+- The abstract class ``ProjectService`` has been removed. Services can no longer
+ designate that they require a project, but should instead use the
+ :py:meth:`~craft_application.services.project.ProjectService.get()` method of the
+ ``ProjectService`` to retrieve the project. It will error accordingly.
For a complete list of commits, check out the `5.0.0`_ release on GitHub.
diff --git a/docs/reference/index.rst b/docs/reference/index.rst
index e9fa0028..892d5869 100644
--- a/docs/reference/index.rst
+++ b/docs/reference/index.rst
@@ -10,6 +10,7 @@ Reference
environment-variables
platforms
pytest-plugin
+ services/index
Indices and tables
==================
diff --git a/docs/reference/services/index.rst b/docs/reference/services/index.rst
new file mode 100644
index 00000000..4733a6f3
--- /dev/null
+++ b/docs/reference/services/index.rst
@@ -0,0 +1,9 @@
+.. _reference-services:
+
+Services
+========
+
+.. toctree::
+ :maxdepth: 1
+
+ project
diff --git a/docs/reference/services/project.rst b/docs/reference/services/project.rst
new file mode 100644
index 00000000..ed8c6e43
--- /dev/null
+++ b/docs/reference/services/project.rst
@@ -0,0 +1,97 @@
+.. py:currentmodule:: craft_application.services.project
+
+``ProjectService``
+==================
+
+The ``ProjectService`` is a service for handling access to this run's project.
+
+Project loading
+---------------
+
+The project service is responsible for loading, validating and rendering the project
+file to a :py:class:`~craft_application.models.Project` model. While the service can
+render a project as though it is running on any architecture and building for
+any platform or architecture, the most common use case is to render the project
+as though running on the current architecture, for the targeted platform.
+Here, the steps taken are as follows:
+
+.. How do I make this numbered?
+.. contents::
+ :class: this-will-duplicate-information-and-it-is-still-useful-here
+ :local:
+
+Configure the ProjectService
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The first step is for the ``Application`` to configure the project service. This means
+several things:
+
+1. Set the project directory location.
+2. Set any ``platform`` and ``build_for`` hints.
+
+Find the project file
+~~~~~~~~~~~~~~~~~~~~~
+
+Once a project directory is declared,
+:py:meth:`ProjectService.resolve_project_file_path` can be used to find the path to the
+actual project file. By default, this is simply ``.yaml`` in the project
+directory. However, applications may override this if they need to search for the
+project file at other locations.
+
+Parse the project file YAML
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Once the project file is found, it is parsed as a `YAML`_ file. The file has additional
+requirements beyond being a valid YAML document:
+
+1. A file may only contain a single document.
+2. The top level of this document must be a map whose keys are strings.
+
+Validate grammar
+~~~~~~~~~~~~~~~~
+
+At this step, any user-added grammar is validated using
+:external+craft-grammar:doc:`Craft Grammar `. No grammar is applied yet.
+
+Perform application-specific transforms
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+At this step, any application-specific transformations of the document are applied
+using the :py:meth:`ProjectService._app_preprocess_project` method. By default,
+nothing happens here.
+
+Expand environment
+~~~~~~~~~~~~~~~~~~
+
+Next, :external+craft-parts:func:`craft_parts.expand_environment` is called to replace
+global parts variables with their expected values.
+
+Process parts grammar
+~~~~~~~~~~~~~~~~~~~~~
+
+At this point, grammar is processed for each part defined. This includes parts added
+during the application-specific transforms step, meaning that transforms may add
+grammar-aware syntax if needed.
+
+Validate Pydantic model
+~~~~~~~~~~~~~~~~~~~~~~~
+
+A Pydantic model of the project is loaded, validating more thoroughly.
+
+Check mandatory adoptable fields
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The final step in loading the project is checking mandatory adoptable fields. If
+the ``adopt-info`` key is not set on the project, any mandatory fields that are
+presented as optional because of their adoptability are checked to ensure they are
+set.
+
+
+API documentation
+-----------------
+
+
+.. autoclass:: ProjectService
+ :members:
+ :private-members: _app_preprocess_project,_app_legacy_platforms_render
+ :undoc-members:
diff --git a/docs/reuse/links.txt b/docs/reuse/links.txt
new file mode 100644
index 00000000..cddd6f60
--- /dev/null
+++ b/docs/reuse/links.txt
@@ -0,0 +1 @@
+.. _`YAML`: https://yaml.org/
diff --git a/pyproject.toml b/pyproject.toml
index 150a2320..94bcecbd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -371,3 +371,7 @@ conflicts = [
[[tool.uv.index]]
name = "python-apt-wheels"
url = "https://people.canonical.com/~lengau/python-apt-ubuntu-wheels/" # workaround to get python-apt to install across multiple platforms
+explicit = true
+
+[tool.uv.sources]
+python-apt = { index = "python-apt-wheels" }
diff --git a/tests/conftest.py b/tests/conftest.py
index 8c9c91a1..44e185c2 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -17,6 +17,7 @@
from __future__ import annotations
+import io
import os
import pathlib
import shutil
@@ -27,6 +28,8 @@
from unittest.mock import Mock
import craft_parts
+import craft_platforms
+import distro
import jinja2
import pydantic
import pytest
@@ -38,11 +41,95 @@
import craft_application
from craft_application import application, git, launchpad, models, services, util
from craft_application.services import service_factory
+from craft_application.util import yaml
if TYPE_CHECKING: # pragma: no cover
from collections.abc import Iterator
+FAKE_PROJECT_YAML_TEMPLATE = """\
+name: full-project
+title: A fully-defined project
+summary: A fully-defined craft-application project.
+description: |
+ A fully-defined craft-application project.
+
+ This is a full description.
+version: 1.0.0.post64+git12345678
+license: LGPLv3
+
+base: {base}
+platforms:
+ 64-bit-pc:
+ build-on: [amd64]
+ build-for: [amd64]
+ some-phone:
+ build-on: [amd64, arm64, s390x]
+ build-for: [arm64]
+ ppc64el:
+ risky:
+ build-on: [amd64, arm64, ppc64el, riscv64, s390x]
+ build-for: [riscv64]
+ s390x:
+ build-on: [amd64, arm64, ppc64el, riscv64, s390x]
+ build-for: [s390x]
+ platform-independent:
+ build-on: [amd64, arm64, ppc64el, riscv64, s390x]
+ build-for: [all]
+
+contact: author@project.org
+issues: https://github.com/canonical/craft-application/issues
+source-code: https://github.com/canonical/craft-application
+
+parts:
+ some-part:
+ plugin: nil
+ build-environment:
+ - BUILD_ON: $CRAFT_ARCH_BUILD_ON
+ - BUILD_FOR: $CRAFT_ARCH_BUILD_FOR
+"""
+
+
+@pytest.fixture(
+ params=[
+ "64-bit-pc",
+ "some-phone",
+ "ppc64el",
+ "risky",
+ "s390x",
+ "platform-independent",
+ ]
+)
+def fake_platform(request: pytest.FixtureRequest) -> str:
+ return request.param
+
+
+@pytest.fixture
+def fake_project_yaml():
+ current_base = craft_platforms.DistroBase.from_linux_distribution(
+ distro.LinuxDistribution(
+ include_lsb=False, include_uname=False, include_oslevel=False
+ )
+ )
+ return FAKE_PROJECT_YAML_TEMPLATE.format(
+ base=f"{current_base.distribution}@{current_base.series}"
+ )
+
+
+@pytest.fixture
+def fake_project_file(in_project_path, fake_project_yaml):
+ project_file = in_project_path / "testcraft.yaml"
+ project_file.write_text(fake_project_yaml)
+
+ return project_file
+
+
+@pytest.fixture
+def fake_project(fake_project_yaml) -> models.Project:
+ with io.StringIO(fake_project_yaml) as project_io:
+ return models.Project.unmarshal(yaml.safe_yaml_load(project_io))
+
+
def _create_fake_build_plan(num_infos: int = 1) -> list[models.BuildInfo]:
"""Create a build plan that is able to execute on the running system."""
arch = util.get_host_architecture()
@@ -56,6 +143,12 @@ def reset_services():
service_factory.ServiceFactory.reset()
+@pytest.fixture
+def in_project_dir(project_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Put us in the project directory made by project_path."""
+ monkeypatch.chdir(project_path)
+
+
class FakeConfigModel(craft_application.ConfigModel):
my_str: str
my_int: int
@@ -111,27 +204,6 @@ def app_metadata_docs() -> craft_application.AppMetadata:
)
-@pytest.fixture
-def fake_project() -> models.Project:
- arch = util.get_host_architecture()
- return models.Project(
- name="full-project", # pyright: ignore[reportArgumentType]
- title="A fully-defined project", # pyright: ignore[reportArgumentType]
- base="ubuntu@24.04",
- version="1.0.0.post64+git12345678", # pyright: ignore[reportArgumentType]
- contact="author@project.org",
- issues="https://github.com/canonical/craft-application/issues",
- source_code="https://github.com/canonical/craft-application", # pyright: ignore[reportArgumentType]
- summary="A fully-defined craft-application project.", # pyright: ignore[reportArgumentType]
- description="A fully-defined craft-application project. (description)",
- license="LGPLv3",
- parts={"my-part": {"plugin": "nil"}},
- platforms={"foo": models.Platform(build_on=[arch], build_for=[arch])},
- package_repositories=None,
- adopt_info=None,
- )
-
-
@pytest.fixture
def fake_build_plan(request) -> list[models.BuildInfo]:
num_infos = getattr(request, "param", 1)
@@ -225,20 +297,39 @@ def emitter_verbosity(request):
@pytest.fixture
-def fake_provider_service_class(fake_build_plan):
+def fake_project_service_class(fake_project) -> type[services.ProjectService]:
+ class FakeProjectService(services.ProjectService):
+ # This is a final method, but we're overriding it here for convenience when
+ # doing internal testing.
+ @override
+ def _load_raw_project(self): # type: ignore[reportIncompatibleMethodOverride]
+ return fake_project.marshal()
+
+ # Don't care if the project file exists during this testing.
+ # Silencing B019 because we're replicating an inherited method.
+ @override
+ def resolve_project_file_path(self):
+ return (self._project_dir / f"{self._app.name}.yaml").resolve()
+
+ def set(self, value: models.Project) -> None:
+ """Set the project model. Only for use during testing!"""
+ self._project_model = value
+
+ return FakeProjectService
+
+
+@pytest.fixture
+def fake_provider_service_class(fake_build_plan, project_path):
class FakeProviderService(services.ProviderService):
def __init__(
self,
app: application.AppMetadata,
services: services.ServiceFactory,
- *,
- project: models.Project,
):
super().__init__(
app,
services,
- project=project,
- work_dir=pathlib.Path(),
+ work_dir=project_path,
build_plan=fake_build_plan,
)
@@ -252,7 +343,7 @@ def pack(
self, prime_dir: pathlib.Path, dest: pathlib.Path
) -> list[pathlib.Path]:
assert prime_dir.exists()
- pkg = dest / f"package_{self._project.version}.tar.zst"
+ pkg = dest / "package_1.0.tar.zst"
pkg.touch()
return [pkg]
@@ -269,7 +360,6 @@ class FakeLifecycleService(services.LifecycleService):
def __init__(
self,
app: application.AppMetadata,
- project: models.Project,
services: services.ServiceFactory,
**kwargs: Any,
):
@@ -277,7 +367,6 @@ def __init__(
super().__init__(
app,
services,
- project=project,
work_dir=kwargs.pop("work_dir", tmp_path / "work"),
cache_dir=kwargs.pop("cache_dir", tmp_path / "cache"),
platform=None,
@@ -314,17 +403,25 @@ def fake_services(
fake_project,
fake_lifecycle_service_class,
fake_package_service_class,
+ fake_project_service_class,
fake_init_service_class,
fake_remote_build_service_class,
+ project_path,
):
services.ServiceFactory.register("package", fake_package_service_class)
services.ServiceFactory.register("lifecycle", fake_lifecycle_service_class)
services.ServiceFactory.register("init", fake_init_service_class)
services.ServiceFactory.register("remote_build", fake_remote_build_service_class)
- factory = services.ServiceFactory(app_metadata, project=fake_project)
+ services.ServiceFactory.register("project", fake_project_service_class)
+ factory = services.ServiceFactory(app_metadata)
factory.update_kwargs(
"lifecycle", work_dir=tmp_path, cache_dir=tmp_path / "cache", build_plan=[]
)
+ factory.update_kwargs(
+ "project",
+ project_dir=project_path,
+ )
+ factory.get("project").render_once()
return factory
@@ -338,23 +435,12 @@ class FakeApplication(application.Application):
def set_project(self, project):
self._Application__project = project
- @override
- def _extra_yaml_transform(
- self,
- yaml_data: dict[str, Any],
- *,
- build_on: str,
- build_for: str | None,
- ) -> dict[str, Any]:
- self.build_on = build_on
- self.build_for = build_for
-
- return yaml_data
-
@pytest.fixture
-def app(app_metadata, fake_services):
- return FakeApplication(app_metadata, fake_services)
+def app(app_metadata, fake_services, tmp_path):
+ application = FakeApplication(app_metadata, fake_services)
+ application._work_dir = tmp_path
+ return application
@pytest.fixture
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 7ec1b8d8..a7fbe483 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -45,12 +45,11 @@ def pytest_runtest_setup(item: pytest.Item):
@pytest.fixture
-def provider_service(app_metadata, fake_project, fake_build_plan, fake_services):
+def provider_service(app_metadata, fake_build_plan, fake_services):
"""Provider service with install snap disabled for integration tests"""
return provider.ProviderService(
app_metadata,
fake_services,
- project=fake_project,
work_dir=pathlib.Path(),
build_plan=fake_build_plan,
install_snap=False,
diff --git a/tests/integration/services/project_files/grammarcraft-full.on-amd64.for-riscv64 b/tests/integration/services/project_files/grammarcraft-full.on-amd64.for-riscv64
new file mode 100644
index 00000000..27e9e386
--- /dev/null
+++ b/tests/integration/services/project_files/grammarcraft-full.on-amd64.for-riscv64
@@ -0,0 +1,71 @@
+name: myproject
+version: "1.0"
+base: ubuntu@24.04
+platforms:
+ dev-board:
+ build-on: [amd64, riscv64]
+ build-for: [riscv64]
+ mainframe:
+ build-on: [amd64, riscv64]
+ build-for: [s390x]
+parts:
+ mypart:
+ plugin: nil
+ source: on-amd64-to-riscv64
+ source-checksum: to-riscv64-checksum
+ source-branch: riscv64-branch
+ source-commit: riscv64-commit
+ source-depth: 1
+ source-subdir: riscv64-subdir
+ source-submodules:
+ - riscv64-submodules-1
+ - riscv64-submodules-2
+ source-tag: riscv64-tag
+ source-type: riscv64-type
+ disable-parallel: true
+ after:
+ - riscv64-after
+ organize:
+ riscv64-organize-1: riscv64-organize-2
+ riscv64-organize-3: riscv64-organize-4
+ stage:
+ - riscv64-stage-1
+ - riscv64-stage-2
+ stage-snaps:
+ - riscv64-snap-1
+ - riscv64-snap-2
+ stage-packages:
+ - riscv64-package-1
+ - riscv64-package-2
+ prime:
+ - riscv64-prime-1
+ - riscv64-prime-2
+ build-snaps:
+ - riscv64-snap-1
+ - riscv64-snap-2
+ build-packages:
+ - riscv64-package-1
+ - riscv64-package-2
+ build-environment:
+ - MY_VAR: riscv64-value
+ - MY_VAR2: riscv64-value2
+ build-attributes:
+ - rifcv64-attr-1
+ - rifcv64-attr-2
+ override-pull: |-
+ riscv64-override-pull
+ override-build: |-
+ riscv64-override-build
+ override-stage: |-
+ riscv64-override-stage
+ override-prime: |-
+ riscv64-override-prime
+ permissions:
+ - path: riscv64-perm-1
+ owner: 123
+ group: 123
+ mode: "777"
+ - path: riscv64-perm-2
+ owner: 456
+ group: 456
+ mode: "666"
diff --git a/tests/integration/services/project_files/grammarcraft-full.on-amd64.for-s390x b/tests/integration/services/project_files/grammarcraft-full.on-amd64.for-s390x
new file mode 100644
index 00000000..3100fbac
--- /dev/null
+++ b/tests/integration/services/project_files/grammarcraft-full.on-amd64.for-s390x
@@ -0,0 +1,71 @@
+name: myproject
+version: "1.0"
+base: ubuntu@24.04
+platforms:
+ dev-board:
+ build-on: [amd64, riscv64]
+ build-for: [riscv64]
+ mainframe:
+ build-on: [amd64, riscv64]
+ build-for: [s390x]
+parts:
+ mypart:
+ plugin: nil
+ source: on-amd64-to-s390x
+ source-checksum: to-s390x-checksum
+ source-branch: s390x-branch
+ source-commit: s390x-commit
+ source-depth: 2
+ source-subdir: s390x-subdir
+ source-submodules:
+ - s390x-submodules-1
+ - s390x-submodules-2
+ source-tag: s390x-tag
+ source-type: s390x-type
+ disable-parallel: false
+ after:
+ - s390x-after
+ organize:
+ s390x-organize-1: s390x-organize-2
+ s390x-organize-3: s390x-organize-4
+ stage:
+ - s390x-stage-1
+ - s390x-stage-2
+ stage-snaps:
+ - s390x-snap-1
+ - s390x-snap-2
+ stage-packages:
+ - s390x-package-1
+ - s390x-package-2
+ prime:
+ - s390x-prime-1
+ - s390x-prime-2
+ build-snaps:
+ - s390x-snap-1
+ - s390x-snap-2
+ build-packages:
+ - s390x-package-1
+ - s390x-package-2
+ build-environment:
+ - MY_VAR: s390x-value
+ - MY_VAR2: s390x-value2
+ build-attributes:
+ - s390x-attr-1
+ - s390x-attr-2
+ override-pull: |-
+ s390x-override-pull
+ override-build: |-
+ s390x-override-build
+ override-stage: |-
+ s390x-override-stage
+ override-prime: |-
+ s390x-override-prime
+ permissions:
+ - path: s390x-perm-1
+ owner: 123
+ group: 123
+ mode: "666"
+ - path: s390x-perm-2
+ owner: 456
+ group: 456
+ mode: "777"
diff --git a/tests/integration/services/project_files/grammarcraft-full.on-riscv64.for-riscv64 b/tests/integration/services/project_files/grammarcraft-full.on-riscv64.for-riscv64
new file mode 100644
index 00000000..2fa88936
--- /dev/null
+++ b/tests/integration/services/project_files/grammarcraft-full.on-riscv64.for-riscv64
@@ -0,0 +1,71 @@
+name: myproject
+version: "1.0"
+base: ubuntu@24.04
+platforms:
+ dev-board:
+ build-on: [amd64, riscv64]
+ build-for: [riscv64]
+ mainframe:
+ build-on: [amd64, riscv64]
+ build-for: [s390x]
+parts:
+ mypart:
+ plugin: rust
+ source: on-riscv64-to-riscv64
+ source-checksum: to-riscv64-checksum
+ source-branch: riscv64-branch
+ source-commit: riscv64-commit
+ source-depth: 1
+ source-subdir: riscv64-subdir
+ source-submodules:
+ - riscv64-submodules-1
+ - riscv64-submodules-2
+ source-tag: riscv64-tag
+ source-type: riscv64-type
+ disable-parallel: true
+ after:
+ - riscv64-after
+ organize:
+ riscv64-organize-1: riscv64-organize-2
+ riscv64-organize-3: riscv64-organize-4
+ stage:
+ - riscv64-stage-1
+ - riscv64-stage-2
+ stage-snaps:
+ - riscv64-snap-1
+ - riscv64-snap-2
+ stage-packages:
+ - riscv64-package-1
+ - riscv64-package-2
+ prime:
+ - riscv64-prime-1
+ - riscv64-prime-2
+ build-snaps:
+ - riscv64-snap-1
+ - riscv64-snap-2
+ build-packages:
+ - riscv64-package-1
+ - riscv64-package-2
+ build-environment:
+ - MY_VAR: riscv64-value
+ - MY_VAR2: riscv64-value2
+ build-attributes:
+ - rifcv64-attr-1
+ - rifcv64-attr-2
+ override-pull: |-
+ riscv64-override-pull
+ override-build: |-
+ riscv64-override-build
+ override-stage: |-
+ riscv64-override-stage
+ override-prime: |-
+ riscv64-override-prime
+ permissions:
+ - path: riscv64-perm-1
+ owner: 123
+ group: 123
+ mode: "777"
+ - path: riscv64-perm-2
+ owner: 456
+ group: 456
+ mode: "666"
diff --git a/tests/integration/services/project_files/grammarcraft-full.on-riscv64.for-s390x b/tests/integration/services/project_files/grammarcraft-full.on-riscv64.for-s390x
new file mode 100644
index 00000000..eddcafaa
--- /dev/null
+++ b/tests/integration/services/project_files/grammarcraft-full.on-riscv64.for-s390x
@@ -0,0 +1,71 @@
+name: myproject
+version: "1.0"
+base: ubuntu@24.04
+platforms:
+ dev-board:
+ build-on: [amd64, riscv64]
+ build-for: [riscv64]
+ mainframe:
+ build-on: [amd64, riscv64]
+ build-for: [s390x]
+parts:
+ mypart:
+ plugin: rust
+ source: on-riscv64-to-s390x
+ source-checksum: to-s390x-checksum
+ source-branch: s390x-branch
+ source-commit: s390x-commit
+ source-depth: 2
+ source-subdir: s390x-subdir
+ source-submodules:
+ - s390x-submodules-1
+ - s390x-submodules-2
+ source-tag: s390x-tag
+ source-type: s390x-type
+ disable-parallel: false
+ after:
+ - s390x-after
+ organize:
+ s390x-organize-1: s390x-organize-2
+ s390x-organize-3: s390x-organize-4
+ stage:
+ - s390x-stage-1
+ - s390x-stage-2
+ stage-snaps:
+ - s390x-snap-1
+ - s390x-snap-2
+ stage-packages:
+ - s390x-package-1
+ - s390x-package-2
+ prime:
+ - s390x-prime-1
+ - s390x-prime-2
+ build-snaps:
+ - s390x-snap-1
+ - s390x-snap-2
+ build-packages:
+ - s390x-package-1
+ - s390x-package-2
+ build-environment:
+ - MY_VAR: s390x-value
+ - MY_VAR2: s390x-value2
+ build-attributes:
+ - s390x-attr-1
+ - s390x-attr-2
+ override-pull: |-
+ s390x-override-pull
+ override-build: |-
+ s390x-override-build
+ override-stage: |-
+ s390x-override-stage
+ override-prime: |-
+ s390x-override-prime
+ permissions:
+ - path: s390x-perm-1
+ owner: 123
+ group: 123
+ mode: "666"
+ - path: s390x-perm-2
+ owner: 456
+ group: 456
+ mode: "777"
diff --git a/tests/integration/services/project_files/grammarcraft-full.yaml b/tests/integration/services/project_files/grammarcraft-full.yaml
new file mode 100644
index 00000000..7daafebb
--- /dev/null
+++ b/tests/integration/services/project_files/grammarcraft-full.yaml
@@ -0,0 +1,151 @@
+name: myproject
+version: 1.0
+base: ubuntu@24.04
+platforms:
+ dev-board:
+ build-on: [amd64, riscv64]
+ build-for: [riscv64]
+ mainframe:
+ build-on: [amd64, riscv64]
+ build-for: [s390x]
+parts:
+ mypart:
+ plugin:
+ - on amd64: nil
+ - on s390x: dump
+ - on riscv64: rust
+ source:
+ - on amd64 to s390x: on-amd64-to-s390x
+ - on riscv64 to s390x: on-riscv64-to-s390x
+ - on amd64 to riscv64: on-amd64-to-riscv64
+ - on riscv64 to riscv64: on-riscv64-to-riscv64
+ source-checksum:
+ - to riscv64: to-riscv64-checksum
+ - to s390x: to-s390x-checksum
+ source-branch:
+ - to s390x: s390x-branch
+ - to riscv64: riscv64-branch
+ source-commit:
+ - to riscv64: riscv64-commit
+ - to s390x: s390x-commit
+ source-depth:
+ - to s390x: 2
+ - to riscv64: 1
+ source-subdir:
+ - to riscv64: riscv64-subdir
+ - to s390x: s390x-subdir
+ source-submodules:
+ - to s390x:
+ - s390x-submodules-1
+ - s390x-submodules-2
+ - to riscv64:
+ - riscv64-submodules-1
+ - riscv64-submodules-2
+ source-tag:
+ - to riscv64: riscv64-tag
+ - to s390x: s390x-tag
+ source-type:
+ - to s390x: s390x-type
+ - to riscv64: riscv64-type
+ disable-parallel:
+ - to riscv64: true
+ - else: false
+ after:
+ - to s390x:
+ - s390x-after
+ - to riscv64:
+ - riscv64-after
+ organize:
+ - to riscv64:
+ riscv64-organize-1: riscv64-organize-2
+ riscv64-organize-3: riscv64-organize-4
+ - to s390x:
+ s390x-organize-1: s390x-organize-2
+ s390x-organize-3: s390x-organize-4
+ stage:
+ - to riscv64:
+ - riscv64-stage-1
+ - riscv64-stage-2
+ - to s390x:
+ - s390x-stage-1
+ - s390x-stage-2
+ stage-snaps:
+ - to s390x:
+ - s390x-snap-1
+ - s390x-snap-2
+ - to riscv64:
+ - riscv64-snap-1
+ - riscv64-snap-2
+ stage-packages:
+ - to riscv64:
+ - riscv64-package-1
+ - riscv64-package-2
+ - to s390x:
+ - s390x-package-1
+ - s390x-package-2
+ prime:
+ - to s390x:
+ - s390x-prime-1
+ - s390x-prime-2
+ - to riscv64:
+ - riscv64-prime-1
+ - riscv64-prime-2
+ build-snaps:
+ - to riscv64:
+ - riscv64-snap-1
+ - riscv64-snap-2
+ - to s390x:
+ - s390x-snap-1
+ - s390x-snap-2
+ build-packages:
+ - to s390x:
+ - s390x-package-1
+ - s390x-package-2
+ - to riscv64:
+ - riscv64-package-1
+ - riscv64-package-2
+ build-environment:
+ - to riscv64:
+ - MY_VAR: riscv64-value
+ - MY_VAR2: riscv64-value2
+ - to s390x:
+ - MY_VAR: s390x-value
+ - MY_VAR2: s390x-value2
+ build-attributes:
+ - to s390x:
+ - s390x-attr-1
+ - s390x-attr-2
+ - to riscv64:
+ - rifcv64-attr-1
+ - rifcv64-attr-2
+ override-pull:
+ - to riscv64: riscv64-override-pull
+ - to s390x: s390x-override-pull
+ override-build:
+ - to s390x: s390x-override-build
+ - to riscv64: riscv64-override-build
+ override-stage:
+ - to riscv64: riscv64-override-stage
+ - to s390x: s390x-override-stage
+ override-prime:
+ - to s390x: s390x-override-prime
+ - to riscv64: riscv64-override-prime
+ permissions:
+ - to riscv64:
+ - path: riscv64-perm-1
+ owner: 123
+ group: 123
+ mode: "777"
+ - path: riscv64-perm-2
+ owner: 456
+ group: 456
+ mode: "666"
+ - to s390x:
+ - path: s390x-perm-1
+ owner: 123
+ group: 123
+ mode: "666"
+ - path: s390x-perm-2
+ owner: 456
+ group: 456
+ mode: "777"
diff --git a/tests/integration/services/project_files/overlaycraft-full.out b/tests/integration/services/project_files/overlaycraft-full.out
new file mode 100644
index 00000000..8e64fc5b
--- /dev/null
+++ b/tests/integration/services/project_files/overlaycraft-full.out
@@ -0,0 +1,71 @@
+name: myproject
+version: "1.0"
+base: ubuntu@24.04
+platforms:
+ arm64:
+ build-on: [arm64]
+ build-for: [arm64]
+parts:
+ mypart:
+ plugin: nil
+ source: non-grammar-source
+ source-checksum: on-amd64-to-riscv64-checksum
+ source-branch: riscv64-branch
+ source-commit: riscv64-commit
+ source-depth: 1
+ source-subdir: riscv64-subdir
+ source-submodules:
+ - riscv64-submodules-1
+ - riscv64-submodules-2
+ source-tag: riscv64-tag
+ source-type: riscv64-type
+ disable-parallel: true
+ after:
+ - riscv64-after
+ organize:
+ riscv64-organize-1: riscv64-organize-2
+ riscv64-organize-3: riscv64-organize-4
+ overlay:
+ - riscv64-overlay-1
+ - riscv64-overlay-2
+ overlay-packages:
+ - riscv64-overlay-1
+ - riscv64-overlay-2
+ overlay-script: riscv64-overlay-script
+ stage:
+ - riscv64-stage-1
+ - riscv64-stage-2
+ stage-snaps:
+ - riscv64-snap-1
+ - riscv64-snap-2
+ stage-packages:
+ - riscv64-package-1
+ - riscv64-package-2
+ prime:
+ - riscv64-prime-1
+ - riscv64-prime-2
+ build-snaps:
+ - riscv64-snap-1
+ - riscv64-snap-2
+ build-packages:
+ - riscv64-package-1
+ - riscv64-package-2
+ build-environment:
+ - MY_VAR: riscv64-value
+ - MY_VAR2: riscv64-value2
+ build-attributes:
+ - rifcv64-attr-1
+ - rifcv64-attr-2
+ override-pull: riscv64-override-pull
+ override-build: riscv64-override-build
+ override-stage: riscv64-override-stage
+ override-prime: riscv64-override-prime
+ permissions:
+ - path: riscv64-perm-1
+ owner: 123
+ group: 123
+ mode: "777"
+ - path: riscv64-perm-2
+ owner: 456
+ group: 456
+ mode: "666"
diff --git a/tests/integration/services/project_files/overlaycraft-full.yaml b/tests/integration/services/project_files/overlaycraft-full.yaml
new file mode 100644
index 00000000..915dc1aa
--- /dev/null
+++ b/tests/integration/services/project_files/overlaycraft-full.yaml
@@ -0,0 +1,69 @@
+name: myproject
+version: 1.0
+base: ubuntu@24.04
+platforms:
+ arm64:
+parts:
+ mypart:
+ plugin: nil
+ source: non-grammar-source
+ source-checksum: on-amd64-to-riscv64-checksum
+ source-branch: riscv64-branch
+ source-commit: riscv64-commit
+ source-depth: 1
+ source-subdir: riscv64-subdir
+ source-submodules:
+ - riscv64-submodules-1
+ - riscv64-submodules-2
+ source-tag: riscv64-tag
+ source-type: riscv64-type
+ disable-parallel: true
+ after:
+ - riscv64-after
+ organize:
+ riscv64-organize-1: riscv64-organize-2
+ riscv64-organize-3: riscv64-organize-4
+ overlay:
+ - riscv64-overlay-1
+ - riscv64-overlay-2
+ overlay-packages:
+ - riscv64-overlay-1
+ - riscv64-overlay-2
+ overlay-script: riscv64-overlay-script
+ stage:
+ - riscv64-stage-1
+ - riscv64-stage-2
+ stage-snaps:
+ - riscv64-snap-1
+ - riscv64-snap-2
+ stage-packages:
+ - riscv64-package-1
+ - riscv64-package-2
+ prime:
+ - riscv64-prime-1
+ - riscv64-prime-2
+ build-snaps:
+ - riscv64-snap-1
+ - riscv64-snap-2
+ build-packages:
+ - riscv64-package-1
+ - riscv64-package-2
+ build-environment:
+ - MY_VAR: riscv64-value
+ - MY_VAR2: riscv64-value2
+ build-attributes:
+ - rifcv64-attr-1
+ - rifcv64-attr-2
+ override-pull: riscv64-override-pull
+ override-build: riscv64-override-build
+ override-stage: riscv64-override-stage
+ override-prime: riscv64-override-prime
+ permissions:
+ - path: riscv64-perm-1
+ owner: 123
+ group: 123
+ mode: "777"
+ - path: riscv64-perm-2
+ owner: 456
+ group: 456
+ mode: "666"
diff --git a/tests/integration/services/project_files/testcraft-basic.out b/tests/integration/services/project_files/testcraft-basic.out
new file mode 100644
index 00000000..d4b34466
--- /dev/null
+++ b/tests/integration/services/project_files/testcraft-basic.out
@@ -0,0 +1,7 @@
+name: testy-mctestface
+version: "0.1"
+platforms:
+ all:
+ build-on: [amd64, arm64, armhf, i386, ppc64el, riscv64, s390x]
+ build-for: [all]
+parts: {}
diff --git a/tests/integration/services/project_files/testcraft-basic.yaml b/tests/integration/services/project_files/testcraft-basic.yaml
new file mode 100644
index 00000000..4164719f
--- /dev/null
+++ b/tests/integration/services/project_files/testcraft-basic.yaml
@@ -0,0 +1,6 @@
+name: testy-mctestface
+version: 0.1
+platforms:
+ all:
+ build-on: [amd64, arm64, armhf, i386, ppc64el, riscv64, s390x]
+ build-for: [all]
diff --git a/tests/integration/services/project_files/testcraft-full.out b/tests/integration/services/project_files/testcraft-full.out
new file mode 100644
index 00000000..ff4d44cc
--- /dev/null
+++ b/tests/integration/services/project_files/testcraft-full.out
@@ -0,0 +1,64 @@
+name: myproject
+version: "1.0"
+base: ubuntu@24.04
+platforms:
+ unlikely: # A platform we probably won't run tests on.
+ build-on: [i386]
+ build-for: [all]
+parts:
+ mypart:
+ plugin: nil
+ source: non-grammar-source
+ source-checksum: on-amd64-to-i386-checksum
+ source-branch: i386-branch
+ source-commit: i386-commit
+ source-depth: 1
+ source-subdir: i386-subdir
+ source-submodules:
+ - i386-submodules-1
+ - i386-submodules-2
+ source-tag: i386-tag
+ source-type: i386-type
+ disable-parallel: true
+ after:
+ - i386-after
+ organize:
+ i386-organize-1: i386-organize-2
+ i386-organize-3: i386-organize-4
+ stage:
+ - i386-stage-1
+ - i386-stage-2
+ stage-snaps:
+ - i386-snap-1
+ - i386-snap-2
+ stage-packages:
+ - i386-package-1
+ - i386-package-2
+ prime:
+ - i386-prime-1
+ - i386-prime-2
+ build-snaps:
+ - i386-snap-1
+ - i386-snap-2
+ build-packages:
+ - i386-package-1
+ - i386-package-2
+ build-environment:
+ - MY_VAR: i386-value
+ - MY_VAR2: i386-value2
+ build-attributes:
+ - rifcv64-attr-1
+ - rifcv64-attr-2
+ override-pull: i386-override-pull
+ override-build: i386-override-build
+ override-stage: i386-override-stage
+ override-prime: i386-override-prime
+ permissions:
+ - path: i386-perm-1
+ owner: 123
+ group: 123
+ mode: "777"
+ - path: i386-perm-2
+ owner: 456
+ group: 456
+ mode: "666"
diff --git a/tests/integration/services/project_files/testcraft-full.yaml b/tests/integration/services/project_files/testcraft-full.yaml
new file mode 100644
index 00000000..3e7d2372
--- /dev/null
+++ b/tests/integration/services/project_files/testcraft-full.yaml
@@ -0,0 +1,64 @@
+name: myproject
+version: 1.0
+base: ubuntu@24.04
+platforms:
+ unlikely: # A platform we probably won't run tests on.
+ build-on: [i386]
+ build-for: [all]
+parts:
+ mypart:
+ plugin: nil
+ source: non-grammar-source
+ source-checksum: on-amd64-to-i386-checksum
+ source-branch: i386-branch
+ source-commit: i386-commit
+ source-depth: 1
+ source-subdir: i386-subdir
+ source-submodules:
+ - i386-submodules-1
+ - i386-submodules-2
+ source-tag: i386-tag
+ source-type: i386-type
+ disable-parallel: true
+ after:
+ - i386-after
+ organize:
+ i386-organize-1: i386-organize-2
+ i386-organize-3: i386-organize-4
+ stage:
+ - i386-stage-1
+ - i386-stage-2
+ stage-snaps:
+ - i386-snap-1
+ - i386-snap-2
+ stage-packages:
+ - i386-package-1
+ - i386-package-2
+ prime:
+ - i386-prime-1
+ - i386-prime-2
+ build-snaps:
+ - i386-snap-1
+ - i386-snap-2
+ build-packages:
+ - i386-package-1
+ - i386-package-2
+ build-environment:
+ - MY_VAR: i386-value
+ - MY_VAR2: i386-value2
+ build-attributes:
+ - rifcv64-attr-1
+ - rifcv64-attr-2
+ override-pull: i386-override-pull
+ override-build: i386-override-build
+ override-stage: i386-override-stage
+ override-prime: i386-override-prime
+ permissions:
+ - path: i386-perm-1
+ owner: 123
+ group: 123
+ mode: "777"
+ - path: i386-perm-2
+ owner: 456
+ group: 456
+ mode: "666"
diff --git a/tests/integration/services/test_fetch.py b/tests/integration/services/test_fetch.py
index 219a0408..c79d42c8 100644
--- a/tests/integration/services/test_fetch.py
+++ b/tests/integration/services/test_fetch.py
@@ -84,7 +84,6 @@ def app_service(app_metadata, fake_services, fake_project, fake_build_plan):
fetch_service = services.FetchService(
app_metadata,
fake_services,
- project=fake_project,
build_plan=fake_build_plan,
session_policy="permissive",
)
diff --git a/tests/integration/services/test_lifecycle.py b/tests/integration/services/test_lifecycle.py
index 38b873dd..6e839b58 100644
--- a/tests/integration/services/test_lifecycle.py
+++ b/tests/integration/services/test_lifecycle.py
@@ -37,6 +37,7 @@
def parts_lifecycle(
app_metadata, fake_project, fake_services, tmp_path, request, fake_build_plan
):
+ fake_services.get("project").set(fake_project)
fake_project.parts = request.param
service = LifecycleService(
@@ -104,10 +105,17 @@ def test_lifecycle_messages_no_duplicates(parts_lifecycle, request, capsys):
assert expected_output in stderr
+@pytest.mark.slow
@pytest.mark.usefixtures("enable_overlay")
def test_package_repositories_in_overlay(
- app_metadata, fake_project, fake_services, tmp_path, mocker, fake_build_plan
+ app_metadata,
+ fake_project,
+ fake_services,
+ tmp_path,
+ mocker,
+ fake_build_plan,
):
+ fake_services.get("project").set(fake_project)
# Mock overlay-related calls that need root; we won't be actually installing
# any packages, just checking that the repositories are correctly installed
# in the overlay.
diff --git a/tests/integration/services/test_project.py b/tests/integration/services/test_project.py
new file mode 100644
index 00000000..d97c8331
--- /dev/null
+++ b/tests/integration/services/test_project.py
@@ -0,0 +1,113 @@
+# Copyright 2025 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
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE.
+# See the GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with this program. If not, see .
+"""Integration tests for the ProjectService."""
+
+import pathlib
+import shutil
+
+import craft_platforms
+import pytest
+
+from craft_application.services.project import ProjectService
+from craft_application.util import yaml
+
+PROJECT_FILES_PATH = pathlib.Path(__file__).parent / "project_files"
+
+
+@pytest.fixture
+def service(app_metadata, fake_services, in_project_path: pathlib.Path):
+ return ProjectService(
+ app=app_metadata, services=fake_services, project_dir=in_project_path
+ )
+
+
+@pytest.fixture(
+ params=[
+ pytest.param(path, id=path.name)
+ for path in PROJECT_FILES_PATH.glob("testcraft-*.yaml")
+ ]
+)
+def project_file(
+ project_path: pathlib.Path, app_metadata, request: pytest.FixtureRequest
+):
+ project_file = project_path / f"{app_metadata.name}.yaml"
+ shutil.copyfile(request.param, project_file)
+ return request.param
+
+
+def test_load_project(service: ProjectService, project_file: pathlib.Path):
+ project = service.render_once()
+
+ with project_file.with_suffix(".out").open() as f:
+ expected = yaml.safe_yaml_load(f)
+
+ assert project.marshal() == expected
+
+
+@pytest.fixture(
+ params=[
+ pytest.param(path, id=path.name)
+ for path in PROJECT_FILES_PATH.glob("overlaycraft-*.yaml")
+ ]
+)
+def overlay_project_file(
+ project_path: pathlib.Path, app_metadata, request: pytest.FixtureRequest
+):
+ project_file = project_path / f"{app_metadata.name}.yaml"
+ shutil.copyfile(request.param, project_file)
+ return request.param
+
+
+@pytest.mark.usefixtures("enable_overlay")
+def test_load_overlay_project(service: ProjectService, overlay_project_file):
+ project = service.render_once()
+
+ with overlay_project_file.with_suffix(".out").open() as f:
+ expected = yaml.safe_yaml_load(f)
+
+ assert project.marshal() == expected
+
+
+@pytest.fixture(
+ params=[
+ pytest.param(path, id=path.name)
+ for path in PROJECT_FILES_PATH.glob("grammarcraft-*.yaml")
+ ]
+)
+def grammar_project_file(
+ project_path: pathlib.Path, app_metadata, request: pytest.FixtureRequest
+):
+ project_file = project_path / f"{app_metadata.name}.yaml"
+ shutil.copyfile(request.param, project_file)
+ return request.param
+
+
+@pytest.mark.parametrize("build_for", ["riscv64", "s390x"])
+@pytest.mark.parametrize("build_on", ["amd64", "riscv64"])
+def test_load_grammar_project(
+ mocker, service: ProjectService, grammar_project_file, build_on, build_for
+):
+ mocker.patch(
+ "craft_platforms.DebianArchitecture.from_host",
+ return_value=craft_platforms.DebianArchitecture(build_on),
+ )
+
+ project = service.render_once(build_for=build_for)
+
+ with grammar_project_file.with_suffix(
+ f".on-{build_on}.for-{build_for}"
+ ).open() as f:
+ expected = yaml.safe_yaml_load(f)
+
+ assert project.marshal() == expected
diff --git a/tests/integration/services/test_service_factory.py b/tests/integration/services/test_service_factory.py
index 71b7023a..75d87727 100644
--- a/tests/integration/services/test_service_factory.py
+++ b/tests/integration/services/test_service_factory.py
@@ -24,8 +24,10 @@ def test_gets_dataclass_services(
check,
app_metadata,
fake_project,
+ project_path,
fake_package_service_class,
fake_lifecycle_service_class,
+ fake_project_service_class,
fake_provider_service_class,
):
with pytest.warns(DeprecationWarning, match="Use ServiceFactory.register"):
@@ -34,8 +36,11 @@ def test_gets_dataclass_services(
project=fake_project,
PackageClass=fake_package_service_class,
LifecycleClass=fake_lifecycle_service_class,
+ ProjectClass=fake_project_service_class,
ProviderClass=fake_provider_service_class,
)
+ factory.update_kwargs("project", project_dir=project_path)
+ factory.get("project").set(fake_project) # type: ignore[reportAttributeAccessIssue]
check.is_instance(factory.package, services.PackageService)
check.is_instance(factory.lifecycle, services.LifecycleService)
@@ -45,22 +50,27 @@ def test_gets_dataclass_services(
def test_gets_registered_services(
check,
app_metadata,
+ project_path,
fake_project,
fake_package_service_class,
fake_lifecycle_service_class,
fake_provider_service_class,
+ fake_project_service_class,
):
services.ServiceFactory.register("package", fake_package_service_class)
+ services.ServiceFactory.register("project", fake_project_service_class)
services.ServiceFactory.register("lifecycle", fake_lifecycle_service_class)
services.ServiceFactory.register("provider", fake_provider_service_class)
factory = services.ServiceFactory(
app_metadata,
- project=fake_project,
)
+ factory.update_kwargs("project", project_dir=project_path)
+ factory.get("project").set(fake_project) # type: ignore[reportAttributeAccessIssue]
- check.is_instance(factory.package, services.PackageService)
- check.is_instance(factory.lifecycle, services.LifecycleService)
- check.is_instance(factory.provider, services.ProviderService)
+ check.is_instance(factory.get("package"), services.PackageService)
+ check.is_instance(factory.get("project"), services.ProjectService)
+ check.is_instance(factory.get("lifecycle"), services.LifecycleService)
+ check.is_instance(factory.get("provider"), services.ProviderService)
def test_real_service_error(app_metadata, fake_project):
diff --git a/tests/integration/test_application.py b/tests/integration/test_application.py
index ca068eef..4bd27d1d 100644
--- a/tests/integration/test_application.py
+++ b/tests/integration/test_application.py
@@ -17,10 +17,13 @@
import pathlib
import shutil
import textwrap
+from datetime import date
import craft_cli
+import craft_platforms
import pytest
import pytest_check
+from craft_providers.bases import BaseName
from typing_extensions import override
import craft_application
@@ -184,6 +187,19 @@ def test_project_managed(capsys, monkeypatch, tmp_path, project, create_app):
app = create_app()
app._work_dir = tmp_path
+ # Workaround until we implement CRAFT-4159
+ app._configure_early_services() # We need to access the project service.
+ app.services.get("project").render_once()
+ if date.today() < date(2025, 3, 1):
+ app._build_plan = [
+ models.BuildInfo(
+ platform=next(iter(app.services.get("project").get().platforms)),
+ build_on=craft_platforms.DebianArchitecture.from_host(),
+ build_for=craft_platforms.DebianArchitecture.from_host(),
+ base=BaseName("ubuntu", "22.04"),
+ )
+ ]
+
assert app.run() == 0
assert (tmp_path / "package_1.0.tar.zst").exists()
@@ -217,6 +233,20 @@ def test_project_destructive(
["testcraft", "pack", "--destructive-mode", "--platform", platform],
)
app = create_app()
+
+ # Workaround until we implement CRAFT-4159
+ app._configure_early_services() # We need to access the project service.
+ app.services.get("project").render_once()
+ if date.today() < date(2025, 3, 1):
+ app._build_plan = [
+ models.BuildInfo(
+ platform=next(iter(app.services.get("project").get().platforms)),
+ build_on=craft_platforms.DebianArchitecture.from_host(),
+ build_for=craft_platforms.DebianArchitecture.from_host(),
+ base=BaseName("ubuntu", "22.04"),
+ )
+ ]
+
app.run()
assert (tmp_path / "package_1.0.tar.zst").exists()
@@ -330,6 +360,10 @@ def test_invalid_command_argument(monkeypatch, capsys, app):
["--platform", "my-platform"],
],
)
+@pytest.mark.skipif(
+ date.today() < date(2025, 2, 27),
+ reason="Skip until we implement the BuildPlanService. (CRAFT-4159)",
+)
def test_global_environment(
arguments,
create_app,
@@ -393,6 +427,20 @@ def test_lifecycle_error_logging(monkeypatch, tmp_path, create_app):
monkeypatch.setattr("sys.argv", ["testcraft", "pack", "--destructive-mode"])
app = create_app()
+
+ # Workaround until we implement CRAFT-4159
+ app._configure_early_services() # We need to access the project service.
+ app.services.get("project").render_once()
+ if date.today() < date(2025, 3, 1):
+ app._build_plan = [
+ models.BuildInfo(
+ platform=next(iter(app.services.get("project").get().platforms)),
+ build_on=craft_platforms.DebianArchitecture.from_host(),
+ build_for=craft_platforms.DebianArchitecture.from_host(),
+ base=BaseName("ubuntu", "22.04"),
+ )
+ ]
+
app.run()
log_contents = craft_cli.emit._log_filepath.read_text()
@@ -420,6 +468,19 @@ def test_runtime_error_logging(monkeypatch, tmp_path, create_app, mocker):
monkeypatch.setattr("sys.argv", ["testcraft", "pack", "--destructive-mode"])
app = create_app()
+ # Workaround until we implement CRAFT-4159
+ app._configure_early_services() # We need to access the project service.
+ app.services.get("project").render_once()
+ if date.today() < date(2025, 3, 1):
+ app._build_plan = [
+ models.BuildInfo(
+ platform=next(iter(app.services.get("project").get().platforms)),
+ build_on=craft_platforms.DebianArchitecture.from_host(),
+ build_for=craft_platforms.DebianArchitecture.from_host(),
+ base=BaseName("ubuntu", "22.04"),
+ )
+ ]
+
with pytest.raises(RuntimeError):
app.run()
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
index 316fb822..3c7f55b0 100644
--- a/tests/unit/conftest.py
+++ b/tests/unit/conftest.py
@@ -24,17 +24,16 @@
from craft_application import git, services
from craft_application.services import service_factory
+from craft_application.services.project import ProjectService
-BASIC_PROJECT_YAML = """
-name: myproject
-version: 1.0
-base: ubuntu@24.04
-platforms:
- arm64:
-parts:
- mypart:
- plugin: nil
-"""
+
+@pytest.fixture
+def project_service(app_metadata, fake_services, project_path):
+ return ProjectService(
+ app_metadata,
+ fake_services,
+ project_dir=project_path,
+ )
@pytest.fixture
@@ -44,7 +43,6 @@ def provider_service(
return services.ProviderService(
app_metadata,
fake_services,
- project=fake_project,
work_dir=tmp_path,
build_plan=fake_build_plan,
)
@@ -101,11 +99,3 @@ def expected_git_command(
which_res = f"/some/path/to/{git.CRAFTGIT_BINARY_NAME}" if craftgit_exists else None
mocker.patch("shutil.which", return_value=which_res)
return git.CRAFTGIT_BINARY_NAME if craftgit_exists else git.GIT_FALLBACK_BINARY_NAME
-
-
-@pytest.fixture
-def fake_project_file(in_project_path):
- project_file = in_project_path / "testcraft.yaml"
- project_file.write_text(BASIC_PROJECT_YAML)
-
- return project_file
diff --git a/tests/unit/services/test_fetch.py b/tests/unit/services/test_fetch.py
index 51748b2f..dfb5fa69 100644
--- a/tests/unit/services/test_fetch.py
+++ b/tests/unit/services/test_fetch.py
@@ -53,7 +53,6 @@ def fetch_service(app, fake_services, fake_project):
return services.FetchService(
app,
fake_services,
- project=fake_project,
build_plan=[build_info],
session_policy="strict",
)
diff --git a/tests/unit/services/test_init.py b/tests/unit/services/test_init.py
index b40e364f..ff47e9a8 100644
--- a/tests/unit/services/test_init.py
+++ b/tests/unit/services/test_init.py
@@ -18,6 +18,7 @@
import os
import pathlib
+import shutil
import textwrap
import jinja2
@@ -146,8 +147,14 @@ def test_get_templates_environment(init_service, mocker):
@pytest.mark.usefixtures("mock_loader")
@pytest.mark.parametrize("project_file", [None, "file.txt"])
-def test_check_for_existing_files(init_service, tmp_path, project_file):
+def test_check_for_existing_files(
+ init_service, tmp_path, project_path: pathlib.Path, project_file
+):
"""No-op if there are no overlapping files."""
+ # Cleanup: we don't want the project directory to exist in this case.
+ assert project_path.is_relative_to(tmp_path)
+ shutil.rmtree(project_path)
+
# create template
template_dir = tmp_path / "templates"
template_dir.mkdir()
@@ -164,7 +171,7 @@ def test_check_for_existing_files(init_service, tmp_path, project_file):
@pytest.mark.usefixtures("mock_loader")
-def test_check_for_existing_files_error(init_service, tmp_path):
+def test_check_for_existing_files_error(init_service, tmp_path, project_path):
"""Error if there are overlapping files."""
expected_error = textwrap.dedent(
f"""\
@@ -177,13 +184,11 @@ def test_check_for_existing_files_error(init_service, tmp_path):
template_dir.mkdir()
(template_dir / "file.txt").touch()
# create project with a different file
- project_dir = tmp_path / "project"
- project_dir.mkdir()
- (project_dir / "file.txt").touch()
+ (project_path / "file.txt").touch()
with pytest.raises(errors.InitError, match=expected_error):
init_service.check_for_existing_files(
- project_dir=project_dir, template_dir=template_dir
+ project_dir=project_path, template_dir=template_dir
)
@@ -196,7 +201,7 @@ def test_copy_template_file(init_service, tmp_path, template_filename):
template_file.write_text("content")
# create project with an existing file
project_dir = tmp_path / "project"
- project_dir.mkdir()
+ project_dir.mkdir(exist_ok=True)
init_service._copy_template_file(template_filename, template_dir, project_dir)
@@ -229,7 +234,7 @@ def test_copy_template_file_exists(init_service, tmp_path, template_name, emitte
def test_render_project_with_templates(filename, init_service, tmp_path):
"""Render template files."""
project_dir = tmp_path / "project"
- project_dir.mkdir()
+ project_dir.mkdir(exist_ok=True)
template_dir = tmp_path / "templates"
(template_dir / filename).parent.mkdir(parents=True, exist_ok=True)
(template_dir / filename).write_text("{{ name }}")
@@ -250,7 +255,7 @@ def test_render_project_with_templates(filename, init_service, tmp_path):
def test_render_project_non_templates(filename, init_service, tmp_path):
"""Copy non-template files when rendering a project."""
project_dir = tmp_path / "project"
- project_dir.mkdir()
+ project_dir.mkdir(exist_ok=True)
template_dir = tmp_path / "templates"
(template_dir / filename).parent.mkdir(parents=True, exist_ok=True)
(template_dir / filename).write_text("test content")
@@ -270,7 +275,7 @@ def test_render_project_non_templates(filename, init_service, tmp_path):
def test_render_project_executable(init_service, tmp_path):
"""Test that executable permissions are set on rendered files."""
project_dir = tmp_path / "project"
- project_dir.mkdir()
+ project_dir.mkdir(exist_ok=True)
template_dir = tmp_path / "templates"
template_dir.mkdir()
for filename in ["file-1.sh.j2", "file-2.sh"]:
diff --git a/tests/unit/services/test_lifecycle.py b/tests/unit/services/test_lifecycle.py
index c7674179..b6ccc6c3 100644
--- a/tests/unit/services/test_lifecycle.py
+++ b/tests/unit/services/test_lifecycle.py
@@ -70,7 +70,6 @@ def fake_parts_lifecycle(
fake_service = FakePartsLifecycle(
app_metadata,
fake_services,
- project=fake_project,
work_dir=work_dir,
cache_dir=cache_dir,
build_plan=fake_build_plan,
@@ -237,7 +236,6 @@ def test_init_success(
service = lifecycle.LifecycleService(
app_metadata,
fake_services,
- project=fake_project,
work_dir=tmp_path,
cache_dir=tmp_path,
platform=None,
@@ -295,10 +293,11 @@ def test_init_with_feature_package_repositories(
):
package_repositories = [{"type": "apt", "ppa": "ppa/ppa"}]
fake_project.package_repositories = package_repositories.copy()
+ fake_services.get("project").set(fake_project)
+
service = lifecycle.LifecycleService(
app_metadata,
fake_services,
- project=fake_project,
work_dir=tmp_path,
cache_dir=tmp_path,
platform=None,
@@ -488,7 +487,7 @@ def test_clean(part_names, message, emitter, fake_parts_lifecycle, check):
def test_repr(fake_parts_lifecycle, app_metadata, fake_project):
- start = f"FakePartsLifecycle({app_metadata!r}, {fake_project!r}, "
+ start = f"FakePartsLifecycle({app_metadata!r}, "
actual = repr(fake_parts_lifecycle)
@@ -499,7 +498,8 @@ def test_repr(fake_parts_lifecycle, app_metadata, fake_project):
r"cache_dir=(Posix|Windows)Path\('.+'\), "
r"plan=\[BuildInfo\(.+\)], \*\*{}\)",
actual,
- )
+ ),
+ f"Does not match expected regex: {actual}",
)
@@ -580,6 +580,7 @@ def test_lifecycle_package_repositories(
"""Test that package repositories installation is called in the lifecycle."""
fake_repositories = [{"type": "apt", "ppa": "ppa/ppa"}]
fake_project.package_repositories = fake_repositories.copy()
+ fake_services.get("project").set(fake_project)
work_dir = tmp_path / "work"
service = lifecycle.LifecycleService(
@@ -591,6 +592,7 @@ def test_lifecycle_package_repositories(
platform=None,
build_plan=fake_build_plan,
)
+ service.setup()
mocker.patch.object(service, "_get_local_keys_path", return_value=local_keys_path)
service._lcm = mock.MagicMock(spec=LifecycleManager)
@@ -651,6 +653,7 @@ class LocalProject(models.Project):
platform=None,
build_plan=fake_build_plan,
)
+ service._project = fake_project
service._lcm = mock.MagicMock(spec=LifecycleManager)
service._lcm.project_info = mock.MagicMock(spec=ProjectInfo)
service._lcm.project_info.get_project_var = lambda _: "foo"
diff --git a/tests/unit/services/test_package.py b/tests/unit/services/test_package.py
index be930fbd..d639b1b3 100644
--- a/tests/unit/services/test_package.py
+++ b/tests/unit/services/test_package.py
@@ -37,7 +37,7 @@ def metadata(self) -> models.BaseMetadata:
def test_write_metadata(tmp_path, app_metadata, fake_project, fake_services):
- service = FakePackageService(app_metadata, fake_services, project=fake_project)
+ service = FakePackageService(app_metadata, fake_services)
metadata_file = tmp_path / "metadata.yaml"
assert not metadata_file.exists()
@@ -68,7 +68,6 @@ def test_update_project_variable_unset(
service = FakePackageService(
app_metadata,
fake_services,
- project=fake_project,
)
def _get_project_var(name: str, *, raw_read: bool = False) -> str:
@@ -97,7 +96,6 @@ def test_update_project_variable_optional(
service = FakePackageService(
app_metadata,
fake_services,
- project=fake_project,
)
def _get_project_var(name: str, *, raw_read: bool = False) -> str:
@@ -107,4 +105,4 @@ def _get_project_var(name: str, *, raw_read: bool = False) -> str:
service.update_project()
- assert service._project.version == "foo"
+ assert fake_services.get("project").get().version == "foo"
diff --git a/tests/unit/services/test_project.py b/tests/unit/services/test_project.py
new file mode 100644
index 00000000..a6075033
--- /dev/null
+++ b/tests/unit/services/test_project.py
@@ -0,0 +1,413 @@
+# Copyright 2025 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
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE.
+# See the GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with this program. If not, see .
+"""Unit tests for the ProjectService."""
+
+import dataclasses
+import pathlib
+import textwrap
+from typing import cast
+from unittest import mock
+
+import craft_platforms
+import pytest
+
+from craft_application import errors
+from craft_application.application import AppMetadata
+from craft_application.services.project import ProjectService
+
+
+def test_resolve_file_path_success(
+ project_service: ProjectService,
+ project_path: pathlib.Path,
+ app_metadata: AppMetadata,
+):
+ project_file = project_path / f"{app_metadata.name}.yaml"
+ project_file.touch()
+
+ assert project_service.resolve_project_file_path() == project_file
+
+
+def test_resolve_file_path_missing(
+ project_service: ProjectService, project_path: pathlib.Path
+):
+ with pytest.raises(
+ errors.ProjectFileMissingError,
+ match=rf"Project file '[a-z]+.yaml' not found in '{project_path}'.",
+ ):
+ project_service.resolve_project_file_path()
+
+
+@pytest.mark.parametrize(
+ ("project_yaml", "expected"),
+ [
+ pytest.param("{}", {}, id="empty-dict"),
+ pytest.param("name: thing!", {"name": "thing!"}, id="name-only"),
+ ],
+)
+def test_load_raw_project(
+ project_service: ProjectService, project_path, project_yaml, expected
+):
+ (project_path / "testcraft.yaml").write_text(project_yaml)
+
+ assert project_service._load_raw_project() == expected
+
+
+@pytest.mark.parametrize(
+ ("invalid_yaml", "details"),
+ [
+ ("", "Project file should be a YAML mapping, not 'NoneType'"),
+ ("'Hello'", "Project file should be a YAML mapping, not 'str'"),
+ ],
+)
+def test_load_raw_project_invalid(
+ project_service: ProjectService, project_path, invalid_yaml, details
+):
+ (project_path / "testcraft.yaml").write_text(invalid_yaml)
+
+ with pytest.raises(
+ errors.ProjectFileInvalidError, match="^Invalid project file.$"
+ ) as exc_info:
+ project_service._load_raw_project()
+
+ assert exc_info.value.details == details
+
+
+@pytest.mark.parametrize(
+ ("platforms", "expected"),
+ [
+ pytest.param({}, {}, id="empty"),
+ *(
+ pytest.param(
+ {str(arch): None},
+ {str(arch): {"build-on": [str(arch)], "build-for": [str(arch)]}},
+ id=f"expand-{arch}",
+ )
+ for arch in craft_platforms.DebianArchitecture
+ ),
+ *(
+ pytest.param(
+ {"anything": {"build-on": [str(arch)], "build-for": ["all"]}},
+ {"anything": {"build-on": [str(arch)], "build-for": ["all"]}},
+ id=f"on-{arch}-for-all",
+ )
+ for arch in craft_platforms.DebianArchitecture
+ ),
+ ],
+)
+def test_get_platforms(
+ project_service: ProjectService,
+ platforms: dict[str, dict[str, list[str] | None]],
+ expected,
+):
+ project_service._load_raw_project = lambda: {"platforms": platforms} # type: ignore # noqa: PGH003
+
+ assert project_service.get_platforms() == expected
+
+
+@pytest.mark.parametrize(
+ ("data", "expected"),
+ [
+ pytest.param({}, {"version": ""}, id="empty"),
+ pytest.param(
+ {"version": "3.14", "unrelated": "pi"},
+ {"version": "3.14"},
+ id="version-set",
+ ),
+ ],
+)
+def test_get_project_vars(project_service: ProjectService, data, expected):
+ assert project_service._get_project_vars(data) == expected
+
+
+def test_partitions_with_partitions_disabled(project_service: ProjectService):
+ assert project_service.get_partitions() is None
+
+
+@pytest.mark.usefixtures("enable_partitions")
+def test_default_partitions_when_enabled(project_service: ProjectService):
+ assert project_service.get_partitions() == ["default"]
+
+
+@pytest.mark.parametrize(
+ ("project_data", "expected"),
+ [
+ pytest.param({}, {}, id="empty"),
+ pytest.param(
+ {
+ "name": "my-name",
+ "version": "1.2.3",
+ "parts": {
+ "my-part": {
+ "plugin": "nil",
+ "source-tag": "v$CRAFT_PROJECT_VERSION",
+ "build-environment": [
+ {"BUILD_ON": "$CRAFT_ARCH_BUILD_ON"},
+ ],
+ "override-build": "echo $CRAFT_PROJECT_NAME",
+ }
+ },
+ },
+ {
+ "name": "my-name",
+ "version": "1.2.3",
+ "parts": {
+ "my-part": {
+ "plugin": "nil",
+ "source-tag": "v1.2.3",
+ "build-environment": [
+ {
+ "BUILD_ON": craft_platforms.DebianArchitecture.from_host().value
+ },
+ ],
+ "override-build": "echo my-name",
+ }
+ },
+ },
+ id="basic",
+ ),
+ ],
+)
+@pytest.mark.parametrize(
+ "build_for", [arch.value for arch in craft_platforms.DebianArchitecture] + ["all"]
+)
+def test_expand_environment_no_partitions_any_platform(
+ project_service: ProjectService, project_data, build_for, expected
+):
+ project_service._expand_environment(project_data, build_for)
+ assert project_data == expected
+
+
+@pytest.mark.parametrize(
+ ("project_data", "expected"),
+ [
+ pytest.param(
+ {
+ "name": "my-name",
+ "version": "1.2.3",
+ "parts": {
+ "my-part": {
+ "plugin": "nil",
+ "source-tag": "v$CRAFT_PROJECT_VERSION",
+ "build-environment": [
+ {"BUILD_ON": "$CRAFT_ARCH_BUILD_ON"},
+ {"BUILD_FOR": "$CRAFT_ARCH_BUILD_FOR"},
+ ],
+ "override-build": "echo $CRAFT_PROJECT_NAME",
+ }
+ },
+ },
+ {
+ "name": "my-name",
+ "version": "1.2.3",
+ "parts": {
+ "my-part": {
+ "plugin": "nil",
+ "source-tag": "v1.2.3",
+ "build-environment": [
+ {"BUILD_ON": mock.ANY},
+ {"BUILD_FOR": "riscv64"},
+ ],
+ "override-build": "echo my-name",
+ }
+ },
+ },
+ id="basic",
+ ),
+ ],
+)
+def test_expand_environment_for_riscv64(
+ project_service: ProjectService, project_data, expected, fake_host_architecture
+):
+ project_service._expand_environment(project_data, "riscv64")
+ assert project_data == expected
+
+
+@pytest.mark.parametrize(
+ "build_for", [arch.value for arch in craft_platforms.DebianArchitecture] + ["all"]
+)
+@pytest.mark.usefixtures("enable_partitions")
+def test_expand_environment_stage_dirs(
+ project_service: ProjectService, build_for: str, project_path: pathlib.Path
+):
+ default_stage_dir = project_path / "stage"
+ a_stage_dir = project_path / "partitions/a/stage"
+ default_prime_dir = project_path / "prime"
+ a_prime_dir = project_path / "partitions/a/prime"
+ project_service.get_partitions = lambda: ["default", "a"]
+ my_part = {
+ "plugin": "nil",
+ "override-stage": "echo $CRAFT_STAGE\necho $CRAFT_DEFAULT_STAGE\necho $CRAFT_A_STAGE",
+ "override-prime": "echo $CRAFT_PRIME\necho $CRAFT_DEFAULT_PRIME\necho $CRAFT_A_PRIME",
+ }
+ data = {"parts": {"my-part": my_part}}
+ project_service._expand_environment(data, build_for)
+ assert data["parts"]["my-part"]["override-stage"] == textwrap.dedent(
+ f"""\
+ echo {default_stage_dir}
+ echo {default_stage_dir}
+ echo {a_stage_dir}"""
+ )
+ assert data["parts"]["my-part"]["override-prime"] == textwrap.dedent(
+ f"""\
+ echo {default_prime_dir}
+ echo {default_prime_dir}
+ echo {a_prime_dir}"""
+ )
+
+
+@pytest.mark.parametrize(
+ "build_for", [arch.value for arch in craft_platforms.DebianArchitecture]
+)
+@pytest.mark.parametrize(
+ "build_on", [arch.value for arch in craft_platforms.DebianArchitecture]
+)
+@pytest.mark.usefixtures("fake_project_file")
+def test_render_for(
+ project_service: ProjectService, build_for, build_on, fake_platform
+):
+ result = project_service.render_for(
+ build_for=build_for, build_on=build_on, platform=fake_platform
+ )
+
+ assert result.parts["some-part"]["build-environment"][1]["BUILD_FOR"] == build_for
+
+ # The actual host value can be removed when here when we fix
+ # https://github.com/canonical/craft-parts/issues/1018
+ expected_build_ons = (
+ build_on,
+ craft_platforms.DebianArchitecture.from_host().value,
+ )
+ actual_build_on = result.parts["some-part"]["build-environment"][0]["BUILD_ON"]
+ assert actual_build_on in expected_build_ons
+
+
+@pytest.mark.parametrize(
+ "build_for", [arch.value for arch in craft_platforms.DebianArchitecture]
+)
+@pytest.mark.parametrize(
+ "build_on", [arch.value for arch in craft_platforms.DebianArchitecture]
+)
+@pytest.mark.parametrize("platform", ["invalid"])
+@pytest.mark.usefixtures("fake_project_file")
+def test_render_for_invalid_platform(
+ project_service: ProjectService, build_for, build_on, platform
+):
+ with pytest.raises(errors.InvalidPlatformError) as exc_info:
+ project_service.render_for(
+ build_for=build_for, build_on=build_on, platform=platform
+ )
+
+ assert cast(str, exc_info.value.details).startswith("Valid platforms are: '")
+
+
+@pytest.mark.parametrize(
+ "build_for", [arch.value for arch in craft_platforms.DebianArchitecture]
+)
+@pytest.mark.usefixtures("fake_project_file")
+def test_render_once_by_build_for_and_platform(
+ project_service: ProjectService, build_for, fake_platform
+):
+ result = project_service.render_once(platform=fake_platform, build_for=build_for)
+ assert (
+ result.parts["some-part"]["build-environment"][0]["BUILD_ON"]
+ == craft_platforms.DebianArchitecture.from_host().value
+ )
+ assert result.parts["some-part"]["build-environment"][1]["BUILD_FOR"] == build_for
+
+ # Test that we can't re-render no matter the arguments.
+ with pytest.raises(RuntimeError, match="Project should only be rendered once."):
+ project_service.render_once(platform=fake_platform, build_for=build_for)
+
+ with pytest.raises(RuntimeError, match="Project should only be rendered once."):
+ project_service.render_once(platform=fake_platform)
+
+ with pytest.raises(RuntimeError, match="Project should only be rendered once."):
+ project_service.render_once(build_for=build_for)
+
+ with pytest.raises(RuntimeError, match="Project should only be rendered once."):
+ project_service.render_once()
+
+
+@pytest.mark.usefixtures("fake_project_file")
+def test_render_once_by_platform(project_service: ProjectService, fake_platform: str):
+ result = project_service.render_once(platform=fake_platform)
+ assert (
+ result.parts["some-part"]["build-environment"][0]["BUILD_ON"]
+ == craft_platforms.DebianArchitecture.from_host().value
+ )
+ build_for = cast(dict, result.platforms)[fake_platform].build_for[0]
+ # Workaround for https://github.com/canonical/craft-parts/issues/1019
+ if build_for != "all":
+ assert (
+ result.parts["some-part"]["build-environment"][1]["BUILD_FOR"] == build_for
+ )
+
+
+@pytest.mark.usefixtures("fake_project_file")
+@pytest.mark.parametrize(
+ "build_for", [arch.value for arch in craft_platforms.DebianArchitecture]
+)
+def test_render_once_by_build_for(
+ project_service: ProjectService, build_for: str, fake_host_architecture
+):
+ # This test takes two paths because not all build-on/build-for combinations are
+ # valid. If the combination is valid, we check that we got the expected output.
+ # If the combination is invalid, we check that the error was correct.
+ try:
+ result = project_service.render_once(build_for=build_for)
+ except RuntimeError as exc:
+ assert ( # noqa: PT017
+ exc.args[0]
+ == f"Cannot generate a project that builds on {fake_host_architecture} and builds for {build_for}"
+ )
+ else:
+ assert (
+ result.parts["some-part"]["build-environment"][0]["BUILD_ON"]
+ == craft_platforms.DebianArchitecture.from_host().value
+ )
+ assert (
+ result.parts["some-part"]["build-environment"][1]["BUILD_FOR"] == build_for
+ )
+
+
+def test_get_not_rendered(project_service: ProjectService):
+ with pytest.raises(RuntimeError, match="Project not rendered yet."):
+ project_service.get()
+
+
+@pytest.mark.usefixtures("fake_project_file")
+def test_get_already_rendered(project_service: ProjectService):
+ rendered = project_service.render_once()
+
+ assert project_service.get() == rendered
+
+
+def test_mandatory_adoptable_fields(
+ app_metadata, project_service: ProjectService, fake_project_file: pathlib.Path
+):
+ """Verify if mandatory adoptable fields are defined if not using adopt-info."""
+ project_service._app = dataclasses.replace(
+ app_metadata, mandatory_adoptable_fields=["version", "license"]
+ )
+
+ project_yaml = fake_project_file.read_text()
+ fake_project_file.write_text(project_yaml.replace("license:", "# licence:"))
+
+ with pytest.raises(errors.CraftValidationError) as exc_info:
+ _ = project_service.render_once()
+
+ assert (
+ str(exc_info.value)
+ == "'adopt-info' not set and required fields are missing: 'license'"
+ )
diff --git a/tests/unit/services/test_provider.py b/tests/unit/services/test_provider.py
index f53f7dce..07dc32e7 100644
--- a/tests/unit/services/test_provider.py
+++ b/tests/unit/services/test_provider.py
@@ -87,7 +87,6 @@ def test_setup_proxy_environment(
service = provider.ProviderService(
app_metadata,
fake_services,
- project=fake_project,
work_dir=pathlib.Path(),
build_plan=fake_build_plan,
)
@@ -181,7 +180,6 @@ def test_install_snap(
service = provider.ProviderService(
app_metadata,
fake_services,
- project=fake_project,
work_dir=pathlib.Path(),
build_plan=fake_build_plan,
install_snap=install_snap,
@@ -259,7 +257,9 @@ def test_get_instance_name(platform, platform_str, new_dir, provider_service):
expected_name = f"testcraft-full-project-{platform_str}-{inode_number}"
assert (
- provider_service._get_instance_name(work_dir=new_dir, build_info=build_info)
+ provider_service._get_instance_name(
+ work_dir=new_dir, build_info=build_info, project_name="full-project"
+ )
== expected_name
)
diff --git a/tests/unit/services/test_request.py b/tests/unit/services/test_request.py
index 4fa11f11..82fbf285 100644
--- a/tests/unit/services/test_request.py
+++ b/tests/unit/services/test_request.py
@@ -99,10 +99,14 @@ def test_download_files_with_progress(tmp_path, emitter, request_service, downlo
results = request_service.download_files_with_progress(files)
- assert emitter.interactions[0] == call(
- "progress_bar",
- f"Downloading {len(downloads)} files",
- sum(len(dl) for dl in downloads.values()),
+ emitter.assert_interactions(
+ [
+ call(
+ "progress_bar",
+ f"Downloading {len(downloads)} files",
+ sum(len(dl) for dl in downloads.values()),
+ )
+ ]
)
for file in downloads.values():
if len(file) > 0: # Advance doesn't get called on empty files
diff --git a/tests/unit/services/test_service_factory.py b/tests/unit/services/test_service_factory.py
index 16272092..50ef3f4b 100644
--- a/tests/unit/services/test_service_factory.py
+++ b/tests/unit/services/test_service_factory.py
@@ -15,6 +15,7 @@
from __future__ import annotations
+import pathlib
from unittest import mock
import pytest
@@ -37,6 +38,7 @@ def factory(
tmp_path,
app_metadata,
fake_project,
+ fake_project_file,
fake_package_service_class,
fake_lifecycle_service_class,
):
@@ -45,7 +47,6 @@ def factory(
factory = services.ServiceFactory(
app_metadata,
- project=fake_project,
)
factory.update_kwargs(
"lifecycle",
@@ -53,6 +54,7 @@ def factory(
cache_dir=tmp_path / "cache",
build_plan=[],
)
+ factory.update_kwargs("project", project_dir=fake_project_file.parent)
return factory
@@ -98,21 +100,17 @@ def test_register_service_by_reference_with_module():
def test_register_services_in_init(
app_metadata,
- fake_project,
fake_package_service_class,
fake_lifecycle_service_class,
fake_provider_service_class,
):
factory = services.ServiceFactory(
app_metadata,
- project=fake_project,
PackageClass=fake_package_service_class,
- LifecycleClass=fake_lifecycle_service_class,
ProviderClass=fake_provider_service_class,
)
pytest_check.is_instance(factory.package, fake_package_service_class)
- pytest_check.is_instance(factory.lifecycle, fake_lifecycle_service_class)
pytest_check.is_instance(factory.provider, fake_provider_service_class)
@@ -132,9 +130,8 @@ class MockPackageService(fake_package_service_class):
def __new__(cls, *args, **kwargs):
return cls.mock_class(*args, **kwargs)
- factory = services.ServiceFactory(
- app_metadata, project=fake_project, PackageClass=MockPackageService
- )
+ services.ServiceFactory.register("package", MockPackageService)
+ factory = services.ServiceFactory(app_metadata, project=fake_project)
with pytest.warns(DeprecationWarning):
factory.set_kwargs("package", **kwargs)
@@ -142,7 +139,7 @@ def __new__(cls, *args, **kwargs):
check.equal(factory.package, MockPackageService.mock_class.return_value)
with check:
MockPackageService.mock_class.assert_called_once_with(
- app=app_metadata, services=factory, project=fake_project, **kwargs
+ app=app_metadata, services=factory, **kwargs
)
@@ -176,9 +173,8 @@ class MockPackageService(fake_package_service_class):
def __new__(cls, *args, **kwargs):
return cls.mock_class(*args, **kwargs)
- factory = services.ServiceFactory(
- app_metadata, project=fake_project, PackageClass=MockPackageService
- )
+ services.ServiceFactory.register("package", MockPackageService)
+ factory = services.ServiceFactory(app_metadata, project=fake_project)
factory.update_kwargs("package", **first_kwargs)
factory.update_kwargs("package", **second_kwargs)
@@ -186,7 +182,7 @@ def __new__(cls, *args, **kwargs):
pytest_check.is_(factory.package, MockPackageService.mock_class.return_value)
with pytest_check.check():
MockPackageService.mock_class.assert_called_once_with(
- app=app_metadata, services=factory, project=fake_project, **expected
+ app=app_metadata, services=factory, **expected
)
@@ -221,6 +217,10 @@ def test_get_class_not_registered():
def test_get_default_services(
factory, fake_package_service_class, fake_lifecycle_service_class
):
+ project_service = factory.get("project")
+ pytest_check.is_instance(project_service, services.ProjectService)
+ project_service.render_once()
+
pytest_check.is_instance(factory.get("package"), fake_package_service_class)
pytest_check.is_instance(factory.get("lifecycle"), fake_lifecycle_service_class)
pytest_check.is_instance(factory.get("config"), services.ConfigService)
@@ -251,8 +251,7 @@ def test_get_unregistered_service(factory):
def test_get_project_service_error(factory):
- factory.project = None
- with pytest.raises(ValueError, match="LifecycleService requires a project"):
+ with pytest.raises(RuntimeError, match="Project not rendered yet."):
factory.get("lifecycle")
@@ -285,18 +284,6 @@ class InvalidClass:
_ = factory.package
-def test_getattr_project_none(app_metadata, fake_package_service_class):
- factory = services.ServiceFactory(
- app_metadata, PackageClass=fake_package_service_class
- )
-
- with pytest.raises(
- ValueError,
- match="^FakePackageService requires a project to be available before creation.$",
- ):
- _ = factory.package
-
-
def test_service_setup(app_metadata, fake_project, fake_package_service_class, emitter):
class FakePackageService(fake_package_service_class):
def setup(self) -> None:
@@ -314,7 +301,10 @@ def test_mandatory_adoptable_field(
fake_project,
fake_lifecycle_service_class,
fake_package_service_class,
+ fake_project_service_class,
+ fake_project_file: pathlib.Path,
):
+ services.ServiceFactory.register("project", fake_project_service_class)
app_metadata = AppMetadata(
"testcraft",
"A fake app for testing craft-application",
@@ -329,8 +319,10 @@ def test_mandatory_adoptable_field(
PackageClass=fake_package_service_class,
LifecycleClass=fake_lifecycle_service_class,
)
+ factory.update_kwargs("project", project_dir=fake_project_file.parent)
+ factory.get("project").set(fake_project) # type: ignore[reportAttributeAccessIssue]
- _ = factory.lifecycle
+ factory.get("lifecycle")
@pytest.mark.parametrize(
diff --git a/tests/unit/test_application.py b/tests/unit/test_application.py
index 16033e97..3383ab7a 100644
--- a/tests/unit/test_application.py
+++ b/tests/unit/test_application.py
@@ -16,8 +16,6 @@
"""Unit tests for craft-application app classes."""
import argparse
-import copy
-import dataclasses
import importlib
import importlib.metadata
import logging
@@ -26,9 +24,9 @@
import subprocess
import sys
import textwrap
+from datetime import date
from io import StringIO
from textwrap import dedent
-from typing import Any
from unittest import mock
import craft_cli
@@ -63,250 +61,9 @@
get_host_architecture, # pyright: ignore[reportGeneralTypeIssues]
)
from tests.conftest import FakeApplication
-from tests.unit.conftest import BASIC_PROJECT_YAML
EMPTY_COMMAND_GROUP = craft_cli.CommandGroup("FakeCommands", [])
-FULL_PROJECT_YAML = """
-name: myproject
-version: 1.0
-base: ubuntu@24.04
-platforms:
- arm64:
-parts:
- mypart:
- plugin: nil
- source: non-grammar-source
- source-checksum: on-amd64-to-riscv64-checksum
- source-branch: riscv64-branch
- source-commit: riscv64-commit
- source-depth: 1
- source-subdir: riscv64-subdir
- source-submodules:
- - riscv64-submodules-1
- - riscv64-submodules-2
- source-tag: riscv64-tag
- source-type: riscv64-type
- disable-parallel: true
- after:
- - riscv64-after
- organize:
- riscv64-organize-1: riscv64-organize-2
- riscv64-organize-3: riscv64-organize-4
- overlay:
- - riscv64-overlay-1
- - riscv64-overlay-2
- overlay-packages:
- - riscv64-overlay-1
- - riscv64-overlay-2
- overlay-script: riscv64-overlay-script
- stage:
- - riscv64-stage-1
- - riscv64-stage-2
- stage-snaps:
- - riscv64-snap-1
- - riscv64-snap-2
- stage-packages:
- - riscv64-package-1
- - riscv64-package-2
- prime:
- - riscv64-prime-1
- - riscv64-prime-2
- build-snaps:
- - riscv64-snap-1
- - riscv64-snap-2
- build-packages:
- - riscv64-package-1
- - riscv64-package-2
- build-environment:
- - MY_VAR: riscv64-value
- - MY_VAR2: riscv64-value2
- build-attributes:
- - rifcv64-attr-1
- - rifcv64-attr-2
- override-pull: riscv64-override-pull
- override-build: riscv64-override-build
- override-stage: riscv64-override-stage
- override-prime: riscv64-override-prime
- permissions:
- - path: riscv64-perm-1
- owner: 123
- group: 123
- mode: "777"
- - path: riscv64-perm-2
- owner: 456
- group: 456
- mode: "666"
-"""
-
-FULL_GRAMMAR_PROJECT_YAML = """
-name: myproject
-version: 1.0
-base: ubuntu@24.04
-platforms:
- riscv64:
- build-on: [amd64]
- build-for: [riscv64]
- s390x:
- build-on: [amd64]
- build-for: [s390x]
-parts:
- mypart:
- plugin:
- - on amd64 to riscv64: nil
- - on amd64 to s390x: dump
- source:
- - on amd64 to s390x: on-amd64-to-s390x
- - on amd64 to riscv64: on-amd64-to-riscv64
- source-checksum:
- - on amd64 to riscv64: on-amd64-to-riscv64-checksum
- - on amd64 to s390x: on-amd64-to-s390x-checksum
- source-branch:
- - on amd64 to s390x: s390x-branch
- - on amd64 to riscv64: riscv64-branch
- source-commit:
- - on amd64 to riscv64: riscv64-commit
- - on amd64 to s390x: s390x-commit
- source-depth:
- - on amd64 to s390x: 2
- - on amd64 to riscv64: 1
- source-subdir:
- - on amd64 to riscv64: riscv64-subdir
- - on amd64 to s390x: s390x-subdir
- source-submodules:
- - on amd64 to s390x:
- - s390x-submodules-1
- - s390x-submodules-2
- - on amd64 to riscv64:
- - riscv64-submodules-1
- - riscv64-submodules-2
- source-tag:
- - on amd64 to riscv64: riscv64-tag
- - on amd64 to s390x: s390x-tag
- source-type:
- - on amd64 to s390x: s390x-type
- - on amd64 to riscv64: riscv64-type
- disable-parallel:
- - on amd64 to riscv64: true
- - on amd64 to s390x: false
- after:
- - on amd64 to s390x:
- - s390x-after
- - on amd64 to riscv64:
- - riscv64-after
- organize:
- - on amd64 to riscv64:
- riscv64-organize-1: riscv64-organize-2
- riscv64-organize-3: riscv64-organize-4
- - on amd64 to s390x:
- s390x-organize-1: s390x-organize-2
- s390x-organize-3: s390x-organize-4
- overlay:
- - on amd64 to s390x:
- - s390x-overlay-1
- - s390x-overlay-2
- - on amd64 to riscv64:
- - riscv64-overlay-1
- - riscv64-overlay-2
- overlay-packages:
- - on amd64 to riscv64:
- - riscv64-overlay-1
- - riscv64-overlay-2
- - on amd64 to s390x:
- - s390x-overlay-1
- - s390x-overlay-2
- overlay-script:
- - on amd64 to s390x: s390x-overlay-script
- - on amd64 to riscv64: riscv64-overlay-script
- stage:
- - on amd64 to riscv64:
- - riscv64-stage-1
- - riscv64-stage-2
- - on amd64 to s390x:
- - s390x-stage-1
- - s390x-stage-2
- stage-snaps:
- - on amd64 to s390x:
- - s390x-snap-1
- - s390x-snap-2
- - on amd64 to riscv64:
- - riscv64-snap-1
- - riscv64-snap-2
- stage-packages:
- - on amd64 to riscv64:
- - riscv64-package-1
- - riscv64-package-2
- - on amd64 to s390x:
- - s390x-package-1
- - s390x-package-2
- prime:
- - on amd64 to s390x:
- - s390x-prime-1
- - s390x-prime-2
- - on amd64 to riscv64:
- - riscv64-prime-1
- - riscv64-prime-2
- build-snaps:
- - on amd64 to riscv64:
- - riscv64-snap-1
- - riscv64-snap-2
- - on amd64 to s390x:
- - s390x-snap-1
- - s390x-snap-2
- build-packages:
- - on amd64 to s390x:
- - s390x-package-1
- - s390x-package-2
- - on amd64 to riscv64:
- - riscv64-package-1
- - riscv64-package-2
- build-environment:
- - on amd64 to riscv64:
- - MY_VAR: riscv64-value
- - MY_VAR2: riscv64-value2
- - on amd64 to s390x:
- - MY_VAR: s390x-value
- - MY_VAR2: s390x-value2
- build-attributes:
- - on amd64 to s390x:
- - s390x-attr-1
- - s390x-attr-2
- - on amd64 to riscv64:
- - rifcv64-attr-1
- - rifcv64-attr-2
- override-pull:
- - on amd64 to riscv64: riscv64-override-pull
- - on amd64 to s390x: s390x-override-pull
- override-build:
- - on amd64 to s390x: s390x-override-build
- - on amd64 to riscv64: riscv64-override-build
- override-stage:
- - on amd64 to riscv64: riscv64-override-stage
- - on amd64 to s390x: s390x-override-stage
- override-prime:
- - on amd64 to s390x: s390x-override-prime
- - on amd64 to riscv64: riscv64-override-prime
- permissions:
- - on amd64 to riscv64:
- - path: riscv64-perm-1
- owner: 123
- group: 123
- mode: "777"
- - path: riscv64-perm-2
- owner: 456
- group: 456
- mode: "666"
- - on amd64 to s390x:
- - path: s390x-perm-1
- owner: 123
- group: 123
- mode: "666"
- - path: s390x-perm-2
- owner: 456
- group: 456
- mode: "777"
-"""
-
@pytest.mark.parametrize("summary", ["A summary", None])
def test_app_metadata_post_init_correct(summary):
@@ -703,15 +460,10 @@ def test_craft_lib_log_level(app_metadata, fake_services):
assert logger.level == logging.DEBUG
-def test_gets_project(monkeypatch, tmp_path, app_metadata, fake_services):
- project_file = tmp_path / "testcraft.yaml"
- project_file.write_text(BASIC_PROJECT_YAML)
+def test_gets_project(monkeypatch, fake_project_file, app_metadata, fake_services):
monkeypatch.setattr(sys, "argv", ["testcraft", "pull", "--destructive-mode"])
app = FakeApplication(app_metadata, fake_services)
- app.project_dir = tmp_path
-
- fake_services.project = None
app.run()
@@ -720,14 +472,13 @@ def test_gets_project(monkeypatch, tmp_path, app_metadata, fake_services):
def test_fails_without_project(
- monkeypatch, capsys, tmp_path, app_metadata, fake_services
+ monkeypatch, capsys, tmp_path, app_metadata, fake_services, app, debug_mode
):
- monkeypatch.setattr(sys, "argv", ["testcraft", "prime"])
+ # Set up a real project service - the fake one for testing gets a fake project!
+ del app.services._services["project"]
+ app.services.register("project", services.ProjectService)
- app = FakeApplication(app_metadata, fake_services)
- app.project_dir = tmp_path
-
- fake_services.project = None
+ monkeypatch.setattr(sys, "argv", ["testcraft", "prime"])
assert app.run() == 66
@@ -1252,9 +1003,11 @@ def test_filter_plan(mocker, plan, platform, build_for, host_arch, result):
assert application.filter_plan(plan, platform, build_for, host_arch) == result
-@pytest.mark.usefixtures("fake_project_file")
+@pytest.mark.usefixtures("fake_project_file", "in_project_dir")
def test_work_dir_project_non_managed(monkeypatch, app_metadata, fake_services):
monkeypatch.setenv(fake_services.ProviderClass.managed_mode_env_var, "0")
+ # We want to use the real ProjectService here.
+ fake_services.register("project", services.ProjectService)
app = application.Application(app_metadata, fake_services)
assert app._work_dir == pathlib.Path.cwd()
@@ -1263,8 +1016,8 @@ def test_work_dir_project_non_managed(monkeypatch, app_metadata, fake_services):
# Make sure the project is loaded correctly (from the cwd)
assert project is not None
- assert project.name == "myproject"
- assert project.version == "1.0"
+ assert project.name == "full-project"
+ assert project.version == "1.0.0.post64+git12345678"
@pytest.mark.usefixtures("fake_project_file")
@@ -1279,8 +1032,8 @@ def test_work_dir_project_managed(monkeypatch, app_metadata, fake_services):
# Make sure the project is loaded correctly (from the cwd)
assert project is not None
- assert project.name == "myproject"
- assert project.version == "1.0"
+ assert project.name == "full-project"
+ assert project.version == "1.0.0.post64+git12345678"
@pytest.fixture
@@ -1310,62 +1063,6 @@ def environment_project(in_project_path):
return in_project_path
-@pytest.mark.usefixtures("in_project_path", "fake_host_architecture")
-def test_expand_environment_build_for_all(
- monkeypatch, app_metadata, project_path, fake_services, emitter
-):
- """Expand build-for to the host arch when build-for is 'all'."""
- project_file = project_path / "testcraft.yaml"
- project_file.write_text(
- dedent(
- f"""\
- name: myproject
- version: 1.2.3
- base: ubuntu@24.04
- platforms:
- platform1:
- build-on: [{util.get_host_architecture()}]
- build-for: [all]
- parts:
- mypart:
- plugin: nil
- build-environment:
- - BUILD_ON: $CRAFT_ARCH_BUILD_ON
- - BUILD_FOR: $CRAFT_ARCH_BUILD_FOR
- """
- )
- )
-
- app = application.Application(app_metadata, fake_services)
- project = app.get_project()
-
- # Make sure the project is loaded correctly (from the cwd)
- assert project is not None
- assert project.parts["mypart"]["build-environment"] == [
- {"BUILD_ON": util.get_host_architecture()},
- {"BUILD_FOR": util.get_host_architecture()},
- ]
- emitter.assert_debug(
- "Expanding environment variables with the host architecture "
- f"{util.get_host_architecture()!r} as the build-for architecture "
- "because 'all' was specified."
- )
-
-
-@pytest.mark.usefixtures("environment_project", "fake_host_architecture")
-def test_application_expand_environment(app_metadata, fake_services):
- app = application.Application(app_metadata, fake_services)
- project = app.get_project(build_for=get_host_architecture())
-
- # Make sure the project is loaded correctly (from the cwd)
- assert project is not None
- assert project.parts["mypart"]["source-tag"] == "v1.2.3"
- assert project.parts["mypart"]["build-environment"] == [
- {"BUILD_ON": util.get_host_architecture()},
- {"BUILD_FOR": util.get_host_architecture()},
- ]
-
-
@pytest.mark.usefixtures("fake_project_file")
def test_get_project_current_dir(app):
# Load a project file from the current directory
@@ -1380,23 +1077,6 @@ def test_get_project_all_platform(app):
app.get_project(platform="arm64")
-@pytest.mark.usefixtures("fake_project_file")
-def test_get_project_invalid_platform(app):
- # Load a project file from the current directory
-
- with pytest.raises(errors.InvalidPlatformError) as raised:
- app.get_project(platform="invalid")
-
- assert (
- str(raised.value) == "Platform 'invalid' not found in the project definition."
- )
-
-
-@pytest.mark.usefixtures("fake_project_file")
-def test_get_project_property(app):
- assert app.project == app.get_project()
-
-
def test_get_cache_dir(tmp_path, app):
"""Test that the cache dir is created and returned."""
with mock.patch.dict("os.environ", {"XDG_CACHE_HOME": str(tmp_path / "cache")}):
@@ -1461,38 +1141,6 @@ def test_register_plugins_default(mocker, app_metadata, fake_services):
assert reg.call_count == 0
-def test_extra_yaml_transform(tmp_path, app_metadata, fake_services):
- project_file = tmp_path / "testcraft.yaml"
- project_file.write_text(BASIC_PROJECT_YAML)
-
- app = FakeApplication(app_metadata, fake_services)
- app.project_dir = tmp_path
- _ = app.get_project(build_for="s390x")
-
- assert app.build_on == util.get_host_architecture()
- assert app.build_for == "s390x"
-
-
-def test_mandatory_adoptable_fields(tmp_path, app_metadata, fake_services):
- """Verify if mandatory adoptable fields are defined if not using adopt-info."""
- project_file = tmp_path / "testcraft.yaml"
- project_file.write_text(BASIC_PROJECT_YAML)
- app_metadata = dataclasses.replace(
- app_metadata, mandatory_adoptable_fields=["license"]
- )
-
- app = application.Application(app_metadata, fake_services)
- app.project_dir = tmp_path
-
- with pytest.raises(errors.CraftValidationError) as exc_info:
- _ = app.get_project(build_for=get_host_architecture())
-
- assert (
- str(exc_info.value)
- == "Required field 'license' is not set and 'adopt-info' not used."
- )
-
-
@pytest.fixture
def grammar_project_mini(tmp_path):
"""A project that builds on amd64 to riscv64 and s390x."""
@@ -1536,438 +1184,6 @@ def grammar_project_mini(tmp_path):
project_file.write_text(contents)
-@pytest.fixture
-def non_grammar_project_full(tmp_path):
- """A project that builds on amd64 to riscv64."""
- project_file = tmp_path / "testcraft.yaml"
- project_file.write_text(FULL_PROJECT_YAML)
-
-
-@pytest.fixture
-def grammar_project_full(tmp_path):
- """A project that builds on amd64 to riscv64 and s390x."""
- project_file = tmp_path / "testcraft.yaml"
- project_file.write_text(FULL_GRAMMAR_PROJECT_YAML)
-
-
-@pytest.fixture
-def non_grammar_build_plan(mocker, fake_host_architecture):
- """A build plan to build on amd64 to riscv64."""
- base = util.get_host_base()
- build_plan = [
- models.BuildInfo(
- "platform-riscv64",
- fake_host_architecture,
- "riscv64",
- base,
- )
- ]
-
- mocker.patch.object(models.BuildPlanner, "get_build_plan", return_value=build_plan)
-
-
-@pytest.fixture
-def grammar_build_plan(mocker):
- """A build plan to build on amd64 to riscv64 and s390x."""
- host_arch = "amd64"
- base = util.get_host_base()
- build_plan = [
- models.BuildInfo(
- f"platform-{build_for}",
- host_arch,
- build_for,
- base,
- )
- for build_for in ("riscv64", "s390x")
- ]
-
- mocker.patch.object(models.BuildPlanner, "get_build_plan", return_value=build_plan)
-
-
-@pytest.fixture
-def grammar_app_mini(
- tmp_path,
- grammar_project_mini,
- grammar_build_plan,
- app_metadata,
- fake_services,
-):
- app = application.Application(app_metadata, fake_services)
- app.project_dir = tmp_path
-
- return app
-
-
-@pytest.fixture
-def non_grammar_app_full(
- tmp_path,
- non_grammar_project_full,
- non_grammar_build_plan,
- app_metadata,
- fake_services,
-):
- app = application.Application(app_metadata, fake_services)
- app.project_dir = tmp_path
-
- return app
-
-
-@pytest.fixture
-def grammar_app_full(
- tmp_path,
- grammar_project_full,
- grammar_build_plan,
- app_metadata,
- fake_services,
-):
- app = application.Application(app_metadata, fake_services)
- app.project_dir = tmp_path
-
- return app
-
-
-def test_process_grammar_build_for(grammar_app_mini):
- """Test that a provided build-for is used to process the grammar."""
- project = grammar_app_mini.get_project(build_for="s390x")
- assert project.parts["mypart"]["source"] == "on-amd64-to-s390x"
- assert project.parts["mypart"]["build-packages"] == [
- "test-package",
- "on-amd64-to-s390x",
- ]
-
-
-def test_process_grammar_to_all(tmp_path, app_metadata, fake_services):
- """Test that 'to all' is a valid grammar statement."""
- contents = dedent(
- f"""\
- name: myproject
- version: 1.0
- base: ubuntu@24.04
- platforms:
- myplatform:
- build-on: [{util.get_host_architecture()}]
- build-for: [all]
- parts:
- mypart:
- plugin: nil
- build-packages:
- - test-package
- - on {util.get_host_architecture()} to all:
- - on-host-to-all
- - to all:
- - to-all
- - on {util.get_host_architecture()} to s390x:
- - on-host-to-s390x
- - to s390x:
- - on-amd64-to-s390x
- """
- )
- project_file = tmp_path / "testcraft.yaml"
- project_file.write_text(contents)
- app = application.Application(app_metadata, fake_services)
- app.project_dir = tmp_path
-
- project = app.get_project()
-
- assert project.parts["mypart"]["build-packages"] == [
- "test-package",
- "on-host-to-all",
- "to-all",
- ]
-
-
-def test_process_grammar_platform(grammar_app_mini):
- """Test that a provided platform is used to process the grammar."""
- project = grammar_app_mini.get_project(platform="platform-riscv64")
- assert project.parts["mypart"]["source"] == "on-amd64-to-riscv64"
- assert project.parts["mypart"]["build-packages"] == [
- "test-package",
- "on-amd64-to-riscv64",
- ]
-
-
-def test_process_grammar_non_grammar(grammar_app_mini):
- """Non-grammar keywords should not be modified."""
- project = grammar_app_mini.get_project(platform="platform-riscv64")
-
- assert project.parts["mypart"]["meson-parameters"] == ["foo", "bar"]
-
-
-def test_process_grammar_default(grammar_app_mini):
- """Test that if nothing is provided the first BuildInfo is used by the grammar."""
- project = grammar_app_mini.get_project()
- assert project.parts["mypart"]["source"] == "on-amd64-to-riscv64"
- assert project.parts["mypart"]["build-packages"] == [
- "test-package",
- "on-amd64-to-riscv64",
- ]
-
-
-def test_process_grammar_no_match(grammar_app_mini, mocker):
- """Test that if the build plan is empty, the grammar uses the host as target arch."""
- mocker.patch("craft_application.util.get_host_architecture", return_value="i386")
- project = grammar_app_mini.get_project()
-
- assert project.parts["mypart"]["source"] == "other"
- assert project.parts["mypart"]["build-packages"] == ["test-package"]
-
-
-class FakeApplicationWithYamlTransform(FakeApplication):
- """Application class that adds data in `_extra_yaml_transform`."""
-
- @override
- def _extra_yaml_transform(
- self,
- yaml_data: dict[str, Any],
- *,
- build_on: str,
- build_for: str | None,
- ) -> dict[str, Any]:
- # do not modify the dict passed in
- new_yaml_data = copy.deepcopy(yaml_data)
- new_yaml_data["parts"] = {
- "mypart": {
- "plugin": "nil",
- # advanced grammar
- "build-packages": [
- "test-package",
- {"to riscv64": "riscv64-package"},
- {"to s390x": "s390x-package"},
- ],
- "build-environment": [
- # project variables
- {"hello": "$CRAFT_ARCH_BUILD_ON"},
- ],
- }
- }
-
- return new_yaml_data
-
-
-@pytest.mark.enable_features("build_secrets")
-def test_process_yaml_from_extra_transform(
- app_metadata, fake_services, tmp_path, monkeypatch
-):
- """Test that grammar is applied on data from `_extra_yaml_transform`."""
- monkeypatch.setenv("SECRET_VAR", "secret-value")
- project_file = tmp_path / "testcraft.yaml"
- project_file.write_text(BASIC_PROJECT_YAML)
-
- app = FakeApplicationWithYamlTransform(app_metadata, fake_services)
- app.project_dir = tmp_path
- project = app.get_project(build_for="riscv64")
-
- # process grammar
- assert project.parts["mypart"]["build-packages"] == [
- "test-package",
- "riscv64-package",
- ]
- assert project.parts["mypart"]["build-environment"] == [
- # evaluate project variables
- {"hello": get_host_architecture()},
- ]
-
-
-class FakePartitionsApplication(FakeApplication):
- """A partition using FakeApplication."""
-
- @override
- def _setup_partitions(self, yaml_data) -> list[str]:
- _ = yaml_data
- return ["default", "mypartition"]
-
-
-@pytest.fixture
-def environment_partitions_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
- base: ubuntu@24.04
- platforms:
- arm64:
- parts:
- mypart:
- plugin: nil
- source-tag: v$CRAFT_PROJECT_VERSION
- override-stage: |
- touch $CRAFT_STAGE/default
- touch $CRAFT_MYPARTITION_STAGE/partition
- override-prime: |
- touch $CRAFT_PRIME/default
- touch $CRAFT_MYPARTITION_PRIME/partition
- """
- )
- )
- monkeypatch.chdir(project_dir)
-
- return project_path
-
-
-@pytest.mark.usefixtures("enable_partitions")
-@pytest.mark.usefixtures("environment_partitions_project")
-def test_partition_application_expand_environment(app_metadata, fake_services):
- app = FakePartitionsApplication(app_metadata, fake_services)
- project = app.get_project(build_for=get_host_architecture())
-
- assert craft_parts.Features().enable_partitions is True
- # Make sure the project is loaded correctly (from the cwd)
- assert project is not None
- assert project.parts["mypart"]["source-tag"] == "v1.2.3"
- assert project.parts["mypart"]["override-stage"] == dedent(
- f"""\
- touch {app.project_dir}/stage/default
- touch {app.project_dir}/partitions/mypartition/stage/partition
- """
- )
- assert project.parts["mypart"]["override-prime"] == dedent(
- f"""\
- touch {app.project_dir}/prime/default
- touch {app.project_dir}/partitions/mypartition/prime/partition
- """
- )
-
-
-@pytest.mark.usefixtures("enable_overlay")
-def test_process_non_grammar_full(non_grammar_app_full):
- """Test that the non-grammar project is processed correctly.
-
- The following fields are not included due to not able to be tested in this context:
- - parse-info
- """
- project = non_grammar_app_full.get_project()
- assert project.parts["mypart"]["plugin"] == "nil"
- assert project.parts["mypart"]["source"] == "non-grammar-source"
- assert project.parts["mypart"]["source-checksum"] == "on-amd64-to-riscv64-checksum"
- assert project.parts["mypart"]["source-branch"] == "riscv64-branch"
- assert project.parts["mypart"]["source-commit"] == "riscv64-commit"
- assert project.parts["mypart"]["source-depth"] == 1
- assert project.parts["mypart"]["source-subdir"] == "riscv64-subdir"
- assert project.parts["mypart"]["source-submodules"] == [
- "riscv64-submodules-1",
- "riscv64-submodules-2",
- ]
- assert project.parts["mypart"]["source-tag"] == "riscv64-tag"
- assert project.parts["mypart"]["source-type"] == "riscv64-type"
- assert project.parts["mypart"]["disable-parallel"] is True
- assert project.parts["mypart"]["after"] == ["riscv64-after"]
- assert project.parts["mypart"]["organize"] == {
- "riscv64-organize-1": "riscv64-organize-2",
- "riscv64-organize-3": "riscv64-organize-4",
- }
- assert project.parts["mypart"]["overlay"] == [
- "riscv64-overlay-1",
- "riscv64-overlay-2",
- ]
- assert project.parts["mypart"]["overlay-script"] == "riscv64-overlay-script"
- assert project.parts["mypart"]["stage"] == ["riscv64-stage-1", "riscv64-stage-2"]
- assert project.parts["mypart"]["stage-snaps"] == [
- "riscv64-snap-1",
- "riscv64-snap-2",
- ]
- assert project.parts["mypart"]["stage-packages"] == [
- "riscv64-package-1",
- "riscv64-package-2",
- ]
- assert project.parts["mypart"]["prime"] == ["riscv64-prime-1", "riscv64-prime-2"]
- assert project.parts["mypart"]["build-snaps"] == [
- "riscv64-snap-1",
- "riscv64-snap-2",
- ]
- assert project.parts["mypart"]["build-packages"] == [
- "riscv64-package-1",
- "riscv64-package-2",
- ]
- assert project.parts["mypart"]["build-environment"] == [
- {"MY_VAR": "riscv64-value"},
- {"MY_VAR2": "riscv64-value2"},
- ]
- assert project.parts["mypart"]["build-attributes"] == [
- "rifcv64-attr-1",
- "rifcv64-attr-2",
- ]
- assert project.parts["mypart"]["override-pull"] == "riscv64-override-pull"
- assert project.parts["mypart"]["override-build"] == "riscv64-override-build"
- assert project.parts["mypart"]["override-stage"] == "riscv64-override-stage"
- assert project.parts["mypart"]["override-prime"] == "riscv64-override-prime"
- assert project.parts["mypart"]["permissions"] == [
- {"path": "riscv64-perm-1", "owner": 123, "group": 123, "mode": "777"},
- {"path": "riscv64-perm-2", "owner": 456, "group": 456, "mode": "666"},
- ]
-
-
-@pytest.mark.usefixtures("enable_overlay")
-def test_process_grammar_full(grammar_app_full):
- """Test that the nearly all grammar is processed correctly.
-
- The following fields are not included due to not able to be tested in this context:
- - parse-info
- """
- project = grammar_app_full.get_project()
- assert project.parts["mypart"]["plugin"] == "nil"
- assert project.parts["mypart"]["source"] == "on-amd64-to-riscv64"
- assert project.parts["mypart"]["source-checksum"] == "on-amd64-to-riscv64-checksum"
- assert project.parts["mypart"]["source-branch"] == "riscv64-branch"
- assert project.parts["mypart"]["source-commit"] == "riscv64-commit"
- assert project.parts["mypart"]["source-depth"] == 1
- assert project.parts["mypart"]["source-subdir"] == "riscv64-subdir"
- assert project.parts["mypart"]["source-submodules"] == [
- "riscv64-submodules-1",
- "riscv64-submodules-2",
- ]
- assert project.parts["mypart"]["source-tag"] == "riscv64-tag"
- assert project.parts["mypart"]["source-type"] == "riscv64-type"
- assert project.parts["mypart"]["disable-parallel"] is True
- assert project.parts["mypart"]["after"] == ["riscv64-after"]
- assert project.parts["mypart"]["organize"] == {
- "riscv64-organize-1": "riscv64-organize-2",
- "riscv64-organize-3": "riscv64-organize-4",
- }
- assert project.parts["mypart"]["overlay"] == [
- "riscv64-overlay-1",
- "riscv64-overlay-2",
- ]
- assert project.parts["mypart"]["overlay-script"] == "riscv64-overlay-script"
- assert project.parts["mypart"]["stage"] == ["riscv64-stage-1", "riscv64-stage-2"]
- assert project.parts["mypart"]["stage-snaps"] == [
- "riscv64-snap-1",
- "riscv64-snap-2",
- ]
- assert project.parts["mypart"]["stage-packages"] == [
- "riscv64-package-1",
- "riscv64-package-2",
- ]
- assert project.parts["mypart"]["prime"] == ["riscv64-prime-1", "riscv64-prime-2"]
- assert project.parts["mypart"]["build-snaps"] == [
- "riscv64-snap-1",
- "riscv64-snap-2",
- ]
- assert project.parts["mypart"]["build-packages"] == [
- "riscv64-package-1",
- "riscv64-package-2",
- ]
- assert project.parts["mypart"]["build-environment"] == [
- {"MY_VAR": "riscv64-value"},
- {"MY_VAR2": "riscv64-value2"},
- ]
- assert project.parts["mypart"]["build-attributes"] == [
- "rifcv64-attr-1",
- "rifcv64-attr-2",
- ]
- assert project.parts["mypart"]["override-pull"] == "riscv64-override-pull"
- assert project.parts["mypart"]["override-build"] == "riscv64-override-build"
- assert project.parts["mypart"]["override-stage"] == "riscv64-override-stage"
- assert project.parts["mypart"]["override-prime"] == "riscv64-override-prime"
- assert project.parts["mypart"]["permissions"] == [
- {"path": "riscv64-perm-1", "owner": 123, "group": 123, "mode": "777"},
- {"path": "riscv64-perm-2", "owner": 456, "group": 456, "mode": "666"},
- ]
-
-
def test_enable_features(app, mocker):
calls = []
@@ -2008,6 +1224,10 @@ def get_build_plan(self) -> list[BuildInfo]:
return []
+@pytest.mark.skipif(
+ date.today() < date(2025, 2, 27),
+ reason="CRAFT-4159, This will no longer be the responsibility of the application.",
+)
def test_build_planner_errors(tmp_path, monkeypatch, fake_services):
monkeypatch.chdir(tmp_path)
app_metadata = craft_application.AppMetadata(
@@ -2057,9 +1277,21 @@ def test_emitter_docs_url(monkeypatch, mocker, app):
assert spied_init.mock_calls[0].kwargs["docs_base_url"] == expected_url
-def test_clean_platform(monkeypatch, tmp_path, app_metadata, fake_services, mocker):
+@pytest.mark.skipif(
+ date.today() < date(2025, 3, 1),
+ reason="CRAFT-4163, This will no longer be the responsibility of the application.",
+)
+def test_clean_platform(
+ monkeypatch,
+ tmp_path,
+ app_metadata,
+ fake_services,
+ mocker,
+ fake_project_yaml,
+ fake_provider_service_class,
+):
"""Test that calling "clean --platform=x" correctly filters the build plan."""
- data = util.safe_yaml_load(StringIO(BASIC_PROJECT_YAML))
+ data = util.safe_yaml_load(StringIO(fake_project_yaml))
# Put a few different platforms on the project
arch = util.get_host_architecture()
build_on_for = {