diff --git a/charmcraft/dispatch.py b/charmcraft/dispatch.py new file mode 100644 index 000000000..8d2126c9b --- /dev/null +++ b/charmcraft/dispatch.py @@ -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 diff --git a/charmcraft/services/lifecycle.py b/charmcraft/services/lifecycle.py index 4989793c1..b932e0753 100644 --- a/charmcraft/services/lifecycle.py +++ b/charmcraft/services/lifecycle.py @@ -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.""" @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 7645ca2aa..50323ed7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/tests/unit/test_dispatch.py b/tests/unit/test_dispatch.py new file mode 100644 index 000000000..4e52170f9 --- /dev/null +++ b/tests/unit/test_dispatch.py @@ -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)