Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!(services): add a project service #646

Merged
merged 42 commits into from
Feb 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
04bc7b4
feat!(partial): project service
lengau Feb 13, 2025
231ba21
refactor(tests/fixtures): create a single default fake project
lengau Feb 19, 2025
7b01dfa
feat(revert): fix service factory removal of attributes
lengau Feb 19, 2025
1cada53
refactor(ServiceFactory): de-dataclass the ServiceFactory
lengau Feb 19, 2025
89563c9
fix(services): make the FetchService use the ProjectService
lengau Feb 19, 2025
4f29d58
fix(tests): fix InitService tests for fixture changes
lengau Feb 19, 2025
9e0c751
fix(lifecycle): make LifecycleService no longer a ProjectService
lengau Feb 19, 2025
a1857c7
fix(services/package): make PackageService use ProjectService
lengau Feb 19, 2025
6644cf2
fix(services): make ProviderService use ProjectService
lengau Feb 19, 2025
a7ae8c5
fix(tests): fix request service test
lengau Feb 19, 2025
cd52083
fix(services): make RemoteBuildService use ProjectService
lengau Feb 19, 2025
522716a
fix(tests): fix the service factory tests
lengau Feb 19, 2025
b2a37b7
test(ProjectService): add expand_environment tests for the ProjectSer…
lengau Feb 19, 2025
7cc0653
test: add more unit tests for ProjectService
lengau Feb 20, 2025
cc23eb7
feat!(secrets): remove secrets
lengau Feb 20, 2025
fb7dd89
chore!(application): remove yaml transforms and environment from app
lengau Feb 20, 2025
79b8ee2
test: add integration tests for rendering a project
lengau Feb 20, 2025
8008304
fix(application): fix some application tests
lengau Feb 20, 2025
adb77bc
test: remove unneeded tests
lengau Feb 20, 2025
87cb9d0
test: move adopt-info test to project service
lengau Feb 21, 2025
7eff14d
test: ignore unnecessary test
lengau Feb 21, 2025
9502d62
fix(test): fix application tests
lengau Feb 21, 2025
463b75c
fix(remote-build): make the remote build command use the project service
lengau Feb 21, 2025
176f37e
build(deps): only use python-apt-wheels repo for python-apt
lengau Feb 21, 2025
232cbfa
fix(tests/lifecycle): set the fake project before running lifecycle i…
lengau Feb 21, 2025
888de79
test(lifecycle): correctly set up the package service for lifecycle i…
lengau Feb 21, 2025
5260738
fix(tests): fix the service factory tests for new fixtures
lengau Feb 21, 2025
a481640
feat(application): make the application bootstrap early-run services
lengau Feb 21, 2025
6c811b9
test: fix or skip application integration tests
lengau Feb 21, 2025
9bf69f5
test: don't pass a project to the provider service
lengau Feb 21, 2025
20842fa
test: don't pass a project to the fetch service
lengau Feb 21, 2025
ad23ccf
chore: eliminate the last vestiges of the old ProjectService
lengau Feb 21, 2025
571ff81
style: fix linting issues
lengau Feb 21, 2025
07de5cb
chore: revert accidental commit of common.mk change
lengau Feb 21, 2025
e7acb23
fix(ProjectService): mark most methods as final
lengau Feb 21, 2025
e513598
fix(lint): fix linter issue
lengau Feb 21, 2025
39cd8a9
fix(tests): ensure tests don't have side effects
lengau Feb 21, 2025
ccdd3fd
fix: pr suggestions
lengau Feb 25, 2025
e38da2d
fix: pr feedback
lengau Feb 26, 2025
4fc04fc
fix: add changelog entries
lengau Feb 26, 2025
5026b63
fix: tests that used the old name of configure_early_services
lengau Feb 26, 2025
7aca291
Merge branch 'feature/hybrid-commands' into work/project-service
lengau Feb 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 31 additions & 164 deletions craft_application/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just to double-check: is snapcraft expected to just drop the override on their version? Or are there more changes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Snapcraft will need to move that override to the ProjectService's `resolve_project_file_path()'. Should work roughly the same though (just has to use the class attribute for project dir rather than a method arg)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My concern is that Snapcraft calls that one extremely early, before even _configure_early_services() has been called

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is worth sweating too much just now; since this is going to a feature branch we'll have time to assess the changes in Snapcraft before setting anything in stone.

"""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,
*,
Expand All @@ -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:
Expand Down Expand Up @@ -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."""
Expand All @@ -629,18 +580,18 @@ 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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about the platform and build_for parameters? How do they end up in the service in this specific code path?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks!


provider_name = command.provider_name(dispatcher.parsed_args())

craft_cli.emit.debug(f"Build plan: platform={platform}, build_for={build_for}")
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
Expand All @@ -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()

Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions craft_application/commands/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ".")
Expand Down
Loading
Loading