Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add a 'remote-build' command
Browse files Browse the repository at this point in the history
The implementation comes from Charmcraft. This commit has the code changes to
make the command craft-tool-agnostic, plus some general test and linting fixes
and improvements.
tigarmo committed Dec 11, 2024

Verified

This commit was signed with the committer’s verified signature.
tigarmo Tiago Nobrega
1 parent dad1991 commit 699920d
Showing 7 changed files with 160 additions and 45 deletions.
2 changes: 2 additions & 0 deletions craft_application/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -20,11 +20,13 @@
from .init import InitCommand
from .lifecycle import get_lifecycle_command_group, LifecycleCommand
from .other import get_other_command_group
from .remote import RemoteBuild # Not part of the default commands.

__all__ = [
"AppCommand",
"ExtensibleCommand",
"InitCommand",
"RemoteBuild",
"lifecycle",
"LifecycleCommand",
"get_lifecycle_command_group",
38 changes: 21 additions & 17 deletions craft_application/commands/remote.py
Original file line number Diff line number Diff line change
@@ -20,13 +20,13 @@
from collections.abc import Collection
from typing import Any, cast

from craft_application.commands import ExtensibleCommand
from craft_application.launchpad.models import Build, BuildState
from craft_application.remote.utils import get_build_id
from craft_cli import emit
from overrides import override # pyright: ignore[reportUnknownVariableType]

from charmcraft import models, utils
from craft_application import models, util
from craft_application.commands import ExtensibleCommand
from craft_application.launchpad.models import Build, BuildState
from craft_application.remote.utils import get_build_id

OVERVIEW = """\
Command remote-build sends the current project to be built
@@ -53,10 +53,10 @@


class RemoteBuild(ExtensibleCommand):
"""Run analysis on a built charm."""
"""Build a project on Launchpad."""

name = "remote-build"
help_msg = "Build a charm remotely on Launchpad."
help_msg = "Build a project remotely on Launchpad."
overview = OVERVIEW
always_load_project = True

@@ -79,8 +79,10 @@ def _fill_parser(self, parser: argparse.ArgumentParser) -> None:
)

def _run(
self, parsed_args: argparse.Namespace, **kwargs: Any
) -> int | None: # noqa: ANN401
self,
parsed_args: argparse.Namespace,
**_kwargs: Any, # noqa: ANN401 (use of Any)
) -> int | None:
"""Run the remote-build command.
:param parsed_args: parsed argument namespace from craft_cli.
@@ -100,13 +102,15 @@ def _run(
permanent=True,
)

if not parsed_args.launchpad_accept_public_upload:
if not utils.confirm_with_user(_CONFIRMATION_PROMPT, default=False):
emit.message("Cannot proceed without accepting a public upload.")
return 77 # permission denied from sysexits.h
if (
not parsed_args.launchpad_accept_public_upload
and not util.confirm_with_user(_CONFIRMATION_PROMPT, default=False)
):
emit.message("Cannot proceed without accepting a public upload.")
return 77 # permission denied from sysexits.h

builder = self._services.remote_build
project = cast(models.Charm, self._services.project)
project = cast(models.Project, self._services.project)
config = cast(dict[str, Any], self.config)
project_dir = (
pathlib.Path(config.get("global_args", {}).get("project_dir") or ".")
@@ -132,7 +136,7 @@ def _run(
try:
returncode = self._monitor_and_complete(build_id, builds)
except KeyboardInterrupt:
if utils.confirm_with_user("Cancel builds?", default=True):
if util.confirm_with_user("Cancel builds?", default=True):
emit.progress("Cancelling builds.")
builder.cancel_builds()
emit.progress("Cleaning up")
@@ -143,7 +147,7 @@ def _run(
builder.cleanup()
return returncode

def _monitor_and_complete(
def _monitor_and_complete( # noqa: PLR0912 (too many branches)
self, build_id: str | None, builds: Collection[Build]
) -> int:
builder = self._services.remote_build
@@ -165,11 +169,11 @@ def _monitor_and_complete(
not_building.add(arch)
progress_parts: list[str] = []
if not_building:
progress_parts.append("Stopped: " + ",".join(sorted(not_building)))
progress_parts.append("Stopped: " + ", ".join(sorted(not_building)))
if building:
progress_parts.append("Building: " + ", ".join(sorted(building)))
if uploading:
progress_parts.append("Uploading: " + ",".join(sorted(uploading)))
progress_parts.append("Uploading: " + ", ".join(sorted(uploading)))
if succeeded:
progress_parts.append("Succeeded: " + ", ".join(sorted(succeeded)))
emit.progress("; ".join(progress_parts))
10 changes: 3 additions & 7 deletions craft_application/util/cli.py
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@
from craft_cli import emit


def confirm_with_user(prompt: str, default: bool = False) -> bool:
def confirm_with_user(prompt: str, *, default: bool = False) -> bool:
"""Query user for yes/no answer.
If stdin is not a tty, the default value is returned.
@@ -29,9 +29,6 @@ def confirm_with_user(prompt: str, default: bool = False) -> bool:
:returns: True if answer starts with [yY], False if answer starts with [nN],
otherwise the default.
"""
if is_charmcraft_running_in_managed_mode():
raise RuntimeError("confirmation not yet supported in managed-mode")

if not sys.stdin.isatty():
return default

@@ -42,7 +39,6 @@ def confirm_with_user(prompt: str, default: bool = False) -> bool:

if reply and reply[0] == "y":
return True
elif reply and reply[0] == "n":
if reply and reply[0] == "n":
return False
else:
return default
return default
6 changes: 6 additions & 0 deletions docs/reference/changelog.rst
Original file line number Diff line number Diff line change
@@ -7,6 +7,12 @@ Changelog
4.6.0 (YYYY-MMM-DD)
-------------------

Commands
========

- Add a ``remote-build`` command. This command is not registered by default,
but is available for application use.

Git
===

13 changes: 13 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -23,6 +23,7 @@
from dataclasses import dataclass
from importlib import metadata
from typing import TYPE_CHECKING, Any
from unittest.mock import Mock

import craft_application
import craft_parts
@@ -315,6 +316,16 @@ def _get_loader(self, template_dir: pathlib.Path) -> jinja2.BaseLoader:
return FakeInitService


@pytest.fixture
def fake_remote_build_service_class():
class FakeRemoteBuild(services.RemoteBuildService):
@override
def _get_lp_client(self) -> launchpad.Launchpad:
return Mock(spec=launchpad.Launchpad)

return FakeRemoteBuild


@pytest.fixture
def fake_services(
tmp_path,
@@ -323,10 +334,12 @@ def fake_services(
fake_lifecycle_service_class,
fake_package_service_class,
fake_init_service_class,
fake_remote_build_service_class,
):
services.ServiceFactory.register("package", fake_package_service_class)
services.ServiceFactory.register("lifecycle", fake_lifecycle_service_class)
services.ServiceFactory.register("init", fake_init_service_class)
services.ServiceFactory.register("remote_build", fake_remote_build_service_class)
factory = services.ServiceFactory(app_metadata, project=fake_project)
factory.update_kwargs(
"lifecycle", work_dir=tmp_path, cache_dir=tmp_path / "cache", build_plan=[]
104 changes: 104 additions & 0 deletions tests/unit/commands/test_remote.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# This file is part of craft-application.
#
# Copyright 2024 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License version 3, as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE.
# See the GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
"""Tests for remote-build commands."""
import argparse

import pytest
from craft_application import util
from craft_application.commands import RemoteBuild
from craft_application.launchpad.models import BuildState


@pytest.fixture
def remote_build(
app_metadata,
fake_services,
):
config = {"app": app_metadata, "services": fake_services}
return RemoteBuild(config)


def test_remote_build_no_accept_upload(remote_build, mocker):
parsed_args = argparse.Namespace(launchpad_accept_public_upload=False)

mocker.patch.object(util, "confirm_with_user", return_value=False)
assert remote_build.run(parsed_args) == 77 # noqa: PLR2004 (magic value)


def test_remote_build_run(remote_build, mocker, fake_services, tmp_path, emitter):
builder = fake_services.remote_build

build_states = [
# All 3 builds still pending
{
"arch1": BuildState.PENDING,
"arch2": BuildState.PENDING,
"arch3": BuildState.PENDING,
},
# 2 builds running, 1 pending
{
"arch1": BuildState.BUILDING,
"arch2": BuildState.BUILDING,
"arch3": BuildState.PENDING,
},
# 1 uploading, 1 building, 1 pending
{
"arch1": BuildState.UPLOADING,
"arch2": BuildState.BUILDING,
"arch3": BuildState.PENDING,
},
# All 3 succeeded
{
"arch1": BuildState.SUCCESS,
"arch2": BuildState.SUCCESS,
"arch3": BuildState.SUCCESS,
},
]

mocker.patch.object(
builder, "start_builds", return_value=["arch1", "arch2", "arch3"]
)
mocker.patch.object(builder, "monitor_builds", side_effect=[build_states])

logs = {
"arch1": tmp_path / "log1.txt",
"arch2": tmp_path / "log2.txt",
"arch3": tmp_path / "log3.txt",
}
mocker.patch.object(builder, "fetch_logs", return_value=logs)

artifacts = [tmp_path / "art1.zip", tmp_path / "art2.zip", tmp_path / "art3.zip"]
mocker.patch.object(builder, "fetch_artifacts", return_value=artifacts)

parsed_args = argparse.Namespace(
launchpad_accept_public_upload=True, launchpad_timeout=None, recover=False
)
assert remote_build.run(parsed_args) is None

emitter.assert_progress(
"Starting new build. It may take a while to upload large projects."
)
emitter.assert_progress("Stopped: arch1, arch2, arch3")
emitter.assert_progress("Stopped: arch3; Building: arch1, arch2")
emitter.assert_progress("Stopped: arch3; Building: arch2; Uploading: arch1")
emitter.assert_progress("Succeeded: arch1, arch2, arch3")
emitter.assert_progress("Fetching 3 build logs...")
emitter.assert_progress("Fetching build artifacts...")
emitter.assert_message(
"Build completed.\n"
"Log files: log1.txt, log2.txt, log3.txt\n"
"Artifacts: art1.zip, art2.zip, art3.zip"
)
32 changes: 11 additions & 21 deletions tests/unit/util/test_cli.py
Original file line number Diff line number Diff line change
@@ -14,22 +14,20 @@
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Tests for cli functions."""
from unittest.mock import call, patch
from unittest.mock import call

import pytest
from craft_application.util import confirm_with_user


@pytest.fixture
def mock_isatty():
with patch("sys.stdin.isatty", return_value=True) as mock_isatty:
yield mock_isatty
def mock_isatty(mocker):
return mocker.patch("sys.stdin.isatty", return_value=True)


@pytest.fixture
def mock_input():
with patch("charmcraft.utils.cli.input", return_value="") as mock_input:
yield mock_input
def mock_input(mocker):
return mocker.patch("builtins.input", return_value="")


def test_confirm_with_user_defaults_with_tty(mock_input, mock_isatty):
@@ -66,30 +64,22 @@ def test_confirm_with_user_defaults_without_tty(mock_input, mock_isatty):
("NO", False),
],
)
def test_confirm_with_user(user_input, expected, mock_input, mock_isatty):
@pytest.mark.usefixtures("mock_isatty")
def test_confirm_with_user(user_input, expected, mock_input):
mock_input.return_value = user_input

assert confirm_with_user("prompt") == expected
assert mock_input.mock_calls == [call("prompt [y/N]: ")]


def test_confirm_with_user_errors_in_managed_mode(
mock_is_charmcraft_running_in_managed_mode,
):
mock_is_charmcraft_running_in_managed_mode.return_value = True

with pytest.raises(RuntimeError):
confirm_with_user("prompt")


def test_confirm_with_user_pause_emitter(mock_isatty, emitter):
def test_confirm_with_user_pause_emitter(mock_isatty, emitter, mocker):
"""The emitter should be paused when using the terminal."""
mock_isatty.return_value = True

def fake_input(prompt):
def fake_input(_prompt):
"""Check if the Emitter is paused."""
assert emitter.paused
return ""

with patch("charmcraft.utils.cli.input", fake_input):
confirm_with_user("prompt")
mocker.patch("builtins.input", fake_input)
confirm_with_user("prompt")

0 comments on commit 699920d

Please sign in to comment.