Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3ff8255
feat: skip repacking if snap content is not changed
cmatsuoka Jul 7, 2026
e885eb1
feat: mediate manifest.yaml
cmatsuoka Jul 7, 2026
0256d4f
feat: mediate component.yaml and project copy
cmatsuoka Jul 7, 2026
fdfde7f
feat: mediate hook files
cmatsuoka Jul 7, 2026
c02b3a9
feat: mediate creation of icon and desktop assets
cmatsuoka Jul 13, 2026
9993fb3
chore: reuse existing helpers instead of reimplementing
cmatsuoka Jul 14, 2026
6281ff9
chore: better filtering for hook file creation
cmatsuoka Jul 14, 2026
e541acf
chore: handle asset clash issue raised in review
cmatsuoka Jul 14, 2026
47a34c7
chore: handle icon retrieval errors
cmatsuoka Jul 14, 2026
da83271
chore: update docstring
cmatsuoka Jul 14, 2026
7303ca3
chore: address linter warnings
cmatsuoka Jul 14, 2026
d0ae763
chore: address linter warnings
cmatsuoka Jul 14, 2026
f91dfc0
chore: address linter warnings
cmatsuoka Jul 14, 2026
6b466d1
chore: address linter warnings
cmatsuoka Jul 14, 2026
0ac9ce1
chore: address linter warnings
cmatsuoka Jul 14, 2026
4a7f0b5
chore: address linter warnings
cmatsuoka Jul 14, 2026
a536eb9
chore: address linter warning
cmatsuoka Jul 14, 2026
b7f8d08
chore: remove unused methods
cmatsuoka Jul 14, 2026
9ed5ca3
Merge branch 'main' into work/SNAPCRAFT-1364-Skip-repacking-if-artifa…
cmatsuoka Jul 14, 2026
a8cf43e
chore: address linter warning
cmatsuoka Jul 14, 2026
159cc2d
chore: fix mediated icon asset error
cmatsuoka Jul 14, 2026
df7707a
Merge branch 'main' into work/SNAPCRAFT-1364-Skip-repacking-if-artifa…
cmatsuoka Jul 14, 2026
f2e8202
chore: address linter warning
cmatsuoka Jul 14, 2026
bfbbb1e
chore: handle empty environment variables
cmatsuoka Jul 15, 2026
a23571c
chore: fix icon download issue
cmatsuoka Jul 15, 2026
71e73ca
Merge branch 'main' into work/SNAPCRAFT-1364-Skip-repacking-if-artifa…
cmatsuoka Jul 15, 2026
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ dynamic = ["version"]
dependencies = [
"catkin-pkg==1.1.0; sys_platform == 'linux'",
"click>=8.2",
"craft-application[remote]>=6.4.0",
"craft-application[remote]>=7.1.0",
"craft-archives>=2.2.0",
"craft-cli>=3.4.0",
"craft-grammar>=2.3.0",
Expand Down
1 change: 1 addition & 0 deletions snapcraft/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
mandatory_adoptable_fields=list(models.MANDATORY_ADOPTABLE_FIELDS),
docs_url="https://documentation.ubuntu.com/snapcraft/{version}",
enable_pro_support=True,
always_repack=False,
)


Expand Down
29 changes: 4 additions & 25 deletions snapcraft/commands/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,11 @@ class PackCommand(craft_application.commands.lifecycle.PackCommand):

name = "pack"
help_msg = "Create the final artifact"
overview = textwrap.dedent(
"""
overview = textwrap.dedent("""
Process parts and create a snap file containing the project payload
with the provided metadata. If a directory is specified, pack its
contents instead.
"""
)
""")

@override
def _fill_parser(self, parser: argparse.ArgumentParser) -> None:
Expand Down Expand Up @@ -84,35 +82,16 @@ def needs_project(self, parsed_args: argparse.Namespace) -> bool:
emit.debug("Loading project because a directory was not provided.")
return True

@override
def run_managed(self, parsed_args: argparse.Namespace) -> bool:
"""Return whether the command should run in managed mode or not.

Packing a directory always runs locally.
"""
if parsed_args.directory:
emit.debug("Not running managed mode because a directory was provided.")
return False

return super().run_managed(parsed_args)


class TryCommand(PackCommand):
"""Prepare the parts for ``snap try``."""

name = "try"
help_msg = 'Prepare a snap for "snap try".'
overview = textwrap.dedent(
"""
overview = textwrap.dedent("""
Process parts and expose the ``prime`` directory containing the
final payload, in preparation for ``snap try prime``.
"""
)

@override
def run_managed(self, parsed_args: argparse.Namespace) -> bool:
"""Overridden to return false, such that the command fails early."""
return False
""")

@override
def _run(
Expand Down
31 changes: 17 additions & 14 deletions snapcraft/meta/component_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,8 @@ class ComponentMetadata(SnapcraftMetadata):
provenance: str | None = None


def write(
project: models.Project, component_name: str, component_prime_dir: Path
) -> None:
"""Create a component.yaml file.

:param project: The snapcraft project.
:param component_name: Name of the component.
:param component_prime_dir: The directory containing the component's primed contents.
"""
meta_dir = component_prime_dir / "meta"
meta_dir.mkdir(parents=True, exist_ok=True)

def get_metadata(project: models.Project, component_name: str) -> ComponentMetadata:
"""Create the component metadata model for a project component."""
if not project.components:
raise SnapcraftError("Project does not contain any components.")

Expand All @@ -59,7 +49,7 @@ def write(
if not component:
raise SnapcraftError("Component does not exist.")

component_metadata = ComponentMetadata(
return ComponentMetadata(
component=f"{project.name}+{component_name}",
type=component.type,
version=component.version,
Expand All @@ -68,4 +58,17 @@ def write(
provenance=project.provenance,
)

component_metadata.to_yaml_file(meta_dir / "component.yaml")

def write(
project: models.Project, component_name: str, component_prime_dir: Path
) -> None:
"""Create a component.yaml file.

:param project: The snapcraft project.
:param component_name: Name of the component.
:param component_prime_dir: The directory containing the component's primed contents.
"""
meta_dir = component_prime_dir / "meta"
meta_dir.mkdir(parents=True, exist_ok=True)

get_metadata(project, component_name).to_yaml_file(meta_dir / "component.yaml")
20 changes: 16 additions & 4 deletions snapcraft/parts/desktop_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from __future__ import annotations

import configparser
import io
import os
import shlex
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -89,6 +90,13 @@ def _parse_and_reformat_section(
if icon_path is not None:
icon = icon_path

# An explicit icon path is a mediated asset destination that may
# not have been written into prime yet.
icon = icon[1:] if icon.startswith("/") else icon
icon = icon[8:] if icon.startswith("${SNAP}") else icon
self._parser[section]["Icon"] = os.path.join("${SNAP}", icon)
return

# Strip any leading slash.
icon = icon[1:] if icon.startswith("/") else icon

Expand Down Expand Up @@ -123,14 +131,19 @@ def _parse_and_reformat(self, *, icon_path: str | None = None) -> None:
for section in self._parser.sections():
self._parse_and_reformat_section(section=section, icon_path=icon_path)

def render(self, *, icon_path: str | None = None) -> str:
"""Return the rewritten desktop file contents."""
self._parse_and_reformat(icon_path=icon_path)
output = io.StringIO()
self._parser.write(output, space_around_delimiters=False)
return output.getvalue()

def write(self, *, gui_dir: Path, icon_path: str | None = None) -> None:
"""Write the desktop file.

:param gui_dir: The desktop file destination directory.
:param icon_path: The icon corresponding to this desktop file.
"""
self._parse_and_reformat(icon_path=icon_path)

gui_dir.mkdir(parents=True, exist_ok=True)

# Rename the desktop file to match the app name. This will help
Expand All @@ -141,5 +154,4 @@ def write(self, *, gui_dir: Path, icon_path: str | None = None) -> None:
# Unlikely. A desktop file in meta/gui/ already existed for
# this app. Let's pretend it wasn't there and overwrite it.
target.unlink()
with target.open("w", encoding="utf-8") as target_file:
self._parser.write(target_file, space_around_delimiters=False)
target.write_text(self.render(icon_path=icon_path), encoding="utf-8")
34 changes: 17 additions & 17 deletions snapcraft/parts/setup_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def setup_assets(

if project.apps:
for app_name, app in project.apps.items():
_validate_command_chain(
validate_command_chain(
app.command_chain, name=f"app {app_name!r}", prime_dir=prime_dir
)

Expand Down Expand Up @@ -121,11 +121,11 @@ def copy_assets(
if meta_directory_handler:
meta_directory_handler(assets_dir, prime_dir)
else:
_write_snap_directory(
write_snap_directory(
assets_dir=assets_dir, prime_dir=prime_dir, meta_dir=prime_dir / "meta"
)
# create wrappers for hooks in the snap/hooks directory
_create_hook_wrappers(prime_dir)
create_hook_wrappers(prime_dir)


def setup_hooks(hooks: dict[str, models.Hook] | None, prime_dir: Path) -> None:
Expand All @@ -139,15 +139,15 @@ def setup_hooks(hooks: dict[str, models.Hook] | None, prime_dir: Path) -> None:
if hooks:
for hook_name, hook in hooks.items():
if hook.command_chain:
_validate_command_chain(
validate_command_chain(
hook.command_chain, name=f"hook {hook_name!r}", prime_dir=prime_dir
)
_ensure_hook(hooks_dir / hook_name)
ensure_hook(hooks_dir / hook_name)

# Ensure all hooks are executable
if hooks_dir.is_dir():
for hook in hooks_dir.iterdir():
_ensure_hook_executable(hook)
ensure_hook_executable(hook)


def _finalize_icon(
Expand All @@ -162,7 +162,7 @@ def _finalize_icon(

# Nothing to do if no icon is configured, search for existing icon.
if icon is None:
return _find_icon_file(assets_dir)
return find_icon_file(assets_dir)

# Extracted appstream icon paths will either:
# (1) point to a file relative to prime
Expand Down Expand Up @@ -196,21 +196,21 @@ def _finalize_icon(
_copy_file(parsed_path, target_icon_path)
else:
# No icon found, fall back to searching for existing icon.
return _find_icon_file(assets_dir)
return find_icon_file(assets_dir)
else:
raise RuntimeError(f"Unexpected icon path: {parsed_url!r}")

return target_icon_path


def _find_icon_file(assets_dir: Path) -> Path | None:
def find_icon_file(assets_dir: Path) -> Path | None:
for icon_path in (assets_dir / "gui/icon.png", assets_dir / "gui/icon.svg"):
if icon_path.is_file():
return icon_path
return None


def _validate_command_chain(
def validate_command_chain(
command_chain: list[str], *, name: str, prime_dir: Path
) -> None:
"""Verify if each item in the command chain is executable."""
Expand All @@ -236,7 +236,7 @@ def _is_executable(path: Path) -> bool:
return bool(mode & stat.S_IXUSR or mode & stat.S_IXGRP or mode & stat.S_IXOTH)


def _write_snap_directory(*, assets_dir: Path, prime_dir: Path, meta_dir: Path) -> None:
def write_snap_directory(*, assets_dir: Path, prime_dir: Path, meta_dir: Path) -> None:
"""Record manifest and copy assets found under the assets directory.

These assets have priority over any code generated assets and include:
Expand All @@ -263,7 +263,7 @@ def _write_snap_directory(*, assets_dir: Path, prime_dir: Path, meta_dir: Path)
_copy_file(source, destination, follow_symlinks=True)


def _ensure_hook(hook_path: Path) -> None:
def ensure_hook(hook_path: Path) -> None:
"""Create a stub for hook_path if it does not exist.

A stub for hook_name is generated if a command-chain entry is defined
Expand All @@ -280,7 +280,7 @@ def _ensure_hook(hook_path: Path) -> None:
hook_path.write_text("#!/bin/true\n")


def _ensure_hook_executable(hook_path: Path) -> None:
def ensure_hook_executable(hook_path: Path) -> None:
"""Ensure hook is executable.

:param hook_path: file path of the hook
Expand All @@ -289,7 +289,7 @@ def _ensure_hook_executable(hook_path: Path) -> None:
hook_path.chmod(0o755)


def _create_hook_wrappers(prime_dir: Path) -> None:
def create_hook_wrappers(prime_dir: Path) -> None:
"""Create wrappers for hooks.

Hooks in the snap/hooks/ directory are typically built by parts.
Expand All @@ -313,11 +313,11 @@ def _create_hook_wrappers(prime_dir: Path) -> None:

# create a wrapper for each hook
for hook in hooks_in_snap_dir:
_ensure_hook_executable(hook)
_write_hook_wrapper(hook.name, hooks_meta_dir / hook.name)
ensure_hook_executable(hook)
write_hook_wrapper(hook.name, hooks_meta_dir / hook.name)


def _write_hook_wrapper(hook_name: str, wrapper_path: Path) -> None:
def write_hook_wrapper(hook_name: str, wrapper_path: Path) -> None:
"""Write hook wrapper file.

The wrapper is a minimal shell script that calls a hook in $SNAP/snap/hooks/
Expand Down
Loading
Loading