Skip to content

Commit

Permalink
feat: create-track command (#2024)
Browse files Browse the repository at this point in the history
Adds a create-track command right in time for Christmas for @addyess

Requires canonical/craft-store#240

CRAFT-3424
  • Loading branch information
lengau authored Dec 16, 2024
1 parent c9b5103 commit 7615a1c
Show file tree
Hide file tree
Showing 10 changed files with 612 additions and 2,130 deletions.
3 changes: 3 additions & 0 deletions charmcraft/application/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
UploadCommand,
ListRevisionsCommand,
# release process, and show status
CreateTrack,
ReleaseCommand,
PromoteBundleCommand,
StatusCommand,
Expand Down Expand Up @@ -88,6 +89,7 @@ def fill_command_groups(app: craft_application.Application) -> None:
UploadCommand,
ListRevisionsCommand,
# release process, and show status
CreateTrack,
ReleaseCommand,
PromoteBundleCommand,
StatusCommand,
Expand Down Expand Up @@ -142,6 +144,7 @@ def fill_command_groups(app: craft_application.Application) -> None:
"ListNamesCommand",
"UploadCommand",
"ListRevisionsCommand",
"CreateTrack",
"ReleaseCommand",
"PromoteBundleCommand",
"StatusCommand",
Expand Down
64 changes: 63 additions & 1 deletion charmcraft/application/commands/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from craft_cli import ArgumentParsingError, emit
from craft_cli.errors import CraftError
from craft_parts import Step
from craft_store import attenuations, models
from craft_store import attenuations, models, publisher
from craft_store.errors import CredentialsUnavailable
from craft_store.models import ResponseCharmResourceBase
from humanize import naturalsize
Expand Down Expand Up @@ -2374,3 +2374,65 @@ def _get_architectures_from_bases(
for architecture in base.architectures:
architectures.add(architecture)
return sorted(architectures)


class CreateTrack(CharmcraftCommand):
"""Create one or more tracks."""

name = "create-track"
help_msg = "Create one or more tracks for a charm on Charmhub"
overview = textwrap.dedent(
"""\
Create one or more tracks for a charm on Charmhub.
Returns the list of created tracks. Tracks must match an existing guardrail
for this charm. Guardrails can be requested in the charmhub requests category
at https://discourse.charmhub.io.
"""
)
format_option = True

def fill_parser(self, parser: argparse.ArgumentParser) -> None:
"""Add own parameters to the general parser."""
super().fill_parser(parser=parser)
parser.add_argument(
"name",
help="The store name onto which to create the track",
)
parser.add_argument(
"track",
nargs="+",
help="The track name to create",
)
parser.add_argument(
"--automatic-phasing-percentage",
type=int,
default=None,
help="Automatic phasing percentage",
)

def run(self, parsed_args: argparse.Namespace) -> None:
"""Run the command."""
emit.progress(f"Creating {len(parsed_args.track)} tracks on the store")
pct = parsed_args.automatic_phasing_percentage
tracks: list[publisher.CreateTrackRequest] = [
{"name": track, "automatic-phasing-percentage": pct}
for track in parsed_args.track
]
output_tracks = self._services.store.create_tracks(
parsed_args.name,
*tracks,
)

if fmt := parsed_args.format:
emit.message(cli.format_content(tracks, fmt))
return
data = [
{
"Name": track.name,
"Created at": track.created_at,
"Automatic phasing percentage": track.automatic_phasing_percentage,
}
for track in output_tracks
]
emit.message(tabulate(data, headers="keys"))
30 changes: 29 additions & 1 deletion charmcraft/services/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import craft_application
import craft_store
from craft_cli import emit
from craft_store import models
from craft_store import models, publisher
from craft_store.errors import StoreServerError
from overrides import override

Expand Down Expand Up @@ -194,6 +194,34 @@ def setup(self) -> None:
api_base_url=self._base_url,
storage_base_url=self._storage_url,
)
self._auth = craft_store.Auth(
application_name=self._app.name,
host=self._base_url,
environment_auth=self._environment_auth,
)
self._publisher = craft_store.PublisherGateway(
base_url=self._base_url,
namespace="charm",
auth=self._auth,
)

def create_tracks(
self, name: str, *tracks: publisher.CreateTrackRequest
) -> Sequence[publisher.Track]:
"""Create tracks in the store.
:param name: The package name to which the tracks should be attached.
:param tracks: Each item is a dictionary of the track request.
:returns: A sequence of the created tracks as dictionaries.
"""
self._publisher.create_tracks(name, *tracks)
track_names = {track["name"] for track in tracks}

return [
track
for track in self._publisher.get_package_metadata(name).tracks
if track.name in track_names
]

def set_resource_revisions_architectures(
self, name: str, resource_name: str, updates: dict[int, list[str]]
Expand Down
4 changes: 2 additions & 2 deletions charmcraft/utils/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import sys
from collections.abc import Collection, Iterable
from dataclasses import dataclass
from typing import Literal, overload
from typing import Any, overload

import tabulate
from craft_cli import emit
Expand Down Expand Up @@ -182,7 +182,7 @@ class OutputFormat(enum.Enum):

@overload
def format_content(
content: dict[str, str], fmt: Literal[OutputFormat.TABLE, "table"]
content: dict[str, Any] | list[dict[str, Any]], fmt: OutputFormat | str | None
) -> str: ...


Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ dependencies = [
"craft-providers>=2.0.0",
"craft-platforms~=0.3",
"craft-providers>=2.0.0",
"craft-store>=3.0.0",
"craft-store>=3.1.0",
"distro>=1.7.0",
"docker>=7.0.0",
"humanize>=2.6.0",
Expand Down Expand Up @@ -408,6 +408,7 @@ conflicts = [

[dependency-groups]
dev = [
"charmcraft[lint,types]",
"coverage>=7.6.8",
"freezegun>=1.5.1",
"hypothesis>=6.122.1",
Expand Down
2 changes: 1 addition & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ craft-grammar==2.0.1
craft-parts==2.1.4
craft-platforms==0.4.0
craft-providers==2.0.4
craft-store==3.0.2
craft-store==3.1.0
cryptography==43.0.3
dill==0.3.9
distro==1.9.0
Expand Down
52 changes: 52 additions & 0 deletions tests/integration/commands/test_store_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@
"""Integration tests for store commands."""

import argparse
import datetime
import sys
from unittest import mock

import pytest
from craft_store import publisher

from charmcraft import env
from charmcraft.application.commands import FetchLibCommand
from charmcraft.application.commands.store import CreateTrack
from charmcraft.store.models import Library
from tests import factory

Expand Down Expand Up @@ -510,3 +513,52 @@ def test_fetchlib_store_same_versions_different_hash(


# endregion


def test_create_track(emitter, service_factory, config):
cmd = CreateTrack(config)
args = argparse.Namespace(
name="my-charm",
track=["my-track"],
automatic_phasing_percentage=None,
format="json",
)
mock_create_tracks = mock.Mock()
track = publisher.Track.unmarshal(
{
"name": "my-track",
"automatic-phasing-percentage": None,
"created-at": datetime.datetime.now(),
}
)
mock_get_package_metadata = mock.Mock(
return_value=publisher.RegisteredName.unmarshal(
{
"id": "mentalism",
"private": False,
"publisher": {"id": "EliBosnick"},
"status": "hungry",
"store": "charmhub",
"type": "charm",
"tracks": [
track,
publisher.Track.unmarshal(
{
"name": "latest",
"automatic-phasing-percentage": None,
"created-at": datetime.datetime.now(),
}
),
],
}
)
)

service_factory.store._publisher.create_tracks = mock_create_tracks
service_factory.store._publisher.get_package_metadata = mock_get_package_metadata

cmd.run(args)

emitter.assert_json_output(
[{"name": "my-track", "automatic-phasing-percentage": None}]
)
6 changes: 6 additions & 0 deletions tests/spread/store/charm-upload-and-release/task.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ execute: |
# release that last revision to edge
charmcraft release $CHARM_DEFAULT_NAME -r $uploaded_revno -c edge
# Create a track and release it to that, too.
track_name=$(date +%s)
charmcraft create-track $CHARM_DEFAULT_NAME $track_name
charmcraft release $CHARM_DEFAULT_NAME -r $uploaded_revno -c ${track_name}/edge/testing
# Releases may have some delay in the craft store, so sleep for a bit before trying and try up to 10 times.
sleep 10
for i in {1..10}
Expand All @@ -57,6 +62,7 @@ execute: |
break
fi
done
track_release=$(charmcraft status $CHARM_DEFAULT_NAME --format=json | jq -r ".[] | select(.track==\"${track_name}\") | .mappings[0].releases | .[] | select(.channel==\"${track_name}/edge\")")
if [ $edge_revision -lt $uploaded_revno ]; then
ERROR "Revision wasn't released. Uploaded revision: $uploaded_revno; Currently on edge: $edge_revision"
fi
39 changes: 38 additions & 1 deletion tests/unit/services/test_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# For further info, check https://github.com/canonical/charmcraft
"""Tests for the store service."""

import datetime
import platform
from typing import cast
from unittest import mock
Expand All @@ -25,12 +26,13 @@
import pytest
import requests
from craft_cli.pytest_plugin import RecordingEmitter
from craft_store import models
from craft_store import models, publisher
from hypothesis import given, strategies

import charmcraft
from charmcraft import application, errors, services
from charmcraft.models.project import CharmLib
from charmcraft.services.store import StoreService
from charmcraft.store import client
from tests import get_fake_revision

Expand All @@ -49,6 +51,7 @@ def store(service_factory, mock_store_anonymous_client) -> services.StoreService
def reusable_store():
store = services.StoreService(app=application.APP_METADATA, services=None)
store.client = mock.Mock(spec_set=craft_store.StoreClient)
store._publisher = mock.Mock(spec_set=craft_store.PublisherGateway)
return store


Expand Down Expand Up @@ -170,6 +173,40 @@ def test_logout(store):
client.logout.assert_called_once_with()


def test_create_tracks(reusable_store: StoreService):
mock_create = cast(mock.Mock, reusable_store._publisher.create_tracks)
mock_md = cast(mock.Mock, reusable_store._publisher.get_package_metadata)
user_track = {
"name": "my-track",
"automatic-phasing-percentage": None,
}
created_at = {"created-at": datetime.datetime.now()}
return_track = publisher.Track.unmarshal(user_track | created_at)
mock_md.return_value = publisher.RegisteredName.unmarshal(
{
"id": "mentalism",
"private": False,
"publisher": {"id": "EliBosnick"},
"status": "hungry",
"store": "charmhub",
"type": "charm",
"tracks": [
return_track,
publisher.Track.unmarshal(
{
"name": "latest",
"automatic-phasing-percentage": None,
}
| created_at
),
],
}
)

assert reusable_store.create_tracks("my-name", user_track) == [return_track]
mock_create.assert_called_once_with("my-name", user_track)


@pytest.mark.parametrize(
("updates", "expected_request"),
[
Expand Down
Loading

0 comments on commit 7615a1c

Please sign in to comment.