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 = {