diff --git a/snapcraft/models/project.py b/snapcraft/models/project.py index ccfdc64b70..9ebbd25cf0 100644 --- a/snapcraft/models/project.py +++ b/snapcraft/models/project.py @@ -15,10 +15,12 @@ # along with this program. If not, see . """Project file definition and helpers.""" + from __future__ import annotations import copy import re +import textwrap from typing import Any, Literal, Mapping, Tuple, cast import pydantic @@ -265,7 +267,7 @@ def _validate_component(name: str) -> str: def _get_partitions_from_components( - components_data: dict[str, Any] | None + components_data: dict[str, Any] | None, ) -> list[str] | None: """Get a list of partitions based on the project's components. @@ -289,8 +291,13 @@ def _validate_mandatory_base(base: str | None, snap_type: str | None) -> None: class Socket(models.CraftBaseModel): """Snapcraft app socket definition.""" - listen_stream: int | str - socket_mode: int | None = None + listen_stream: int | str = pydantic.Field( + description="The socket's abstract name or socket path.", + examples=["listen-stream: $SNAP_COMMON/lxd/unix.socket", "listen-stream: 80"], + ) + socket_mode: int | None = pydantic.Field( + default=None, description="The socket's mode and permissions." + ) @pydantic.field_validator("listen_stream") @classmethod @@ -324,7 +331,9 @@ class Lint(models.CraftBaseModel): The "known" linter names are the keys in :ref:`LINTERS` """ - ignore: list[str | dict[str, list[str]]] + ignore: list[str | dict[str, list[str]]] = pydantic.Field( + description="Linters or files to skip when linting." + ) # A private field to simplify lookup. _lint_ignores: dict[str, list[str]] = PrivateAttr(default_factory=dict) @@ -370,24 +379,91 @@ def ignored_files(self, linter_name: str) -> list[str]: class App(models.CraftBaseModel): """Snapcraft project app definition.""" - command: str - autostart: str | None = None - common_id: str | None = None - bus_name: str | None = None - desktop: str | None = None - completer: str | None = None - stop_command: str | None = None - post_stop_command: str | None = None - start_timeout: str | None = None - stop_timeout: str | None = None - watchdog_timeout: str | None = None - reload_command: str | None = None - restart_delay: str | None = None - timer: str | None = None - daemon: Literal["simple", "forking", "oneshot", "notify", "dbus"] | None = None - after: UniqueList[str] = pydantic.Field(default_factory=list) - before: UniqueList[str] = pydantic.Field(default_factory=list) - refresh_mode: Literal["endure", "restart", "ignore-running"] | None = None + command: str = pydantic.Field( + description="The command to run inside the snap when the app is invoked.", + examples=["command: bin/app-1"], + ) + autostart: str | None = pydantic.Field( + default=None, + description="The desktop file used to start an application when the desktop environment starts.", + examples=["autostart: my-app.desktop"], + ) + common_id: str | None = pydantic.Field( + default=None, + description="The app's unique `AppStream identifier `_.", + examples=["common-id: org.canonical.foo"], + ) + bus_name: str | None = pydantic.Field( + default=None, + description="The bus name that the app or service exposes through D-Bus.", + ) + desktop: str | None = pydantic.Field( + default=None, description="The desktop file used to start the app." + ) + completer: str | None = pydantic.Field( + default=None, description="The name of the bash completion script for the app." + ) + stop_command: str | None = pydantic.Field( + default=None, + description="The command that stops the service.", + examples=["stop-command: bin/foo-app --halt"], + ) + post_stop_command: str | None = pydantic.Field( + default=None, description="The command to run after the service is stopped." + ) + start_timeout: str | None = pydantic.Field( + default=None, + description="The maximum amount of time to wait for the service to start.", + ) + stop_timeout: str | None = pydantic.Field( + default=None, + description="The maximum amount of time to wait for the service to stop.", + ) + watchdog_timeout: str | None = pydantic.Field( + default=None, + description="The maximum amount of time the service can run without sending a heartbeat to the watchdog.", + ) + reload_command: str | None = pydantic.Field( + default=None, + description="The command to run to restart the service.", + examples=["reload-command: bin/foo-app --restart"], + ) + restart_delay: str | None = pydantic.Field( + default=None, + description="The time to wait between service restarts.", + examples=["restart-delay: 10s", "restart-delay: 2m"], + ) + timer: str | None = pydantic.Field( + default=None, + description="The time or schedule to run a service.", + examples=[ + "timer: 23:00", + "timer: 00:00-24:00/24", + "timer: mon,10:00,,fri,15:00", + ], + ) + daemon: Literal["simple", "forking", "oneshot", "notify", "dbus"] | None = ( + pydantic.Field( + default=None, + description="Configures the app as a service, and sets its runtime and notification behavior.", + ) + ) + after: UniqueList[str] = pydantic.Field( + default_factory=list, + description="The sequence of apps that the service runs after it launches.", + examples=["after: [foo-app, bar-app]"], + ) + before: UniqueList[str] = pydantic.Field( + default_factory=list, + description="The sequence of apps that the service runs before it launches.", + examples=["before: [baz-app, quz-app]"], + ) + refresh_mode: Literal["endure", "restart", "ignore-running"] | None = ( + pydantic.Field( + default=None, + description="Determines how the service should restart when the snap refreshed.", + ) + ) stop_mode: ( Literal[ "sigterm", @@ -402,7 +478,9 @@ class App(models.CraftBaseModel): "sigint-all", ] | None - ) = None + ) = pydantic.Field( + default=None, description="The signal to send when stopping the service." + ) restart_condition: ( Literal[ "on-success", @@ -414,17 +492,51 @@ class App(models.CraftBaseModel): "never", ] | None - ) = None - install_mode: Literal["enable", "disable"] | None = None - slots: UniqueList[str] | None = None - plugs: UniqueList[str] | None = None - aliases: UniqueList[str] | None = None - environment: dict[str, str] | None = None - command_chain: list[str] = [] - sockets: dict[str, Socket] | None = None - daemon_scope: Literal["system", "user"] | None = None - activates_on: UniqueList[str] | None = None - passthrough: dict[str, Any] | None = None + ) = pydantic.Field( + default=None, description="The condition under which the service restarts." + ) + install_mode: Literal["enable", "disable"] | None = pydantic.Field( + default=None, + description="Whether snapd can automatically start the service when the snap is installed.", + ) + slots: UniqueList[str] | None = pydantic.Field( + default=None, description="The app's slots." + ) + plugs: UniqueList[str] | None = pydantic.Field( + default=None, description="The interfaces that the app can connect to." + ) + aliases: UniqueList[str] | None = pydantic.Field( + default=None, description="The app's alternative internal identifiers." + ) + environment: dict[str, str] | None = pydantic.Field( + default=None, + description="The runtime environment variables available to the snap's apps.", + examples=[ + "environment: {PYTHONPATH: $SNAP/usr/lib/python3/dist-packages, DISABLE_WAYLAND: 1" + ], + ) + command_chain: list[str] = pydantic.Field( + default_factory=list, + description="The sequence of commands to run before the app's command runs. Also applied when running ``snap run --shell``", + ) + sockets: dict[str, Socket] | None = pydantic.Field( + default=None, + description="The app's sockets.", + ) + daemon_scope: Literal["system", "user"] | None = pydantic.Field( + default=None, + description="Determines whether the service is run on a system or user instance of systemd.", + examples=["daemon-scope: user"], + ) + activates_on: UniqueList[str] | None = pydantic.Field( + default=None, + description="The slots exposed by the snap to activate the service with D-Bus.", + examples=["activates-on: gnome-shell-dbus"], + ) + passthrough: dict[str, Any] | None = pydantic.Field( + default=None, + description="Attributes to not validate for correctness. Useful for testing experimental snapd features.", + ) @pydantic.field_validator("autostart") @classmethod @@ -482,10 +594,20 @@ def _validate_aliases(cls, aliases): class Hook(models.CraftBaseModel): """Snapcraft project hook definition.""" - command_chain: list[str] | None = None - environment: dict[str, str] | None = None - plugs: UniqueList[str] | None = None - passthrough: dict[str, Any] | None = None + command_chain: list[str] = pydantic.Field( + default_factory=list, + description="The sequence of commands to run before the app's command runs. Also applied when running ``snap run --shell``", + ) + environment: dict[str, str | None] | None = pydantic.Field( + default=None, description="The hook's run-time environment variables." + ) + plugs: UniqueList[str] | None = pydantic.Field( + default=None, description="The interfaces that the hook can connect to." + ) + passthrough: dict[str, Any] | None = pydantic.Field( + default=None, + description="Attributes to not validate for correctness. Useful for testing experimental snapd features.", + ) @pydantic.field_validator("command_chain") @classmethod @@ -503,17 +625,33 @@ def _validate_plugs(cls, plugs): class Architecture(models.CraftBaseModel, extra="forbid"): """Snapcraft project architecture definition.""" - build_on: str | UniqueList[str] - build_for: str | UniqueList[str] | None = None + build_on: str | UniqueList[str] = pydantic.Field( + description="The architectures on which the snap can be built." + ) + build_for: str | UniqueList[str] | None = pydantic.Field( + default=None, + description="The single element list of the architecture where the snap can be run", + ) class ContentPlug(models.CraftBaseModel): """Snapcraft project content plug definition.""" - content: str | None = None - interface: str - target: str - default_provider: str | None = None + content: str | None = pydantic.Field( + default=None, + description="The name for the content type.", + examples=["content: themes"], + ) + interface: str = pydantic.Field(description="The name of the interface.") + target: str = pydantic.Field( + description="The path to the producer's files in the snap.", + examples=["target: $SNAP/data-dir/themes"], + ) + default_provider: str | None = pydantic.Field( + default=None, + description="The default snap install to satisfy the interface.", + examples=["default-provider: gtk-common-themes"], + ) @pydantic.field_validator("default_provider") @classmethod @@ -529,8 +667,13 @@ def _validate_default_provider(cls, default_provider): class Platform(models.Platform): """Snapcraft project platform definition.""" - build_on: UniqueList[str] | None = pydantic.Field(min_length=1) - build_for: SingleEntryList | None = None + build_on: UniqueList[str] | None = pydantic.Field( + description="The architectures on which the snap can be built.", min_length=1 + ) + build_for: SingleEntryList | None = pydantic.Field( + default=None, + description="The single element list of the architecture the snap is built for.", + ) @pydantic.field_validator("build_on", "build_for", mode="before") @classmethod @@ -585,11 +728,19 @@ def from_architectures( class Component(models.CraftBaseModel): """Snapcraft component definition.""" - summary: SummaryStr - description: str - type: Literal["test", "kernel-modules", "standard"] - version: VersionStr | None = None - hooks: dict[str, Hook] | None = None + summary: SummaryStr = pydantic.Field(description="The summary of the component.") + description: str = pydantic.Field( + description="The full description of the component." + ) + type: Literal["test", "kernel-modules", "standard"] = pydantic.Field( + description="The component's type." + ) + version: VersionStr | None = pydantic.Field( + default=None, description="The version of the component." + ) + hooks: dict[str, Hook] | None = pydantic.Field( + default=None, description="Configures the component's hooks." + ) MANDATORY_ADOPTABLE_FIELDS = ("version", "summary", "description") @@ -606,42 +757,137 @@ class Project(models.Project): # snapcraft's `name` is more general than craft-application name: ProjectName # type: ignore[assignment] - build_base: str | None = pydantic.Field(validate_default=True, default=None) - compression: Literal["lzo", "xz"] = "xz" + build_base: str | None = pydantic.Field( + validate_default=True, + default=None, + description="The build environment to use when building the snap", + ) + compression: Literal["lzo", "xz"] = pydantic.Field( + default="xz", description="Specifies the algorithm that compresses the snap." + ) version: VersionStr | None = None - donation: UniqueList[str] | None = None + donation: UniqueList[str] | None = pydantic.Field( + default=None, description="The snap's donation links." + ) # snapcraft's `source_code` is more general than craft-application - source_code: UniqueList[str] | None = None # type: ignore[assignment] - contact: UniqueList[str] | None = None # type: ignore[assignment] - issues: UniqueList[str] | None = None # type: ignore[assignment] - website: UniqueList[str] | None = None + source_code: UniqueList[str] | None = pydantic.Field( # type: ignore[assignment] + default=None, + description="The links to the source code of the snap or the original project.", + examples=["source-code: https://example.com/source-code"], + ) + contact: UniqueList[str] | None = pydantic.Field( # type: ignore[reportIncompatibleVariableOverride] + default=None, description="The snap author's contact links and email addresses." + ) + issues: UniqueList[str] | None = pydantic.Field( # type: ignore[reportIncompatibleVariableOverride] + default=None, + description="The links and email addresses for submitting issues, bugs, and feature requests.", + ) + website: UniqueList[str] | None = pydantic.Field( + default=None, description="The links to the original software's web pages." + ) type: Literal["app", "base", "gadget", "kernel", "snapd"] | None = None - icon: str | None = None - confinement: Literal["classic", "devmode", "strict"] + icon: str | None = pydantic.Field( + default=None, description="The path to the snap's icon." + ) + confinement: Literal["classic", "devmode", "strict"] = pydantic.Field( + description="The amount of isolation the snap has from the host system." + ) layout: ( dict[str, SingleEntryDict[Literal["symlink", "bind", "bind-file", "type"], str]] | None - ) = None - grade: Literal["stable", "devel"] | None = None - architectures: list[str | Architecture] | None = None + ) = pydantic.Field( + default=None, description="The file layouts in the execution environment." + ) + grade: Literal["stable", "devel"] | None = pydantic.Field( + default=None, description="Publication guardrail for the snap" + ) + architectures: list[str | Architecture] | None = pydantic.Field( + default=None, + description="Determines which instruction set architectures the snap builds on and runs on.", + examples=[ + "architectures: [amd64, riscv64]", + "architectures: [{build-on: [amd64], build-for: [amd64]}]", + "architectures: [{build-on: [amd64, riscv64], build-for: [riscv64]}]", + ], + ) _architectures_in_yaml: bool | None = None - platforms: dict[str, Platform] | None = None # type: ignore[assignment,reportIncompatibleVariableOverride] - assumes: UniqueList[str] = pydantic.Field(default_factory=list) - hooks: dict[str, Hook] | None = None - passthrough: dict[str, Any] | None = None - apps: dict[str, App] | None = None - plugs: dict[str, ContentPlug | Any] | None = None - slots: dict[str, Any] | None = None - lint: Lint | None = None - epoch: str | None = None - adopt_info: str | None = None - system_usernames: dict[str, Any] | None = None - environment: dict[str, str | None] | None = None - build_packages: Grammar[list[str]] | None = None - build_snaps: Grammar[list[str]] | None = None - ua_services: set[str] | None = None - provenance: str | None = None - components: dict[ProjectName, Component] | None = None + platforms: dict[str, Platform] | None = pydantic.Field( # type: ignore[assignment,reportIncompatibleVariableOverride] + default=None, + description="Determines which instruction set architectures the snap builds on and runs on.", + examples=[ + "platforms: {amd64: {build-on: [amd64], build-for: [amd64]}, arm64: {build-on: [amd64, arm64], build-for: [arm64]}}" + ], + ) + assumes: UniqueList[str] = pydantic.Field( + default_factory=list, + description="The snapd features or minimum version of snapd required by the snap.", + examples=["assumes: [snapd2.66]", "assumes: [common-data-dir]"], + ) + hooks: dict[str, Hook] | None = pydantic.Field( + default=None, description="Configures the snap's hooks." + ) + passthrough: dict[str, Any] | None = pydantic.Field( + default=None, + description="Attributes to not validate for correctness. Useful for testing experimental snapd features.", + ) + apps: dict[str, App] | None = pydantic.Field( + default=None, + description="Declares the individual programs and services that the snap runs.", + examples=["apps: {foo-app: {command: bin/foo-app}}"], + ) + plugs: dict[str, ContentPlug | Any] | None = pydantic.Field( + default=None, description="Declares the snap's plugs." + ) + slots: dict[str, Any] | None = pydantic.Field( + default=None, description="Declares the snap's slots." + ) + lint: Lint | None = pydantic.Field( + default=None, + description="The linter configuration.", + examples=["lint: {ignore: [classic, library: [usr/lib/**/libfoo.so*]]}"], + ) + epoch: str | None = pydantic.Field( + default=None, description="The epoch associated with this version of the snap." + ) + adopt_info: str | None = pydantic.Field( + default=None, + description=textwrap.dedent( + """\ + Selects a part to inherit metadata from and reuse for the snap's metadata. + + Required if one of ``version``, ``summary``, or ``description`` isn't set.""" + ), + examples=["adopt-info: foo-part"], + ) + system_usernames: dict[str, Any] | None = pydantic.Field( + default=None, + description="The system usernames that the snap can use to run services.", + ) + environment: dict[str, str | None] | None = pydantic.Field( + default=None, description="The snap's run-time environment variables." + ) + build_packages: Grammar[list[str]] | None = pydantic.Field( + default=None, + description="Base dependent build dependencies required to build parts.", + examples=["build-packages: libssl-dev, libyaml-dev"], + ) + build_snaps: Grammar[list[str]] | None = pydantic.Field( + default=None, + description="Snap dependencies required to build parts.", + examples=["build-snaps: go/1.22/stable, yq"], + ) + ua_services: set[str] | None = pydantic.Field( + default=None, + description="The Ubuntu Pro services to enable when building the snap.", + ) + provenance: str | None = pydantic.Field( + default=None, + description="The primary-key header for snaps signed by third parties.", + ) + components: dict[ProjectName, Component] | None = pydantic.Field( + default=None, + description="Declares the components to pack in conjunction with the snap.", + ) @override @classmethod @@ -970,7 +1216,9 @@ class _GrammarAwarePart(_GrammarAwareModel): class GrammarAwareProject(_GrammarAwareModel): """Project definition containing grammar-aware components.""" - parts: dict[str, _GrammarAwarePart] + parts: dict[str, _GrammarAwarePart] = pydantic.Field( + description="Declares the self-contained software pieces needed to create the snap." + ) @classmethod def validate_grammar(cls, data: dict[str, Any]) -> None: