Skip to content

Commit

Permalink
feat(package): create a dispatch file if none exists (#1898)
Browse files Browse the repository at this point in the history
This creates a dispatch file during the post-prime step if there is no
existing dispatch file and there isn't a hooks directory.

Progress towards #1813
  • Loading branch information
lengau authored Sep 26, 2024
1 parent 97c8559 commit b4f91c7
Show file tree
Hide file tree
Showing 4 changed files with 158 additions and 0 deletions.
63 changes: 63 additions & 0 deletions charmcraft/dispatch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# 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
"""Module for helping with creating a dispatch script for charms."""

import pathlib

import craft_cli

from charmcraft import const

DISPATCH_SCRIPT_TEMPLATE = """\
#!/bin/sh
dispatch_path="$(dirname $(realpath $0))"
python_path="${{dispatch_path}}/venv/bin/python"
if [ ! -e "${{python_path}}" ]; then
ln -s $(which python3) "${{python_path}}"
fi
# Add charm lib and source directories to PYTHONPATH so the charm can import
# libraries and its own modules as expected.
export PYTHONPATH="${{dispatch_path}}/lib:${{dispatch_path}}/src"
# Add the charm's lib and usr/lib directories to LD_LIBRARY_PATH, allowing
# staged packages to be discovered by the dynamic linker.
export LD_LIBRARY_PATH="${{dispatch_path}}/usr/lib:${{dispatch_path}}/lib:${{dispatch_path}}/usr/lib/$(uname -m)-linux-gnu"
exec "${{python_path}}" "${{dispatch_path}}/{entrypoint}"
"""


def create_dispatch(*, prime_dir: pathlib.Path, entrypoint: str = "src/charm.py") -> bool:
"""If the charm has no hooks or dispatch, create a dispatch file.
:param prime_dir: the prime directory to inspect and create the file in.
:returns: True if the file was created, False otherwise.
"""
dispatch_path = prime_dir / const.DISPATCH_FILENAME
hooks_path = prime_dir / const.HOOKS_DIRNAME

if hooks_path.is_dir() or dispatch_path.is_file():
return False

if not (prime_dir / entrypoint).exists():
return False

craft_cli.emit.progress("Creating dispatch file")
dispatch_path.write_text(DISPATCH_SCRIPT_TEMPLATE.format(entrypoint=entrypoint))
dispatch_path.chmod(mode=0o755)

return True
13 changes: 13 additions & 0 deletions charmcraft/services/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@
"""Service class for running craft lifecycle commands."""
from __future__ import annotations

from typing import cast

import craft_parts
from craft_application import services, util
from craft_cli import emit
from overrides import override

from charmcraft import dispatch


class LifecycleService(services.LifecycleService):
"""Business logic for lifecycle builds."""
Expand Down Expand Up @@ -55,3 +60,11 @@ def _get_build_for(self) -> str:
return arch

return host_arch

@override
def post_prime(self, step_info: craft_parts.StepInfo) -> bool:
return_value = super().post_prime(step_info)

project_info = cast(craft_parts.ProjectInfo, step_info.project_info)
# TODO: include an entrypoint override. #1896
return return_value | dispatch.create_dispatch(prime_dir=project_info.dirs.prime_dir)
11 changes: 11 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,17 @@ lint.ignore = [
# Allow Pydantic's `@validator` decorator to trigger class method treatment.
classmethod-decorators = ["pydantic.validator"]

[tool.ruff.lint.pydocstyle]
ignore-decorators = [ # Functions with these decorators don't have to have docstrings.
"typing.overload", # Default configuration
# The next four are all variations on override, so child classes don't have to
# repeat parent classes' docstrings.
"overrides.override",
"overrides.overrides",
"typing.override",
"typing_extensions.override",
]

[tool.ruff.lint.per-file-ignores]
"tests/**.py" = [ # Some things we want for the moin project are unnecessary in tests.
"D", # Ignore docstring rules in tests
Expand Down
71 changes: 71 additions & 0 deletions tests/unit/test_dispatch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# 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 dispatch script creation."""


import pathlib

import pytest
import pytest_check

from charmcraft import const, dispatch


def test_create_dispatch_hooks_exist(fake_path: pathlib.Path):
"""Test that nothing happens if a hooks directory exists."""
prime_dir = fake_path / "prime"
(prime_dir / const.HOOKS_DIRNAME).mkdir(parents=True)

pytest_check.is_false(dispatch.create_dispatch(prime_dir=prime_dir))

pytest_check.is_false((prime_dir / const.DISPATCH_FILENAME).exists())


def test_create_dispatch_dispatch_exists(fake_path: pathlib.Path):
"""Test that nothing happens if dispatch file already exists."""
prime_dir = fake_path / "prime"
prime_dir.mkdir()
dispatch_path = prime_dir / const.DISPATCH_FILENAME
dispatch_path.write_text("DO NOT OVERWRITE")

pytest_check.is_false(dispatch.create_dispatch(prime_dir=prime_dir))

pytest_check.equal(dispatch_path.read_text(), "DO NOT OVERWRITE")


@pytest.mark.parametrize("entrypoint", ["src/charm.py", "src/some_entrypoint.py"])
def test_create_dispatch_no_entrypoint(fake_path: pathlib.Path, entrypoint):
prime_dir = fake_path / "prime"
prime_dir.mkdir()
dispatch_path = prime_dir / const.DISPATCH_FILENAME

pytest_check.is_false(dispatch.create_dispatch(prime_dir=prime_dir, entrypoint=entrypoint))

pytest_check.is_false(dispatch_path.exists())


@pytest.mark.parametrize("entrypoint", ["src/charm.py", "src/some_entrypoint.py"])
def test_create_dispatch_with_entrypoint(fake_path: pathlib.Path, entrypoint):
prime_dir = fake_path / "prime"
prime_dir.mkdir()
entrypoint = prime_dir / entrypoint
entrypoint.parent.mkdir(parents=True, exist_ok=True)
entrypoint.touch()
dispatch_file = prime_dir / const.DISPATCH_FILENAME
expected = dispatch.DISPATCH_SCRIPT_TEMPLATE.format(entrypoint=entrypoint)

pytest_check.is_true(dispatch.create_dispatch(prime_dir=prime_dir, entrypoint=entrypoint))
pytest_check.equal(dispatch_file.read_text(), expected)

0 comments on commit b4f91c7

Please sign in to comment.