Repository: canonical/charmcraft

Issue:
Charm `dispatch` fails with poetry plugin
### Bug Description

`dispatch` file generated by charmcraft with poetry plugin fails all juju events
```
ln: failed to create symbolic link '/var/lib/juju/agents/unit-mysql-router-k8s-0/charm/venv/bin/python': No such file or directory
./dispatch: 16: exec: /var/lib/juju/agents/unit-mysql-router-k8s-0/charm/venv/bin/python: not found
```

Discovered after working around #1994 by renaming kubernetes_charm.py to charm.py

### To Reproduce

```
git clone https://github.com/canonical/mysql-router-k8s-operator
cd mysql-router-k8s-operator
git checkout a0c3fc45b3f1bd71529747103fc6e37403e658d9
charmcraft pack -v
juju deploy ./mysql-router-k8s_ubuntu-22.04-amd64.charm --resource mysql-router-image=ghcr.io/canonical/charmed-mysql@sha256:89b8305613f6ce94f78a7c9b4baedef78f2816fd6bc74c00f6607bc5e57bd8e6
juju status
```
juju status shows error

```
juju debug-hooks mysql-router-k8s/0
```
in other terminal, run `juju resolve mysql-router-k8s/0`
```
# ./dispatch 
ln: failed to create symbolic link '/var/lib/juju/agents/unit-mysql-router-k8s-0/charm/venv/bin/python': No such file or directory
./dispatch: 16: exec: /var/lib/juju/agents/unit-mysql-router-k8s-0/charm/venv/bin/python: not found
```


### Environment

Ubuntu 22.04

```
$ snap list
Name              Version                 Rev    Tracking            Publisher          Notes
charmcraft        3.2.2.post75+gff00a899  5394   3.x/edge            canonical✓         classic,held
core18            20240920                2846   latest/stable       canonical✓         base
core20            20240911                2434   latest/stable       canonical✓         base
core22            20241001                1663   latest/stable       canonical✓         base
juju              3.5.4                   28520  3/stable            canonical✓         -
lxd               5.0.3-80aeff7           29351  5.0/stable/…        canonical✓         -
microk8s          v1.29.9                 7227   1.29-strict/stable  canonical✓         -
snapd             2.63                    21759  latest/stable       canonical✓         snapd
```

### charmcraft.yaml

```yaml
# Copyright 2022 Canonical Ltd.
# See LICENSE file for licensing details.

type: charm
bases:
  - name: ubuntu
    channel: "22.04"
    architectures: [amd64]
  - name: ubuntu
    channel: "22.04"
    architectures: [arm64]
parts:
  poetry-deps:
    plugin: nil
    build-packages:
      - curl
    override-build: |
      python3 -m pip install --upgrade pip==24.3.1
      curl -sSL https://install.python-poetry.org | python3 - --version 1.8.4
      ln -sf $HOME/.local/bin/poetry /usr/local/bin/poetry
  foo:
    plugin: poetry
    source: .
    after:
      - poetry-deps
    poetry-with:
      - charm-libs
    build-snaps:
      - rustup
    build-packages:
      - libffi-dev
      - libssl-dev
      - pkg-config
    override-build: |
      rustup default stable
      craftctl default

#
#  files:
#    plugin: dump
#    source: .
#    build-packages:
#      - git
#    override-build: |
#      # Workaround to add unique identifier (git hash) to charm version while specification
#      # DA053 - Charm versioning
#      # (https://docs.google.com/document/d/1Jv1jhWLl8ejK3iJn7Q3VbCIM9GIhp8926bgXpdtx-Sg/edit?pli=1)
#      # is pending review.
#      python3 -c 'import pathlib; import shutil; import subprocess; git_hash=subprocess.run(["git", "describe", "--always", "--dirty"], capture_output=True, check=True, encoding="utf-8").stdout; file = pathlib.Path("charm_version"); shutil.copy(file, pathlib.Path("charm_version.backup")); version = file.read_text().strip(); file.write_text(f"{version}+{git_hash}")'
#
#      craftctl default
#    stage:
#      # Exclude requirements.txt file during staging
#      # Workaround for https://github.com/canonical/charmcraft/issues/1389 on charmcraft 2
#      - -requirements.txt
#    prime:
#      - charm_version
#      - workload_version
#      - scripts
#  charm:
#    build-snaps:
#      - rustup
#    build-packages:
#      - libffi-dev
#      - libssl-dev
#      - pkg-config
#    override-build: |
#      rustup default stable
#
#      # Convert subset of poetry.lock to requirements.txt
#      curl -sSL https://install.python-poetry.org | python3 -
#      /root/.local/bin/poetry export --only main,charm-libs --output requirements.txt
#
#      craftctl default
#    stage:
#      # Exclude charm_version file during staging
#      - -charm_version
#    charm-strict-dependencies: true
#    charm-requirements: [requirements.txt]
#    charm-entrypoint: src/kubernetes_charm.py
```


### Relevant log output

[charmcraft-20241115-112046.686344.log](https://github.com/user-attachments/files/17774740/charmcraft-20241115-112046.686344.log)



Discussion:

Patch:
diff --git a/charmcraft/dispatch.py b/charmcraft/dispatch.py
index 6222bd0a..cd02457b 100644
--- a/charmcraft/dispatch.py
+++ b/charmcraft/dispatch.py
@@ -24,8 +24,10 @@ from charmcraft import const
 DISPATCH_SCRIPT_TEMPLATE = """\
 #!/bin/sh
 dispatch_path="$(dirname $(realpath $0))"
-python_path="${{dispatch_path}}/venv/bin/python"
+venv_bin_path="${{dispatch_path}}/venv/bin"
+python_path="${{venv_bin_path}}/python"
 if [ ! -e "${{python_path}}" ]; then
+    mkdir -p "{{venv_bin_path}}"
     ln -s $(which python3) "${{python_path}}"
 fi
 
diff --git a/charmcraft/linters.py b/charmcraft/linters.py
index c8d50392..40e639ca 100644
--- a/charmcraft/linters.py
+++ b/charmcraft/linters.py
@@ -1,4 +1,4 @@
-# Copyright 2021-2022 Canonical Ltd.
+# Copyright 2021-2024 Canonical Ltd.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -22,6 +22,8 @@ import os
 import pathlib
 import re
 import shlex
+import subprocess
+import sys
 import typing
 from collections.abc import Generator
 from typing import final
@@ -689,6 +691,63 @@ class AdditionalFiles(Linter):
         return self._check_additional_files(stage_dir, basedir)
 
 
+class PipCheck(Linter):
+    """Check that the pip virtual environment is valid."""
+
+    name = "pip-check"
+    text = "Virtual environment is valid."
+    url = "https://pip.pypa.io/en/stable/cli/pip_check/"
+
+    def run(self, basedir: pathlib.Path) -> str:
+        """Run pip check."""
+        venv_dir = basedir / "venv"
+        if not venv_dir.is_dir():
+            self.text = "Charm does not contain a Python venv."
+            return self.Result.NONAPPLICABLE
+        if not (venv_dir / "lib").is_dir():
+            self.text = "Python venv is not valid."
+            return self.Result.NONAPPLICABLE
+        if sys.platform == "win32":
+            self.text = "Linter does not work on Windows."
+            return self.Result.NONAPPLICABLE
+        python_exe = venv_dir / "bin" / "python"
+        delete_parent = False
+        if not python_exe.parent.exists():
+            delete_parent = True
+            python_exe.parent.mkdir()
+        if not python_exe.exists():
+            delete_python_exe = True
+            python_exe.symlink_to(sys.executable)
+        else:
+            delete_python_exe = False
+
+        pip_cmd = [sys.executable, "-m", "pip", "--python", str(python_exe), "check"]
+        try:
+            check = subprocess.run(
+                pip_cmd,
+                text=True,
+                capture_output=True,
+                check=False,
+            )
+            if check.returncode == os.EX_OK:
+                result = self.Result.OK
+            else:
+                self.text = check.stdout
+                result = self.Result.WARNING
+        except (FileNotFoundError, PermissionError) as e:
+            self.text = (
+                f"{e.strerror}: Could not run Python executable at {sys.executable}."
+            )
+            result = self.Result.NONAPPLICABLE
+        finally:
+            if delete_python_exe:
+                python_exe.unlink()
+            if delete_parent:
+                python_exe.parent.rmdir()
+
+        return result
+
+
 # all checkers to run; the order here is important, as some checkers depend on the
 # results from others
 CHECKERS: list[type[BaseChecker]] = [
@@ -701,4 +760,5 @@ CHECKERS: list[type[BaseChecker]] = [
     Entrypoint,
     OpsMainCall,
     AdditionalFiles,
+    PipCheck,
 ]
diff --git a/charmcraft/parts/plugins/_poetry.py b/charmcraft/parts/plugins/_poetry.py
index 21415dd6..5af1d1f2 100644
--- a/charmcraft/parts/plugins/_poetry.py
+++ b/charmcraft/parts/plugins/_poetry.py
@@ -91,7 +91,9 @@ class PoetryPlugin(poetry_plugin.PoetryPlugin):
     @override
     def get_build_commands(self) -> list[str]:
         """Get the build commands for the Python plugin."""
-        if self._options.poetry_keep_bins:
-            return super().get_build_commands()
-        venv_bin = self._get_venv_directory() / "bin"
-        return [*super().get_build_commands(), f"rm -rf {venv_bin}"]
+        return [
+            *super().get_build_commands(),
+            *utils.get_venv_cleanup_commands(
+                self._get_venv_directory(), keep_bins=self._options.poetry_keep_bins
+            ),
+        ]
diff --git a/charmcraft/parts/plugins/_python.py b/charmcraft/parts/plugins/_python.py
index f4dabd3f..99925008 100644
--- a/charmcraft/parts/plugins/_python.py
+++ b/charmcraft/parts/plugins/_python.py
@@ -96,7 +96,9 @@ class PythonPlugin(python_plugin.PythonPlugin):
     @override
     def get_build_commands(self) -> list[str]:
         """Get the build commands for the Python plugin."""
-        if self._options.python_keep_bins:
-            return super().get_build_commands()
-        venv_bin = self._get_venv_directory() / "bin"
-        return [*super().get_build_commands(), f"rm -rf {venv_bin}"]
+        return [
+            *super().get_build_commands(),
+            *utils.get_venv_cleanup_commands(
+                self._get_venv_directory(), keep_bins=self._options.python_keep_bins
+            ),
+        ]
diff --git a/charmcraft/utils/__init__.py b/charmcraft/utils/__init__.py
index 48269b18..dad8da54 100644
--- a/charmcraft/utils/__init__.py
+++ b/charmcraft/utils/__init__.py
@@ -66,6 +66,7 @@ from charmcraft.utils.package import (
 from charmcraft.utils.parts import (
     extend_python_build_environment,
     get_charm_copy_commands,
+    get_venv_cleanup_commands,
 )
 from charmcraft.utils.project import (
     find_charm_sources,
@@ -116,6 +117,7 @@ __all__ = [
     "humanize_list",
     "extend_python_build_environment",
     "get_charm_copy_commands",
+    "get_venv_cleanup_commands",
     "find_charm_sources",
     "get_charm_name_from_path",
     "get_templates_environment",
diff --git a/charmcraft/utils/parts.py b/charmcraft/utils/parts.py
index 11583aa3..2c994621 100644
--- a/charmcraft/utils/parts.py
+++ b/charmcraft/utils/parts.py
@@ -17,6 +17,7 @@
 
 import pathlib
 import shlex
+import textwrap
 from collections.abc import Collection
 
 
@@ -54,3 +55,22 @@ def get_charm_copy_commands(
         )
 
     return commands
+
+
+def get_venv_cleanup_commands(venv_path: pathlib.Path, *, keep_bins: bool) -> list[str]:
+    """Get a script do Charmcraft-specific venv cleanup.
+
+    :param venv_path: The path to the venv.
+    :param keep_bins: Whether to keep the bin directory of the venv.
+    :returns: A shell script to do this, as a string.
+    """
+    venv_bin = venv_path / "bin"
+    venv_lib64 = venv_path / "lib64"
+    delete_bins = [] if keep_bins else [f"rm -rf {venv_bin}"]
+    delete_lib64 = textwrap.dedent(f"""
+        if [ -L '{venv_lib64}' ]; then
+          rm -f '{venv_lib64}'
+        fi
+    """)
+
+    return [*delete_bins, delete_lib64]
diff --git a/docs/howto/charm-to-poetry.rst b/docs/howto/charm-to-poetry.rst
new file mode 100644
index 00000000..5b2d3655
--- /dev/null
+++ b/docs/howto/charm-to-poetry.rst
@@ -0,0 +1,108 @@
+.. _howto-migrate-to-poetry:
+
+Migrate from the Charm plugin to the Poetry plugin
+==================================================
+
+Many charms use `Poetry`_ to manage their Python projects. For these charms, Charmcraft
+has a :ref:`craft_parts_poetry_plugin`. Migrating from the Charm plugin provides some
+benefits, such as no longer having to maintain a ``requirements.txt`` file. If the
+charm to be migrated does not currently use poetry, refer to the
+`Poetry documentation <https://python-poetry.org/docs/basic-usage/>`_ for instructions
+on how to use poetry for a Python project.
+
+Update ``charmcraft.yaml``
+--------------------------
+
+The first step is to update ``charmcraft.yaml`` to include the correct parts definition.
+Depending on the history of a specific charm, it may not have an explicitly-included
+``parts`` section determining how to build the charm. In this case, a ``parts`` section
+can be created as follows:
+
+.. code-block:: yaml
+
+    parts:
+      my-charm:  # This can be named anything you want
+        plugin: poetry
+        source: .
+
+Select compatible versions of ``pip`` and ``poetry``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The Poetry plugin requires at least `pip 22.3
+<https://pypi.org/project/pip/22.3>`_, released in October 2022. If the
+charm's base uses an older version of pip, a newer version can be installed in the
+build environment using a dependency part. Likewise, a charm may require a newer
+version of Poetry than is available in the distribution's repositories. The following
+``parts`` section can be used in place of the section above to upgrade pip and Poetry
+for charms that build on Ubuntu 22.04 or earlier:
+
+.. code-block:: yaml
+    :emphasize-lines: 2-9,11
+
+    parts:
+      poetry-deps:
+        plugin: nil
+        build-packages:
+          - curl
+        override-build: |
+          /usr/bin/python3 -m pip install pip==24.2
+          curl -sSL https://install.python-poetry.org | python3 -
+          ln -sf $HOME/.local/bin/poetry /usr/local/bin/poetry
+      my-charm:  # This can be named anything you want
+        after: [poetry-deps]
+        plugin: poetry
+        source: .
+
+Add optional dependency groups
+------------------------------
+
+If the charm has optional `dependency groups`_ that should be included when creating
+the virtual environment, the ``poetry-with`` key can be used to include those groups
+when creating the virtual environment.
+
+.. note::
+    This is useful and encouraged, though not mandatory, for keeping track of
+    library dependencies, as covered in the next section. For an example, see
+    `postgresql-operator`_.
+
+Include charm library dependencies
+----------------------------------
+
+Unlike the Charm plugin, the Poetry plugin does not install the dependencies for
+included charmlibs. If any of the charm libraries used have PYDEPS, these will
+need to be added to the charm's dependencies, potentially as their own
+`dependency group <dependency groups_>`_.
+
+To find these dependencies, check each library file for its ``PYDEPS``. A command
+that can find these is::
+
+    find lib -name "*.py" -exec awk '/PYDEPS = \[/,/\]/' {} +
+
+If run from the base directory of a charm, this will show all the PYDEPS declarations
+from all loaded charm libs.
+
+Include extra files
+-------------------
+
+A Poetry plugin only includes the contents of the ``src`` and ``lib`` directories
+as well as the generated virtual environment. If other files were previously included
+from the main directory, they can be included again using the
+:ref:`craft_parts_dump_plugin`:
+
+.. code-block:: yaml
+    :emphasize-lines: 5-9
+
+    parts:
+      my-charm:  # This can be named anything you want
+        plugin: poetry
+        source: .
+      version-file:
+        plugin: dump
+        source: .
+        stage:
+          - charm_version
+
+
+.. _dependency groups: https://python-poetry.org/docs/managing-dependencies/#dependency-groups
+.. _Poetry: https://python-poetry.org
+.. _postgresql-operator: https://github.com/canonical/postgresql-operator/blob/3c7c783d61d4bee4ce64c190a9f7d4a78048e4e7/pyproject.toml#L22-L35
diff --git a/docs/howto/charm-to-python.rst b/docs/howto/charm-to-python.rst
new file mode 100644
index 00000000..cf530aa7
--- /dev/null
+++ b/docs/howto/charm-to-python.rst
@@ -0,0 +1,129 @@
+.. _howto-migrate-to-python:
+
+Migrate from the Charm plugin to the Python plugin
+==================================================
+
+The Python plugin in Charmcraft offers a faster, stricter means of packing an operator
+charm with a virtual environment. This guide shows how to migrate from a charm using
+the default Charm plugin to using the Python plugin.
+
+Update ``charmcraft.yaml``
+--------------------------
+
+The first step is to update ``charmcraft.yaml`` to include the correct parts definition.
+Depending on the history of a specific charm, it may not have an explicitly-included
+``parts`` section determining how to build the charm. In this case, a ``parts`` section
+can be created as follows:
+
+.. code-block:: yaml
+
+    parts:
+      my-charm:  # This can be named anything you want
+        plugin: python
+        source: .
+        python-requirements:
+          - requirements.txt  # Or whatever your requirements file is called.
+
+Select a compatible version of ``pip``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The Python plugin requires at least `pip 22.3`_, released in October 2022. If the
+charm's base uses an older version of pip, a newer version can be installed in the
+build environment using a dependency part. The following ``parts`` section can be
+used in place of the section above to upgrade pip for charms that build on Ubuntu
+22.04 or earlier:
+
+.. code-block:: yaml
+    :emphasize-lines: 2-5,7
+
+    parts:
+      python-deps:
+        plugin: nil
+        override-build: |
+          /usr/bin/python3 -m pip install pip==24.2
+      my-charm:  # This can be named anything you want
+        after: [python-deps]
+        plugin: python
+        source: .
+        python-requirements:
+          - requirements.txt  # Or whatever your requirements file is called.
+
+Flatten ``requirements.txt``
+----------------------------
+
+One difference between the Python plugin and the Charm plugin is that the Python
+plugin does not install dependencies, so the ``requirements.txt`` file must be a
+complete set of packages needed in the charm's virtual environment.
+
+.. note::
+    There are several tools for creating an exhaustive ``requirements.txt`` file.
+    Charmcraft works with any as long as it generates a requirements file that ``pip``
+    understands. Because different versions of packages may have different
+    dependencies, it is recommended that the requirements file be generated using a
+    tool that will lock the dependencies to specific versions.
+    A few examples include:
+
+    - `uv export <https://docs.astral.sh/uv/reference/cli/#uv-export>`_
+    - `pip-compile <https://pip-tools.readthedocs.io/en/stable/cli/pip-compile/>`_
+    - `pip freeze <https://pip.pypa.io/en/stable/cli/pip_freeze/>`_
+
+A basic ``requirements.txt`` file for a charm with no dependencies other than the
+Operator framework may look something like::
+
+    ops==2.17.0
+    pyyaml==6.0.2
+    websocket-client==1.8.0
+
+To check that the virtual environment for the charm would be valid, activate an
+empty virtual environment and then run::
+
+    pip install --no-deps -r requirements.txt
+    pip check
+
+Include charm library dependencies
+----------------------------------
+
+Unlike the Charm plugin, the Python plugin does not install the dependencies
+for included charmlibs. If any of the charm libraries used have PYDEPS, these will
+need to be added to a requirements file as well.
+
+.. note::
+    All requirements files are included in the same ``pip`` command to prevent
+    conflicting requirements from overriding each other. However, this means
+    that a charm will fail to build if it has conflicting requirements. A single
+    ``requirements.txt`` file, while not mandatory, is recommended.
+
+To find these dependencies, check each library file for its ``PYDEPS``. A command
+that can find these is::
+
+    find lib -name "*.py" -exec awk '/PYDEPS = \[/,/\]/' {} +
+
+If run from the base directory of a charm, this will show all the PYDEPS declarations
+from all loaded charm libs, which can be used to help generate the input for a tool
+that generates ``requirements.txt``.
+
+Include extra files
+-------------------
+
+The Python plugin only includes the contents of the ``src`` and ``lib`` directories
+as well as the generated virtual environment. If other files were previously included
+from the main directory, they can be included again using the
+:ref:`craft_parts_dump_plugin`:
+
+.. code-block:: yaml
+    :emphasize-lines: 7-11
+
+    parts:
+      my-charm:  # This can be named anything you want
+        plugin: python
+        source: .
+        python-requirements:
+          - requirements.txt  # Or whatever your requirements file is called.
+      version-file:
+        plugin: dump
+        source: .
+        stage:
+          - charm_version
+
+
+.. _pip 22.3: https://pip.pypa.io/en/stable/news/#v22-3
diff --git a/docs/howto/index.rst b/docs/howto/index.rst
new file mode 100644
index 00000000..9bf00089
--- /dev/null
+++ b/docs/howto/index.rst
@@ -0,0 +1,11 @@
+.. _howto:
+
+How-To
+******
+
+.. toctree::
+   :maxdepth: 2
+
+   charm-to-poetry
+   charm-to-python
+   shared-cache
diff --git a/docs/howto/shared-cache.rst b/docs/howto/shared-cache.rst
new file mode 100644
index 00000000..9fe793c6
--- /dev/null
+++ b/docs/howto/shared-cache.rst
@@ -0,0 +1,78 @@
+.. _howto-shared-cache:
+
+Cache intermediate build artefacts
+==================================
+
+Because Charmcraft builds Python packages from source rather than using pre-built
+wheels, the initial builds of charms can take a while. The intermediate artefacts
+get cached, which significantly speeds up subsequent builds.
+
+When installed  as a snap, Charmcraft automatically caches these wheels in the
+``~/snap/charmcraft/common/cache`` directory. However, in some cases, it may be
+beneficial to change this directory.
+
+This can be especially useful in CI, where you may wish to specify a directory that
+gets cached between CI runs.
+
+Local usage
+-----------
+
+When packing locally, you can change where Charmcraft caches build artefacts by setting
+the ``CRAFT_SHARED_CACHE`` environment variable to the path of an existing directory to
+use instead::
+
+  mkdir -p /tmp/charmcraft
+  CRAFT_SHARED_CACHE=/tmp/charmcraft charmcraft pack
+
+On GitHub
+---------
+
+While it's recommended that you use the ``charmcraft/pack`` action from
+`craft-actions`_ where possible, the following workflow will manually pack a charm,
+caching the intermediate files:
+
+.. code-block:: yaml
+
+    name: Pack charm
+    on:
+      pull_request:
+    jobs:
+      pack:
+        runs-on: ubuntu-latest
+        steps:
+        - uses: actions/checkout@v4
+        - uses: canonical/craft-actions/charmcraft/setup
+        - uses: actions/cache@v4
+            with:
+              path: ${{ runner.temp }}
+              key: charmcraft-cache-${{ hashfiles('requirements.txt') }}
+              restore-keys: |
+                charmcraft-cache-
+        - env:
+            CRAFT_SHARED_CACHE: ${{ runner.temp }
+          run: |
+            charmcraft pack
+
+On GitLab
+---------
+
+The following example ``gitlab-ci.yml`` will install and run Charmcraft to pack your
+charm, caching the intermediate artefacts:
+
+.. code-block:: yaml
+
+    pack-charm:
+      cache:
+        - key:
+            files:
+              - requirements.txt
+          paths:
+            - .charmcraft_cache/
+      variables:
+        CRAFT_SHARED_CACHE: .charmcraft_cache/
+      script:
+        - mkdir -p .charmcraft_cache
+        - snap install charmcraft
+        - charmcraft pack
+
+.. _craft-actions: https://github.com/canonical/craft-actions
diff --git a/docs/index.rst b/docs/index.rst
index b0bd3386..4e9931d9 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -10,9 +10,20 @@ Most of Charmcraft's documentation is available there.
    :maxdepth: 1
    :hidden:
 
+   howto/index
    reference/index
    explanation/index
 
+.. grid:: 1 1 2 2
+
+   .. grid-item-card:: `Tutorial <https://juju.is/docs/sdk/tutorials>`_
+
+      **Get started** with a hands-on introduction to Charmcraft
+
+   .. grid-item-card:: :ref:`How-to guides <howto>`
+
+      **Step-by-step guides** covering key operations and common tasks
+
 .. grid:: 1 1 2 2
    :reverse:
 
diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst
index 20941fe6..12cac5a8 100644
--- a/docs/reference/changelog.rst
+++ b/docs/reference/changelog.rst
@@ -375,3 +375,4 @@ page.
 .. _3.1.2: https://github.com/canonical/charmcraft/releases/tag/3.1.2
 .. _3.2.0: https://github.com/canonical/charmcraft/releases/tag/3.2.0
 .. _3.2.1: https://github.com/canonical/charmcraft/releases/tag/3.2.1
+.. _3.2.2: https://github.com/canonical/charmcraft/releases/tag/3.2.2
diff --git a/docs/reference/plugins/python_plugin.rst b/docs/reference/plugins/python_plugin.rst
index 911c94c2..3b9c8a25 100644
--- a/docs/reference/plugins/python_plugin.rst
+++ b/docs/reference/plugins/python_plugin.rst
@@ -49,7 +49,6 @@ During the build step, the plugin performs the following actions:
 4. It copies any existing ``src`` and ``lib`` directories from your charm project into
    the final charm.
 
-
 Example
 -------
 
diff --git a/pyproject.toml b/pyproject.toml
index 2355e36f..821beff3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,6 +24,7 @@ dependencies = [
     "requests-toolbelt",
     "snap-helpers",
     "tabulate",
+    "pip>=24.2",
 ]
 classifiers = [
     "Development Status :: 5 - Production/Stable",
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 275b8926..e199d392 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -43,6 +43,7 @@ more-itertools==10.5.0
 oauthlib==3.2.2
 overrides==7.7.0
 packaging==24.1
+pip==24.2
 platformdirs==4.3.6
 pluggy==1.5.0
 protobuf==5.28.2
diff --git a/requirements.txt b/requirements.txt
index 4823bfac..a13dd5aa 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -35,6 +35,7 @@ more-itertools==10.5.0
 oauthlib==3.2.2
 overrides==7.7.0
 packaging==24.1
+pip==24.2
 platformdirs==4.3.6
 protobuf==5.28.2
 pycparser==2.22
diff --git a/snap/hooks/configure b/snap/hooks/configure
index 3594428e..9683bbf5 100755
--- a/snap/hooks/configure
+++ b/snap/hooks/configure
@@ -73,6 +73,10 @@ def _delete_lxd_instance(instance: dict) -> None:
         print(f"Failed to remove LXD instance {instance['name']}.", file=sys.stderr)
 
 
+def _has_lxd() -> bool:
+    return subprocess.run(["snap", "list", "lxd"]).returncode == 0
+
+
 def configure_hook_main():
     # Unique valid base instances directory to prevent duplication.
     image_slots = {}
@@ -85,6 +89,10 @@ def configure_hook_main():
         print(f"Unsupported snap configuration: {reason}.", file=sys.stderr)
         sys.exit(1)
 
+    if not _has_lxd():
+        print("LXD is not installed.", file=sys.stderr)
+        return
+
     # Remove only base images in LXD related project
     try:
         lxd_images_json = subprocess.check_output(
diff --git a/snap/hooks/remove b/snap/hooks/remove
index 90789673..77332f2e 100755
--- a/snap/hooks/remove
+++ b/snap/hooks/remove
@@ -9,7 +9,15 @@ import sys
 PROJECT_NAME = "charmcraft"
 
 
+def _has_lxd() -> bool:
+    return subprocess.run(["snap", "list", "lxd"]).returncode == 0
+
+
 def remove_hook_main():
+    if not _has_lxd():
+        print("LXD is not installed.", file=sys.stderr)
+        return
+
     # Remove all images in LXD related project
     try:
         lxd_images_json = subprocess.check_output(


Test patch:
diff --git a/tests/integration/parts/plugins/test_poetry.py b/tests/integration/parts/plugins/test_poetry.py
index f210e2d6..a0493982 100644
--- a/tests/integration/parts/plugins/test_poetry.py
+++ b/tests/integration/parts/plugins/test_poetry.py
@@ -88,3 +88,4 @@ def test_poetry_plugin(
     # Check that the stage directory looks correct.
     assert (stage_path / "src" / "charm.py").read_text() == "# Charm file"
     assert (stage_path / "venv" / "lib").is_dir()
+    assert not (stage_path / "venv" / "lib64").is_symlink()
diff --git a/tests/integration/parts/plugins/test_python.py b/tests/integration/parts/plugins/test_python.py
index d03e7193..4d393333 100644
--- a/tests/integration/parts/plugins/test_python.py
+++ b/tests/integration/parts/plugins/test_python.py
@@ -87,3 +87,4 @@ def test_python_plugin(
     # Check that the stage directory looks correct.
     assert (stage_path / "src" / "charm.py").read_text() == "# Charm file"
     assert (stage_path / "venv" / "lib").is_dir()
+    assert not (stage_path / "venv" / "lib64").is_symlink()
diff --git a/tests/integration/test_linters.py b/tests/integration/test_linters.py
new file mode 100644
index 00000000..97645f3f
--- /dev/null
+++ b/tests/integration/test_linters.py
@@ -0,0 +1,61 @@
+# Copyright 2024 Canonical Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# For further info, check https://github.com/canonical/charmcraft
+"""Unit tests for linters."""
+
+import pathlib
+import subprocess
+import sys
+
+import pytest
+
+from charmcraft import linters
+from charmcraft.models.lint import LintResult
+
+pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="Windows not supported")
+
+
+@pytest.mark.parametrize(
+    "pip_cmd",
+    [
+        ["--version"],
+        ["install", "pytest", "hypothesis"],
+    ],
+)
+@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
+def test_pip_check_success(tmp_path: pathlib.Path, pip_cmd: list[str]):
+    venv_path = tmp_path / "venv"
+    subprocess.run([sys.executable, "-m", "venv", venv_path], check=True)
+    subprocess.run([venv_path / "bin" / "python", "-m", "pip", *pip_cmd], check=True)
+
+    lint = linters.PipCheck()
+    assert lint.run(tmp_path) == LintResult.OK
+    assert lint.text == linters.PipCheck.text
+
+
+@pytest.mark.parametrize(
+    "pip_cmd",
+    [
+        ["install", "--no-deps", "pydantic==2.9.2"],
+    ],
+)
+def test_pip_check_failure(tmp_path: pathlib.Path, pip_cmd: list[str]):
+    venv_path = tmp_path / "venv"
+    subprocess.run([sys.executable, "-m", "venv", venv_path], check=True)
+    subprocess.run([venv_path / "bin" / "python", "-m", "pip", *pip_cmd], check=True)
+
+    lint = linters.PipCheck()
+    assert lint.run(tmp_path) == LintResult.WARNING
+    assert "pydantic 2.9.2 requires pydantic-core" in lint.text
diff --git a/tests/unit/test_linters.py b/tests/unit/test_linters.py
new file mode 100644
index 00000000..ca1e7207
--- /dev/null
+++ b/tests/unit/test_linters.py
@@ -0,0 +1,138 @@
+# Copyright 2024 Canonical Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# For further info, check https://github.com/canonical/charmcraft
+"""Unit tests for linters."""
+
+import pathlib
+import subprocess
+import sys
+
+import pytest
+
+from charmcraft import linters
+from charmcraft.models.lint import LintResult
+
+
+@pytest.fixture
+def valid_venv_path(fake_path) -> pathlib.Path:
+    """Create and return a fakefs path that contains a valid venv structure"""
+    (fake_path / "venv" / "lib").mkdir(parents=True)
+    return fake_path
+
+
+def test_pip_check_not_venv(fake_path: pathlib.Path):
+    lint = linters.PipCheck()
+    assert lint.run(fake_path) == LintResult.NONAPPLICABLE
+    assert lint.text == "Charm does not contain a Python venv."
+
+
+def test_pip_invalid_venv(fake_path: pathlib.Path):
+    (fake_path / "venv").mkdir()
+    lint = linters.PipCheck()
+    assert lint.run(fake_path) == LintResult.NONAPPLICABLE
+    assert lint.text == "Python venv is not valid."
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported.")
+def test_pip_check_success(valid_venv_path: pathlib.Path, fp):
+    fp.register(
+        [sys.executable, "-m", "pip", "--python", fp.any(), "check"],
+        returncode=0,
+        stdout="Loo loo loo, doing pip stuff. Pip stuff is my favourite stuff.",
+    )
+
+    lint = linters.PipCheck()
+    assert lint.run(valid_venv_path) == LintResult.OK
+    assert lint.text == linters.PipCheck.text
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported.")
+def test_pip_check_warning(valid_venv_path: pathlib.Path, fp):
+    fp.register(
+        [sys.executable, "-m", "pip", "--python", fp.any(), "check"],
+        returncode=1,
+        stdout="This error was sponsored by Raytheon Knife Missiles™",
+    )
+
+    lint = linters.PipCheck()
+    assert lint.run(valid_venv_path) == LintResult.WARNING
+    assert lint.text == "This error was sponsored by Raytheon Knife Missiles™"
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported.")
+def test_pip_check_exception(valid_venv_path: pathlib.Path, monkeypatch):
+    def _raises_eperm(*args, **kwargs) -> None:
+        raise PermissionError(13, "Permission denied")
+
+    monkeypatch.setattr(subprocess, "run", _raises_eperm)
+
+    lint = linters.PipCheck()
+    assert lint.run(valid_venv_path) == LintResult.NONAPPLICABLE
+    assert (
+        lint.text
+        == f"Permission denied: Could not run Python executable at {sys.executable}."
+    )
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported.")
+def test_pip_check_repair_no_bin(valid_venv_path: pathlib.Path, fp):
+    """Check that the bin directory is deleted if it was missing before"""
+    fp.register(
+        [sys.executable, "-m", "pip", "--python", fp.any(), "check"],
+        returncode=0,
+        stdout="Gosh, I sure hope I remember where everything went.",
+    )
+    lint = linters.PipCheck()
+
+    # Make sure it doesn't leave behind "bin" if it didn't exist
+    assert lint.run(valid_venv_path) == LintResult.OK
+    assert lint.text == "Virtual environment is valid."
+    assert not (valid_venv_path / "venv" / "bin").exists()
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported.")
+def test_pip_check_repair_no_py(valid_venv_path: pathlib.Path, fp):
+    """Check that the python symlink is deleted if it was missing before"""
+    fp.register(
+        [sys.executable, "-m", "pip", "--python", fp.any(), "check"],
+        returncode=0,
+        stdout="Gosh, I sure hope I remember where everything went.",
+    )
+    lint = linters.PipCheck()
+
+    # Make sure it keeps "bin" if only the Python binary didn't exist
+    (valid_venv_path / "venv" / "bin").mkdir()
+    assert lint.run(valid_venv_path) == LintResult.OK
+    assert lint.text == "Virtual environment is valid."
+    assert (valid_venv_path / "venv" / "bin").exists()
+    assert not (valid_venv_path / "venv" / "bin" / "python").exists()
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported.")
+def test_pip_check_repair_all(valid_venv_path: pathlib.Path, fp):
+    """Check that nothing is changed if all components are present"""
+    fp.register(
+        [sys.executable, "-m", "pip", "--python", fp.any(), "check"],
+        returncode=0,
+        stdout="Gosh, I sure hope I remember where everything went.",
+    )
+    lint = linters.PipCheck()
+
+    (valid_venv_path / "venv" / "bin").mkdir()
+    (valid_venv_path / "venv" / "bin" / "python").touch()
+
+    assert lint.run(valid_venv_path) == LintResult.OK
+    assert lint.text == "Virtual environment is valid."
+    assert (valid_venv_path / "venv" / "bin" / "python").is_file()