Build and Publish a Snap with Python

Build a Tkinter GUI app that generates YAML configs, then package and publish it as a snap.

Introduction

30 Second Summary

Every developer who packages software for Linux knows the pain of writing configuration files from memory. One wrong indent or a forgotten field means a failed build and time wasted debugging.

In this project, you will build a Python desktop application that generates valid packaging YAML through an intuitive form interface. You will then package the app itself as a snap and publish it to the Snap Store for anyone to install.

What You'll Build

A desktop GUI application called "Craft YAML Generator" that helps developers create valid snapcraft.yaml and rockcraft.yaml files from form inputs, packaged and published as a snap.

By the end of this project, you'll have:

  • A Python + Tkinter GUI application with a tabbed form interface for generating packaging YAML files.
  • A plugin sync module that pulls live plugin data from installed craft tools with a minimal fallback list.
  • A published snap in the Snap Store on the edge channel, installable by any Linux user.
  • Secret Mission: A live preview pane that updates the YAML output in real-time as you modify form fields.

Are there any prerequisites?

This project assumes familiarity with Python, basic Linux CLI usage, and an understanding of what snaps and rocks are at a high level. You do not need prior experience building or publishing snaps.

Before We Start

Before you can build and publish the Craft YAML Generator, you need a working environment with Snapcraft installed and authenticated against the Snap Store.

In this step, you will install Snapcraft, create an Ubuntu One account for publishing, and scaffold the project directory with its configuration files.

In this step, get ready to:
  • Install Snapcraft on your Ubuntu system.
  • Create an Ubuntu One account and authenticate with the Snap Store.
  • Scaffold the project directory structure and configuration files.
Install Snapcraft and verify

Snapcraft is the build tool that packages Python applications into distributable snap files. It requires --classic confinement because it needs broad system access for compiler toolchains and build environments.

  • Install Snapcraft by running this command:
sudo snap install snapcraft --classic

What does --classic mean?

Snap packages normally run inside a sandbox (strict confinement). The --classic flag gives Snapcraft unrestricted access to your system so it can invoke compilers, access build caches, and manage LXD containers during builds.

  • Verify the installation succeeded by running:
snapcraft --version

✔️ I see a version number

Snapcraft is installed and ready. Move on to the next substep.

ⓧ Command not found

Make sure you are running this on Ubuntu (or WSL with snapd configured). The snap command itself must be available before you can install Snapcraft.

  • Confirm snapd is installed by running:
sudo apt update && sudo apt install snapd
  • Then retry the Snapcraft install command above.
Create your Ubuntu One account and log in

Publishing snaps to the Snap Store requires an Ubuntu One account. This is the same account used across all Canonical services.

  • Open https://login.ubuntu.com/ in your browser.
  • Click Create account and follow the registration prompts.
  • After creating your account, log in to the Snap Store CLI by running:
snapcraft login

What happens when you run this?

Your terminal will display a URL or open a browser window for you to authenticate. Follow the prompts to authorize your terminal session with the Ubuntu One credentials you just created.

  • Confirm your session is authenticated by running:
snapcraft whoami

You should see your account email and developer ID in the output.

Not seeing your account details?

If the command reports that you are not logged in, run snapcraft login again and make sure you complete the full browser authentication flow.

If your browser did not open automatically, copy the URL from the terminal and paste it into your browser manually.

Help me troubleshoot my snapcraft login

Scaffold the project directory

The project uses a standard Python package layout with source code in src/ and snap packaging metadata in snap/.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project directories and move into the project root by running these commands:
mkdir -p craft-yaml-gen/src/craft_yaml_gen craft-yaml-gen/snap
cd craft-yaml-gen
  • Confirm the directories exist by running:
ls

You should see snap, src, and nothing else listed.

  • Create requirements.txt with the project dependency by adding this content to the file:
PyYAML
  • Create src/craft_yaml_gen/__init__.py with the package docstring:
"""Craft YAML Generator - A GUI tool for generating snapcraft.yaml and rockcraft.yaml files."""
  • Create setup.py with the packaging configuration by adding this content:
from setuptools import setup, find_packages

setup(
    name="craft-yaml-gen",
    version="1.0.0",
    description="A GUI tool for generating snapcraft.yaml and rockcraft.yaml files",
    author="Developer",
    packages=find_packages(where="src"),
    package_dir={"": "src"},
    install_requires=[
        "PyYAML",
    ],
    entry_points={
        "gui_scripts": [
            "craft-yaml-gen=craft_yaml_gen.app:main",
        ],
    },
    python_requires=">=3.10",
)

What does this setup.py do?

This file tells Python (and Snapcraft's python plugin) how to install your application.

  • find_packages(where="src") locates the craft_yaml_gen package inside the src/ directory.
  • The gui_scripts entry point creates a craft-yaml-gen command that calls the main() function in app.py.
  • python_requires=">=3.10" ensures the snap targets a modern Python version.
  • Also create empty placeholder files for the modules you will build in the next steps:
touch src/craft_yaml_gen/app.py src/craft_yaml_gen/generator.py src/craft_yaml_gen/plugins.py
  • Verify your full project structure by running:
find . -type f | sort

You should see all the files listed including requirements.txt, setup.py, and the four Python files inside src/craft_yaml_gen/.

Missing files or directories?

Make sure you ran the mkdir -p command from your Desktop, and that you cd craft-yaml-gen before creating files. If a file is missing, create it now with touch and add its content.

Help me fix my project directory structure

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files.

ⓧ I'd like to double check the full code

Here are the three files that should have content at this point.

PyYAML
from setuptools import setup, find_packages

setup(
    name="craft-yaml-gen",
    version="1.0.0",
    description="A GUI tool for generating snapcraft.yaml and rockcraft.yaml files",
    author="Developer",
    packages=find_packages(where="src"),
    package_dir={"": "src"},
    install_requires=[
        "PyYAML",
    ],
    entry_points={
        "gui_scripts": [
            "craft-yaml-gen=craft_yaml_gen.app:main",
        ],
    },
    python_requires=">=3.10",
)
"""Craft YAML Generator - A GUI tool for generating snapcraft.yaml and rockcraft.yaml files."""

Your environment is ready and your project skeleton is in place. Next up, you will build the GUI layout with a tabbed interface for the Craft YAML Generator.

Build the GUI Layout

Your project skeleton is in place. Now it is time to turn those empty Python files into a real desktop application.

In this step, you will build the visual interface for the Craft YAML Generator using Tkinter. The app will feature a tabbed layout where each tab handles a specific section of the YAML file the user wants to generate.

In this step, get ready to:
  • Create the main application window with a tabbed notebook layout.
  • Build the Metadata tab with form fields for project configuration.
  • Build the Apps tab with plug checkboxes in a grid layout.
Create the main application class in app.py

The app imports functions from generator.py and plugins.py that you will implement in later steps. For now, you need placeholder stubs so the imports resolve without errors.

  • Add placeholder functions to plugins.py by replacing its contents with this code:
def get_plugins(tool="snapcraft"):
    """Placeholder - implemented in a later step."""
    return []


def sync_plugins(tool="snapcraft"):
    """Placeholder - implemented in a later step."""
    return None
  • Add a placeholder function to generator.py by replacing its contents with this code:
def generate_yaml(data):
    """Placeholder - implemented in a later step."""
    return ""

Why add placeholders now?

Python resolves all import statements when a file loads. If app.py imports generate_yaml and get_plugins from modules that do not define them, the app crashes immediately with an ImportError. These stubs let you build the GUI first and fill in the real logic later.

Now you can build the main application class that sets up the window and its tabbed layout.

  • Open src/craft_yaml_gen/app.py and replace its contents with the following code:
import tkinter as tk
from tkinter import ttk, filedialog, messagebox

from craft_yaml_gen.generator import generate_yaml
from craft_yaml_gen.plugins import get_plugins, sync_plugins


class CraftYAMLGenerator(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Craft YAML Generator")
        self.geometry("800x600")

        # Track which artifact type the user selects
        self.artifact_type = tk.StringVar(value="snapcraft")

        # Create a tabbed container for the form sections
        self.notebook = ttk.Notebook(self)
        self.notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)

        # Create four tab frames
        self.metadata_tab = ttk.Frame(self.notebook)
        self.apps_tab = ttk.Frame(self.notebook)
        self.parts_tab = ttk.Frame(self.notebook)
        self.output_tab = ttk.Frame(self.notebook)

        # Register each tab with a label
        self.notebook.add(self.metadata_tab, text="Metadata")
        self.notebook.add(self.apps_tab, text="Apps")
        self.notebook.add(self.parts_tab, text="Parts")
        self.notebook.add(self.output_tab, text="Output")

        # Build the form content for each tab
        self.setup_metadata_tab()
        self.setup_apps_tab()


def main():
    app = CraftYAMLGenerator()
    app.mainloop()


if __name__ == "__main__":
    main()

What does this code do?

The CraftYAMLGenerator class extends tk.Tk, which creates the root application window. The ttk.Notebook widget provides a tabbed interface where each tab is a separate ttk.Frame.

The artifact_type StringVar tracks whether the user wants to generate a snapcraft.yaml or a rockcraft.yaml. The main() function at the bottom instantiates the class and starts the Tkinter event loop.

You will not see a change yet. The setup_metadata_tab() and setup_apps_tab() methods do not exist, so running the app now would crash. You will add them next.

Build the Metadata tab

The Metadata tab is where users choose their artifact type and fill in project-level fields like name, version, and confinement. These values map directly to the top-level keys in a snapcraft.yaml file.

  • In app.py, add the setup_metadata_tab method inside the CraftYAMLGenerator class, directly below the __init__ method:
    def setup_metadata_tab(self):
        # Artifact type selector - snap vs rock
        type_frame = ttk.LabelFrame(self.metadata_tab, text="Artifact Type")
        type_frame.pack(fill=tk.X, padx=10, pady=5)
        ttk.Radiobutton(
            type_frame, text="snapcraft.yaml",
            variable=self.artifact_type, value="snapcraft"
        ).pack(side=tk.LEFT, padx=10, pady=5)
        ttk.Radiobutton(
            type_frame, text="rockcraft.yaml",
            variable=self.artifact_type, value="rockcraft"
        ).pack(side=tk.LEFT, padx=10, pady=5)

        # Form fields for project metadata
        fields_frame = ttk.LabelFrame(self.metadata_tab, text="Project Metadata")
        fields_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        ttk.Label(fields_frame, text="Name:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3)
        self.name_entry = ttk.Entry(fields_frame, width=40)
        self.name_entry.grid(row=0, column=1, padx=5, pady=3)

        ttk.Label(fields_frame, text="Version:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3)
        self.version_entry = ttk.Entry(fields_frame, width=40)
        self.version_entry.grid(row=1, column=1, padx=5, pady=3)

        ttk.Label(fields_frame, text="Summary:").grid(row=2, column=0, sticky=tk.W, padx=5, pady=3)
        self.summary_entry = ttk.Entry(fields_frame, width=40)
        self.summary_entry.grid(row=2, column=1, padx=5, pady=3)

        ttk.Label(fields_frame, text="Description:").grid(row=3, column=0, sticky=tk.W, padx=5, pady=3)
        self.description_text = tk.Text(fields_frame, width=40, height=3)
        self.description_text.grid(row=3, column=1, padx=5, pady=3)

        # Dropdown selectors for base, confinement, grade
        ttk.Label(fields_frame, text="Base:").grid(row=4, column=0, sticky=tk.W, padx=5, pady=3)
        self.base_combo = ttk.Combobox(fields_frame, values=["core24", "core22"], state="readonly", width=37)
        self.base_combo.grid(row=4, column=1, padx=5, pady=3)
        self.base_combo.set("core24")

        ttk.Label(fields_frame, text="Confinement:").grid(row=5, column=0, sticky=tk.W, padx=5, pady=3)
        self.confinement_combo = ttk.Combobox(fields_frame, values=["strict", "classic", "devmode"], state="readonly", width=37)
        self.confinement_combo.grid(row=5, column=1, padx=5, pady=3)
        self.confinement_combo.set("strict")

        ttk.Label(fields_frame, text="Grade:").grid(row=6, column=0, sticky=tk.W, padx=5, pady=3)
        self.grade_combo = ttk.Combobox(fields_frame, values=["stable", "devel"], state="readonly", width=37)
        self.grade_combo.grid(row=6, column=1, padx=5, pady=3)
        self.grade_combo.set("stable")

What does this code do?

  • The RadioButton pair lets the user toggle between generating a snapcraft.yaml or a rockcraft.yaml. Both are tied to the same artifact_type StringVar, so selecting one automatically deselects the other.
  • The Entry widgets collect single-line text values (name, version, summary). The Text widget is used for description because it supports multi-line input.
  • Each Combobox is set to readonly so users can only pick from the allowed values. The defaults (core24, strict, stable) represent the most common snap configuration.

You will not see a change yet. The app still needs the setup_apps_tab() method before it can launch.

Build the Apps tab

The Apps tab captures the snap application entry point and its interface connections (plugs). Plugs define which system resources the packaged app can access at runtime.

  • Still in app.py, add the setup_apps_tab method below setup_metadata_tab, inside the class:
    def setup_apps_tab(self):
        apps_frame = ttk.LabelFrame(self.apps_tab, text="App Configuration")
        apps_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        # App entry point fields
        ttk.Label(apps_frame, text="App Name:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3)
        self.app_name_entry = ttk.Entry(apps_frame, width=40)
        self.app_name_entry.grid(row=0, column=1, padx=5, pady=3)

        ttk.Label(apps_frame, text="Command:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3)
        self.command_entry = ttk.Entry(apps_frame, width=40)
        self.command_entry.grid(row=1, column=1, padx=5, pady=3)

        # Checkboxes for common snap plugs in a 3-column grid
        plugs_frame = ttk.LabelFrame(apps_frame, text="Plugs")
        plugs_frame.grid(row=2, column=0, columnspan=2, sticky=tk.W + tk.E, padx=5, pady=5)

        self.plug_vars = {}
        available_plugs = [
            "home", "network", "network-bind",
            "desktop", "desktop-legacy", "x11",
            "wayland", "audio-playback", "removable-media",
            "browser-support", "display-control",
        ]
        for i, plug in enumerate(available_plugs):
            var = tk.BooleanVar()
            self.plug_vars[plug] = var
            ttk.Checkbutton(plugs_frame, text=plug, variable=var).grid(
                row=i // 3, column=i % 3, sticky=tk.W, padx=5, pady=2
            )

What does this code do?

  • The plug_vars dictionary maps each plug name to a BooleanVar. When the user checks a box, the corresponding variable becomes True. Later, the generator reads this dictionary to decide which plugs to include in the YAML output.
  • The row=i // 3, column=i % 3 pattern arranges all 11 checkboxes into a 3-column grid automatically. Integer division gives the row, and modulo gives the column.
  • Save app.py, plugins.py, and generator.py.
  • Run the app from your terminal to verify the GUI launches:
cd ~/Desktop/craft-yaml-gen/src
python3 -m craft_yaml_gen.app

You should see a window titled "Craft YAML Generator" at 800x600 pixels with four tabs across the top. Click the Metadata and Apps tabs to confirm the form fields and checkboxes are visible.

Window not appearing?

  • If you see ModuleNotFoundError: No module named 'craft_yaml_gen', make sure you are running the command from inside the src/ directory. The -m flag requires the package to be discoverable from the current directory.
  • If you see ImportError: cannot import name 'generate_yaml', double check that you added the placeholder function to generator.py earlier in this step.
  • If the window opens but appears empty, confirm that setup_metadata_tab and setup_apps_tab are indented inside the class (they are methods, not standalone functions).

Help me debug my GUI launch

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py, plugins.py, and generator.py.

ⓧ I'd like to double check the full code

Here is the complete app.py file at the end of this step:

import tkinter as tk
from tkinter import ttk, filedialog, messagebox

from craft_yaml_gen.generator import generate_yaml
from craft_yaml_gen.plugins import get_plugins, sync_plugins


class CraftYAMLGenerator(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Craft YAML Generator")
        self.geometry("800x600")

        self.artifact_type = tk.StringVar(value="snapcraft")

        self.notebook = ttk.Notebook(self)
        self.notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)

        self.metadata_tab = ttk.Frame(self.notebook)
        self.apps_tab = ttk.Frame(self.notebook)
        self.parts_tab = ttk.Frame(self.notebook)
        self.output_tab = ttk.Frame(self.notebook)

        self.notebook.add(self.metadata_tab, text="Metadata")
        self.notebook.add(self.apps_tab, text="Apps")
        self.notebook.add(self.parts_tab, text="Parts")
        self.notebook.add(self.output_tab, text="Output")

        self.setup_metadata_tab()
        self.setup_apps_tab()

    def setup_metadata_tab(self):
        type_frame = ttk.LabelFrame(self.metadata_tab, text="Artifact Type")
        type_frame.pack(fill=tk.X, padx=10, pady=5)
        ttk.Radiobutton(
            type_frame, text="snapcraft.yaml",
            variable=self.artifact_type, value="snapcraft"
        ).pack(side=tk.LEFT, padx=10, pady=5)
        ttk.Radiobutton(
            type_frame, text="rockcraft.yaml",
            variable=self.artifact_type, value="rockcraft"
        ).pack(side=tk.LEFT, padx=10, pady=5)

        fields_frame = ttk.LabelFrame(self.metadata_tab, text="Project Metadata")
        fields_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        ttk.Label(fields_frame, text="Name:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3)
        self.name_entry = ttk.Entry(fields_frame, width=40)
        self.name_entry.grid(row=0, column=1, padx=5, pady=3)

        ttk.Label(fields_frame, text="Version:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3)
        self.version_entry = ttk.Entry(fields_frame, width=40)
        self.version_entry.grid(row=1, column=1, padx=5, pady=3)

        ttk.Label(fields_frame, text="Summary:").grid(row=2, column=0, sticky=tk.W, padx=5, pady=3)
        self.summary_entry = ttk.Entry(fields_frame, width=40)
        self.summary_entry.grid(row=2, column=1, padx=5, pady=3)

        ttk.Label(fields_frame, text="Description:").grid(row=3, column=0, sticky=tk.W, padx=5, pady=3)
        self.description_text = tk.Text(fields_frame, width=40, height=3)
        self.description_text.grid(row=3, column=1, padx=5, pady=3)

        ttk.Label(fields_frame, text="Base:").grid(row=4, column=0, sticky=tk.W, padx=5, pady=3)
        self.base_combo = ttk.Combobox(fields_frame, values=["core24", "core22"], state="readonly", width=37)
        self.base_combo.grid(row=4, column=1, padx=5, pady=3)
        self.base_combo.set("core24")

        ttk.Label(fields_frame, text="Confinement:").grid(row=5, column=0, sticky=tk.W, padx=5, pady=3)
        self.confinement_combo = ttk.Combobox(fields_frame, values=["strict", "classic", "devmode"], state="readonly", width=37)
        self.confinement_combo.grid(row=5, column=1, padx=5, pady=3)
        self.confinement_combo.set("strict")

        ttk.Label(fields_frame, text="Grade:").grid(row=6, column=0, sticky=tk.W, padx=5, pady=3)
        self.grade_combo = ttk.Combobox(fields_frame, values=["stable", "devel"], state="readonly", width=37)
        self.grade_combo.grid(row=6, column=1, padx=5, pady=3)
        self.grade_combo.set("stable")

    def setup_apps_tab(self):
        apps_frame = ttk.LabelFrame(self.apps_tab, text="App Configuration")
        apps_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        ttk.Label(apps_frame, text="App Name:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3)
        self.app_name_entry = ttk.Entry(apps_frame, width=40)
        self.app_name_entry.grid(row=0, column=1, padx=5, pady=3)

        ttk.Label(apps_frame, text="Command:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3)
        self.command_entry = ttk.Entry(apps_frame, width=40)
        self.command_entry.grid(row=1, column=1, padx=5, pady=3)

        plugs_frame = ttk.LabelFrame(apps_frame, text="Plugs")
        plugs_frame.grid(row=2, column=0, columnspan=2, sticky=tk.W + tk.E, padx=5, pady=5)

        self.plug_vars = {}
        available_plugs = [
            "home", "network", "network-bind",
            "desktop", "desktop-legacy", "x11",
            "wayland", "audio-playback", "removable-media",
            "browser-support", "display-control",
        ]
        for i, plug in enumerate(available_plugs):
            var = tk.BooleanVar()
            self.plug_vars[plug] = var
            ttk.Checkbutton(plugs_frame, text=plug, variable=var).grid(
                row=i // 3, column=i % 3, sticky=tk.W, padx=5, pady=2
            )


def main():
    app = CraftYAMLGenerator()
    app.mainloop()


if __name__ == "__main__":
    main()

Your GUI skeleton is alive. Next up, you will wire in the plugin sync module and the Parts tab so the app can dynamically pull the real list of available build plugins.

Implement Plugin Sync and Parts Configuration

Your GUI has Metadata and Apps tabs collecting user input. The next piece is the Parts tab, where developers configure how their snap gets built.

Parts depend on plugins, and plugin availability changes depending on which craft tool is installed and which base version is selected. In this step, you'll build a plugin module that syncs live data from the system and falls back to a safe minimal list when the tool is missing.

In this step, get ready to:
  • Create the plugin sync module with a minimal fallback list and CLI integration.
  • Build the Parts tab with a plugin dropdown, sync button, and package fields.
Create the plugin module with fallback data

The plugin module needs to handle two scenarios: when the craft tool is installed (query it directly) and when it is not (provide a sensible default list).

  • Open src/craft_yaml_gen/plugins.py in your editor.
  • Add the imports and minimal plugin list by pasting this code:
import subprocess
import shutil


MINIMAL_PLUGINS = [
    "autotools",
    "cmake",
    "dump",
    "go",
    "make",
    "meson",
    "nil",
    "npm",
    "python",
    "rust",
]

What does this code do?

subprocess lets us run CLI commands (like snapcraft list-plugins) from Python. shutil.which checks whether a binary exists on the system PATH.

MINIMAL_PLUGINS is the fallback list of the most common build plugins. If the craft tool is not installed, the app still has sensible options to offer.

  • Below the MINIMAL_PLUGINS list, add the get_plugins() function:
def get_plugins(tool="snapcraft"):
    """Get plugins - try syncing first, fall back to minimal list."""
    synced = sync_plugins(tool)
    if synced:
        return synced
    return MINIMAL_PLUGINS

What does this function do?

get_plugins() is the public entry point. It tries to get live plugin data from the system first. If that fails (tool not installed, command errors), it returns the safe MINIMAL_PLUGINS list instead.

  • Below get_plugins(), add the sync_plugins() function that does the actual CLI call:
def sync_plugins(tool="snapcraft"):
    """Attempt to get plugins from the installed craft tool."""
    if not shutil.which(tool):
        return None
    try:
        result = subprocess.run(
            [tool, "list-plugins"],
            capture_output=True,
            text=True,
            timeout=10,
        )
        if result.returncode == 0:
            plugins = [
                line.strip()
                for line in result.stdout.strip().split("\n")
                if line.strip()
            ]
            return sorted(plugins) if plugins else None
        return None
    except (subprocess.TimeoutExpired, OSError):
        return None

What does this function do?

shutil.which(tool) checks if the tool binary exists on the system. If it does not, the function returns None immediately.

If the tool is found, it runs snapcraft list-plugins (or the equivalent rockcraft command), parses each line of output into a sorted list, and returns it. The 10-second timeout prevents the app from hanging if the command stalls.

  • Save plugins.py.
  • Test the module by running this command from your project root:
cd src && python -c "from craft_yaml_gen.plugins import get_plugins; print(get_plugins())" && cd ..

You should see a list of plugin names printed to the terminal. If Snapcraft is installed, you'll see the full synced list. Otherwise, you'll see the minimal fallback list.

Seeing a ModuleNotFoundError?

Make sure you're running the command from the craft-yaml-gen project folder, not from inside src/. The cd src in the command handles this for you.

Also check that __init__.py exists inside src/craft_yaml_gen/. Python needs it to recognize the folder as a package.

Help me debug my plugins.py import error

✔️ Awesome, I've got everything!

Great. Double check you've saved plugins.py before moving on.

ⓧ I'd like to double check the full code

import subprocess
import shutil


MINIMAL_PLUGINS = [
    "autotools",
    "cmake",
    "dump",
    "go",
    "make",
    "meson",
    "nil",
    "npm",
    "python",
    "rust",
]


def get_plugins(tool="snapcraft"):
    """Get plugins - try syncing first, fall back to minimal list."""
    synced = sync_plugins(tool)
    if synced:
        return synced
    return MINIMAL_PLUGINS


def sync_plugins(tool="snapcraft"):
    """Attempt to get plugins from the installed craft tool."""
    if not shutil.which(tool):
        return None
    try:
        result = subprocess.run(
            [tool, "list-plugins"],
            capture_output=True,
            text=True,
            timeout=10,
        )
        if result.returncode == 0:
            plugins = [
                line.strip()
                for line in result.stdout.strip().split("\n")
                if line.strip()
            ]
            return sorted(plugins) if plugins else None
        return None
    except (subprocess.TimeoutExpired, OSError):
        return None
Build the Parts tab with plugin sync UI

The Parts tab needs a plugin dropdown that uses the module you just built, a button to trigger a live sync, and fields for build and stage packages.

  • In app.py, find the end of the __init__ method. Locate these two lines:
        self.setup_metadata_tab()
        self.setup_apps_tab()
  • Add self.setup_parts_tab() on a new line directly below self.setup_apps_tab(), so the block now looks like this:
        self.setup_metadata_tab()
        self.setup_apps_tab()
        self.setup_parts_tab()
  • Now add the setup_parts_tab() method to the CraftYAMLGenerator class, below the existing setup_apps_tab() method:
    def setup_parts_tab(self):
        parts_frame = ttk.LabelFrame(self.parts_tab, text="Parts Configuration")
        parts_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        ttk.Label(parts_frame, text="Part Name:").grid(
            row=0, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.part_name_entry = ttk.Entry(parts_frame, width=40)
        self.part_name_entry.grid(row=0, column=1, padx=5, pady=3)

        plugin_frame = ttk.Frame(parts_frame)
        plugin_frame.grid(row=1, column=0, columnspan=2, sticky=tk.W + tk.E, padx=5, pady=3)
        ttk.Label(plugin_frame, text="Plugin:").pack(side=tk.LEFT, padx=5)
        self.plugin_combo = ttk.Combobox(
            plugin_frame, values=get_plugins(self.artifact_type.get()),
            state="readonly", width=30
        )
        self.plugin_combo.pack(side=tk.LEFT, padx=5)
        self.sync_btn = ttk.Button(
            plugin_frame, text="Sync Plugins", command=self.do_sync_plugins
        )
        self.sync_btn.pack(side=tk.LEFT, padx=5)
        self.sync_status = ttk.Label(plugin_frame, text="")
        self.sync_status.pack(side=tk.LEFT, padx=5)

        ttk.Label(parts_frame, text="Source:").grid(
            row=2, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.source_entry = ttk.Entry(parts_frame, width=40)
        self.source_entry.grid(row=2, column=1, padx=5, pady=3)
        self.source_entry.insert(0, ".")

        ttk.Label(parts_frame, text="Build Packages\n(comma-separated):").grid(
            row=3, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.build_packages_entry = ttk.Entry(parts_frame, width=40)
        self.build_packages_entry.grid(row=3, column=1, padx=5, pady=3)

        ttk.Label(parts_frame, text="Stage Packages\n(comma-separated):").grid(
            row=4, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.stage_packages_entry = ttk.Entry(parts_frame, width=40)
        self.stage_packages_entry.grid(row=4, column=1, padx=5, pady=3)

What does this code do?

  • plugin_combo is a dropdown pre-populated by calling get_plugins() from your plugins module. It starts with the minimal list or the full synced list depending on whether the craft tool is available.
  • sync_btn triggers a fresh plugin sync at runtime. The status label next to it shows whether the sync succeeded or if the tool needs installing.
  • source_entry defaults to "." (the current directory), which is the most common source path for snap parts.
  • Below setup_parts_tab(), add the do_sync_plugins() method that handles the button click:
    def do_sync_plugins(self):
        tool = self.artifact_type.get()
        plugins = sync_plugins(tool)
        if plugins:
            self.plugin_combo["values"] = plugins
            self.sync_status.config(text="Synced!", foreground="green")
        else:
            self.sync_status.config(
                text=f"Install {tool} to sync full list", foreground="red"
            )

What does this method do?

When the user clicks Sync Plugins, this method reads the current artifact type (snapcraft or rockcraft), calls sync_plugins(), and either refreshes the dropdown values or shows a red message telling the user to install the missing tool.

  • Save app.py.
  • Test the app by running this command from your craft-yaml-gen folder:
cd src && python -m craft_yaml_gen.app && cd ..

Click the Parts tab. You should see the Part Name field, a Plugin dropdown with the plugin list, a Sync Plugins button, and the Source, Build Packages, and Stage Packages fields.

  • Click the Sync Plugins button.
  • If Snapcraft is installed, you should see "Synced!" in green and the dropdown updates with the full plugin list.
  • If the tool is not found, you should see "Install snapcraft to sync full list" in red.

Seeing an import error about generator?

The top of app.py imports from craft_yaml_gen.generator, which you have not built yet. To test the Parts tab now, temporarily comment out the line from craft_yaml_gen.generator import generate_yaml by adding a # at the start. You will uncomment it in the next step.

Help me debug my app.py import error

Window opens but Parts tab is empty?

Make sure you added self.setup_parts_tab() inside the __init__ method, on its own line after self.setup_apps_tab(). Without that call, the method exists but never runs.

Help me fix my empty Parts tab

✔️ Awesome, I've got everything!

Great. Make sure both plugins.py and app.py are saved before continuing.

ⓧ I'd like to double check the full code

Here is the complete app.py at this point:

import tkinter as tk
from tkinter import ttk, filedialog, messagebox

from craft_yaml_gen.generator import generate_yaml
from craft_yaml_gen.plugins import get_plugins, sync_plugins


class CraftYAMLGenerator(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Craft YAML Generator")
        self.geometry("800x600")

        self.artifact_type = tk.StringVar(value="snapcraft")

        self.notebook = ttk.Notebook(self)
        self.notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)

        self.metadata_tab = ttk.Frame(self.notebook)
        self.apps_tab = ttk.Frame(self.notebook)
        self.parts_tab = ttk.Frame(self.notebook)
        self.output_tab = ttk.Frame(self.notebook)

        self.notebook.add(self.metadata_tab, text="Metadata")
        self.notebook.add(self.apps_tab, text="Apps")
        self.notebook.add(self.parts_tab, text="Parts")
        self.notebook.add(self.output_tab, text="Output")

        self.setup_metadata_tab()
        self.setup_apps_tab()
        self.setup_parts_tab()

    def setup_metadata_tab(self):
        type_frame = ttk.LabelFrame(self.metadata_tab, text="Artifact Type")
        type_frame.pack(fill=tk.X, padx=10, pady=5)
        ttk.Radiobutton(type_frame, text="snapcraft.yaml", variable=self.artifact_type, value="snapcraft").pack(side=tk.LEFT, padx=10, pady=5)
        ttk.Radiobutton(type_frame, text="rockcraft.yaml", variable=self.artifact_type, value="rockcraft").pack(side=tk.LEFT, padx=10, pady=5)

        fields_frame = ttk.LabelFrame(self.metadata_tab, text="Project Metadata")
        fields_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        ttk.Label(fields_frame, text="Name:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3)
        self.name_entry = ttk.Entry(fields_frame, width=40)
        self.name_entry.grid(row=0, column=1, padx=5, pady=3)

        ttk.Label(fields_frame, text="Version:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3)
        self.version_entry = ttk.Entry(fields_frame, width=40)
        self.version_entry.grid(row=1, column=1, padx=5, pady=3)

        ttk.Label(fields_frame, text="Summary:").grid(row=2, column=0, sticky=tk.W, padx=5, pady=3)
        self.summary_entry = ttk.Entry(fields_frame, width=40)
        self.summary_entry.grid(row=2, column=1, padx=5, pady=3)

        ttk.Label(fields_frame, text="Description:").grid(row=3, column=0, sticky=tk.W, padx=5, pady=3)
        self.description_text = tk.Text(fields_frame, width=40, height=3)
        self.description_text.grid(row=3, column=1, padx=5, pady=3)

        ttk.Label(fields_frame, text="Base:").grid(row=4, column=0, sticky=tk.W, padx=5, pady=3)
        self.base_combo = ttk.Combobox(fields_frame, values=["core24", "core22"], state="readonly", width=37)
        self.base_combo.grid(row=4, column=1, padx=5, pady=3)
        self.base_combo.set("core24")

        ttk.Label(fields_frame, text="Confinement:").grid(row=5, column=0, sticky=tk.W, padx=5, pady=3)
        self.confinement_combo = ttk.Combobox(fields_frame, values=["strict", "classic", "devmode"], state="readonly", width=37)
        self.confinement_combo.grid(row=5, column=1, padx=5, pady=3)
        self.confinement_combo.set("strict")

        ttk.Label(fields_frame, text="Grade:").grid(row=6, column=0, sticky=tk.W, padx=5, pady=3)
        self.grade_combo = ttk.Combobox(fields_frame, values=["stable", "devel"], state="readonly", width=37)
        self.grade_combo.grid(row=6, column=1, padx=5, pady=3)
        self.grade_combo.set("stable")

    def setup_apps_tab(self):
        apps_frame = ttk.LabelFrame(self.apps_tab, text="App Configuration")
        apps_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        ttk.Label(apps_frame, text="App Name:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3)
        self.app_name_entry = ttk.Entry(apps_frame, width=40)
        self.app_name_entry.grid(row=0, column=1, padx=5, pady=3)

        ttk.Label(apps_frame, text="Command:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3)
        self.command_entry = ttk.Entry(apps_frame, width=40)
        self.command_entry.grid(row=1, column=1, padx=5, pady=3)

        plugs_frame = ttk.LabelFrame(apps_frame, text="Plugs")
        plugs_frame.grid(row=2, column=0, columnspan=2, sticky=tk.W + tk.E, padx=5, pady=5)

        self.plug_vars = {}
        available_plugs = ["home", "network", "network-bind", "desktop", "desktop-legacy", "x11", "wayland", "audio-playback", "removable-media", "browser-support", "display-control"]
        for i, plug in enumerate(available_plugs):
            var = tk.BooleanVar()
            self.plug_vars[plug] = var
            ttk.Checkbutton(plugs_frame, text=plug, variable=var).grid(row=i // 3, column=i % 3, sticky=tk.W, padx=5, pady=2)

    def setup_parts_tab(self):
        parts_frame = ttk.LabelFrame(self.parts_tab, text="Parts Configuration")
        parts_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        ttk.Label(parts_frame, text="Part Name:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3)
        self.part_name_entry = ttk.Entry(parts_frame, width=40)
        self.part_name_entry.grid(row=0, column=1, padx=5, pady=3)

        plugin_frame = ttk.Frame(parts_frame)
        plugin_frame.grid(row=1, column=0, columnspan=2, sticky=tk.W + tk.E, padx=5, pady=3)
        ttk.Label(plugin_frame, text="Plugin:").pack(side=tk.LEFT, padx=5)
        self.plugin_combo = ttk.Combobox(plugin_frame, values=get_plugins(self.artifact_type.get()), state="readonly", width=30)
        self.plugin_combo.pack(side=tk.LEFT, padx=5)
        self.sync_btn = ttk.Button(plugin_frame, text="Sync Plugins", command=self.do_sync_plugins)
        self.sync_btn.pack(side=tk.LEFT, padx=5)
        self.sync_status = ttk.Label(plugin_frame, text="")
        self.sync_status.pack(side=tk.LEFT, padx=5)

        ttk.Label(parts_frame, text="Source:").grid(row=2, column=0, sticky=tk.W, padx=5, pady=3)
        self.source_entry = ttk.Entry(parts_frame, width=40)
        self.source_entry.grid(row=2, column=1, padx=5, pady=3)
        self.source_entry.insert(0, ".")

        ttk.Label(parts_frame, text="Build Packages\n(comma-separated):").grid(row=3, column=0, sticky=tk.W, padx=5, pady=3)
        self.build_packages_entry = ttk.Entry(parts_frame, width=40)
        self.build_packages_entry.grid(row=3, column=1, padx=5, pady=3)

        ttk.Label(parts_frame, text="Stage Packages\n(comma-separated):").grid(row=4, column=0, sticky=tk.W, padx=5, pady=3)
        self.stage_packages_entry = ttk.Entry(parts_frame, width=40)
        self.stage_packages_entry.grid(row=4, column=1, padx=5, pady=3)

    def do_sync_plugins(self):
        tool = self.artifact_type.get()
        plugins = sync_plugins(tool)
        if plugins:
            self.plugin_combo["values"] = plugins
            self.sync_status.config(text="Synced!", foreground="green")
        else:
            self.sync_status.config(text=f"Install {tool} to sync full list", foreground="red")


def main():
    app = CraftYAMLGenerator()
    app.mainloop()


if __name__ == "__main__":
    main()

Your plugin system is wired up and the Parts tab is ready to collect build configuration. Next up, you'll connect everything together with the YAML generator that turns all these form values into valid packaging files.

Add YAML Generation and File Save

Your form collects all the data needed for a packaging configuration. The missing piece is the logic that transforms those form values into valid YAML output.

In this step, you will build the generator module that produces correctly structured snapcraft.yaml and rockcraft.yaml content from form data. Then you will connect it to the Output tab with buttons for generating and saving files.

In this step, get ready to:
  • Build a YAML generator module that converts form data into valid snapcraft.yaml and rockcraft.yaml.
  • Wire up the Output tab with Generate and Save buttons.
Create the YAML generator module

The generator module needs to handle two distinct YAML schemas. Snapcraft YAML includes apps and confinement settings, while rockcraft YAML uses a different base format and focuses on container platforms.

  • Open src/craft_yaml_gen/generator.py (currently empty) and add the import, dispatcher function, and snapcraft generator by pasting this code:
import yaml


def generate_yaml(data):
    """Route to the correct generator based on artifact type."""
    artifact_type = data["artifact_type"]
    if artifact_type == "snapcraft":
        return generate_snapcraft_yaml(data)
    elif artifact_type == "rockcraft":
        return generate_rockcraft_yaml(data)
    return ""


def generate_snapcraft_yaml(data):
    """Generate a valid snapcraft.yaml from form data."""
    # Build the top-level metadata dictionary
    snap_dict = {
        "name": data["name"],
        "version": data["version"] or "1.0",
        "summary": data["summary"] or "A snap application",
        "description": data["description"] or "A snap application",
        "base": data["base"],
        "confinement": data["confinement"],
        "grade": data["grade"],
    }

    # Add the apps section if app name and command are provided
    if data["app_name"] and data["command"]:
        app_config = {"command": data["command"]}
        if data["plugs"]:
            app_config["plugs"] = data["plugs"]
        snap_dict["apps"] = {data["app_name"]: app_config}

    # Add the parts section if part name and plugin are provided
    if data["part_name"] and data["plugin"]:
        part_config = {
            "plugin": data["plugin"],
            "source": data["source"] or ".",
        }
        if data["build_packages"]:
            part_config["build-packages"] = data["build_packages"]
        if data["stage_packages"]:
            part_config["stage-packages"] = data["stage_packages"]
        snap_dict["parts"] = {data["part_name"]: part_config}

    # Produce clean YAML with preserved key order
    return yaml.dump(snap_dict, default_flow_style=False, sort_keys=False)

What does this code do?

The generate_yaml() dispatcher checks which artifact type the user selected and routes to the correct generator function.

The generate_snapcraft_yaml() function builds a Python dictionary matching the snapcraft.yaml schema. It conditionally adds apps and parts sections only when the user has filled in those fields. The or fallbacks provide sensible defaults for optional fields.

The sort_keys=False argument to yaml.dump() preserves the dictionary insertion order, producing YAML that matches the conventional field ordering developers expect.

  • Now add the rockcraft generator function below the snapcraft function in the same file:
def generate_rockcraft_yaml(data):
    """Generate a valid rockcraft.yaml from form data."""
    # Rockcraft uses ubuntu@24.04 format instead of core24
    rock_dict = {
        "name": data["name"],
        "version": data["version"] or "1.0",
        "summary": data["summary"] or "A rock container",
        "description": data["description"] or "A rock container",
        "base": "ubuntu@24.04",
        "platforms": {"amd64": None},
    }

    # Rocks only have parts, no apps section
    if data["part_name"] and data["plugin"]:
        part_config = {
            "plugin": data["plugin"],
            "source": data["source"] or ".",
        }
        if data["build_packages"]:
            part_config["build-packages"] = data["build_packages"]
        if data["stage_packages"]:
            part_config["stage-packages"] = data["stage_packages"]
        rock_dict["parts"] = {data["part_name"]: part_config}

    return yaml.dump(rock_dict, default_flow_style=False, sort_keys=False)

How does rockcraft differ from snapcraft?

Rockcraft uses ubuntu@24.04 as its base format instead of core24. This reflects that rocks are OCI containers built on Ubuntu base images rather than snap runtime bases.

Rocks also require a platforms field (defaulting to amd64) and have no apps section since containers expose services differently than desktop snaps.

  • Save generator.py.

Seeing a syntax error?

Make sure the generate_rockcraft_yaml function is at the module level (same indentation as generate_snapcraft_yaml), not nested inside another function.

Check that you have import yaml at the very top of the file.

Help me debug a syntax error in my generator.py file.

✔️ Awesome, I've got everything!

Great. Make sure you have saved generator.py before moving on.

ⓧ I'd like to double check the full code

import yaml


def generate_yaml(data):
    """Route to the correct generator based on artifact type."""
    artifact_type = data["artifact_type"]
    if artifact_type == "snapcraft":
        return generate_snapcraft_yaml(data)
    elif artifact_type == "rockcraft":
        return generate_rockcraft_yaml(data)
    return ""


def generate_snapcraft_yaml(data):
    """Generate a valid snapcraft.yaml from form data."""
    snap_dict = {
        "name": data["name"],
        "version": data["version"] or "1.0",
        "summary": data["summary"] or "A snap application",
        "description": data["description"] or "A snap application",
        "base": data["base"],
        "confinement": data["confinement"],
        "grade": data["grade"],
    }

    if data["app_name"] and data["command"]:
        app_config = {"command": data["command"]}
        if data["plugs"]:
            app_config["plugs"] = data["plugs"]
        snap_dict["apps"] = {data["app_name"]: app_config}

    if data["part_name"] and data["plugin"]:
        part_config = {
            "plugin": data["plugin"],
            "source": data["source"] or ".",
        }
        if data["build_packages"]:
            part_config["build-packages"] = data["build_packages"]
        if data["stage_packages"]:
            part_config["stage-packages"] = data["stage_packages"]
        snap_dict["parts"] = {data["part_name"]: part_config}

    return yaml.dump(snap_dict, default_flow_style=False, sort_keys=False)


def generate_rockcraft_yaml(data):
    """Generate a valid rockcraft.yaml from form data."""
    rock_dict = {
        "name": data["name"],
        "version": data["version"] or "1.0",
        "summary": data["summary"] or "A rock container",
        "description": data["description"] or "A rock container",
        "base": "ubuntu@24.04",
        "platforms": {"amd64": None},
    }

    if data["part_name"] and data["plugin"]:
        part_config = {
            "plugin": data["plugin"],
            "source": data["source"] or ".",
        }
        if data["build_packages"]:
            part_config["build-packages"] = data["build_packages"]
        if data["stage_packages"]:
            part_config["stage-packages"] = data["stage_packages"]
        rock_dict["parts"] = {data["part_name"]: part_config}

    return yaml.dump(rock_dict, default_flow_style=False, sort_keys=False)
Wire up the Output tab with generate and save

The generator module is ready. Now you need to connect it to the GUI so the user can trigger generation and save the result to a file.

  • In src/craft_yaml_gen/app.py, find the __init__ method. Locate this line:
        self.setup_parts_tab()
  • Add this line directly below it:
        self.setup_output_tab()
  • Now scroll down to after the do_sync_plugins method and add the setup_output_tab() method by pasting this code:
    def setup_output_tab(self):
        output_frame = ttk.Frame(self.output_tab)
        output_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        # Button toolbar for generate and save actions
        btn_frame = ttk.Frame(output_frame)
        btn_frame.pack(fill=tk.X, pady=5)
        ttk.Button(btn_frame, text="Generate YAML", command=self.generate).pack(
            side=tk.LEFT, padx=5
        )
        ttk.Button(btn_frame, text="Save to File", command=self.save_file).pack(
            side=tk.LEFT, padx=5
        )

        # Text widget to display the generated YAML output
        self.output_text = tk.Text(output_frame, wrap=tk.WORD, font=("Courier", 10))
        self.output_text.pack(fill=tk.BOTH, expand=True)

What does this code do?

The Output tab has two buttons. Generate YAML triggers the generation logic, and Save to File opens a file dialog to write the output to disk.

The output_text widget uses a monospace Courier font so the YAML renders with consistent indentation, making it easy to read.

  • Below setup_output_tab(), add the collect_data() method that gathers all form values into a single dictionary:
    def collect_data(self):
        # Gather every form field into one dictionary for the generator
        data = {
            "artifact_type": self.artifact_type.get(),
            "name": self.name_entry.get(),
            "version": self.version_entry.get(),
            "summary": self.summary_entry.get(),
            "description": self.description_text.get("1.0", tk.END).strip(),
            "base": self.base_combo.get(),
            "confinement": self.confinement_combo.get(),
            "grade": self.grade_combo.get(),
            "app_name": self.app_name_entry.get(),
            "command": self.command_entry.get(),
            "plugs": [plug for plug, var in self.plug_vars.items() if var.get()],
            "part_name": self.part_name_entry.get(),
            "plugin": self.plugin_combo.get(),
            "source": self.source_entry.get(),
            "build_packages": [
                p.strip() for p in self.build_packages_entry.get().split(",") if p.strip()
            ],
            "stage_packages": [
                p.strip() for p in self.stage_packages_entry.get().split(",") if p.strip()
            ],
        }
        return data

What does this code do?

The collect_data() method pulls values from every widget across all four tabs into a flat dictionary. This dictionary is exactly what the generator module expects as input.

The plugs field uses a list comprehension to collect only the checked plug names. The build_packages and stage_packages fields split the comma-separated input into clean lists.

  • Finally, add the generate() and save_file() methods below collect_data():
    def generate(self):
        data = self.collect_data()
        # Validate that the name field is filled in
        if not data["name"]:
            messagebox.showwarning("Missing Field", "Please enter a project name.")
            return
        yaml_output = generate_yaml(data)
        self.output_text.delete("1.0", tk.END)
        self.output_text.insert("1.0", yaml_output)

    def save_file(self):
        content = self.output_text.get("1.0", tk.END).strip()
        if not content:
            messagebox.showwarning("No Content", "Generate YAML first.")
            return
        # Default filename matches the selected artifact type
        artifact = self.artifact_type.get()
        filename = f"{artifact}.yaml"
        filepath = filedialog.asksaveasfilename(
            defaultextension=".yaml",
            filetypes=[("YAML files", "*.yaml"), ("All files", "*.*")],
            initialfile=filename,
        )
        if filepath:
            with open(filepath, "w") as f:
                f.write(content)
            messagebox.showinfo("Saved", f"File saved to {filepath}")

What does this code do?

The generate() method validates that the user entered a project name, then passes the collected form data to the generator. The resulting YAML replaces whatever is currently in the output text widget.

The save_file() method opens a native file save dialog defaulting to snapcraft.yaml or rockcraft.yaml depending on the selected artifact type. If the user picks a location, it writes the output content to that file.

  • Save app.py.
  • Test the app by running this command from your craft-yaml-gen project folder:
python3 src/craft_yaml_gen/app.py
  • In the Metadata tab, enter my-test-snap in the Name field.
  • Switch to the Output tab and click Generate YAML.
  • You should see valid YAML appear in the text area with your project name, default version, and the metadata fields.

Seeing a ModuleNotFoundError?

Make sure you are running the command from the craft-yaml-gen root folder (the one containing the src/ directory). Python needs to find the craft_yaml_gen package.

If the error mentions yaml, install PyYAML by running pip3 install PyYAML in your terminal.

Help me fix a ModuleNotFoundError when running my Craft YAML Generator app.

✔️ Awesome, I've got everything!

Great. Double check you have saved both generator.py and app.py.

ⓧ I'd like to double check the full code

Here is the full app.py at this point:

import tkinter as tk
from tkinter import ttk, filedialog, messagebox

from craft_yaml_gen.generator import generate_yaml
from craft_yaml_gen.plugins import get_plugins, sync_plugins


class CraftYAMLGenerator(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Craft YAML Generator")
        self.geometry("800x600")

        self.artifact_type = tk.StringVar(value="snapcraft")

        self.notebook = ttk.Notebook(self)
        self.notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)

        self.metadata_tab = ttk.Frame(self.notebook)
        self.apps_tab = ttk.Frame(self.notebook)
        self.parts_tab = ttk.Frame(self.notebook)
        self.output_tab = ttk.Frame(self.notebook)

        self.notebook.add(self.metadata_tab, text="Metadata")
        self.notebook.add(self.apps_tab, text="Apps")
        self.notebook.add(self.parts_tab, text="Parts")
        self.notebook.add(self.output_tab, text="Output")

        self.setup_metadata_tab()
        self.setup_apps_tab()
        self.setup_parts_tab()
        self.setup_output_tab()

    def setup_metadata_tab(self):
        type_frame = ttk.LabelFrame(self.metadata_tab, text="Artifact Type")
        type_frame.pack(fill=tk.X, padx=10, pady=5)
        ttk.Radiobutton(
            type_frame, text="snapcraft.yaml",
            variable=self.artifact_type, value="snapcraft"
        ).pack(side=tk.LEFT, padx=10, pady=5)
        ttk.Radiobutton(
            type_frame, text="rockcraft.yaml",
            variable=self.artifact_type, value="rockcraft"
        ).pack(side=tk.LEFT, padx=10, pady=5)

        fields_frame = ttk.LabelFrame(self.metadata_tab, text="Project Metadata")
        fields_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        ttk.Label(fields_frame, text="Name:").grid(
            row=0, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.name_entry = ttk.Entry(fields_frame, width=40)
        self.name_entry.grid(row=0, column=1, padx=5, pady=3)

        ttk.Label(fields_frame, text="Version:").grid(
            row=1, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.version_entry = ttk.Entry(fields_frame, width=40)
        self.version_entry.grid(row=1, column=1, padx=5, pady=3)

        ttk.Label(fields_frame, text="Summary:").grid(
            row=2, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.summary_entry = ttk.Entry(fields_frame, width=40)
        self.summary_entry.grid(row=2, column=1, padx=5, pady=3)

        ttk.Label(fields_frame, text="Description:").grid(
            row=3, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.description_text = tk.Text(fields_frame, width=40, height=3)
        self.description_text.grid(row=3, column=1, padx=5, pady=3)

        ttk.Label(fields_frame, text="Base:").grid(
            row=4, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.base_combo = ttk.Combobox(
            fields_frame, values=["core24", "core22"], state="readonly", width=37
        )
        self.base_combo.grid(row=4, column=1, padx=5, pady=3)
        self.base_combo.set("core24")

        ttk.Label(fields_frame, text="Confinement:").grid(
            row=5, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.confinement_combo = ttk.Combobox(
            fields_frame, values=["strict", "classic", "devmode"],
            state="readonly", width=37
        )
        self.confinement_combo.grid(row=5, column=1, padx=5, pady=3)
        self.confinement_combo.set("strict")

        ttk.Label(fields_frame, text="Grade:").grid(
            row=6, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.grade_combo = ttk.Combobox(
            fields_frame, values=["stable", "devel"], state="readonly", width=37
        )
        self.grade_combo.grid(row=6, column=1, padx=5, pady=3)
        self.grade_combo.set("stable")

    def setup_apps_tab(self):
        apps_frame = ttk.LabelFrame(self.apps_tab, text="App Configuration")
        apps_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        ttk.Label(apps_frame, text="App Name:").grid(
            row=0, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.app_name_entry = ttk.Entry(apps_frame, width=40)
        self.app_name_entry.grid(row=0, column=1, padx=5, pady=3)

        ttk.Label(apps_frame, text="Command:").grid(
            row=1, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.command_entry = ttk.Entry(apps_frame, width=40)
        self.command_entry.grid(row=1, column=1, padx=5, pady=3)

        plugs_frame = ttk.LabelFrame(apps_frame, text="Plugs")
        plugs_frame.grid(row=2, column=0, columnspan=2, sticky=tk.W + tk.E, padx=5, pady=5)

        self.plug_vars = {}
        available_plugs = [
            "home", "network", "network-bind",
            "desktop", "desktop-legacy", "x11",
            "wayland", "audio-playback", "removable-media",
            "browser-support", "display-control",
        ]
        for i, plug in enumerate(available_plugs):
            var = tk.BooleanVar()
            self.plug_vars[plug] = var
            ttk.Checkbutton(plugs_frame, text=plug, variable=var).grid(
                row=i // 3, column=i % 3, sticky=tk.W, padx=5, pady=2
            )

    def setup_parts_tab(self):
        parts_frame = ttk.LabelFrame(self.parts_tab, text="Parts Configuration")
        parts_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        ttk.Label(parts_frame, text="Part Name:").grid(
            row=0, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.part_name_entry = ttk.Entry(parts_frame, width=40)
        self.part_name_entry.grid(row=0, column=1, padx=5, pady=3)

        plugin_frame = ttk.Frame(parts_frame)
        plugin_frame.grid(row=1, column=0, columnspan=2, sticky=tk.W + tk.E, padx=5, pady=3)
        ttk.Label(plugin_frame, text="Plugin:").pack(side=tk.LEFT, padx=5)
        self.plugin_combo = ttk.Combobox(
            plugin_frame, values=get_plugins(self.artifact_type.get()),
            state="readonly", width=30
        )
        self.plugin_combo.pack(side=tk.LEFT, padx=5)
        self.sync_btn = ttk.Button(
            plugin_frame, text="Sync Plugins", command=self.do_sync_plugins
        )
        self.sync_btn.pack(side=tk.LEFT, padx=5)
        self.sync_status = ttk.Label(plugin_frame, text="")
        self.sync_status.pack(side=tk.LEFT, padx=5)

        ttk.Label(parts_frame, text="Source:").grid(
            row=2, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.source_entry = ttk.Entry(parts_frame, width=40)
        self.source_entry.grid(row=2, column=1, padx=5, pady=3)
        self.source_entry.insert(0, ".")

        ttk.Label(parts_frame, text="Build Packages\n(comma-separated):").grid(
            row=3, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.build_packages_entry = ttk.Entry(parts_frame, width=40)
        self.build_packages_entry.grid(row=3, column=1, padx=5, pady=3)

        ttk.Label(parts_frame, text="Stage Packages\n(comma-separated):").grid(
            row=4, column=0, sticky=tk.W, padx=5, pady=3
        )
        self.stage_packages_entry = ttk.Entry(parts_frame, width=40)
        self.stage_packages_entry.grid(row=4, column=1, padx=5, pady=3)

    def do_sync_plugins(self):
        tool = self.artifact_type.get()
        plugins = sync_plugins(tool)
        if plugins:
            self.plugin_combo["values"] = plugins
            self.sync_status.config(text="Synced!", foreground="green")
        else:
            self.sync_status.config(
                text=f"Install {tool} to sync full list", foreground="red"
            )

    def setup_output_tab(self):
        output_frame = ttk.Frame(self.output_tab)
        output_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        btn_frame = ttk.Frame(output_frame)
        btn_frame.pack(fill=tk.X, pady=5)
        ttk.Button(btn_frame, text="Generate YAML", command=self.generate).pack(
            side=tk.LEFT, padx=5
        )
        ttk.Button(btn_frame, text="Save to File", command=self.save_file).pack(
            side=tk.LEFT, padx=5
        )

        self.output_text = tk.Text(output_frame, wrap=tk.WORD, font=("Courier", 10))
        self.output_text.pack(fill=tk.BOTH, expand=True)

    def collect_data(self):
        data = {
            "artifact_type": self.artifact_type.get(),
            "name": self.name_entry.get(),
            "version": self.version_entry.get(),
            "summary": self.summary_entry.get(),
            "description": self.description_text.get("1.0", tk.END).strip(),
            "base": self.base_combo.get(),
            "confinement": self.confinement_combo.get(),
            "grade": self.grade_combo.get(),
            "app_name": self.app_name_entry.get(),
            "command": self.command_entry.get(),
            "plugs": [plug for plug, var in self.plug_vars.items() if var.get()],
            "part_name": self.part_name_entry.get(),
            "plugin": self.plugin_combo.get(),
            "source": self.source_entry.get(),
            "build_packages": [
                p.strip() for p in self.build_packages_entry.get().split(",") if p.strip()
            ],
            "stage_packages": [
                p.strip() for p in self.stage_packages_entry.get().split(",") if p.strip()
            ],
        }
        return data

    def generate(self):
        data = self.collect_data()
        if not data["name"]:
            messagebox.showwarning("Missing Field", "Please enter a project name.")
            return
        yaml_output = generate_yaml(data)
        self.output_text.delete("1.0", tk.END)
        self.output_text.insert("1.0", yaml_output)

    def save_file(self):
        content = self.output_text.get("1.0", tk.END).strip()
        if not content:
            messagebox.showwarning("No Content", "Generate YAML first.")
            return
        artifact = self.artifact_type.get()
        filename = f"{artifact}.yaml"
        filepath = filedialog.asksaveasfilename(
            defaultextension=".yaml",
            filetypes=[("YAML files", "*.yaml"), ("All files", "*.*")],
            initialfile=filename,
        )
        if filepath:
            with open(filepath, "w") as f:
                f.write(content)
            messagebox.showinfo("Saved", f"File saved to {filepath}")


def main():
    app = CraftYAMLGenerator()
    app.mainloop()


if __name__ == "__main__":
    main()

Your generator is producing valid YAML from form inputs. Next up, you will package this entire application as a snap and publish it to the Snap Store for anyone to install.

Package as a Snap and Publish

Your GUI application is fully functional. Now it is time to package it as a snap so that any Linux user can install it with a single command.

In this step, you will write the packaging configuration in snap/snapcraft.yaml, build the snap locally, test it on your machine, and then publish it to the Snap Store for the world to download.

In this step, get ready to:
  • Write the snap packaging configuration for your app.
  • Build and test the snap locally.
  • Publish your snap to the Snap Store on the edge channel.
Write the snap/snapcraft.yaml for your app

The snap/snapcraft.yaml file tells Snapcraft how to build, package, and expose your application. It defines metadata, the entry point command, required system permissions (plugs), and build instructions (parts).

  • Open snap/snapcraft.yaml in your editor and paste the following configuration:
name: craft-yaml-gen
version: '1.0.0'
summary: GUI tool for generating snapcraft.yaml and rockcraft.yaml files
description: |
  Craft YAML Generator helps developers create valid snapcraft.yaml
  and rockcraft.yaml files through an intuitive form-based interface.
  Select your artifact type, configure metadata, apps, and parts,
  then generate and save your YAML file.
base: core24
confinement: strict
grade: stable

# Define the executable command and its permissions
apps:
  craft-yaml-gen:
    command: bin/craft-yaml-gen
    plugs:
      - home
      - desktop
      - desktop-legacy
      - x11
      - wayland

# Define how to build the application
parts:
  craft-yaml-gen:
    plugin: python
    source: .
    build-packages:
      - python3-dev
      - python3-tk
    stage-packages:
      - python3-tk

What does this configuration do?

The top section sets project metadata: name, version, summary, and description. These appear in the Snap Store listing.

The apps section maps the command bin/craft-yaml-gen (generated from your setup.py entry point) to the snap name. The plugs grant your app permission to access the home directory and display a GUI window.

The parts section uses the python plugin to install your package. build-packages are available only during the build step, while stage-packages are bundled into the final snap for runtime. Tkinter needs python3-tk staged so the GUI renders when users run your snap.

  • Save snap/snapcraft.yaml.

Why strict confinement?

Your app only needs filesystem access (to save YAML files) and display access (for the GUI window). Strict confinement locks down everything else, which the Snap Store requires for stable-channel apps. The plugs you listed grant exactly the permissions your app needs.

Build and test the snap locally

With the packaging configuration in place, Snapcraft can compile your Python app into a self-contained .snap file. This file bundles your code, dependencies, and the Python runtime together.

  • Make sure your terminal is in the craft-yaml-gen project root (where setup.py lives).
  • Build the snap by running:
snapcraft

What does this command do?

Snapcraft reads your snap/snapcraft.yaml, pulls down the core24 base, installs your build and stage packages, runs pip install via the python plugin, and packs everything into a single .snap file. This can take several minutes on the first run.

  • Confirm the build succeeded by checking for the output file:
ls *.snap
  • You should see craft-yaml-gen_1.0.0_amd64.snap in the output.

Build failed?

Make sure your setup.py and requirements.txt are in the project root alongside the snap/ folder. The python plugin expects to find them at the path specified in source: ..

Check that your YAML indentation is correct. YAML is whitespace-sensitive, so misaligned keys will cause parse errors.

Help me debug my Snapcraft build failure.

  • Install the snap locally for testing by running:
sudo snap install craft-yaml-gen_1.0.0_amd64.snap --dangerous --devmode

What do these flags mean?

The --dangerous flag allows installing a snap that has not been verified by the Snap Store. The --devmode flag relaxes confinement for local testing. Both are necessary when testing a snap you built yourself before publishing.

  • Launch your installed snap by running:
craft-yaml-gen
  • The Craft YAML Generator window should open. Fill in some sample values across the tabs and click Generate YAML to confirm everything works.
  • Close the app window when you are satisfied.

App window does not appear?

If you are running inside WSL, make sure you have an X server (like VcXsrv or WSLg) configured. Tkinter requires a display server to render the GUI.

If you see an error about python3-tk not being found, verify that stage-packages in your snap/snapcraft.yaml includes python3-tk. Rebuild the snap after fixing.

Help me get my snap GUI working.

Register the name and publish to the Snap Store

Publishing to the Snap Store is a three-step process: register the snap name, upload the built file, then release it to a channel. You will publish to the edge channel, which is intended for testing builds.

  • Register your snap name by running:
snapcraft register craft-yaml-gen

What if the name is taken?

Snap names are globally unique. If craft-yaml-gen is already claimed, pick a unique variation (like appending your initials). You will need to update the name field in snap/snapcraft.yaml to match, then rebuild with snapcraft before uploading.

  • Upload your snap file by running:
snapcraft upload craft-yaml-gen_1.0.0_amd64.snap
  • Note the revision number in the output. You will need it for the next command.
  • Release your snap to the edge channel by running:
snapcraft release craft-yaml-gen [[REVISION="the revision number from the upload output"]] edge
  • Verify your snap is live by running:
snap info craft-yaml-gen
  • You should see your snap listing with the version, summary, and the edge channel showing your release.

Upload or release failed?

Make sure you are logged in. Run snapcraft whoami to check your authentication status. If it shows no account, run snapcraft login again.

If the release command fails, double-check that you are using the correct revision number from the upload output.

Help me publish my snap to the Snap Store.

✔️ Awesome, I've got everything!

Great. Double check you have saved snap/snapcraft.yaml and confirmed your snap appears in the Snap Store.

ⓧ I'd like to double check the full code

Here is the complete snap/snapcraft.yaml file:

name: craft-yaml-gen
version: '1.0.0'
summary: GUI tool for generating snapcraft.yaml and rockcraft.yaml files
description: |
  Craft YAML Generator helps developers create valid snapcraft.yaml
  and rockcraft.yaml files through an intuitive form-based interface.
  Select your artifact type, configure metadata, apps, and parts,
  then generate and save your YAML file.
base: core24
confinement: strict
grade: stable

apps:
  craft-yaml-gen:
    command: bin/craft-yaml-gen
    plugs:
      - home
      - desktop
      - desktop-legacy
      - x11
      - wayland

parts:
  craft-yaml-gen:
    plugin: python
    source: .
    build-packages:
      - python3-dev
      - python3-tk
    stage-packages:
      - python3-tk

Secret mission

Add a Live YAML Preview Pane

Your app currently requires the user to click "Generate YAML" every time they want to see the output. What if the YAML updated automatically as you type? In this secret mission, you will add a live preview pane that renders YAML in real-time using debounced event bindings.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely for free. The Snap Store does not charge for hosting your snap, and there are no ongoing costs for keeping your listing on the edge channel.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Snap Store listing for craft-yaml-gen on the edge channel (free, no ongoing cost).
  • Local .snap file on your machine.
  • Local project folder craft-yaml-gen/ on your Desktop.

Keep everything running

No action needed. Choose this if you plan to continue developing your snap or want it available for others to install.

  • Your snap remains in the Snap Store indefinitely at no cost.
  • You can push updates anytime by building a new revision and releasing it.
  • Snapcraft remains installed on your system for future snap development.

Pause - I'll come back to this later

No action needed. Edge channel snaps are not prominently visible to users browsing the Snap Store.

  • Your listing stays on the edge channel but will not appear in default search results for most users.
  • Your local project files remain on disk for when you return.
  • There is nothing to shut down or stop. No processes are running.

Delete - I don't want to use this again

Remove your snap from the edge channel and delete the local project files.

  • Close the edge channel by running the following command:
snapcraft close craft-yaml-gen edge

This removes the snap from the edge channel so no one can install it. To fully unregister the name, contact Snap Store support.

  • Remove the locally installed snap by running:
sudo snap remove craft-yaml-gen
  • Delete the local project folder by running:
cd ~/Desktop
rm -rf craft-yaml-gen/
  • Confirm the folder is gone by running:
ls ~/Desktop | grep craft

You should see no output, confirming the folder has been removed.

Nice Work!

Nice Work!

Nice work! You just built a desktop GUI application from scratch, packaged it as a snap, and published it to the Snap Store for anyone to install.

You've learned how to:

  • Build a Python + Tkinter GUI application with a tabbed form interface that generates valid snapcraft.yaml and rockcraft.yaml files from user inputs.
  • Implement dynamic plugin syncing that pulls live data from installed craft tools with a minimal fallback list for offline use.
  • Package an application as a snap with strict confinement and desktop integration, then publish it to the Snap Store so any Linux user can install it.
  • Secret Mission: Added a live preview pane with debounced real-time YAML rendering that updates as you type.

Ready to quiz yourself?