Skip to content

Commit

Permalink
Provide the building charms functionality through the pack command.
Browse files Browse the repository at this point in the history
  • Loading branch information
facundobatista committed Apr 30, 2021
1 parent 9c12d38 commit 3aa86c8
Show file tree
Hide file tree
Showing 4 changed files with 218 additions and 29 deletions.
89 changes: 81 additions & 8 deletions charmcraft/commands/pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,16 @@

import logging
import zipfile
from argparse import Namespace

from charmcraft.cmdbase import BaseCommand, CommandError
from charmcraft.utils import load_yaml, create_manifest
from charmcraft.commands import build
from charmcraft.utils import (
SingleOptionEnsurer,
create_manifest,
load_yaml,
useful_filepath,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -61,27 +68,93 @@ def get_paths_to_include(config):


_overview = """
Build the bundle and package it as a zip archive.
Build and pack a charm operator package or a bundle.
You can `juju deploy` the bundle .zip file or upload it to
the store (see the "upload" command).
You can `juju deploy` the resulting `.charm` or bundle's `.zip`
file directly, or upload it to Charmhub with `charmcraft upload`.
For the charm you must be inside a charm directory with a valid
`metadata.yaml`, `requirements.txt` including the `ops` package
for the Python operator framework, and an operator entrypoint,
usually `src/charm.py`. See `charmcraft init` to create a
template charm directory structure.
For the bundle you must already have a `bundle.yaml` (can be
generated by Juju) and a README.md file.
"""


class PackCommand(BaseCommand):
"""Build the bundle or the charm.
Eventually this command will also support charms, but for now it will work only
on bundles.
If charmcraft.yaml missing or its 'type' key indicates a charm,
use the "build" infrastructure to create the charm.
Otherwise pack the bundle.
"""

name = 'pack'
help_msg = "Build the bundle"
help_msg = "Build the charm or bundle"
overview = _overview
needs_config = True
needs_config = False # optional until we fully support charms here

def fill_parser(self, parser):
"""Add own parameters to the general parser."""
parser.add_argument(
"-e",
"--entrypoint",
type=SingleOptionEnsurer(useful_filepath),
help=(
"The executable which is the operator entry point; defaults to 'src/charm.py'"
),
)
parser.add_argument(
"-r",
"--requirement",
action="append",
type=useful_filepath,
help=(
"File(s) listing needed PyPI dependencies (can be used multiple "
"times); defaults to 'requirements.txt'"
),
)

def run(self, parsed_args):
"""Run the command."""
# decide if this will work on a charm or a bundle
if self.config.type == "charm" or not self.config.project.config_provided:
self._pack_charm(parsed_args)
else:
if parsed_args.entrypoint is not None:
raise CommandError(
"The -e/--entry option is valid only when packing a charm"
)
if parsed_args.requirement is not None:
raise CommandError(
"The -r/--requirement option is valid only when packing a charm"
)
self._pack_bundle()

def _pack_charm(self, parsed_args):
"""Pack a charm."""
# adapt arguments to use the build infrastructure
parsed_args = Namespace(
**{
"from": self.config.project.dirpath,
"entrypoint": parsed_args.entrypoint,
"requirement": parsed_args.requirement,
}
)

# mimic the "build" command
validator = build.Validator()
args = validator.process(parsed_args)
logger.debug("working arguments: %s", args)
builder = build.Builder(args, self.config)
builder.run()

def _pack_bundle(self):
"""Pack a bundle."""
# get the config files
bundle_filepath = self.config.project.dirpath / 'bundle.yaml'
bundle_config = load_yaml(bundle_filepath)
Expand Down
13 changes: 13 additions & 0 deletions completion.bash
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,19 @@ _charmcraft()
;;
esac
;;
pack)
case "$prev" in
-r|--requirement)
_filedir txt
;;
-e|--entrypoint)
_filedir py
;;
*)
COMPREPLY=( $(compgen -W "${globals[*]} --entrypoint --requirement" -- "$cur") )
;;
esac
;;
release)
COMPREPLY=( $(compgen -W "${globals[*]} --revision --channel --resource" -- "$cur") )
;;
Expand Down
139 changes: 119 additions & 20 deletions tests/commands/test_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,24 @@
import logging
import pathlib
import zipfile
from argparse import Namespace
from unittest.mock import patch
from argparse import Namespace, ArgumentParser
from unittest.mock import patch, MagicMock

import pytest
import yaml

from charmcraft.cmdbase import CommandError
from charmcraft.config import Project
from charmcraft.commands import pack
from charmcraft.commands.pack import (
PackCommand,
build_zip,
get_paths_to_include,
)
from charmcraft.utils import useful_filepath, SingleOptionEnsurer

# empty namespace
noargs = Namespace()
noargs = Namespace(entrypoint=None, requirement=None)


@pytest.fixture
Expand All @@ -51,9 +53,62 @@ def func(*, name):
return func


# -- tests for main building process
# -- tests for the project type decisor

def test_simple_succesful_build(tmp_path, caplog, bundle_yaml, config):
def test_resolve_charm_type(config):
"""The config indicates the project is a charm."""
config.set(type="charm")
cmd = PackCommand("group", config)

with patch.object(cmd, "_pack_charm") as mock:
cmd.run(noargs)
mock.assert_called_with(noargs)


def test_resolve_bundle_type(config):
"""The config indicates the project is a bundle."""
config.set(type="bundle")
cmd = PackCommand("group", config)

with patch.object(cmd, "_pack_bundle") as mock:
cmd.run(noargs)
mock.assert_called_with()


def test_resolve_no_config_packs_charm(config):
"""There is no config, so it's decided to pack a charm."""
config.set(project=Project(config_provided=False))
cmd = PackCommand("group", config)

with patch.object(cmd, "_pack_charm") as mock:
cmd.run(noargs)
mock.assert_called_with(noargs)


def test_resolve_bundle_with_requirement(config):
"""The requirement option is not valid when packing a bundle."""
config.set(type="bundle")
args = Namespace(requirement="reqs.txt", entrypoint=None)

with pytest.raises(CommandError) as cm:
PackCommand("group", config).run(args)
assert str(cm.value) == "The -r/--requirement option is valid only when packing a charm"


def test_resolve_bundle_with_entrypoint(config):
"""The entrypoint option is not valid when packing a bundle."""
config.set(type="bundle")
args = Namespace(requirement=None, entrypoint="mycharm.py")

with pytest.raises(CommandError) as cm:
PackCommand("group", config).run(args)
assert str(cm.value) == "The -e/--entry option is valid only when packing a charm"


# -- tests for main bundle building process


def test_bundle_simple_succesful_build(tmp_path, caplog, bundle_yaml, config):
"""A simple happy story."""
caplog.set_level(logging.INFO, logger="charmcraft.commands")

Expand Down Expand Up @@ -83,7 +138,7 @@ def test_simple_succesful_build(tmp_path, caplog, bundle_yaml, config):
assert not (tmp_path / 'manifest.yaml').exists()


def test_missing_bundle_file(tmp_path, config):
def test_bundle_missing_bundle_file(tmp_path, config):
"""Can not build a bundle without bundle.yaml."""
# build without a bundle.yaml!
with pytest.raises(CommandError) as cm:
Expand All @@ -92,7 +147,7 @@ def test_missing_bundle_file(tmp_path, config):
"Missing or invalid main bundle file: '{}'.".format(tmp_path / 'bundle.yaml'))


def test_missing_other_mandatory_file(tmp_path, config, bundle_yaml):
def test_bundle_missing_other_mandatory_file(tmp_path, config, bundle_yaml):
"""Can not build a bundle without any of the mandatory files."""
bundle_yaml(name='testbundle')
config.set(type='bundle')
Expand All @@ -103,7 +158,7 @@ def test_missing_other_mandatory_file(tmp_path, config, bundle_yaml):
assert str(cm.value) == "Missing mandatory file: {}.".format(tmp_path / 'README.md')


def test_missing_name_in_bundle(tmp_path, bundle_yaml, config):
def test_bundle_missing_name_in_bundle(tmp_path, bundle_yaml, config):
"""Can not build a bundle without name."""
config.set(type='bundle')

Expand All @@ -115,18 +170,6 @@ def test_missing_name_in_bundle(tmp_path, bundle_yaml, config):
.format(tmp_path / 'bundle.yaml'))


def test_bad_type_in_charmcraft(bundle_yaml, config):
"""The charmcraft.yaml file must have a proper type field."""
bundle_yaml(name='testbundle')
config.set(type='charm')

# build!
with pytest.raises(CommandError) as cm:
PackCommand('group', config).run(noargs)
assert str(cm.value) == (
"Bad config: 'type' field in charmcraft.yaml must be 'bundle' for this command.")


# -- tests for get paths helper

def test_getpaths_mandatory_ok(tmp_path, config):
Expand Down Expand Up @@ -339,3 +382,59 @@ def test_zipbuild_symlink_outside(tmp_path):
zf = zipfile.ZipFile(str(zip_filepath)) # str() for Py3.5 support
assert sorted(x.filename for x in zf.infolist()) == ['link.txt']
assert zf.read('link.txt') == b"123\x00456"


# tests for the main charm building process -- so far this is only using the "build" command
# infrastructure, until we migrate the (adapted) behaviour to this command


def test_charm_parameters_requirement(config):
"""The --requirement option implies a set of validations."""
cmd = PackCommand("group", config)
parser = ArgumentParser()
cmd.fill_parser(parser)
(action,) = [action for action in parser._actions if action.dest == "requirement"]
assert action.type is useful_filepath


def test_charm_parameters_entrypoint(config):
"""The --entrypoint option implies a set of validations."""
cmd = PackCommand("group", config)
parser = ArgumentParser()
cmd.fill_parser(parser)
(action,) = [action for action in parser._actions if action.dest == "entrypoint"]
assert isinstance(action.type, SingleOptionEnsurer)
assert action.type.converter is useful_filepath


def test_charm_parameters_validator(config):
"""Check that build.Builder is properly called."""
args = Namespace(requirement="test-reqs", entrypoint="test-epoint")
config.set(type="charm", project=Project(dirpath="test-pdir"))
with patch(
"charmcraft.commands.build.Validator", autospec=True
) as validator_class_mock:
validator_class_mock.return_value = validator_instance_mock = MagicMock()
with patch("charmcraft.commands.build.Builder"):
PackCommand("group", config).run(args)
validator_instance_mock.process.assert_called_with(
Namespace(
**{
"from": "test-pdir",
"requirement": "test-reqs",
"entrypoint": "test-epoint",
}
)
)


def test_charm_builder_infrastructure_called(config):
"""Check that build.Builder is properly called."""
config.set(type="charm")
with patch("charmcraft.commands.build.Validator", autospec=True) as validator_mock:
validator_mock().process.return_value = "processed args"
with patch("charmcraft.commands.build.Builder") as builder_class_mock:
builder_class_mock.return_value = builder_instance_mock = MagicMock()
PackCommand("group", config).run(noargs)
builder_class_mock.assert_called_with("processed args", config)
builder_instance_mock.run.assert_called_with()
6 changes: 5 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ def set(self, **kwargs):
for k, v in kwargs.items():
object.__setattr__(self, k, v)

project = config_module.Project(dirpath=tmp_path, started_at=datetime.datetime.utcnow())
project = config_module.Project(
dirpath=tmp_path,
started_at=datetime.datetime.utcnow(),
config_provided=True,
)
return TestConfig(type='bundle', project=project)


Expand Down

0 comments on commit 3aa86c8

Please sign in to comment.