diff --git a/requirements-devel.txt b/requirements-devel.txt index 76bd289e11..0278ca317d 100644 --- a/requirements-devel.txt +++ b/requirements-devel.txt @@ -11,6 +11,7 @@ click==8.1.7 codespell==2.2.6 colorama==0.4.6 coverage==7.4.0 +craft-application==1.2.1 craft-archives==1.1.3 craft-cli==2.5.1 craft-grammar==1.1.2 diff --git a/requirements.txt b/requirements.txt index 5e83e098f2..e12a3420d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ cffi==1.16.0 chardet==5.2.0 charset-normalizer==3.3.2 click==8.1.7 +craft-application==1.2.1 craft-archives==1.1.3 craft-cli==2.5.1 craft-grammar==1.1.2 diff --git a/setup.py b/setup.py index 2d55d28f73..6490822a2f 100755 --- a/setup.py +++ b/setup.py @@ -97,6 +97,7 @@ def recursive_data_files(directory, install_directory): "attrs", "catkin-pkg; sys_platform == 'linux'", "click", + "craft-application", "craft-archives", "craft-cli", "craft-grammar", @@ -154,7 +155,7 @@ def recursive_data_files(directory, install_directory): entry_points=dict( console_scripts=[ "snapcraft_legacy = snapcraft_legacy.cli.__main__:run", - "snapcraft = snapcraft.cli:run", + "snapcraft = snapcraft.application:main", ] ), data_files=( diff --git a/snapcraft/__main__.py b/snapcraft/__main__.py index 8675237d10..fc8b55b821 100644 --- a/snapcraft/__main__.py +++ b/snapcraft/__main__.py @@ -18,6 +18,6 @@ import sys -from snapcraft import cli +from snapcraft import application -sys.exit(cli.run()) +sys.exit(application.main()) diff --git a/snapcraft/application.py b/snapcraft/application.py new file mode 100644 index 0000000000..d2e83264d9 --- /dev/null +++ b/snapcraft/application.py @@ -0,0 +1,244 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2023 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Main Snapcraft Application.""" + +from __future__ import annotations + +import os +import signal +import sys + +import craft_cli +from craft_application import Application, AppMetadata, util +from craft_cli import emit +from overrides import override + +from snapcraft import cli, errors, models, services +from snapcraft.commands import unimplemented + +APP_METADATA = AppMetadata( + name="snapcraft", + summary="Package, distribute, and update snaps for Linux and IoT", + ProjectClass=models.Project, + source_ignore_patterns=["*.snap"], +) + + +class Snapcraft(Application): + """Snapcraft application definition.""" + + @override + def _configure_services(self, platform: str | None, build_for: str | None) -> None: + if build_for is None: + build_for = util.get_host_architecture() + + self.services.set_kwargs("package", platform=platform, build_for=build_for) + super()._configure_services(platform, build_for) + + @property + def command_groups(self): + """Short-circuit the standard command groups for now.""" + # TODO: Remove this once we've got lifecycle commands and version migrated. + return self._command_groups + + def run(self) -> int: + """Fall back to the old snapcraft entrypoint.""" + self._get_dispatcher() + raise errors.ClassicFallback() + + @override + def _get_dispatcher(self) -> craft_cli.Dispatcher: + """Configure this application. Should be called by the run method. + + Side-effect: This method may exit the process. + + :returns: A ready-to-run Dispatcher object + """ + # Set the logging level to DEBUG for all craft-libraries. This is OK even if + # the specific application doesn't use a specific library, the call does not + # import the package. + util.setup_loggers(*self._cli_loggers) + + craft_cli.emit.init( + mode=craft_cli.EmitterMode.BRIEF, + appname=self.app.name, + greeting=f"Starting {self.app.name}", + log_filepath=self.log_path, + streaming_brief=True, + ) + + dispatcher = craft_cli.Dispatcher( + self.app.name, + self.command_groups, + summary=str(self.app.summary), + extra_global_args=self._global_arguments, + # TODO: craft-application should allow setting the default command without + # overriding `_get_dispatcher()` + default_command=unimplemented.Pack, + ) + + try: + craft_cli.emit.trace("pre-parsing arguments...") + # Workaround for the fact that craft_cli requires a command. + # https://github.com/canonical/craft-cli/issues/141 + if "--version" in sys.argv or "-V" in sys.argv: + try: + global_args = dispatcher.pre_parse_args(["pull", *sys.argv[1:]]) + except craft_cli.ArgumentParsingError: + global_args = dispatcher.pre_parse_args(sys.argv[1:]) + else: + global_args = dispatcher.pre_parse_args(sys.argv[1:]) + + if global_args.get("version"): + craft_cli.emit.ended_ok() + print(f"{self.app.name} {self.app.version}") + sys.exit(0) + except craft_cli.ProvideHelpException as err: + print(err, file=sys.stderr) # to stderr, as argparse normally does + craft_cli.emit.ended_ok() + sys.exit(0) + except craft_cli.ArgumentParsingError as err: + print(err, file=sys.stderr) # to stderr, as argparse normally does + craft_cli.emit.ended_ok() + sys.exit(64) # Command line usage error from sysexits.h + except KeyboardInterrupt as err: + self._emit_error(craft_cli.CraftError("Interrupted."), cause=err) + sys.exit(128 + signal.SIGINT) + # pylint: disable-next=broad-exception-caught + except Exception as err: # noqa: BLE001 + self._emit_error( + craft_cli.CraftError( + f"Internal error while loading {self.app.name}: {err!r}" + ) + ) + if os.getenv("CRAFT_DEBUG") == "1": + raise + sys.exit(70) # EX_SOFTWARE from sysexits.h + + craft_cli.emit.trace("Preparing application...") + self.configure(global_args) + + return dispatcher + + +def main() -> int: + """Run craft-application based snapcraft with classic fallback.""" + util.setup_loggers( + "craft_parts", "craft_providers", "craft_store", "snapcraft.remote" + ) + + snapcraft_services = services.SnapcraftServiceFactory(app=APP_METADATA) + + app = Snapcraft(app=APP_METADATA, services=snapcraft_services) + + app.add_command_group( + "Lifecycle", + [ + unimplemented.Clean, + unimplemented.Pull, + unimplemented.Build, + unimplemented.Stage, + unimplemented.Prime, + unimplemented.Pack, + unimplemented.RemoteBuild, + unimplemented.Snap, # Hidden (legacy compatibility) + unimplemented.Plugins, + unimplemented.ListPlugins, + unimplemented.Try, + ], + ) + app.add_command_group( + "Extensions", + [ + unimplemented.ListExtensions, + unimplemented.Extensions, + unimplemented.ExpandExtensions, + ], + ) + app.add_command_group( + "Store Account", + [ + unimplemented.Login, + unimplemented.ExportLogin, + unimplemented.Logout, + unimplemented.Whoami, + ], + ) + app.add_command_group( + "Store Snap Names", + [ + unimplemented.Register, + unimplemented.Names, + unimplemented.ListRegistered, + unimplemented.List, + unimplemented.Metrics, + unimplemented.UploadMetadata, + ], + ) + app.add_command_group( + "Store Snap Release Management", + [ + unimplemented.Release, + unimplemented.Close, + unimplemented.Status, + unimplemented.Upload, + unimplemented.Push, + unimplemented.Promote, + unimplemented.ListRevisions, + unimplemented.Revisions, + ], + ) + app.add_command_group( + "Store Snap Tracks", + [ + unimplemented.ListTracks, + unimplemented.Tracks, + unimplemented.SetDefaultTrack, + ], + ) + app.add_command_group( + "Store Key Management", + [ + unimplemented.CreateKey, + unimplemented.RegisterKey, + unimplemented.SignBuild, + unimplemented.ListKeys, + ], + ) + app.add_command_group( + "Store Validation Sets", + [ + unimplemented.EditValidationSets, + unimplemented.ListValidationSets, + unimplemented.Validate, + unimplemented.Gated, + ], + ) + app.add_command_group( + "Other", + [ + unimplemented.Version, + unimplemented.Lint, + unimplemented.Init, + ], + ) + + try: + return app.run() + except errors.ClassicFallback: + emit.debug("Falling back from craft-application to snapcraft.") + return cli.run() diff --git a/snapcraft/cli.py b/snapcraft/cli.py index 829c76ec68..0e364df84a 100644 --- a/snapcraft/cli.py +++ b/snapcraft/cli.py @@ -192,19 +192,6 @@ def get_dispatcher() -> craft_cli.Dispatcher: _ORIGINAL_LIB_NAME_LOG_LEVEL[lib_name] = logger.level logger.setLevel(logging.DEBUG) - if utils.is_managed_mode(): - log_filepath = utils.get_managed_environment_log_path() - else: - log_filepath = None - - emit.init( - mode=get_verbosity(), - appname="snapcraft", - greeting=f"Starting Snapcraft {__version__}", - log_filepath=log_filepath, - streaming_brief=True, - ) - return craft_cli.Dispatcher( "snapcraft", COMMAND_GROUPS, diff --git a/snapcraft/commands/discovery.py b/snapcraft/commands/discovery.py index 4f43894c83..2c3fc0d3d6 100644 --- a/snapcraft/commands/discovery.py +++ b/snapcraft/commands/discovery.py @@ -24,14 +24,13 @@ from craft_parts.plugins import get_registered_plugins from overrides import overrides -from snapcraft import errors +from snapcraft import errors, models from snapcraft.parts.yaml_utils import ( apply_yaml, extract_parse_info, get_snap_project, process_yaml, ) -from snapcraft.projects import Project from snapcraft.utils import get_host_architecture if TYPE_CHECKING: @@ -80,7 +79,7 @@ def run(self, parsed_args): # determine the base extract_parse_info(yaml_data_for_arch) - project = Project.unmarshal(yaml_data_for_arch) + project = models.Project.unmarshal(yaml_data_for_arch) base = project.get_effective_base() message = ( f"Displaying plugins available to the current base {base!r} project" diff --git a/snapcraft/commands/extensions.py b/snapcraft/commands/extensions.py index 0e9d30050a..48d19da778 100644 --- a/snapcraft/commands/extensions.py +++ b/snapcraft/commands/extensions.py @@ -26,14 +26,13 @@ from overrides import overrides from pydantic import BaseModel -from snapcraft import extensions +from snapcraft import extensions, models from snapcraft.parts.yaml_utils import ( apply_yaml, extract_parse_info, get_snap_project, process_yaml, ) -from snapcraft.projects import Project from snapcraft.utils import get_host_architecture from snapcraft_legacy.internal.project_loader import ( find_extension, @@ -135,5 +134,5 @@ def run(self, parsed_args): # not part of the Project model extract_parse_info(yaml_data_for_arch) - Project.unmarshal(yaml_data_for_arch) + models.Project.unmarshal(yaml_data_for_arch) emit.message(yaml.safe_dump(yaml_data_for_arch, indent=4, sort_keys=False)) diff --git a/snapcraft/commands/lint.py b/snapcraft/commands/lint.py index 15fb9bb014..3705519f0c 100644 --- a/snapcraft/commands/lint.py +++ b/snapcraft/commands/lint.py @@ -32,7 +32,7 @@ from craft_providers.util import snap_cmd from overrides import overrides -from snapcraft import errors, linters, projects, providers +from snapcraft import errors, linters, models, providers from snapcraft.meta import snap_yaml from snapcraft.parts.yaml_utils import apply_yaml, extract_parse_info, process_yaml from snapcraft.utils import ( @@ -257,7 +257,7 @@ def _unsquash_snap(self, snap_file: Path) -> Iterator[Path]: yield Path(temp_dir) - def _load_project(self, snapcraft_yaml_file: Path) -> Optional[projects.Project]: + def _load_project(self, snapcraft_yaml_file: Path) -> Optional[models.Project]: """Load a snapcraft Project from a snapcraft.yaml, if present. The snapcraft.yaml exist for snaps built with the `--enable-manifest` parameter. @@ -284,7 +284,7 @@ def _load_project(self, snapcraft_yaml_file: Path) -> Optional[projects.Project] yaml_data_for_arch = apply_yaml(yaml_data, arch, arch) # discard parse-info - it is not needed extract_parse_info(yaml_data_for_arch) - project = projects.Project.unmarshal(yaml_data_for_arch) + project = models.Project.unmarshal(yaml_data_for_arch) return project def _install_snap( @@ -343,14 +343,14 @@ def _install_snap( return Path("/snap") / snap_metadata.name / "current" - def _load_lint_filters(self, project: Optional[projects.Project]) -> projects.Lint: + def _load_lint_filters(self, project: Optional[models.Project]) -> models.Lint: """Load lint filters from a Project and disable the classic linter. :param project: Project from the snap file, if present. :returns: Lint config with classic linter disabled. """ - lint_config = projects.Lint(ignore=["classic"]) + lint_config = models.Lint(ignore=["classic"]) if project: if project.lint: diff --git a/snapcraft/commands/unimplemented.py b/snapcraft/commands/unimplemented.py new file mode 100644 index 0000000000..9c3613285f --- /dev/null +++ b/snapcraft/commands/unimplemented.py @@ -0,0 +1,300 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2023 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Unimplemented commands that should be sent to snapcraft's cli handler instead.""" + +import argparse +from typing import final + +from snapcraft import commands, errors + +# pylint: disable=missing-class-docstring + + +class UnimplementedMixin: + """A mixin that allows you to declare a command unimplemented. + + Lets us scaffold the snapcraft help but then fall back + """ + + @final + def run(self, parsed_args: argparse.Namespace) -> None: + """Execute a command's functionality.""" + raise errors.ClassicFallback() + + +class ExportLogin( + UnimplementedMixin, commands.StoreExportLoginCommand +): # noqa: D101 (missing docstring) + pass + + +class Login( + UnimplementedMixin, commands.StoreLoginCommand +): # noqa: D101 (missing docstring) + pass + + +class Logout( + UnimplementedMixin, commands.StoreLogoutCommand +): # noqa: D101 (missing docstring) + pass + + +class Whoami( + UnimplementedMixin, commands.StoreWhoAmICommand +): # noqa: D101 (missing docstring) + pass + + +class ListPlugins( + UnimplementedMixin, commands.ListPluginsCommand +): # noqa: D101 (missing docstring) + pass + + +class Plugins( + UnimplementedMixin, commands.PluginsCommand +): # noqa: D101 (missing docstring) + pass + + +class ExpandExtensions( + UnimplementedMixin, commands.ExpandExtensionsCommand +): # noqa: D101 (missing docstring) + pass + + +class ListExtensions( + UnimplementedMixin, commands.ListExtensionsCommand +): # noqa: D101 (missing docstring) + pass + + +class Extensions( + UnimplementedMixin, commands.ExtensionsCommand +): # noqa: D101 (missing docstring) + pass + + +class Init(UnimplementedMixin, commands.InitCommand): # noqa: D101 (missing docstring) + pass + + +class CreateKey( + UnimplementedMixin, commands.StoreLegacyCreateKeyCommand +): # noqa: D101 (missing docstring) + pass + + +class Gated( + UnimplementedMixin, commands.StoreLegacyGatedCommand +): # noqa: D101 (missing docstring) + pass + + +class ListKeys( + UnimplementedMixin, commands.StoreLegacyListKeysCommand +): # noqa: D101 (missing docstring) + pass + + +class ListValidationSets( + UnimplementedMixin, commands.StoreLegacyListValidationSetsCommand +): # noqa: D101 (missing docstring) + pass + + +class Metrics( + UnimplementedMixin, commands.StoreLegacyMetricsCommand +): # noqa: D101 (missing docstring) + pass + + +class Promote( + UnimplementedMixin, commands.StoreLegacyPromoteCommand +): # noqa: D101 (missing docstring) + pass + + +class RegisterKey( + UnimplementedMixin, commands.StoreLegacyRegisterKeyCommand +): # noqa: D101 (missing docstring) + pass + + +class SetDefaultTrack( + UnimplementedMixin, commands.StoreLegacySetDefaultTrackCommand +): # noqa: D101 (missing docstring) + pass + + +class SignBuild( + UnimplementedMixin, commands.StoreLegacySignBuildCommand +): # noqa: D101 (missing docstring) + pass + + +class UploadMetadata( + UnimplementedMixin, commands.StoreLegacyUploadMetadataCommand +): # noqa: D101 (missing docstring) + pass + + +class Validate( + UnimplementedMixin, commands.StoreLegacyValidateCommand +): # noqa: D101 (missing docstring) + pass + + +class Build( + UnimplementedMixin, commands.BuildCommand +): # noqa: D101 (missing docstring) + pass + + +class Clean( + UnimplementedMixin, commands.CleanCommand +): # noqa: D101 (missing docstring) + pass + + +class Pack(UnimplementedMixin, commands.PackCommand): # noqa: D101 (missing docstring) + pass + + +class Prime( + UnimplementedMixin, commands.PrimeCommand +): # noqa: D101 (missing docstring) + pass + + +class Pull(UnimplementedMixin, commands.PullCommand): # noqa: D101 (missing docstring) + pass + + +class Snap(UnimplementedMixin, commands.SnapCommand): # noqa: D101 (missing docstring) + pass + + +class Stage( + UnimplementedMixin, commands.StageCommand +): # noqa: D101 (missing docstring) + pass + + +class Try(UnimplementedMixin, commands.TryCommand): # noqa: D101 (missing docstring) + pass + + +class Lint(UnimplementedMixin, commands.LintCommand): # noqa: D101 (missing docstring) + pass + + +class Close( + UnimplementedMixin, commands.StoreCloseCommand +): # noqa: D101 (missing docstring) + pass + + +class Release( + UnimplementedMixin, commands.StoreReleaseCommand +): # noqa: D101 (missing docstring) + pass + + +class List( + UnimplementedMixin, commands.StoreLegacyListCommand +): # noqa: D101 (missing docstring) + pass + + +class ListRegistered( + UnimplementedMixin, commands.StoreLegacyListRegisteredCommand +): # noqa: D101 (missing docstring) + pass + + +class Names( + UnimplementedMixin, commands.StoreNamesCommand +): # noqa: D101 (missing docstring) + pass + + +class Register( + UnimplementedMixin, commands.StoreRegisterCommand +): # noqa: D101 (missing docstring) + pass + + +class ListRevisions( + UnimplementedMixin, commands.StoreListRevisionsCommand +): # noqa: D101 (missing docstring) + pass + + +class ListTracks( + UnimplementedMixin, commands.StoreListTracksCommand +): # noqa: D101 (missing docstring) + pass + + +class Revisions( + UnimplementedMixin, commands.StoreRevisionsCommand +): # noqa: D101 (missing docstring) + pass + + +class Status( + UnimplementedMixin, commands.StoreStatusCommand +): # noqa: D101 (missing docstring) + pass + + +class Tracks( + UnimplementedMixin, commands.StoreTracksCommand +): # noqa: D101 (missing docstring) + pass + + +class Push( + UnimplementedMixin, commands.StoreLegacyPushCommand +): # noqa: D101 (missing docstring) + pass + + +class Upload( + UnimplementedMixin, commands.StoreUploadCommand +): # noqa: D101 (missing docstring) + pass + + +class EditValidationSets( + UnimplementedMixin, commands.StoreEditValidationSetsCommand +): # noqa: D101 (missing docstring) + pass + + +class Version( + UnimplementedMixin, commands.VersionCommand +): # noqa: D101 (missing docstring) + pass + + +class RemoteBuild( + UnimplementedMixin, commands.RemoteBuildCommand +): # noqa: D101 (missing docstring) + pass diff --git a/snapcraft/errors.py b/snapcraft/errors.py index 37148a05ea..8ca46df4ee 100644 --- a/snapcraft/errors.py +++ b/snapcraft/errors.py @@ -21,6 +21,10 @@ from craft_cli import CraftError +class ClassicFallback(Exception): + """Temporary class to fall back to non craft-application launcher.""" + + class SnapcraftError(CraftError): """Failure in a Snapcraft operation.""" diff --git a/snapcraft/linters/base.py b/snapcraft/linters/base.py index c8a819c5b0..e27c8eef5b 100644 --- a/snapcraft/linters/base.py +++ b/snapcraft/linters/base.py @@ -25,8 +25,7 @@ import pydantic from craft_cli import emit -from snapcraft import projects -from snapcraft.elf import ElfFile +from snapcraft import elf, models if TYPE_CHECKING: from snapcraft.meta.snap_yaml import SnapMetadata @@ -91,11 +90,11 @@ def __init__( self, name: str, snap_metadata: "SnapMetadata", - lint: Optional[projects.Lint], + lint: Optional[models.Lint], ): self._name = name self._snap_metadata = snap_metadata - self._lint = lint or projects.Lint(ignore=[]) + self._lint = lint or models.Lint(ignore=[]) @abc.abstractmethod def run(self) -> List[LinterIssue]: @@ -105,7 +104,7 @@ def run(self) -> List[LinterIssue]: """ def _is_file_ignored( - self, filepath: Union[ElfFile, Path], category: str = "" + self, filepath: Union[elf.ElfFile, Path], category: str = "" ) -> bool: """Check if the file name matches an ignored file pattern. @@ -121,7 +120,7 @@ def _is_file_ignored( # No "extend()" because we don't want to affect the original list. ignored_files = ignored_files + self._lint.ignored_files(category) - if isinstance(filepath, ElfFile): + if isinstance(filepath, elf.ElfFile): path = filepath.path else: path = filepath diff --git a/snapcraft/linters/linters.py b/snapcraft/linters/linters.py index 70ca40d838..507c8642cc 100644 --- a/snapcraft/linters/linters.py +++ b/snapcraft/linters/linters.py @@ -26,7 +26,7 @@ from craft_cli import emit -from snapcraft import projects +from snapcraft import models from snapcraft.meta import snap_yaml from .base import Linter, LinterIssue, LinterResult @@ -111,7 +111,7 @@ def _update_status(status: LinterStatus, result: LinterResult) -> LinterStatus: return status -def run_linters(location: Path, *, lint: Optional[projects.Lint]) -> List[LinterIssue]: +def run_linters(location: Path, *, lint: Optional[models.Lint]) -> List[LinterIssue]: """Run all the defined linters. :param location: The root of the snap payload subtree to run linters on. @@ -149,7 +149,7 @@ def run_linters(location: Path, *, lint: Optional[projects.Lint]) -> List[Linter def _ignore_matching_filenames( - issues: List[LinterIssue], *, lint: Optional[projects.Lint] + issues: List[LinterIssue], *, lint: Optional[models.Lint] ) -> None: """Mark any remaining filename match as ignored.""" if lint is None: diff --git a/snapcraft/meta/manifest.py b/snapcraft/meta/manifest.py index 4ae5925cd0..7876e9e2d4 100644 --- a/snapcraft/meta/manifest.py +++ b/snapcraft/meta/manifest.py @@ -23,8 +23,7 @@ from pydantic_yaml import YamlModel -from snapcraft import __version__, errors, os_release, utils -from snapcraft.projects import Project +from snapcraft import __version__, errors, models, os_release, utils class Manifest(YamlModel): @@ -68,7 +67,7 @@ class Config: # pylint: disable=too-few-public-methods def write( # noqa PLR0913 - project: Project, + project: models.Project, prime_dir: Path, *, arch: str, diff --git a/snapcraft/meta/snap_yaml.py b/snapcraft/meta/snap_yaml.py index afb7953f72..65adbed4af 100644 --- a/snapcraft/meta/snap_yaml.py +++ b/snapcraft/meta/snap_yaml.py @@ -21,12 +21,12 @@ from typing import Any, Dict, List, Literal, Optional, Set, Union, cast import yaml +from craft_application.models import UniqueStrList from craft_cli import emit from pydantic import Extra, ValidationError, validator from pydantic_yaml import YamlModel -from snapcraft import errors -from snapcraft.projects import App, Project, UniqueStrList +from snapcraft import errors, models from snapcraft.utils import get_ld_library_paths, process_version @@ -188,13 +188,13 @@ class Links(_SnapMetadataModel): @staticmethod def _normalize_value( value: Optional[Union[str, UniqueStrList]] - ) -> Optional[List[str]]: + ) -> Optional[UniqueStrList]: if isinstance(value, str): - value = [value] + value = cast(UniqueStrList, [value]) return value @classmethod - def from_project(cls, project: Project) -> "Links": + def from_project(cls, project: models.Project) -> "Links": """Create Links from a Project.""" return cls( contact=cls._normalize_value(project.contact), @@ -338,7 +338,7 @@ def read(prime_dir: Path) -> SnapMetadata: return SnapMetadata.unmarshal(data) -def _create_snap_app(app: App, assumes: Set[str]) -> SnapApp: +def _create_snap_app(app: models.App, assumes: Set[str]) -> SnapApp: app_sockets: Dict[str, Socket] = {} if app.sockets: for socket_name, socket in app.sockets.items(): @@ -410,7 +410,7 @@ def _get_grade(grade: Optional[str], build_base: Optional[str]) -> str: return grade -def write(project: Project, prime_dir: Path, *, arch: str): +def write(project: models.Project, prime_dir: Path, *, arch: str): """Create a snap.yaml file. :param project: Snapcraft project. diff --git a/snapcraft/models/__init__.py b/snapcraft/models/__init__.py new file mode 100644 index 0000000000..9f56e187d3 --- /dev/null +++ b/snapcraft/models/__init__.py @@ -0,0 +1,42 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022-2023 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Data models for snapcraft.""" + +from .project import ( + MANDATORY_ADOPTABLE_FIELDS, + App, + Architecture, + ArchitectureProject, + ContentPlug, + GrammarAwareProject, + Hook, + Lint, + Project, + Socket, +) + +__all__ = [ + "MANDATORY_ADOPTABLE_FIELDS", + "App", + "Architecture", + "ArchitectureProject", + "ContentPlug", + "GrammarAwareProject", + "Hook", + "Lint", + "Project", + "Socket", +] diff --git a/snapcraft/projects.py b/snapcraft/models/project.py similarity index 95% rename from snapcraft/projects.py rename to snapcraft/models/project.py index feadca15d7..a05c76a40f 100644 --- a/snapcraft/projects.py +++ b/snapcraft/models/project.py @@ -17,13 +17,15 @@ """Project file definition and helpers.""" import re -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union, cast import pydantic +from craft_application import models +from craft_application.models import BuildInfo, UniqueStrList from craft_archives import repo from craft_cli import emit from craft_grammar.models import GrammarSingleEntryDictList, GrammarStr, GrammarStrList -from pydantic import PrivateAttr, conlist, constr +from pydantic import PrivateAttr, constr from snapcraft import parts, utils from snapcraft.elf.elf_utils import get_arch_triplet @@ -36,35 +38,15 @@ is_architecture_supported, ) - -class ProjectModel(pydantic.BaseModel): - """Base model for snapcraft project classes.""" - - class Config: # pylint: disable=too-few-public-methods - """Pydantic model configuration.""" - - validate_assignment = True - extra = "forbid" - allow_mutation = True # project is updated with adopted metadata - allow_population_by_field_name = True - alias_generator = lambda s: s.replace("_", "-") # noqa: E731 - - # A workaround for mypy false positives # see https://github.com/samuelcolvin/pydantic/issues/975#issuecomment-551147305 # fmt: off if TYPE_CHECKING: ProjectName = str - ProjectSummary = str - ProjectTitle = str ProjectVersion = str - UniqueStrList = List[str] else: ProjectName = constr(max_length=40) - ProjectSummary = constr(max_length=78) - ProjectTitle = constr(max_length=40) ProjectVersion = constr(max_length=32, strict=True) - UniqueStrList = conlist(str, unique_items=True) # fmt: on @@ -149,7 +131,8 @@ def _expand_architectures(architectures): # convert strings into Architecture objects if isinstance(architecture, str): architectures[index] = Architecture( - build_on=[architecture], build_for=[architecture] + build_on=cast(UniqueStrList, [architecture]), + build_for=cast(UniqueStrList, [architecture]), ) elif isinstance(architecture, Architecture): # convert strings to lists @@ -187,7 +170,7 @@ def _validate_architectures_all_keyword(architectures): ) -class Socket(ProjectModel): +class Socket(models.CraftBaseModel): """Snapcraft app socket definition.""" listen_stream: Union[int, str] @@ -212,7 +195,7 @@ def _validate_list_stream(cls, listen_stream): return listen_stream -class Lint(ProjectModel): +class Lint(models.CraftBaseModel): """Linter configuration. :ivar ignore: A list describing which files should have issues ignored for given linters. @@ -264,7 +247,7 @@ def ignored_files(self, linter_name: str) -> List[str]: return self._lint_ignores[linter_name] -class App(ProjectModel): +class App(models.CraftBaseModel): """Snapcraft project app definition.""" command: str @@ -282,8 +265,8 @@ class App(ProjectModel): restart_delay: Optional[str] timer: Optional[str] daemon: Optional[Literal["simple", "forking", "oneshot", "notify", "dbus"]] - after: UniqueStrList = [] - before: UniqueStrList = [] + after: UniqueStrList = cast(UniqueStrList, []) + before: UniqueStrList = cast(UniqueStrList, []) refresh_mode: Optional[Literal["endure", "restart"]] stop_mode: Optional[ Literal[ @@ -368,7 +351,7 @@ def _validate_aliases(cls, aliases): return aliases -class Hook(ProjectModel): +class Hook(models.CraftBaseModel): """Snapcraft project hook definition.""" command_chain: Optional[List[str]] @@ -389,14 +372,14 @@ def _validate_plugs(cls, plugs): return plugs -class Architecture(ProjectModel, extra=pydantic.Extra.forbid): +class Architecture(models.CraftBaseModel, extra=pydantic.Extra.forbid): """Snapcraft project architecture definition.""" build_on: Union[str, UniqueStrList] build_for: Optional[Union[str, UniqueStrList]] -class ContentPlug(ProjectModel): +class ContentPlug(models.CraftBaseModel): """Snapcraft project content plug definition.""" content: Optional[str] @@ -418,7 +401,7 @@ def _validate_default_provider(cls, default_provider): MANDATORY_ADOPTABLE_FIELDS = ("version", "summary", "description") -class Project(ProjectModel): +class Project(models.Project): """Snapcraft project definition. See https://snapcraft.io/docs/snapcraft-yaml-reference @@ -427,29 +410,26 @@ class Project(ProjectModel): - system-usernames """ - name: ProjectName - title: Optional[ProjectTitle] - base: Optional[str] + # snapcraft's `name` is more general than craft-application + name: ProjectName # type: ignore[assignment] build_base: Optional[str] compression: Literal["lzo", "xz"] = "xz" - version: Optional[ProjectVersion] - contact: Optional[Union[str, UniqueStrList]] + # TODO: ensure we have a test for version being retrieved using adopt-info + # snapcraft's `version` is more general than craft-application + version: Optional[ProjectVersion] # type: ignore[assignment] donation: Optional[Union[str, UniqueStrList]] - issues: Optional[Union[str, UniqueStrList]] - source_code: Optional[str] + # snapcraft's `source_code` is more general than craft-application + source_code: Optional[str] # type: ignore[assignment] website: Optional[str] - summary: Optional[ProjectSummary] - description: Optional[str] type: Optional[Literal["app", "base", "gadget", "kernel", "snapd"]] icon: Optional[str] confinement: Literal["classic", "devmode", "strict"] layout: Optional[ Dict[str, Dict[Literal["symlink", "bind", "bind-file", "type"], str]] ] - license: Optional[str] grade: Optional[Literal["stable", "devel"]] architectures: List[Union[str, Architecture]] = [get_host_architecture()] - assumes: UniqueStrList = [] + assumes: UniqueStrList = cast(UniqueStrList, []) package_repositories: List[Dict[str, Any]] = [] # handled by repo hooks: Optional[Dict[str, Hook]] passthrough: Optional[Dict[str, Any]] @@ -457,7 +437,6 @@ class Project(ProjectModel): plugs: Optional[Dict[str, Union[ContentPlug, Any]]] slots: Optional[Dict[str, Any]] lint: Optional[Lint] - parts: Dict[str, Any] # parts are handled by craft-parts epoch: Optional[str] adopt_info: Optional[str] system_usernames: Optional[Dict[str, Any]] @@ -756,6 +735,11 @@ def get_build_for_arch_triplet(self) -> Optional[str]: return None + def get_build_plan(self) -> List[BuildInfo]: + """Get the build plan for this project.""" + # TODO + raise NotImplementedError("Not implemented yet!") + class _GrammarAwareModel(pydantic.BaseModel): class Config: @@ -791,7 +775,7 @@ def validate_grammar(cls, data: Dict[str, Any]) -> None: raise ProjectValidationError(_format_pydantic_errors(err.errors())) from err -class ArchitectureProject(ProjectModel, extra=pydantic.Extra.ignore): +class ArchitectureProject(models.CraftBaseModel, extra=pydantic.Extra.ignore): """Project definition containing only architecture data.""" architectures: List[Union[str, Architecture]] = [get_host_architecture()] diff --git a/snapcraft/parts/lifecycle.py b/snapcraft/parts/lifecycle.py index 6d8ed2625d..f1c82374cf 100644 --- a/snapcraft/parts/lifecycle.py +++ b/snapcraft/parts/lifecycle.py @@ -29,12 +29,11 @@ from craft_parts import ProjectInfo, Step, StepInfo, callbacks from craft_providers import Executor -from snapcraft import errors, linters, pack, providers, ua_manager, utils +from snapcraft import errors, linters, models, pack, providers, ua_manager, utils from snapcraft.elf import Patcher, SonameCache, elf_utils from snapcraft.elf import errors as elf_errors from snapcraft.linters import LinterStatus from snapcraft.meta import manifest, snap_yaml -from snapcraft.projects import Architecture, ArchitectureProject, Project from snapcraft.utils import ( convert_architecture_deb_to_platform, get_host_architecture, @@ -99,7 +98,7 @@ def run(command_name: str, parsed_args: "argparse.Namespace") -> None: parallel_build_count=build_count, target_arch=build_for, ) - project = Project.unmarshal(yaml_data_for_arch) + project = models.Project.unmarshal(yaml_data_for_arch) _run_command( command_name, @@ -115,7 +114,7 @@ def run(command_name: str, parsed_args: "argparse.Namespace") -> None: def _run_command( # noqa PLR0913 # pylint: disable=too-many-branches, too-many-statements command_name: str, *, - project: Project, + project: models.Project, parse_info: Dict[str, List[str]], assets_dir: Path, start_time: datetime, @@ -227,7 +226,7 @@ def _run_lifecycle_and_pack( # noqa PLR0913 *, command_name: str, step_name: str, - project: Project, + project: models.Project, project_dir: Path, assets_dir: Path, start_time: datetime, @@ -278,7 +277,7 @@ def _run_lifecycle_and_pack( # noqa PLR0913 def _generate_metadata( *, - project: Project, + project: models.Project, lifecycle: PartsLifecycle, project_dir: Path, assets_dir: Path, @@ -319,7 +318,7 @@ def _generate_metadata( def _generate_manifest( - project: Project, + project: models.Project, *, lifecycle: PartsLifecycle, start_time: datetime, @@ -353,7 +352,7 @@ def _generate_manifest( shutil.copy(snap_project.project_file, lifecycle.prime_dir / "snap") -def _clean_provider(project: Project, parsed_args: "argparse.Namespace") -> None: +def _clean_provider(project: models.Project, parsed_args: "argparse.Namespace") -> None: """Clean the provider environment. :param project: The project to clean. @@ -374,7 +373,7 @@ def _clean_provider(project: Project, parsed_args: "argparse.Namespace") -> None # pylint: disable-next=too-many-branches, too-many-statements def _run_in_provider( # noqa PLR0915 - project: Project, command_name: str, parsed_args: "argparse.Namespace" + project: models.Project, command_name: str, parsed_args: "argparse.Namespace" ) -> None: """Pack image in provider instance.""" emit.debug("Checking build provider availability") @@ -509,7 +508,7 @@ def _set_global_environment(info: ProjectInfo) -> None: def _check_experimental_plugins( - project: Project, enable_experimental_plugins: bool + project: models.Project, enable_experimental_plugins: bool ) -> None: """Ensure the experimental plugin flag is enabled to use unstable plugins.""" for name, part in project.parts.items(): @@ -642,13 +641,13 @@ def get_build_plan( :return: List of tuples of every valid build-on->build-for combination. """ - archs = ArchitectureProject.unmarshal(yaml_data).architectures + archs = models.ArchitectureProject.unmarshal(yaml_data).architectures host_arch = get_host_architecture() build_plan: List[Tuple[str, str]] = [] # `isinstance()` calls are for mypy type checking and should not change logic - for arch in [arch for arch in archs if isinstance(arch, Architecture)]: + for arch in [arch for arch in archs if isinstance(arch, models.Architecture)]: for build_on in arch.build_on: if build_on in host_arch and isinstance(arch.build_for, list): build_plan.append((host_arch, arch.build_for[0])) diff --git a/snapcraft/parts/project_check.py b/snapcraft/parts/project_check.py index e914a8fdc9..dc51c97d84 100644 --- a/snapcraft/parts/project_check.py +++ b/snapcraft/parts/project_check.py @@ -22,8 +22,7 @@ from craft_cli import emit -from snapcraft import errors -from snapcraft.projects import Project +from snapcraft import errors, models _EXPECTED_SNAP_DIR_PATTERNS = { re.compile(r"^snapcraft.yaml$"), @@ -36,7 +35,7 @@ } -def run_project_checks(project: Project, *, assets_dir: Path) -> None: +def run_project_checks(project: models.Project, *, assets_dir: Path) -> None: """Execute consistency checks for project and project files. The checks done here are meant to be light, and not rely on the diff --git a/snapcraft/parts/setup_assets.py b/snapcraft/parts/setup_assets.py index cfacf41c08..74d20009ba 100644 --- a/snapcraft/parts/setup_assets.py +++ b/snapcraft/parts/setup_assets.py @@ -27,14 +27,13 @@ import requests from craft_cli import emit -from snapcraft import errors -from snapcraft.projects import Project +from snapcraft import errors, models from .desktop_file import DesktopFile def setup_assets( - project: Project, *, assets_dir: Path, project_dir: Path, prime_dir: Path + project: models.Project, *, assets_dir: Path, project_dir: Path, prime_dir: Path ) -> None: """Copy assets to the appropriate locations in the snap filesystem. diff --git a/snapcraft/parts/update_metadata.py b/snapcraft/parts/update_metadata.py index 327fe72b2e..8652601fc9 100644 --- a/snapcraft/parts/update_metadata.py +++ b/snapcraft/parts/update_metadata.py @@ -17,14 +17,15 @@ """External metadata helpers.""" from pathlib import Path -from typing import Dict, Final, List +from typing import Dict, Final, List, cast import pydantic +from craft_application.models import ProjectTitle, SummaryStr, VersionStr from craft_cli import emit from snapcraft import errors from snapcraft.meta import ExtractedMetadata -from snapcraft.projects import MANDATORY_ADOPTABLE_FIELDS, Project +from snapcraft.models import MANDATORY_ADOPTABLE_FIELDS, Project _VALID_ICON_EXTENSIONS: Final[List[str]] = ["png", "svg"] @@ -52,16 +53,16 @@ def update_project_metadata( for metadata in metadata_list: # Data specified in the project yaml has precedence over extracted data if metadata.title and not project.title: - project.title = metadata.title + project.title = cast(ProjectTitle, metadata.title) if metadata.summary and not project.summary: - project.summary = metadata.summary + project.summary = cast(SummaryStr, metadata.summary) if metadata.description and not project.description: project.description = metadata.description if metadata.version and not project.version: - project.version = metadata.version + project.version = cast(VersionStr, metadata.version) if metadata.grade and not project.grade: project.grade = metadata.grade # type: ignore diff --git a/snapcraft/parts/yaml_utils.py b/snapcraft/parts/yaml_utils.py index 053c957c8b..7ca593515e 100644 --- a/snapcraft/parts/yaml_utils.py +++ b/snapcraft/parts/yaml_utils.py @@ -25,7 +25,7 @@ from snapcraft import errors, utils from snapcraft.extensions import apply_extensions -from snapcraft.projects import Architecture, GrammarAwareProject +from snapcraft.models import Architecture, GrammarAwareProject from . import grammar diff --git a/snapcraft/services/__init__.py b/snapcraft/services/__init__.py new file mode 100644 index 0000000000..2df3b797aa --- /dev/null +++ b/snapcraft/services/__init__.py @@ -0,0 +1,25 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2023 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft services.""" + +from snapcraft.services.package import Package +from snapcraft.services.service_factory import SnapcraftServiceFactory + +__all__ = [ + "Package", + "SnapcraftServiceFactory", +] diff --git a/snapcraft/services/package.py b/snapcraft/services/package.py new file mode 100644 index 0000000000..2d5d6f4d95 --- /dev/null +++ b/snapcraft/services/package.py @@ -0,0 +1,57 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2023 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft Package service.""" + +from __future__ import annotations + +import pathlib + +from craft_application import PackageService, models +from overrides import override + + +class Package(PackageService): + """Package service subclass for Snapcraft.""" + + @override + def pack(self, prime_dir: pathlib.Path, dest: pathlib.Path) -> list[pathlib.Path]: + """Create one or more packages as appropriate. + + :param prime_dir: Path to the directory to pack. + :param dest: Directory into which to write the package(s). + + :returns: A list of paths to created packages. + """ + # TODO + raise NotImplementedError( + "Packing using the package service not yet implemented." + ) + + @override + def write_metadata(self, path: pathlib.Path) -> None: + """Write the project metadata to metadata.yaml in the given directory. + + :param path: The path to the prime directory. + """ + # TODO + raise NotImplementedError("Writing metadata not yet implemented.") + + @property + def metadata(self) -> models.BaseMetadata: + """Get the metadata model for this project.""" + # TODO: get metadata from project + return models.BaseMetadata() diff --git a/snapcraft/services/service_factory.py b/snapcraft/services/service_factory.py new file mode 100644 index 0000000000..574944cda1 --- /dev/null +++ b/snapcraft/services/service_factory.py @@ -0,0 +1,37 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2023 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft Service Factory.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from craft_application import ServiceFactory + +from snapcraft import models, services + + +@dataclass +class SnapcraftServiceFactory(ServiceFactory): + """Snapcraft-specific Service Factory.""" + + project: models.Project | None = None # type: ignore[reportIncompatibleVariableOverride] + + # These are overrides of default ServiceFactory services + PackageClass: type[services.Package] = ( # type: ignore[reportIncompatibleVariableOverride] + services.Package + ) diff --git a/snapcraft/snap_config.py b/snapcraft/snap_config.py index 288e1b5d35..e78a6c4282 100644 --- a/snapcraft/snap_config.py +++ b/snapcraft/snap_config.py @@ -58,7 +58,7 @@ def unmarshal(cls, data: Dict[str, Any]) -> "SnapConfig": try: snap_config = cls(**data) except pydantic.ValidationError as error: - # TODO: use `_format_pydantic_errors()` from projects.py + # TODO: use `_format_pydantic_errors()` from project.py raise ValueError(f"error parsing snap config: {error}") from error return snap_config diff --git a/tests/unit/commands/test_init.py b/tests/unit/commands/test_init.py index eb559b5712..91965417ab 100644 --- a/tests/unit/commands/test_init.py +++ b/tests/unit/commands/test_init.py @@ -22,8 +22,8 @@ import pytest from snapcraft import cli +from snapcraft.models.project import Project from snapcraft.parts.yaml_utils import _SNAP_PROJECT_FILES, apply_yaml, process_yaml -from snapcraft.projects import Project @pytest.fixture(autouse=True) diff --git a/tests/unit/commands/test_lint.py b/tests/unit/commands/test_lint.py index 9588d52739..9040af1154 100644 --- a/tests/unit/commands/test_lint.py +++ b/tests/unit/commands/test_lint.py @@ -25,11 +25,10 @@ from craft_providers.bases import BuilddBaseAlias from craft_providers.multipass import MultipassProvider -from snapcraft import cli +from snapcraft import cli, models from snapcraft.commands.lint import LintCommand from snapcraft.errors import SnapcraftError from snapcraft.meta.snap_yaml import SnapMetadata -from snapcraft.projects import Lint, Project @pytest.fixture @@ -70,7 +69,7 @@ def fake_snapcraft_project(): "summary": "test summary", "parts": {"part1": {"plugin": "nil"}}, } - return Project.unmarshal(data) + return models.Project.unmarshal(data) @pytest.fixture @@ -393,7 +392,7 @@ def test_lint_managed_mode( cli.run() mock_run_linters.assert_called_once_with( - lint=Lint(ignore=["classic"]), + lint=models.Lint(ignore=["classic"]), location=Path("/snap/test/current"), ) mock_report.assert_called_once_with( @@ -449,7 +448,7 @@ def test_lint_managed_mode_without_snapcraft_yaml( cli.run() mock_run_linters.assert_called_once_with( - lint=Lint(ignore=["classic"]), + lint=models.Lint(ignore=["classic"]), location=Path("/snap/test/current"), ) mock_report.assert_called_once_with( @@ -602,7 +601,7 @@ def test_lint_managed_mode_assert( cli.run() mock_run_linters.assert_called_once_with( - lint=Lint(ignore=["classic"]), + lint=models.Lint(ignore=["classic"]), location=Path("/snap/test/current"), ) mock_report.assert_called_once_with( @@ -666,7 +665,7 @@ def test_lint_managed_mode_assert_error( cli.run() mock_run_linters.assert_called_once_with( - lint=Lint(ignore=["classic"]), + lint=models.Lint(ignore=["classic"]), location=Path("/snap/test/current"), ) mock_report.assert_called_once_with( @@ -699,30 +698,30 @@ def test_lint_managed_mode_assert_error( ["project_lint", "expected_lint"], [ ( - Lint(ignore=[]), - Lint(ignore=["classic"]), + models.Lint(ignore=[]), + models.Lint(ignore=["classic"]), ), ( - Lint(ignore=["library"]), - Lint(ignore=["library", "classic"]), + models.Lint(ignore=["library"]), + models.Lint(ignore=["library", "classic"]), ), ( - Lint(ignore=["library", "classic"]), - Lint(ignore=["library", "classic"]), + models.Lint(ignore=["library", "classic"]), + models.Lint(ignore=["library", "classic"]), ), ( - Lint(ignore=[{"classic": ["bin/test1", "bin/test2"]}]), - Lint(ignore=["classic"]), + models.Lint(ignore=[{"classic": ["bin/test1", "bin/test2"]}]), + models.Lint(ignore=["classic"]), ), ( - Lint(ignore=["library", {"classic": ["bin/test1", "bin/test2"]}]), - Lint(ignore=["library", "classic"]), + models.Lint(ignore=["library", {"classic": ["bin/test1", "bin/test2"]}]), + models.Lint(ignore=["library", "classic"]), ), ( - Lint( + models.Lint( ignore=["library", "classic", {"classic": ["bin/test1", "bin/test2"]}] ), - Lint(ignore=["library", "classic"]), + models.Lint(ignore=["library", "classic"]), ), ], ) @@ -851,7 +850,7 @@ def test_load_project_complex(mocker, tmp_path): ) result = LintCommand(None)._load_project(snapcraft_yaml_file=snap_file) - assert result == Project.unmarshal( + assert result == models.Project.unmarshal( { "name": "test-name", "base": "core22", diff --git a/tests/unit/linters/test_base.py b/tests/unit/linters/test_base.py index db37e22460..6adc041e19 100644 --- a/tests/unit/linters/test_base.py +++ b/tests/unit/linters/test_base.py @@ -18,7 +18,7 @@ import pytest import yaml -from snapcraft import projects +from snapcraft import models from snapcraft.linters.base import LinterResult @@ -83,7 +83,7 @@ def lint_ignore_data(): def test_lint_all_ignored(lint_ignore_data): - lint = projects.Lint(**lint_ignore_data) + lint = models.Lint(**lint_ignore_data) assert lint.all_ignored("linter1") assert not lint.all_ignored("linter2") @@ -91,7 +91,7 @@ def test_lint_all_ignored(lint_ignore_data): def test_lint_ignored_files(lint_ignore_data): - lint = projects.Lint(**lint_ignore_data) + lint = models.Lint(**lint_ignore_data) assert lint.ignored_files("linter1") == ["*"] assert lint.ignored_files("linter2") == ["file1", "/lib/file2*"] diff --git a/tests/unit/linters/test_classic_linter.py b/tests/unit/linters/test_classic_linter.py index 6c6e7e7e25..04491418de 100644 --- a/tests/unit/linters/test_classic_linter.py +++ b/tests/unit/linters/test_classic_linter.py @@ -19,7 +19,7 @@ import pytest -from snapcraft import linters, projects +from snapcraft import linters, models from snapcraft.elf import elf_utils from snapcraft.linters.base import LinterIssue, LinterResult from snapcraft.linters.classic_linter import ClassicLinter @@ -63,7 +63,7 @@ def test_classic_linter(mocker, new_dir, confinement, stage_libc, text): "parts": {}, } - project = projects.Project.unmarshal(yaml_data) + project = models.Project.unmarshal(yaml_data) snap_yaml.write(project, prime_dir=Path(new_dir), arch="amd64") issues = linters.run_linters(new_dir, lint=None) @@ -125,11 +125,11 @@ def test_classic_linter_filter(mocker, new_dir): "parts": {}, } - project = projects.Project.unmarshal(yaml_data) + project = models.Project.unmarshal(yaml_data) snap_yaml.write(project, prime_dir=Path(new_dir), arch="amd64") issues = linters.run_linters( - new_dir, lint=projects.Lint(ignore=[{"classic": ["elf.*"]}]) + new_dir, lint=models.Lint(ignore=[{"classic": ["elf.*"]}]) ) assert issues == [ LinterIssue( diff --git a/tests/unit/linters/test_library_linter.py b/tests/unit/linters/test_library_linter.py index 683439e0bb..00b3d05cbb 100644 --- a/tests/unit/linters/test_library_linter.py +++ b/tests/unit/linters/test_library_linter.py @@ -20,7 +20,7 @@ import pytest -from snapcraft import linters, projects +from snapcraft import linters, models from snapcraft.elf import _elf_file, elf_utils from snapcraft.linters.base import LinterIssue, LinterResult from snapcraft.linters.library_linter import LibraryLinter @@ -53,7 +53,7 @@ def test_library_linter_missing_library(mocker, new_dir): "parts": {}, } - project = projects.Project.unmarshal(yaml_data) + project = models.Project.unmarshal(yaml_data) snap_yaml.write(project, prime_dir=Path(new_dir), arch="amd64") issues = linters.run_linters(new_dir, lint=None) @@ -108,7 +108,7 @@ def test_library_linter_unused_library(mocker, new_dir): "parts": {}, } - project = projects.Project.unmarshal(yaml_data) + project = models.Project.unmarshal(yaml_data) snap_yaml.write(project, prime_dir=Path(new_dir), arch="amd64") issues = linters.run_linters(new_dir, lint=None) @@ -149,11 +149,11 @@ def test_library_linter_filter_missing_library(mocker, new_dir, filter_name): "parts": {}, } - project = projects.Project.unmarshal(yaml_data) + project = models.Project.unmarshal(yaml_data) snap_yaml.write(project, prime_dir=Path(new_dir), arch="amd64") issues = linters.run_linters( - new_dir, lint=projects.Lint(ignore=[{filter_name: ["elf.*"]}]) + new_dir, lint=models.Lint(ignore=[{filter_name: ["elf.*"]}]) ) assert issues == [] @@ -194,11 +194,11 @@ def test_library_linter_filter_unused_library(mocker, new_dir, filter_name): "parts": {}, } - project = projects.Project.unmarshal(yaml_data) + project = models.Project.unmarshal(yaml_data) snap_yaml.write(project, prime_dir=Path(new_dir), arch="amd64") issues = linters.run_linters( - new_dir, lint=projects.Lint(ignore=[{filter_name: ["lib/libfoo.*"]}]) + new_dir, lint=models.Lint(ignore=[{filter_name: ["lib/libfoo.*"]}]) ) assert issues == [] @@ -231,13 +231,13 @@ def test_library_linter_mixed_filters(mocker, new_dir): "parts": {}, } - project = projects.Project.unmarshal(yaml_data) + project = models.Project.unmarshal(yaml_data) snap_yaml.write(project, prime_dir=Path(new_dir), arch="amd64") # lib/libfoo.so is an *unused* library, but here we filter out *missing* library # issues for this path. issues = linters.run_linters( - new_dir, lint=projects.Lint(ignore=[{"missing-library": ["lib/libfoo.*"]}]) + new_dir, lint=models.Lint(ignore=[{"missing-library": ["lib/libfoo.*"]}]) ) # The "unused library" issue must be generated. assert issues == [ diff --git a/tests/unit/linters/test_linters.py b/tests/unit/linters/test_linters.py index 2daf388518..cf2adec6b7 100644 --- a/tests/unit/linters/test_linters.py +++ b/tests/unit/linters/test_linters.py @@ -21,7 +21,7 @@ import pytest from overrides import overrides -from snapcraft import linters, projects +from snapcraft import linters, models from snapcraft.linters.base import Linter, LinterResult from snapcraft.linters.linters import _ignore_matching_filenames from snapcraft.meta import snap_yaml @@ -164,7 +164,7 @@ def test_run_linters(self, mocker, new_dir, linter_issue): "parts": {}, } - project = projects.Project.unmarshal(yaml_data) + project = models.Project.unmarshal(yaml_data) snap_yaml.write( project, prime_dir=Path(new_dir), @@ -193,14 +193,14 @@ def test_run_linters_ignore(self, mocker, new_dir, linter_issue): "parts": {}, } - project = projects.Project.unmarshal(yaml_data) + project = models.Project.unmarshal(yaml_data) snap_yaml.write( project, prime_dir=Path(new_dir), arch="amd64", ) - lint = projects.Lint(ignore=["test"]) + lint = models.Lint(ignore=["test"]) issues = linters.run_linters(new_dir, lint=lint) assert issues == [] @@ -218,15 +218,15 @@ def test_run_linters_ignore_all_categories(self, mocker, new_dir, linter_issue): "parts": {}, } - project = projects.Project.unmarshal(yaml_data) + project = models.Project.unmarshal(yaml_data) snap_yaml.write(project, prime_dir=Path(new_dir), arch="amd64") - lint = projects.Lint(ignore=["test-1", "test-2"]) + lint = models.Lint(ignore=["test-1", "test-2"]) issues = linters.run_linters(new_dir, lint=lint) assert issues == [] def test_ignore_matching_filenames(self, linter_issue): - lint = projects.Lint(ignore=[{"test": ["foo*", "some/dir/*"]}]) + lint = models.Lint(ignore=[{"test": ["foo*", "some/dir/*"]}]) issues = [ linter_issue(filename="foo.txt", result=LinterResult.WARNING), linter_issue(filename="bar.txt", result=LinterResult.WARNING), @@ -245,7 +245,7 @@ def test_ignore_matching_filenames(self, linter_issue): def test_base_linter_is_file_ignored(): """Test the base Linter class' ignore mechanism with categories.""" - lint = projects.Lint( + lint = models.Lint( ignore=[ {"test": ["test-path"]}, {"test-1": ["test-1-path"]}, diff --git a/tests/unit/meta/test_snap_yaml.py b/tests/unit/meta/test_snap_yaml.py index dafe7a6816..ab9f9d16ce 100644 --- a/tests/unit/meta/test_snap_yaml.py +++ b/tests/unit/meta/test_snap_yaml.py @@ -23,7 +23,7 @@ from snapcraft.meta import snap_yaml from snapcraft.meta.snap_yaml import ContentPlug, ContentSlot, SnapMetadata -from snapcraft.projects import Project +from snapcraft.models import Project def _override_data(to_dict, from_dict): diff --git a/tests/unit/models/__init__.py b/tests/unit/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/test_projects.py b/tests/unit/models/test_projects.py similarity index 99% rename from tests/unit/test_projects.py rename to tests/unit/models/test_projects.py index cb52ec2746..deee80a103 100644 --- a/tests/unit/test_projects.py +++ b/tests/unit/models/test_projects.py @@ -14,13 +14,14 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from typing import Any, Dict +from typing import Any, Dict, cast import pydantic import pytest +from craft_application.models import UniqueStrList from snapcraft import errors -from snapcraft.projects import ( +from snapcraft.models import ( MANDATORY_ADOPTABLE_FIELDS, Architecture, ContentPlug, @@ -100,7 +101,8 @@ def test_project_defaults(self, project_yaml_data): assert project.adopt_info is None assert project.architectures == [ Architecture( - build_on=[get_host_architecture()], build_for=[get_host_architecture()] + build_on=cast(UniqueStrList, [get_host_architecture()]), + build_for=cast(UniqueStrList, [get_host_architecture()]), ) ] assert project.ua_services is None diff --git a/tests/unit/parts/test_lifecycle.py b/tests/unit/parts/test_lifecycle.py index e4f5b33d46..5f782b3b01 100644 --- a/tests/unit/parts/test_lifecycle.py +++ b/tests/unit/parts/test_lifecycle.py @@ -28,10 +28,10 @@ from snapcraft import errors from snapcraft.elf import ElfFile +from snapcraft.models import MANDATORY_ADOPTABLE_FIELDS, Project from snapcraft.parts import lifecycle as parts_lifecycle from snapcraft.parts.plugins import KernelPlugin from snapcraft.parts.update_metadata import update_project_metadata -from snapcraft.projects import MANDATORY_ADOPTABLE_FIELDS, Project from snapcraft.utils import get_host_architecture _SNAPCRAFT_YAML_FILENAMES = [ @@ -1306,8 +1306,12 @@ def test_lifecycle_run_in_provider_default( mock_prepare_instance = mocker.patch( "snapcraft.parts.lifecycle.providers.prepare_instance" ) - mocker.patch("snapcraft.projects.Project.get_build_on", return_value="test-arch-1") - mocker.patch("snapcraft.projects.Project.get_build_for", return_value="test-arch-2") + mocker.patch( + "snapcraft.models.project.Project.get_build_on", return_value="test-arch-1" + ) + mocker.patch( + "snapcraft.models.project.Project.get_build_for", return_value="test-arch-2" + ) expected_command = [ "snapcraft", @@ -1395,8 +1399,12 @@ def test_lifecycle_run_in_provider_all_options( mock_prepare_instance = mocker.patch( "snapcraft.parts.lifecycle.providers.prepare_instance" ) - mocker.patch("snapcraft.projects.Project.get_build_on", return_value="test-arch-1") - mocker.patch("snapcraft.projects.Project.get_build_for", return_value="test-arch-2") + mocker.patch( + "snapcraft.models.project.Project.get_build_on", return_value="test-arch-1" + ) + mocker.patch( + "snapcraft.models.project.Project.get_build_for", return_value="test-arch-2" + ) # build the expected command to be executed in the provider parts = ["test-part-1", "test-part-2"] @@ -1499,8 +1507,12 @@ def test_lifecycle_run_in_provider_try( mocker.patch("snapcraft.parts.lifecycle.providers.capture_logs_from_instance") mocker.patch("snapcraft.parts.lifecycle.providers.ensure_provider_is_available") mocker.patch("snapcraft.parts.lifecycle.providers.prepare_instance") - mocker.patch("snapcraft.projects.Project.get_build_on", return_value="test-arch-1") - mocker.patch("snapcraft.projects.Project.get_build_for", return_value="test-arch-2") + mocker.patch( + "snapcraft.models.project.Project.get_build_on", return_value="test-arch-1" + ) + mocker.patch( + "snapcraft.models.project.Project.get_build_for", return_value="test-arch-2" + ) project = Project.unmarshal(snapcraft_yaml(base="core22")) parts_lifecycle._run_in_provider( @@ -1551,8 +1563,8 @@ def test_lifecycle_run_in_provider( mocker.patch("snapcraft.parts.lifecycle.providers.capture_logs_from_instance") mocker.patch("snapcraft.parts.lifecycle.providers.ensure_provider_is_available") mocker.patch("snapcraft.parts.lifecycle.providers.prepare_instance") - mocker.patch("snapcraft.projects.Project.get_build_on") - mocker.patch("snapcraft.projects.Project.get_build_for") + mocker.patch("snapcraft.models.project.Project.get_build_on") + mocker.patch("snapcraft.models.project.Project.get_build_for") project = Project.unmarshal(snapcraft_yaml(base="core22")) parts_lifecycle._run_in_provider( @@ -1586,7 +1598,9 @@ def test_lifecycle_run_in_provider_devel_base( tmp_path, ): """Verify the `devel` base is handled properly when launching an instance.""" - mocker.patch("snapcraft.projects.Project.get_effective_base", return_value="devel") + mocker.patch( + "snapcraft.models.project.Project.get_effective_base", return_value="devel" + ) mock_base_configuration = Mock() mocker.patch( "snapcraft.parts.lifecycle.providers.get_base_configuration", @@ -1595,8 +1609,8 @@ def test_lifecycle_run_in_provider_devel_base( mocker.patch("snapcraft.parts.lifecycle.providers.capture_logs_from_instance") mocker.patch("snapcraft.parts.lifecycle.providers.ensure_provider_is_available") mocker.patch("snapcraft.parts.lifecycle.providers.prepare_instance") - mocker.patch("snapcraft.projects.Project.get_build_on") - mocker.patch("snapcraft.projects.Project.get_build_for") + mocker.patch("snapcraft.models.project.Project.get_build_on") + mocker.patch("snapcraft.models.project.Project.get_build_for") project = Project.unmarshal(snapcraft_yaml(base="core22")) parts_lifecycle._run_in_provider( diff --git a/tests/unit/parts/test_project_check.py b/tests/unit/parts/test_project_check.py index 0dd680c4e9..035127c934 100644 --- a/tests/unit/parts/test_project_check.py +++ b/tests/unit/parts/test_project_check.py @@ -20,9 +20,8 @@ import pytest import yaml -from snapcraft import errors +from snapcraft import errors, models from snapcraft.parts.project_check import run_project_checks -from snapcraft.projects import Project @pytest.fixture @@ -48,7 +47,7 @@ def snapcraft_yaml(new_dir): def test_no_snap_dir(emitter, snapcraft_yaml): - project = Project.unmarshal(snapcraft_yaml) + project = models.Project.unmarshal(snapcraft_yaml) run_project_checks(project, assets_dir=Path("snap")) emitter.assert_interactions([]) @@ -72,7 +71,7 @@ def test_icon(new_dir): ) yaml_data = yaml.safe_load(content) - project = Project.unmarshal(yaml_data) + project = models.Project.unmarshal(yaml_data) # Test without icon raises error with pytest.raises(errors.SnapcraftError) as raised: @@ -86,7 +85,7 @@ def test_icon(new_dir): def test_accepted_artifacts(new_dir, emitter, snapcraft_yaml): - project = Project.unmarshal(snapcraft_yaml) + project = models.Project.unmarshal(snapcraft_yaml) assets_dir = Path("snap") file_assets = [ @@ -114,7 +113,7 @@ def test_accepted_artifacts(new_dir, emitter, snapcraft_yaml): def test_unexpected_things(new_dir, emitter, snapcraft_yaml): - project = Project.unmarshal(snapcraft_yaml) + project = models.Project.unmarshal(snapcraft_yaml) assets_dir = Path("snap") file_assets = [ diff --git a/tests/unit/parts/test_setup_assets.py b/tests/unit/parts/test_setup_assets.py index eaf75825b1..24510492c5 100644 --- a/tests/unit/parts/test_setup_assets.py +++ b/tests/unit/parts/test_setup_assets.py @@ -22,7 +22,7 @@ import pytest -from snapcraft import errors +from snapcraft import errors, models from snapcraft.parts import setup_assets as parts_setup_assets from snapcraft.parts.setup_assets import ( _create_hook_wrappers, @@ -32,7 +32,6 @@ _write_hook_wrapper, setup_assets, ) -from snapcraft.projects import Project @pytest.fixture @@ -89,7 +88,7 @@ def kernel_yaml_file(new_dir): def test_gadget(yaml_data, gadget_yaml_file, new_dir): - project = Project.unmarshal( + project = models.Project.unmarshal( yaml_data( { "type": "gadget", @@ -113,7 +112,7 @@ def test_gadget(yaml_data, gadget_yaml_file, new_dir): def test_gadget_missing(yaml_data, new_dir): - project = Project.unmarshal( + project = models.Project.unmarshal( yaml_data( { "type": "gadget", @@ -136,7 +135,7 @@ def test_gadget_missing(yaml_data, new_dir): def test_kernel(yaml_data, kernel_yaml_file, new_dir): - project = Project.unmarshal( + project = models.Project.unmarshal( { "name": "custom-kernel", "type": "kernel", @@ -161,7 +160,7 @@ def test_kernel(yaml_data, kernel_yaml_file, new_dir): def test_kernel_missing(yaml_data, new_dir): - project = Project.unmarshal( + project = models.Project.unmarshal( { "name": "custom-kernel", "type": "kernel", @@ -225,7 +224,7 @@ def test_setup_assets_happy(self, desktop_file, yaml_data, new_dir): Path("prime/usr/share/icons/my-icon.svg").touch() # define project - project = Project.unmarshal( + project = models.Project.unmarshal( yaml_data( { "adopt-info": "part", @@ -270,7 +269,7 @@ def test_setup_assets_icon_in_assets_dir(self, desktop_file, yaml_data, new_dir) Path("snap/gui/icon.svg").touch() # define project - project = Project.unmarshal( + project = models.Project.unmarshal( yaml_data( { "adopt-info": "part", @@ -319,7 +318,7 @@ def test_setup_assets_no_apps(self, desktop_file, yaml_data, new_dir): Path("snap/gui").mkdir() # define project - project = Project.unmarshal(yaml_data({"adopt-info": "part"})) + project = models.Project.unmarshal(yaml_data({"adopt-info": "part"})) # setting up assets does not crash setup_assets( @@ -337,7 +336,7 @@ def test_setup_assets_remote_icon(self, desktop_file, yaml_data, new_dir): # define project # pylint: disable=line-too-long - project = Project.unmarshal( + project = models.Project.unmarshal( yaml_data( { "adopt-info": "part", @@ -388,7 +387,7 @@ class TestCommandChain: """Command chain items are valid.""" def test_setup_assets_app_command_chain_error(self, yaml_data, new_dir): - project = Project.unmarshal( + project = models.Project.unmarshal( yaml_data( { "adopt-info": "part1", @@ -417,7 +416,7 @@ def test_setup_assets_app_command_chain_error(self, yaml_data, new_dir): def test_setup_assets_hook_command_chain_error(self, yaml_data, new_dir): # define project - project = Project.unmarshal( + project = models.Project.unmarshal( yaml_data( { "adopt-info": "part1", diff --git a/tests/unit/parts/test_update_metadata.py b/tests/unit/parts/test_update_metadata.py index 538310c93b..893e65db7d 100644 --- a/tests/unit/parts/test_update_metadata.py +++ b/tests/unit/parts/test_update_metadata.py @@ -21,8 +21,8 @@ import pytest from snapcraft.meta import ExtractedMetadata +from snapcraft.models import App, Project from snapcraft.parts.update_metadata import update_project_metadata -from snapcraft.projects import App, Project @pytest.fixture diff --git a/tests/unit/parts/test_yaml_utils.py b/tests/unit/parts/test_yaml_utils.py index 02fe5387f1..6a69e25e26 100644 --- a/tests/unit/parts/test_yaml_utils.py +++ b/tests/unit/parts/test_yaml_utils.py @@ -21,8 +21,8 @@ import pytest from snapcraft import errors +from snapcraft.models import Architecture from snapcraft.parts import yaml_utils -from snapcraft.projects import Architecture def test_yaml_load():