Skip to content

Commit

Permalink
Merge branch 'main' into work/starflow
Browse files Browse the repository at this point in the history
  • Loading branch information
lengau authored Dec 16, 2024
2 parents b6c5b53 + c0cd369 commit cee4eab
Show file tree
Hide file tree
Showing 18 changed files with 650 additions and 56 deletions.
5 changes: 4 additions & 1 deletion craft_store/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@
"""Interact with Canonical services such as Charmhub and the Snap Store."""

from . import creds, endpoints, errors, models
from ._httpx_auth import CandidAuth, DeveloperTokenAuth
from .publisher import PublisherGateway
from .auth import Auth
from .base_client import BaseClient
from .developer_token_auth import DeveloperTokenAuth
from .http_client import HTTPClient
from .store_client import StoreClient
from .ubuntu_one_store_client import UbuntuOneStoreClient
Expand All @@ -43,8 +44,10 @@
"endpoints",
"errors",
"models",
"PublisherGateway",
"Auth",
"BaseClient",
"CandidAuth",
"HTTPClient",
"StoreClient",
"UbuntuOneStoreClient",
Expand Down
60 changes: 39 additions & 21 deletions craft_store/developer_token_auth.py → craft_store/_httpx_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,62 +14,80 @@
# 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/>.

"""Client for making requests towards publisher gateway."""
"""Craft Store Authentication Store."""

import abc
import logging
from collections.abc import Generator
from logging import getLogger
from typing import Literal

import httpx

from craft_store import auth, creds, errors

logger = getLogger(__name__)
logger = logging.getLogger(__name__)


class DeveloperTokenAuth(httpx.Auth):
"""Request authentication using developer token."""
class _TokenAuth(httpx.Auth, metaclass=abc.ABCMeta):
"""Base class for httpx token-based authenticators."""

def __init__(
self,
*,
auth: auth.Auth,
auth_type: Literal["bearer", "macaroon"] = "bearer",
self, *, auth: auth.Auth, auth_type: Literal["bearer", "macaroon"] = "bearer"
) -> None:
super().__init__()
self._token: str | None = None
self._auth = auth
self._auth_type = auth_type
self._token: str | None = None

def auth_flow(
self,
request: httpx.Request,
) -> Generator[httpx.Request, httpx.Response, None]:
"""Update request to include Authorization header."""
logger.debug("Adding developer token to authorize request")
if self._token is None:
self.get_token_from_keyring()
logger.debug("Getting token from keyring")
self._token = self.get_token_from_keyring()

self._update_headers(request)
yield request

def get_token_from_keyring(self) -> None:
@abc.abstractmethod
def get_token_from_keyring(self) -> str:
"""Get token stored in the credentials storage."""
logger.debug("Getting developer token from credential storage")
dev_token = creds.DeveloperToken.model_validate_json(
self._auth.get_credentials()
)
self._token = dev_token.macaroon

def _update_headers(self, request: httpx.Request) -> None:
"""Add token to the request."""
logger.debug("Adding ephemeral token to request headers")
if self._token is None:
raise errors.DeveloperTokenUnavailableError(
message="Developer token is not available"
)
raise errors.AuthTokenUnavailableError(message="Token is not available")
request.headers["Authorization"] = self._format_auth_header()

def _format_auth_header(self) -> str:
if self._auth_type == "bearer":
return f"Bearer {self._token}"
return f"Macaroon {self._token}"


class CandidAuth(_TokenAuth):
"""Candid based authentication class for httpx store clients."""

def __init__(
self, *, auth: auth.Auth, auth_type: Literal["bearer", "macaroon"] = "macaroon"
) -> None:
super().__init__(auth=auth, auth_type=auth_type)

def get_token_from_keyring(self) -> str:
"""Get token stored in the credentials storage."""
logger.debug("Getting candid from credential storage")
return creds.unmarshal_candid_credentials(self._auth.get_credentials())


class DeveloperTokenAuth(_TokenAuth):
"""Developer token based authentication class for httpx store clients."""

def get_token_from_keyring(self) -> str:
"""Get token stored in the credentials storage."""
logger.debug("Getting developer token from credential storage")
return creds.DeveloperToken.model_validate_json(
self._auth.get_credentials()
).macaroon
2 changes: 1 addition & 1 deletion craft_store/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
import keyring.backends.fail
import keyring.errors
from jaraco.classes import properties
from xdg import BaseDirectory # type: ignore[import]
from xdg import BaseDirectory # type: ignore[import-untyped]

from . import errors

Expand Down
40 changes: 34 additions & 6 deletions craft_store/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Craft Store errors."""
from __future__ import annotations

import contextlib
import logging
from typing import Any

import httpx
import requests
import urllib3
import urllib3.exceptions
Expand All @@ -31,9 +33,31 @@
class CraftStoreError(Exception):
"""Base class error for craft-store."""

def __init__(self, message: str, resolution: str | None = None) -> None:
def __init__(
self,
message: str,
details: str | None = None,
resolution: str | None = None,
store_errors: StoreErrorList | None = None,
) -> None:
super().__init__(message)
if store_errors and not details:
details = str(store_errors)
self.details = details
self.resolution = resolution
self.store_errors = store_errors


class InvalidRequestError(CraftStoreError, ValueError):
"""Error when the request is invalid in a known way."""

def __init__(
self,
message: str,
details: str | None = None,
resolution: str | None = None,
) -> None:
super().__init__(message, details, resolution)


class NetworkError(CraftStoreError):
Expand Down Expand Up @@ -75,7 +99,7 @@ def __repr__(self) -> str:
if code:
code_list.append(code)

return "<StoreErrorList: {' '.join(code_list)}>"
return f"<StoreErrorList: {' '.join(code_list)}>"

def __contains__(self, error_code: str) -> bool:
return any(error.get("code") == error_code for error in self._error_list)
Expand Down Expand Up @@ -111,7 +135,7 @@ def _get_raw_error_list(self) -> list[dict[str, str]]:

return error_list

def __init__(self, response: requests.Response) -> None:
def __init__(self, response: requests.Response | httpx.Response) -> None:
self.response = response

try:
Expand All @@ -126,9 +150,13 @@ def __init__(self, response: requests.Response) -> None:
with contextlib.suppress(KeyError):
message = "Store operation failed:\n" + str(self.error_list)
if message is None:
if isinstance(response, httpx.Response):
reason = response.reason_phrase
else:
reason = response.reason
message = (
"Issue encountered while processing your request: "
f"[{response.status_code}] {response.reason}."
f"[{response.status_code}] {reason}."
)

super().__init__(message)
Expand Down Expand Up @@ -193,5 +221,5 @@ def __init__(self, url: str) -> None:
super().__init__(f"Empty token value returned from {url!r}.")


class DeveloperTokenUnavailableError(CraftStoreError):
"""Raised when developer token is not set."""
class AuthTokenUnavailableError(CraftStoreError):
"""Raised when an authorization token is not available."""
2 changes: 1 addition & 1 deletion craft_store/models/track_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
class TrackModel(MarshableModel):
"""A track that a package can be published on."""

automatic_phasing_percentage: int | None = None
automatic_phasing_percentage: float | None = None
created_at: Annotated[ # Prevents pydantic from setting UTC as "...Z"
datetime,
pydantic.WrapSerializer(
Expand Down
41 changes: 41 additions & 0 deletions craft_store/publisher/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# 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 warranty of
# MERCHANTABILITY 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/>.
"""Package containing the Publisher Gateway client and relevant metadata."""

from ._request import (
CreateTrackRequest,
)
from ._publishergw import PublisherGateway

from craft_store.models.account_model import AccountModel as Account
from craft_store.models.registered_name_model import MediaModel as Media
from craft_store.models.registered_name_model import (
RegisteredNameModel as RegisteredName,
)
from craft_store.models.track_guardrail_model import (
TrackGuardrailModel as TrackGuardrail,
)
from craft_store.models.track_model import TrackModel as Track

__all__ = [
"Account",
"CreateTrackRequest",
"Media",
"RegisteredName",
"TrackGuardrail",
"Track",
"PublisherGateway",
]
123 changes: 123 additions & 0 deletions craft_store/publisher/_publishergw.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
#
# 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 warranty of
# MERCHANTABILITY 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/>.
"""Client for the publisher gateway."""
from __future__ import annotations

import re
from json import JSONDecodeError
from typing import TYPE_CHECKING

import httpx

from craft_store import errors, models
from craft_store._httpx_auth import CandidAuth
from craft_store.auth import Auth

if TYPE_CHECKING:
from . import _request


TRACK_NAME_REGEX = re.compile(r"^[a-zA-Z0-9](?:[_.-]?[a-zA-Z0-9])*$")
"""A regular expression guarding track names.
Retrieved from https://api.staging.charmhub.io/docs/default.html#create_tracks
"""


class PublisherGateway:
"""Client for the publisher gateway.
This class is a client wrapper for the Canonical Publisher Gateway.
The latest version of the server API can be seen at: https://api.charmhub.io/docs/
Each instance is only valid for one particular namespace.
"""

def __init__(self, base_url: str, namespace: str, auth: Auth) -> None:
self._namespace = namespace
self._client = httpx.Client(
base_url=base_url,
auth=CandidAuth(auth=auth, auth_type="macaroon"),
)

@staticmethod
def _check_error(response: httpx.Response) -> None:
if response.is_success:
return
try:
error_response = response.json()
except JSONDecodeError as exc:
raise errors.CraftStoreError(
f"Invalid response from server ({response.status_code})",
details=response.text,
) from exc
error_list = error_response.get("error-list", [])
if response.status_code >= 500:
brief = f"Store had an error ({response.status_code})"
else:
brief = f"Error {response.status_code} returned from store"
if len(error_list) == 1:
brief = f"{brief}: {error_list[0].get('message')}"
else:
fancy_error_list = errors.StoreErrorList(error_list)
brief = f"{brief}.\n{fancy_error_list}"
raise errors.CraftStoreError(
brief, store_errors=errors.StoreErrorList(error_list)
)

def get_package_metadata(self, name: str) -> models.RegisteredNameModel:
"""Get general metadata for a package.
:param name: The name of the package to query.
:returns: A dictionary matching the result from the publisher gateway.
API docs: https://api.charmhub.io/docs/default.html#package_metadata
"""
response = self._client.get(
url=f"/v1/{self._namespace}/{name}",
)
self._check_error(response)
return models.RegisteredNameModel.unmarshal(response.json()["metadata"])

def create_tracks(self, name: str, *tracks: _request.CreateTrackRequest) -> int:
"""Create one or more tracks in the store.
:param name: The store name (i.e. the specific charm, snap or other package)
to which this track will be attached.
:param tracks: Each track is a dictionary mapping query values.
:returns: The number of tracks created by the store.
:returns: InvalidRequestError if the name field of any passed track is invalid.
API docs: https://api.charmhub.io/docs/default.html#create_tracks
"""
bad_track_names = {
track["name"]
for track in tracks
if not TRACK_NAME_REGEX.match(track["name"]) or len(track["name"]) > 28
}
if bad_track_names:
bad_tracks = ", ".join(sorted(bad_track_names))
raise errors.InvalidRequestError(
f"The following track names are invalid: {bad_tracks}",
resolution="Ensure all tracks have valid names.",
)

response = self._client.post(
f"/v1/{self._namespace}/{name}/tracks", json=tracks
)
self._check_error(response)

return int(response.json()["num-tracks-created"])
Loading

0 comments on commit cee4eab

Please sign in to comment.