From 22c1a49f5a658991a2b2f7230d61b8389102b162 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 30 Jul 2024 21:06:54 -0400 Subject: [PATCH 01/59] build!: Update to pydantic 2 --- .github/renovate.json5 | 2 -- pyproject.toml | 13 +++++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 1b1554b23..d6fc38d3b 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -4,8 +4,6 @@ ignoreDeps: [ // Each ignore is probably connected with an ignore in pyproject.toml. // Ensure you change this and those simultaneously. - "pydantic", // Needs to wait for libraries. - "pydantic-yaml", // Needs to wait for pydantic "urllib3", "windows", // We'll update Windows versions manually. ], diff --git a/pyproject.toml b/pyproject.toml index 4476ed8e4..2f5ed748e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,20 +4,21 @@ dynamic = ["version"] description = "The main tool to build, upload, and develop in general the Juju charms." readme = "README.md" dependencies = [ - "craft-application~=3.0", + # TODO: Undo these + "craft-application@git+https://github.com/canonical/craft-application@work/345/pydantic-2", "craft-cli>=2.3.0", - "craft-parts>=1.18", + "craft-grammar@git+https://github.com/canonical/craft-grammar@feature/pydantic-2", + "craft-parts@git+https://github.com/canonical/craft-parts@feature/2.0", + "craft-providers@git+https://github.com/canonical/craft-providers@feature/2.0-git-modules", "craft-platforms~=0.1", "craft-providers>=1.23.0", - "craft-store>=2.4", + "craft-store@git+https://github.com/canonical/craft-store@feature/3.0", "distro>=1.3.0", "docker>=7.0.0", "humanize>=2.6.0", "jsonschema", "jinja2", - # Pydantic will need to be updated all at once with our libraries. - # When you update it, remove the pydantic constraints from renovate. - "pydantic>=1.10,<2.0", + "pydantic~=2.0", "python-dateutil", "pyyaml", "requests", From 0fba6af1d4453052d0d5ef0028ea00bf3422c2dd Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 30 Jul 2024 21:29:39 -0400 Subject: [PATCH 02/59] chore: run bump-pydantic --- charmcraft/models/basic.py | 2 ++ charmcraft/models/config.py | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/charmcraft/models/basic.py b/charmcraft/models/basic.py index da15ca4aa..efc2c7cdc 100644 --- a/charmcraft/models/basic.py +++ b/charmcraft/models/basic.py @@ -33,6 +33,8 @@ class CustomStrictStr(pydantic.StrictStr): """Generic class to create custom strict strings validated by pydantic.""" @classmethod + # TODO[pydantic]: We couldn't refactor `__get_validators__`, please create the `__get_pydantic_core_schema__` manually. + # Check https://docs.pydantic.dev/latest/migration/#defining-custom-types for more information. def __get_validators__(cls): """Yield the relevant validators.""" yield from super().__get_validators__() diff --git a/charmcraft/models/config.py b/charmcraft/models/config.py index 85cb57325..ce461a18b 100644 --- a/charmcraft/models/config.py +++ b/charmcraft/models/config.py @@ -20,6 +20,8 @@ import pydantic from charmcraft.models.basic import ModelConfigDefaults +from pydantic import StringConstraints +from typing_extensions import Annotated class _BaseJujuOption(ModelConfigDefaults, frozen=True): @@ -66,7 +68,7 @@ class JujuSecretOption(_BaseJujuOption, frozen=True): # that anyone would know what the secret ID (specific to # the deployment in a model) is at the time that they are # writing the config, but included for completeness. - default: Annotated[str, pydantic.constr(regex=r"^secret:[a-z0-9]{20}$")] | None = None + default: Annotated[str, Annotated[str, StringConstraints(pattern=r"^secret:[a-z0-9]{20}$")]] | None = None JujuOption = Annotated[ From 9ca1e06f3f81ea80f2b9ae53ec19639f17dd9392 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 30 Jul 2024 22:24:55 -0400 Subject: [PATCH 03/59] chore: make most model tests pass BuildPlanner tests are still failing due to validation issues. --- charmcraft/config.py | 80 +-- charmcraft/linters.py | 2 +- charmcraft/metafiles/actions.py | 2 +- charmcraft/metafiles/config.py | 2 +- charmcraft/models/actions.py | 5 +- charmcraft/models/basic.py | 115 ++-- charmcraft/models/charmcraft.py | 563 +++++++++--------- charmcraft/models/config.py | 22 +- charmcraft/models/extension.py | 4 +- charmcraft/models/metadata.py | 8 +- charmcraft/models/project.py | 151 ++--- charmcraft/parts/__init__.py | 2 +- charmcraft/parts/bundle.py | 2 +- charmcraft/parts/charm.py | 17 +- charmcraft/parts/reactive.py | 2 +- charmcraft/services/package.py | 2 +- tests/extensions/test_extensions.py | 2 +- tests/test_models.py | 26 +- tests/unit/models/test_config.py | 16 +- tests/unit/models/test_metadata.py | 4 +- tests/unit/models/test_project.py | 24 +- .../models/valid_charms_yaml/full-bases.yaml | 5 +- .../valid_charms_yaml/full-platforms.yaml | 5 +- tests/unit/services/test_package.py | 6 +- 24 files changed, 489 insertions(+), 578 deletions(-) diff --git a/charmcraft/config.py b/charmcraft/config.py index d21ccfaf2..d434ae104 100644 --- a/charmcraft/config.py +++ b/charmcraft/config.py @@ -78,43 +78,43 @@ get_managed_environment_project_path, is_charmcraft_running_in_managed_mode, ) -from charmcraft.models.charmcraft import CharmcraftConfig, Project -from charmcraft.utils import load_yaml - - -def load(dirpath: str | None) -> CharmcraftConfig: - """Load the config from charmcraft.yaml in the indicated directory.""" - if dirpath is None: - if is_charmcraft_running_in_managed_mode(): - path = get_managed_environment_project_path() - else: - path = pathlib.Path.cwd() - else: - path = pathlib.Path(dirpath).expanduser().resolve() - - now = datetime.datetime.utcnow() - - content = load_yaml(path / const.CHARMCRAFT_FILENAME) - if content is None: - # configuration is mandatory only for some commands; when not provided, it will - # be initialized all with defaults (but marked as not provided for later verification) - return CharmcraftConfig( # pyright: ignore[reportCallIssue] - type="charm", - project=Project( - dirpath=path, - config_provided=False, - started_at=now, - ), - name="missing-charm-name", - summary="missing-charm-summary", - description="missing-charm-description", - ) - - return CharmcraftConfig.unmarshal( - content, - project=Project( - dirpath=path, - config_provided=True, - started_at=now, - ), - ) +# from charmcraft.models.charmcraft import Charmcraft, Project +# from charmcraft.utils import load_yaml +# +# +# def load(dirpath: str | None) -> Charmcraft: +# """Load the config from charmcraft.yaml in the indicated directory.""" +# if dirpath is None: +# if is_charmcraft_running_in_managed_mode(): +# path = get_managed_environment_project_path() +# else: +# path = pathlib.Path.cwd() +# else: +# path = pathlib.Path(dirpath).expanduser().resolve() +# +# now = datetime.datetime.utcnow() +# +# content = load_yaml(path / const.CHARMCRAFT_FILENAME) +# if content is None: +# # configuration is mandatory only for some commands; when not provided, it will +# # be initialized all with defaults (but marked as not provided for later verification) +# return CharmcraftConfig( # pyright: ignore[reportCallIssue] +# type="charm", +# project=Project( +# dirpath=path, +# config_provided=False, +# started_at=now, +# ), +# name="missing-charm-name", +# summary="missing-charm-summary", +# description="missing-charm-description", +# ) +# +# return CharmcraftConfig.unmarshal( +# content, +# project=Project( +# dirpath=path, +# config_provided=True, +# started_at=now, +# ), +# ) diff --git a/charmcraft/linters.py b/charmcraft/linters.py index 87e73a913..c90c415b6 100644 --- a/charmcraft/linters.py +++ b/charmcraft/linters.py @@ -588,7 +588,7 @@ def run(self, basedir: pathlib.Path) -> str: def analyze( - config: config.CharmcraftConfig, + config: "config.CharmcraftConfig", basedir: pathlib.Path, *, override_ignore_config: bool = False, diff --git a/charmcraft/metafiles/actions.py b/charmcraft/metafiles/actions.py index bbb248646..a78d1f599 100644 --- a/charmcraft/metafiles/actions.py +++ b/charmcraft/metafiles/actions.py @@ -69,7 +69,7 @@ def parse_actions_yaml(charm_dir, allow_broken=False): emit.debug(f"Validating {const.JUJU_ACTIONS_FILENAME}") try: - return JujuActions.parse_obj({"actions": actions}) + return JujuActions.model_validate({"actions": actions}) except pydantic.ValidationError as error: if allow_broken: emit.progress( diff --git a/charmcraft/metafiles/config.py b/charmcraft/metafiles/config.py index c61b4e9e5..20723286b 100644 --- a/charmcraft/metafiles/config.py +++ b/charmcraft/metafiles/config.py @@ -63,7 +63,7 @@ def parse_config_yaml(charm_dir: pathlib.Path, allow_broken=False) -> JujuConfig emit.debug(f"Validating {const.JUJU_CONFIG_FILENAME}") try: - return JujuConfig.parse_obj(config) + return JujuConfig.model_validate(config) except pydantic.ValidationError as error: if allow_broken: emit.progress( diff --git a/charmcraft/models/actions.py b/charmcraft/models/actions.py index 4864400d9..7c0bce730 100644 --- a/charmcraft/models/actions.py +++ b/charmcraft/models/actions.py @@ -19,12 +19,11 @@ import keyword import re +from craft_application.models import CraftBaseModel import pydantic -from charmcraft.models.basic import ModelConfigDefaults - -class JujuActions(ModelConfigDefaults, frozen=True): +class JujuActions(CraftBaseModel): """Juju actions for charms. See also: https://juju.is/docs/sdk/actions diff --git a/charmcraft/models/basic.py b/charmcraft/models/basic.py index efc2c7cdc..6b6975146 100644 --- a/charmcraft/models/basic.py +++ b/charmcraft/models/basic.py @@ -15,82 +15,47 @@ # For further info, check https://github.com/canonical/charmcraft """Charmcraft basic pydantic model.""" +from typing import Annotated import craft_application.models +import craft_parts.constraints import pydantic -class ModelConfigDefaults( - craft_application.models.CraftBaseModel, - frozen=True, # pyright: ignore[reportGeneralTypeIssues] - validate_all=True, - allow_population_by_field_name=False, - alias_generator=pydantic.BaseConfig.alias_generator, -): - """Define Charmcraft's defaults for the BaseModel configuration.""" - - -class CustomStrictStr(pydantic.StrictStr): - """Generic class to create custom strict strings validated by pydantic.""" - - @classmethod - # TODO[pydantic]: We couldn't refactor `__get_validators__`, please create the `__get_pydantic_core_schema__` manually. - # Check https://docs.pydantic.dev/latest/migration/#defining-custom-types for more information. - def __get_validators__(cls): - """Yield the relevant validators.""" - yield from super().__get_validators__() - yield cls.custom_validate - - -class RelativePath(CustomStrictStr): - """Constrained string which must be a relative path.""" - - @classmethod - def custom_validate(cls, value: str) -> str: - """Validate relative path. - - Check if it's an absolute path using POSIX's '/' (not os.path.sep, as the charm's - config is independent of the platform where charmcraft is running. - """ - if not value: - raise ValueError(f"{value!r} must be a valid relative path (cannot be empty)") - - if value[0] == "/": - raise ValueError(f"{value!r} must be a valid relative path (cannot start with '/')") - - return value - - -class AttributeName(CustomStrictStr): - """Constrained string that must match the name of an attribute from linters.CHECKERS.""" - - @classmethod - def custom_validate(cls, value: str) -> str: - """Validate attribute name.""" - from charmcraft import linters # import here to avoid cyclic imports - - valid_names = [ - checker.name - for checker in linters.CHECKERS - if checker.check_type == linters.CheckType.ATTRIBUTE - ] - if value not in valid_names: - raise ValueError(f"Bad attribute name {value!r}") - return value - - -class LinterName(CustomStrictStr): - """Constrained string that must match the name of a linter from linters.CHECKERS.""" - - @classmethod - def custom_validate(cls, value: str) -> str: - """Validate attribute name.""" - from charmcraft import linters # import here to avoid cyclic imports - - valid_names = [ - checker.name - for checker in linters.CHECKERS - if checker.check_type == linters.CheckType.LINT - ] - if value not in valid_names: - raise ValueError(f"Bad lint name {value!r}") - return value +def _validate_attribute_name(value: str) -> str: + """Validate attribute name.""" + from charmcraft import linters # import here to avoid cyclic imports + + valid_names = [ + checker.name + for checker in linters.CHECKERS + if checker.check_type == linters.CheckType.ATTRIBUTE + ] + if value not in valid_names: + raise ValueError(f"Bad attribute name {value!r}") + return value + + +def _validate_linter_name(value: str) -> str: + """Validate linter name.""" + from charmcraft import linters # import here to avoid cyclic imports + + valid_names = [ + checker.name + for checker in linters.CHECKERS + if checker.check_type == linters.CheckType.LINT + ] + if value not in valid_names: + raise ValueError(f"Bad lint name {value!r}") + return value + + + +RelativePath = craft_parts.constraints.RelativePathStr +AttributeName = Annotated[ # TODO: Turn this into a StrEnum + str, + pydantic.Field(strict=True), + pydantic.BeforeValidator(_validate_attribute_name), +] +LinterName = Annotated[ + str, pydantic.Field(strict=True), pydantic.BeforeValidator(_validate_linter_name), +] diff --git a/charmcraft/models/charmcraft.py b/charmcraft/models/charmcraft.py index 24e6de03e..e11dbcc57 100644 --- a/charmcraft/models/charmcraft.py +++ b/charmcraft/models/charmcraft.py @@ -20,6 +20,7 @@ import pathlib from typing import Any, Literal, cast +from craft_application.models import CraftBaseModel import pydantic from craft_application import util from craft_cli import CraftError @@ -30,20 +31,16 @@ from charmcraft.format import format_pydantic_errors from charmcraft.metafiles.actions import parse_actions_yaml from charmcraft.metafiles.config import parse_config_yaml -from charmcraft.metafiles.metadata import ( - parse_bundle_metadata_yaml, - parse_charm_metadata_yaml, -) +# from charmcraft.metafiles.metadata import ( +# parse_bundle_metadata_yaml, +# parse_charm_metadata_yaml, +# ) from charmcraft.models.actions import JujuActions -from charmcraft.models.basic import AttributeName, LinterName, ModelConfigDefaults +from charmcraft.models.basic import AttributeName, LinterName from charmcraft.models.config import JujuConfig -class CharmhubConfig( - ModelConfigDefaults, - alias_generator=lambda s: s.replace("_", "-"), - frozen=True, -): +class Charmhub(CraftBaseModel): """Definition of Charmhub endpoint configuration.""" api_url: pydantic.HttpUrl = cast(pydantic.HttpUrl, "https://api.charmhub.io") @@ -51,7 +48,7 @@ class CharmhubConfig( registry_url: pydantic.HttpUrl = cast(pydantic.HttpUrl, "https://registry.jujucharms.com") -class Base(ModelConfigDefaults, frozen=True): +class Base(CraftBaseModel): """Represents a base.""" name: pydantic.StrictStr @@ -69,11 +66,7 @@ def from_str_and_arch(cls, base_str: str, architectures: list[str]) -> Self: return cls(name=name, channel=channel, architectures=architectures) -class BasesConfiguration( - ModelConfigDefaults, - alias_generator=lambda s: s.replace("_", "-"), - frozen=True, -): +class BasesConfiguration(CraftBaseModel): """Definition of build-on/run-on combinations. Example:: @@ -96,7 +89,7 @@ class BasesConfiguration( run_on: list[Base] -class Project(ModelConfigDefaults, frozen=True): +class Project(CraftBaseModel): """Internal-only project configuration.""" # do not verify that `dirpath` is a valid existing directory; it's used externally as a dir @@ -109,293 +102,289 @@ class Project(ModelConfigDefaults, frozen=True): started_at: datetime.datetime -class Ignore(ModelConfigDefaults, frozen=True): +class Ignore(CraftBaseModel): """Definition of `analysis.ignore` configuration.""" attributes: list[AttributeName] = [] linters: list[LinterName] = [] -class AnalysisConfig(ModelConfigDefaults, allow_population_by_field_name=True, frozen=True): +class AnalysisConfig(CraftBaseModel): """Definition of `analysis` configuration.""" ignore: Ignore = Ignore() -class Links(ModelConfigDefaults, frozen=True): +class Links(CraftBaseModel): """Definition of `links` in metadata.""" - contact: pydantic.StrictStr | list[pydantic.StrictStr] | None + contact: pydantic.StrictStr | list[pydantic.StrictStr] | None = None """Instructions for contacting the owner of the charm.""" - documentation: pydantic.AnyHttpUrl | None + documentation: pydantic.AnyHttpUrl | None = None """The URL of the documentation for this charm.""" - issues: pydantic.AnyHttpUrl | list[pydantic.AnyHttpUrl] | None + issues: pydantic.AnyHttpUrl | list[pydantic.AnyHttpUrl] | None = None """A link to the issue tracker for this charm.""" - source: pydantic.AnyHttpUrl | list[pydantic.AnyHttpUrl] | None + source: pydantic.AnyHttpUrl | list[pydantic.AnyHttpUrl] | None = None """Where to find this charm's source code.""" - website: pydantic.AnyHttpUrl | list[pydantic.AnyHttpUrl] | None + website: pydantic.AnyHttpUrl | list[pydantic.AnyHttpUrl] | None = None """The website for this charm.""" -class CharmcraftConfig( - ModelConfigDefaults, - validate_all=False, - alias_generator=lambda s: s.replace("_", "-"), - frozen=True, -): - """Definition of charmcraft.yaml configuration.""" - - # this needs to go before 'parts', as it used by the validator - project: Project - - metadata_legacy: bool = False - - type: Literal["bundle", "charm"] - name: pydantic.StrictStr | None - summary: pydantic.StrictStr | None - description: pydantic.StrictStr | None - charmhub: CharmhubConfig = CharmhubConfig() - parts: dict[str, Any] | None - bases: list[BasesConfiguration] | None - analysis: AnalysisConfig = AnalysisConfig() - actions: JujuActions | None - assumes: list[str | dict[str, list | dict]] | None - containers: dict[str, Any] | None - devices: dict[str, Any] | None - title: pydantic.StrictStr | None - extra_bindings: dict[str, Any] | None - peers: dict[str, Any] | None - provides: dict[str, Any] | None - requires: dict[str, Any] | None - resources: dict[str, Any] | None - storage: dict[str, Any] | None - subordinate: bool | None - terms: list[str] | None - links: Links | None - config: JujuConfig | None - - @pydantic.validator("name", pre=True, always=True) - def validate_name(cls, name, values): - """Verify charm name is valid with exception when instantiated without YAML.""" - if values.get("type") == "charm" and not name: - raise ValueError("needs value") - - return name - - @pydantic.validator("summary", pre=True, always=True) - def validate_summary(cls, summary, values): - """Verify charm summary is valid with exception when instantiated without YAML.""" - if values.get("type") == "charm" and not summary: - raise ValueError("needs value") - - return summary - - @pydantic.validator("description", pre=True, always=True) - def validate_description(cls, description, values): - """Verify charm name is valid with exception when instantiated without YAML.""" - if values.get("type") == "charm" and not description: - raise ValueError("needs value") - - return description - - @pydantic.validator("parts", pre=True, always=True) - def validate_special_parts(cls, parts, values): - """Verify parts type (craft-parts will re-validate the schemas for the plugins).""" - if "type" not in values: - # we need 'type' to be set in this validator; if not there it's an error in - # the schema anyway, so the whole loading will fail (no need to raise an - # extra error here, it gets confusing to the user) - return None - - if not parts: - # no parts indicated, default to the type of package - parts = {values["type"]: {}} - - if not isinstance(parts, dict): - raise TypeError("value must be a dictionary") - - for name, part in parts.items(): - if not isinstance(part, dict): - raise TypeError(f"part {name!r} must be a dictionary") - # implicit plugin fixup - if "plugin" not in part: - part["plugin"] = name - - # if needed, create 'source' properties for special parts "charm" with plugin "charm". - # and "bundle" with plugin "bundle", pointing to project's directory - for name, part in parts.items(): - if name == "charm" and part["plugin"] == "charm": - part.setdefault("source", str(values["project"].dirpath)) - - if name == "bundle" and part["plugin"] == "bundle": - part.setdefault("source", str(values["project"].dirpath)) - - return parts - - @pydantic.validator("parts", each_item=True) - def validate_each_part(cls, item): - """Verify each part in the parts section. Craft-parts will re-validate them.""" - return parts.process_part_config(item) - - @pydantic.validator("bases", pre=True) - def validate_bases_presence(cls, bases, values): - """Forbid 'bases' in bundles. - - This is to avoid a possible confusion of expecting the bundle - to be built in a specific environment - """ - if values.get("type") == "bundle": - raise ValueError("Field not allowed when type=bundle") - return bases - - @pydantic.validator("actions", pre=True, always=True) - def validate_actions(cls, actions, values): - """Verify 'actions' in charms. - - Currently, actions will be passed through to the charms. - And individual "actions.yaml" should not exists when actions - is defined in charmcraft.yaml. - """ - actions_yaml = parse_actions_yaml(values["project"].dirpath, allow_broken=True) - if actions is None: - return actions_yaml - else: - if actions_yaml is not None: - raise ValueError( - "'actions.yaml' file not allowed when an 'actions' section is " - "defined in 'charmcraft.yaml'" - ) - - return JujuActions.parse_obj({"actions": actions}) - - @pydantic.validator("config", pre=True, always=True) - def validate_config(cls, config, values): - """Verify 'actions' in charms. - - Currently, actions will be passed through to the charms. - And individual "actions.yaml" should not exists when actions - is defined in charmcraft.yaml. - """ - config_yaml = parse_config_yaml(values["project"].dirpath, allow_broken=True) - if config is None: - return config_yaml - else: - if config_yaml is not None: - raise ValueError( - "'config.yaml' file not allowed when an 'config' section is " - "defined in 'charmcraft.yaml'" - ) - - return JujuConfig.parse_obj(config) - - @classmethod - def expand_short_form_bases(cls, bases: list[dict[str, Any]]) -> None: - """Expand short-form base configuration into long-form in-place.""" - for index, base in enumerate(bases): - # Skip if already long-form. Account for common typos in case user - # intends to use long-form, but did so incorrectly (for better - # error message handling). - if "run-on" in base or "run_on" in base or "build-on" in base or "build_on" in base: - continue - - try: - converted_base = Base(**base) - except pydantic.ValidationError as error: - # Rewrite location to assist user. - pydantic_errors = error.errors() - for pydantic_error in pydantic_errors: - pydantic_error["loc"] = ("bases", index, pydantic_error["loc"][0]) - - raise CraftError(format_pydantic_errors(pydantic_errors)) - - base.clear() - base["build-on"] = [converted_base.dict()] - base["run-on"] = [converted_base.dict()] - - @classmethod - def unmarshal( # pyright: ignore[reportIncompatibleMethodOverride] - cls, obj: dict[str, Any], project: Project - ): - """Unmarshal object with necessary translations and error handling. - - (1) Perform any necessary translations. - - (2) Standardize error reporting. - - :returns: valid CharmcraftConfig. - - :raises CraftError: On failure to unmarshal object. - """ - try: - # Expand short-form bases if only the bases is a valid list. If it - # is not a valid list, parse_obj() will properly handle the error. - if isinstance(obj.get("bases"), list): - cls.expand_short_form_bases(obj["bases"]) - - obj = apply_extensions(project.dirpath, obj) - - # Re-expand it in case extensions added short-form bases. - if isinstance(obj.get("bases"), list): - cls.expand_short_form_bases(obj["bases"]) - - # If metadata.yaml exists, try merge it into config. - if os.path.isfile(project.dirpath / const.METADATA_FILENAME): - # metadata.yaml exists, so we can't specify metadata keys in charmcraft.yaml. - for key in const.CHARM_METADATA_KEYS.union(const.METADATA_YAML_KEYS): - if key in obj: - raise CraftError( - f"Cannot specify '{key}' in charmcraft.yaml when " - f"'{const.METADATA_FILENAME}' exists" - ) - - if obj.get("type") == "charm": - metadata_legacy = parse_charm_metadata_yaml(project.dirpath, allow_basic=True) - - # need to copy 3 fields from metadata_legacy to charmcraft config - return cls.parse_obj( - { - "project": project, - "name": metadata_legacy.name, - "summary": metadata_legacy.summary, - "description": metadata_legacy.description, - "metadata-legacy": True, - **obj, - } - ) - elif obj.get("type") == "bundle": - # bundle may not have metadata.yaml. - # but if it does, it should have name and optional description - # metadata.yaml will be copied without validation if it exists - metadata_legacy = parse_bundle_metadata_yaml(project.dirpath) - return cls.parse_obj( - { - "project": project, - "name": metadata_legacy.name, - "description": metadata_legacy.description, - "metadata-legacy": True, - **obj, - } - ) - else: - # fallthrough for pydantic to handle - pass - - return cls.parse_obj({"project": project, **obj}) - except pydantic.ValidationError as error: - raise CraftError(format_pydantic_errors(error.errors())) - - @classmethod - def schema( # pyright: ignore[reportIncompatibleMethodOverride] - cls, **kwargs - ) -> dict[str, Any]: - """Perform any schema fixups required to hide internal details.""" - schema = super().schema(**kwargs) - - # The internal __root__ detail is leaked, overwrite it. - schema["properties"]["parts"]["default"] = {} - - # Project is an internal detail, purge references. - schema["definitions"].pop("Project", None) - schema["properties"].pop("project", None) - schema["required"].remove("project") - return schema +# class CharmcraftModel(CraftBaseModel): +# """Definition of charmcraft.yaml configuration.""" +# +# # this needs to go before 'parts', as it used by the validator +# project: Project +# +# metadata_legacy: bool = False +# +# type: Literal["bundle", "charm"] +# name: pydantic.StrictStr | None +# summary: pydantic.StrictStr | None +# description: pydantic.StrictStr | None +# charmhub: Charmhub = Charmhub() +# parts: dict[str, Any] | None +# bases: list[BasesConfiguration] | None +# analysis: AnalysisConfig = AnalysisConfig() +# actions: JujuActions | None +# assumes: list[str | dict[str, list | dict]] | None +# containers: dict[str, Any] | None +# devices: dict[str, Any] | None +# title: pydantic.StrictStr | None +# extra_bindings: dict[str, Any] | None +# peers: dict[str, Any] | None +# provides: dict[str, Any] | None +# requires: dict[str, Any] | None +# resources: dict[str, Any] | None +# storage: dict[str, Any] | None +# subordinate: bool | None +# terms: list[str] | None +# links: Links | None +# juju_config: JujuConfig | None = pydantic.Field(default=None, alias="config") +# +# @pydantic.field_validator("name", mode="before") +# @classmethod +# def _validate_name(cls, name: str, info: pydantic.ValidationInfo): +# """Verify charm name is valid with exception when instantiated without YAML.""" +# if info.data.get("type") == "charm" and not name: +# raise ValueError("needs value") +# +# return name +# +# @pydantic.validator("summary", pre=True, always=True) +# def validate_summary(cls, summary, values): +# """Verify charm summary is valid with exception when instantiated without YAML.""" +# if values.get("type") == "charm" and not summary: +# raise ValueError("needs value") +# +# return summary +# +# @pydantic.validator("description", pre=True, always=True) +# def validate_description(cls, description, values): +# """Verify charm name is valid with exception when instantiated without YAML.""" +# if values.get("type") == "charm" and not description: +# raise ValueError("needs value") +# +# return description +# +# @pydantic.validator("parts", pre=True, always=True) +# def validate_special_parts(cls, parts, values): +# """Verify parts type (craft-parts will re-validate the schemas for the plugins).""" +# if "type" not in values: +# # we need 'type' to be set in this validator; if not there it's an error in +# # the schema anyway, so the whole loading will fail (no need to raise an +# # extra error here, it gets confusing to the user) +# return None +# +# if not parts: +# # no parts indicated, default to the type of package +# parts = {values["type"]: {}} +# +# if not isinstance(parts, dict): +# raise TypeError("value must be a dictionary") +# +# for name, part in parts.items(): +# if not isinstance(part, dict): +# raise TypeError(f"part {name!r} must be a dictionary") +# # implicit plugin fixup +# if "plugin" not in part: +# part["plugin"] = name +# +# # if needed, create 'source' properties for special parts "charm" with plugin "charm". +# # and "bundle" with plugin "bundle", pointing to project's directory +# for name, part in parts.items(): +# if name == "charm" and part["plugin"] == "charm": +# part.setdefault("source", str(values["project"].dirpath)) +# +# if name == "bundle" and part["plugin"] == "bundle": +# part.setdefault("source", str(values["project"].dirpath)) +# +# return parts +# +# @pydantic.validator("parts", each_item=True) +# def validate_each_part(cls, item): +# """Verify each part in the parts section. Craft-parts will re-validate them.""" +# return parts.process_part_config(item) +# +# @pydantic.validator("bases", pre=True) +# def validate_bases_presence(cls, bases, values): +# """Forbid 'bases' in bundles. +# +# This is to avoid a possible confusion of expecting the bundle +# to be built in a specific environment +# """ +# if values.get("type") == "bundle": +# raise ValueError("Field not allowed when type=bundle") +# return bases +# +# @pydantic.validator("actions", pre=True, always=True) +# def validate_actions(cls, actions, values): +# """Verify 'actions' in charms. +# +# Currently, actions will be passed through to the charms. +# And individual "actions.yaml" should not exists when actions +# is defined in charmcraft.yaml. +# """ +# actions_yaml = parse_actions_yaml(values["project"].dirpath, allow_broken=True) +# if actions is None: +# return actions_yaml +# else: +# if actions_yaml is not None: +# raise ValueError( +# "'actions.yaml' file not allowed when an 'actions' section is " +# "defined in 'charmcraft.yaml'" +# ) +# +# return JujuActions.model_validate({"actions": actions}) +# +# @pydantic.validator("juju_config", pre=True, always=True) +# def validate_config(cls, config, values): +# """Verify 'actions' in charms. +# +# Currently, actions will be passed through to the charms. +# And individual "actions.yaml" should not exists when actions +# is defined in charmcraft.yaml. +# """ +# config_yaml = parse_config_yaml(values["project"].dirpath, allow_broken=True) +# if config is None: +# return config_yaml +# else: +# if config_yaml is not None: +# raise ValueError( +# "'config.yaml' file not allowed when an 'config' section is " +# "defined in 'charmcraft.yaml'" +# ) +# +# return JujuConfig.model_validate(config) +# +# @classmethod +# def expand_short_form_bases(cls, bases: list[dict[str, Any]]) -> None: +# """Expand short-form base configuration into long-form in-place.""" +# for index, base in enumerate(bases): +# # Skip if already long-form. Account for common typos in case user +# # intends to use long-form, but did so incorrectly (for better +# # error message handling). +# if "run-on" in base or "run_on" in base or "build-on" in base or "build_on" in base: +# continue +# +# try: +# converted_base = Base(**base) +# except pydantic.ValidationError as error: +# # Rewrite location to assist user. +# pydantic_errors = error.errors() +# for pydantic_error in pydantic_errors: +# pydantic_error["loc"] = ("bases", index, pydantic_error["loc"][0]) +# +# raise CraftError(format_pydantic_errors(pydantic_errors)) +# +# base.clear() +# base["build-on"] = [converted_base.dict()] +# base["run-on"] = [converted_base.dict()] +# +# @classmethod +# def unmarshal( # pyright: ignore[reportIncompatibleMethodOverride] +# cls, obj: dict[str, Any], project: Project +# ): +# """Unmarshal object with necessary translations and error handling. +# +# (1) Perform any necessary translations. +# +# (2) Standardize error reporting. +# +# :returns: valid CharmcraftConfig. +# +# :raises CraftError: On failure to unmarshal object. +# """ +# try: +# # Expand short-form bases if only the bases is a valid list. If it +# # is not a valid list, max_length() will properly handle the error. +# if isinstance(obj.get("bases"), list): +# cls.expand_short_form_bases(obj["bases"]) +# +# obj = apply_extensions(project.dirpath, obj) +# +# # Re-expand it in case extensions added short-form bases. +# if isinstance(obj.get("bases"), list): +# cls.expand_short_form_bases(obj["bases"]) +# +# # If metadata.yaml exists, try merge it into config. +# if os.path.isfile(project.dirpath / const.METADATA_FILENAME): +# # metadata.yaml exists, so we can't specify metadata keys in charmcraft.yaml. +# for key in const.CHARM_METADATA_KEYS.union(const.METADATA_YAML_KEYS): +# if key in obj: +# raise CraftError( +# f"Cannot specify '{key}' in charmcraft.yaml when " +# f"'{const.METADATA_FILENAME}' exists" +# ) +# +# if obj.get("type") == "charm": +# metadata_legacy = parse_charm_metadata_yaml(project.dirpath, allow_basic=True) +# +# # need to copy 3 fields from metadata_legacy to charmcraft config +# return cls.model_validate( +# { +# "project": project, +# "name": metadata_legacy.name, +# "summary": metadata_legacy.summary, +# "description": metadata_legacy.description, +# "metadata-legacy": True, +# **obj, +# } +# ) +# elif obj.get("type") == "bundle": +# # bundle may not have metadata.yaml. +# # but if it does, it should have name and optional description +# # metadata.yaml will be copied without validation if it exists +# metadata_legacy = parse_bundle_metadata_yaml(project.dirpath) +# return cls.model_validate( +# { +# "project": project, +# "name": metadata_legacy.name, +# "description": metadata_legacy.description, +# "metadata-legacy": True, +# **obj, +# } +# ) +# else: +# # fallthrough for pydantic to handle +# pass +# +# return cls.model_validate({"project": project, **obj}) +# except pydantic.ValidationError as error: +# raise CraftError(format_pydantic_errors(error.errors())) +# +# @classmethod +# def schema( # pyright: ignore[reportIncompatibleMethodOverride] +# cls, **kwargs +# ) -> dict[str, Any]: +# """Perform any schema fixups required to hide internal details.""" +# schema = super().schema(**kwargs) +# +# # The internal __root__ detail is leaked, overwrite it. +# schema["properties"]["parts"]["default"] = {} +# +# # Project is an internal detail, purge references. +# schema["definitions"].pop("Project", None) +# schema["properties"].pop("project", None) +# schema["required"].remove("project") +# return schema diff --git a/charmcraft/models/config.py b/charmcraft/models/config.py index ce461a18b..09445b245 100644 --- a/charmcraft/models/config.py +++ b/charmcraft/models/config.py @@ -17,50 +17,48 @@ """Charmcraft Juju Config pydantic model.""" from typing import Annotated, Literal +from craft_application.models import CraftBaseModel import pydantic -from charmcraft.models.basic import ModelConfigDefaults -from pydantic import StringConstraints from typing_extensions import Annotated -class _BaseJujuOption(ModelConfigDefaults, frozen=True): +class _BaseJujuOption(CraftBaseModel): """A Juju option field. Do not use (use the child classes below).""" - type: str description: str | None = None default: str | int | float | bool | None = None -class JujuStringOption(_BaseJujuOption, frozen=True): +class JujuStringOption(_BaseJujuOption): """A Juju option field containing a string.""" type: Literal["string"] default: str | None = None -class JujuIntOption(_BaseJujuOption, frozen=True): +class JujuIntOption(_BaseJujuOption): """A Juju option field containing an integer.""" type: Literal["int"] default: pydantic.StrictInt | None = None -class JujuFloatOption(_BaseJujuOption, frozen=True): +class JujuFloatOption(_BaseJujuOption): """A Juju option field containing a floating-point number.""" type: Literal["float"] default: float | None = None -class JujuBooleanOption(_BaseJujuOption, frozen=True): +class JujuBooleanOption(_BaseJujuOption): """A Juju option field containing a boolean value.""" type: Literal["boolean"] default: bool | None = None -class JujuSecretOption(_BaseJujuOption, frozen=True): +class JujuSecretOption(_BaseJujuOption): """A Juju option field containing a secret ID.""" type: Literal["secret"] @@ -68,7 +66,7 @@ class JujuSecretOption(_BaseJujuOption, frozen=True): # that anyone would know what the secret ID (specific to # the deployment in a model) is at the time that they are # writing the config, but included for completeness. - default: Annotated[str, Annotated[str, StringConstraints(pattern=r"^secret:[a-z0-9]{20}$")]] | None = None + default: Annotated[str, pydantic.StringConstraints(pattern=r"^secret:[a-z0-9]{20}$")] | None = None JujuOption = Annotated[ @@ -77,11 +75,11 @@ class JujuSecretOption(_BaseJujuOption, frozen=True): ] -class JujuConfig(ModelConfigDefaults, frozen=True): +class JujuConfig(CraftBaseModel): """Juju configs for charms. See also: https://juju.is/docs/sdk/config and: https://juju.is/docs/sdk/config-yaml """ - options: dict[str, JujuOption] | None + options: dict[str, JujuOption] | None = None diff --git a/charmcraft/models/extension.py b/charmcraft/models/extension.py index c22057870..f3f32e5b7 100644 --- a/charmcraft/models/extension.py +++ b/charmcraft/models/extension.py @@ -16,11 +16,11 @@ """Extension models.""" from typing import Any -from charmcraft.models.basic import ModelConfigDefaults +from charmcraft.models.basic import CharmcraftModel # Mypy complaining about frozen inheritance. -class ExtensionModel(ModelConfigDefaults, frozen=True): # type: ignore[misc] +class ExtensionModel(CraftBaseModel): # type: ignore[misc] """Extension model for presentation.""" name: str diff --git a/charmcraft/models/metadata.py b/charmcraft/models/metadata.py index 567167c61..2078f3850 100644 --- a/charmcraft/models/metadata.py +++ b/charmcraft/models/metadata.py @@ -85,7 +85,7 @@ def from_charm(cls, charm: Charm) -> Self: if "title" in charm_dict: charm_dict["display-name"] = charm_dict.pop("title") - return cls.parse_obj(charm_dict) + return cls.model_validate(charm_dict) class CharmMetadataLegacy(CharmMetadata): @@ -122,7 +122,7 @@ def unmarshal(cls, data: dict[str, Any]) -> Self: data["maintainers"] = [data["maintainer"]] del data["maintainer"] - return cls.parse_obj(data) + return cls.model_validate(data) class BundleMetadata(models.BaseMetadata): @@ -136,6 +136,6 @@ def from_bundle(cls, bundle: Bundle) -> Self: """Turn a populated bundle model into a metadata.yaml model.""" bundle_dict = bundle.marshal() if "bundle" in bundle_dict: - return cls.parse_obj(bundle_dict["bundle"]) + return cls.model_validate(bundle_dict["bundle"]) del bundle_dict["type"] - return cls.parse_obj(bundle_dict) + return cls.model_validate(bundle_dict) diff --git a/charmcraft/models/project.py b/charmcraft/models/project.py index fc68c2a34..470af6b07 100644 --- a/charmcraft/models/project.py +++ b/charmcraft/models/project.py @@ -22,12 +22,14 @@ import textwrap from collections.abc import Iterable, Iterator from typing import ( + Annotated, Any, Literal, cast, ) import pydantic +import pydantic.v1 from craft_application import errors, models, util from craft_application.util import safe_yaml_load from craft_cli import CraftError @@ -45,7 +47,7 @@ from charmcraft.models.charmcraft import ( AnalysisConfig, BasesConfiguration, - CharmhubConfig, + Charmhub, Links, ) from charmcraft.parts import process_part_config @@ -65,56 +67,41 @@ class BaseDict(TypedDict, total=False): LongFormBasesDict = TypedDict( "LongFormBasesDict", {"build-on": list[BaseDict], "run-on": list[BaseDict]} ) - - -class CharmcraftSummaryStr(models.SummaryStr): - """A brief summary of this charm or bundle. Ideally, this should fit into one line.""" - +CharmcraftSummaryStr = Annotated[ + str, + models.SummaryStr, + pydantic.StringConstraints(max_length=200), # Maximum length was set to 200 characters because the 78 character maximum # inherited from craft-application is too restrictive, as several hundred charms # already exceed this maximum. # Eventually this limit will be reduced, ideally to 78 characters, though that may # never happen entirely. Reductions will only occur on major releases. # https://github.com/canonical/charmcraft/issues/1598 - max_length = 200 +] -class CharmPlatform(pydantic.ConstrainedStr): - """The platform string for a charm file. +def get_charm_file_platform_str(bases: Iterable[charmcraft.Base]) -> str: + """Get the "platform" section of a charm file name from an iterable of bases.""" + base_strings = [] + for base in bases: + name = base.name + version = base.channel + architectures = "-".join(base.architectures) + base_strings.append(f"{name}-{version}-{architectures}") + return "_".join(base_strings) - This is to be generated in the form of the bases config in a charm file name. - A charm's filename may look as follows: - "{name}_{base0}_{base1}_{base...}.charm" - where each base takes the form of: - "{base_name}-{version}-{arch0}-{arch1}-{arch...}" - For example, a charm called "test" that's built to run on Alma Linux 9 and Ubuntu 22.04 - on s390x and riscv64 platforms will have the name: - test_almalinux-9-riscv64-s390x_ubuntu-22.04-riscv64-s390x.charm - """ - - min_length = 4 - strict = True - strip_whitespace = True - _host_arch = util.get_host_architecture() - - @classmethod - def from_bases(cls: type[Self], bases: Iterable[charmcraft.Base]) -> Self: - """Generate a platform name from a list of charm bases.""" - base_strings = [] - for base in bases: - name = base.name - version = base.channel - architectures = "-".join(base.architectures) - base_strings.append(f"{name}-{version}-{architectures}") - return cls("_".join(base_strings)) +CharmPlatform = Annotated[ + str, + pydantic.StringConstraints(min_length=4, strict=True) +] class Platform(models.CraftBaseModel): """Project platform definition.""" - build_on: list[CharmArch] = pydantic.Field(min_items=1) - build_for: list[CharmArch | Literal["all"]] = pydantic.Field(min_items=1, max_items=1) + build_on: list[CharmArch] = pydantic.Field(min_length=1) + build_for: list[CharmArch | Literal["all"]] = pydantic.Field(min_length=1, max_length=1) @pydantic.validator("build_on", "build_for", pre=True) def _listify_architectures(cls, value: str | list[str]) -> list[str]: @@ -128,11 +115,11 @@ class CharmLib(models.CraftBaseModel): lib: str = pydantic.Field( title="Library Path (e.g. my-charm.my_library)", - regex=r"[a-z][a-z0-9_-]+\.[a-z][a-z0-9_]+", + pattern=r"[a-z][a-z0-9_-]+\.[a-z][a-z0-9_]+", ) version: str = pydantic.Field( title="Version filter for the charm. Either an API version or a specific [api].[patch].", - regex=r"[0-9]+(\.[0-9]+)?", + pattern=r"[0-9]+(\.[0-9]+)?", ) @pydantic.validator("lib", pre=True) @@ -237,7 +224,7 @@ def from_build_on_run_on( build_for = "-".join(sorted(all_architectures)) - platform = CharmPlatform.from_bases(run_on) + platform = get_charm_file_platform_str(run_on) return cls( platform=platform, @@ -441,9 +428,9 @@ class CharmcraftProject(models.Project, metaclass=abc.ABCMeta): """ type: Literal["charm", "bundle"] - title: models.ProjectTitle | None - summary: CharmcraftSummaryStr | None - description: str | None + title: models.ProjectTitle | None = None + summary: CharmcraftSummaryStr | None = None + description: str | None = None analysis: AnalysisConfig | None = pydantic.Field( default=None, @@ -454,7 +441,7 @@ class CharmcraftProject(models.Project, metaclass=abc.ABCMeta): Currently the only options are to ignore attributes or linters.""" ), ) - charmhub: CharmhubConfig | None = pydantic.Field( + charmhub: Charmhub | None = pydantic.Field( default=None, description="(DEPRECATED): Configuration for accessing charmhub." ) parts: dict[str, dict[str, Any]] = pydantic.Field(default_factory=dict) @@ -486,7 +473,7 @@ def started_at(self) -> datetime.datetime: def unmarshal(cls, data: dict[str, Any]): """Create a Charmcraft project from a dictionary of data.""" if cls is not CharmcraftProject: - return cls.parse_obj(data) + return cls.model_validate(data) project_type = data.get("type") if project_type == "charm": if "bases" in data: @@ -582,17 +569,8 @@ def validate_each_part(cls, item): return process_part_config(item) -class BasesCharm(CharmcraftProject): - """A charm using the deprecated ``bases`` keyword. - - This type of charm only supports the following bases: - - Ubuntu 18.04 - - Ubuntu 20.04 - - Ubuntu 22.04 - - CentOS 7 - - Alma Linux 9 - """ - +class CharmProject(CharmcraftProject): + """A base class for all charm types.""" type: Literal["charm"] """The type of project. Must be the string ``charm``.""" name: models.ProjectName = pydantic.Field( @@ -615,14 +593,6 @@ class BasesCharm(CharmcraftProject): description="A brief (one-line) summary of your charm.", ) description: str = pydantic.Field(description="A multi-line summary of your charm.") - platforms: None = None # type: ignore[assignment] - - # This is defined this way because using conlist makes mypy sad and using - # a ConstrainedList child class has pydantic issues. This appears to be - # solved with Pydantic 2. - bases: list[BasesConfiguration] = pydantic.Field(min_items=1) - - base: None = None parts: dict[str, dict[str, Any]] = pydantic.Field( default={"charm": {"plugin": "charm", "source": "."}}, @@ -1020,6 +990,29 @@ class BasesCharm(CharmcraftProject): ], ) + +class BasesCharm(CharmProject): + """A charm using the deprecated ``bases`` keyword. + + This type of charm only supports the following bases: + - Ubuntu 18.04 + - Ubuntu 20.04 + - Ubuntu 22.04 + - CentOS 7 + - Alma Linux 9 + """ + + platforms: None = None # type: ignore[assignment] + + # This is defined this way because using conlist makes mypy sad and using + # a ConstrainedList child class has pydantic issues. This appears to be + # solved with Pydantic 2. + bases: list[BasesConfiguration] = pydantic.Field(min_length=1) + + base: None = None + + + @pydantic.validator("bases", pre=True, each_item=True, allow_reuse=True) def _validate_base(cls, base: BaseDict | LongFormBasesDict) -> LongFormBasesDict: """Expand short-form bases into long-form bases.""" @@ -1052,35 +1045,15 @@ def _check_base_is_legacy(base: BaseDict) -> bool: return base in ({"name": "centos", "channel": "7"}, {"name": "almalinux", "channel": "9"}) -class PlatformCharm(CharmcraftProject): +class PlatformCharm(CharmProject): """Model for defining a charm using Platforms.""" - type: Literal["charm"] - name: models.ProjectName - summary: CharmcraftSummaryStr - description: str - # Silencing pyright because it complains about missing default value base: BaseStr # pyright: ignore[reportGeneralTypeIssues] build_base: BuildBaseStr | None = None platforms: dict[str, Platform | None] # type: ignore[assignment] parts: dict[str, dict[str, Any]] # pyright: ignore[reportGeneralTypeIssues] - actions: dict[str, Any] | None - assumes: list[str | dict[str, list | dict]] | None - containers: dict[str, Any] | None - devices: dict[str, Any] | None - extra_bindings: dict[str, Any] | None - peers: dict[str, Any] | None - provides: dict[str, Any] | None - requires: dict[str, Any] | None - resources: dict[str, Any] | None - storage: dict[str, Any] | None - subordinate: bool | None - terms: list[str] | None - links: Links | None - config: dict[str, Any] | None - @staticmethod def _check_base_is_legacy(base: BaseDict) -> bool: """Check that the given base is a legacy base, usable with 'bases'.""" @@ -1114,13 +1087,13 @@ class Bundle(CharmcraftProject): type: Literal["bundle"] bundle: dict[str, Any] = {} name: models.ProjectName | None = None # type: ignore[assignment] - title: models.ProjectTitle | None - summary: CharmcraftSummaryStr | None - description: pydantic.StrictStr | None - charmhub: CharmhubConfig = CharmhubConfig() + title: models.ProjectTitle | None = None + summary: CharmcraftSummaryStr | None = None + description: pydantic.StrictStr | None = None platforms: None = None # type: ignore[assignment] - @pydantic.root_validator(pre=True) + @pydantic.model_validator(mode="before") + @classmethod def preprocess_bundle(cls, values: dict[str, Any]) -> dict[str, Any]: """Preprocess any values that charmcraft infers, before attribute validation.""" if "name" not in values: diff --git a/charmcraft/parts/__init__.py b/charmcraft/parts/__init__.py index 11341631c..0e885564a 100644 --- a/charmcraft/parts/__init__.py +++ b/charmcraft/parts/__init__.py @@ -70,7 +70,7 @@ def process_part_config(data: dict[str, Any]) -> dict[str, Any]: # get plugin properties data if it's model based (otherwise it's empty), and # update with the received config - if isinstance(plugin_properties, plugins.PluginModel): + if isinstance(plugin_properties, plugins.PluginProperties): full_config = plugin_properties.dict(by_alias=True) else: full_config = {} diff --git a/charmcraft/parts/bundle.py b/charmcraft/parts/bundle.py index aac1082e7..8a7adf876 100644 --- a/charmcraft/parts/bundle.py +++ b/charmcraft/parts/bundle.py @@ -21,7 +21,7 @@ from craft_parts import plugins -class BundlePluginProperties(plugins.PluginProperties, plugins.PluginModel): +class BundlePluginProperties(plugins.PluginProperties, frozen=True): """Properties used to pack bundles.""" source: str diff --git a/charmcraft/parts/charm.py b/charmcraft/parts/charm.py index 9eb76e2be..379fc78e6 100644 --- a/charmcraft/parts/charm.py +++ b/charmcraft/parts/charm.py @@ -39,7 +39,7 @@ PACKAGE_NAME_REGEX = re.compile(r"[A-Za-z0-9_.-]+") -class CharmPluginProperties(plugins.PluginProperties, plugins.PluginModel): +class CharmPluginProperties(plugins.PluginProperties, frozen=True): """Properties used in charm building.""" source: str @@ -152,21 +152,6 @@ def validate_strict_dependencies( return charm_strict_dependencies - @classmethod - def unmarshal(cls, data: dict[str, Any]): - """Populate charm properties from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise pydantic.ValidationError: If validation fails. - """ - plugin_data = plugins.extract_plugin_properties( - data, plugin_name="charm", required=["source"] - ) - return cls(**plugin_data) - class CharmPlugin(plugins.Plugin): """Build the charm and prepare for packing. diff --git a/charmcraft/parts/reactive.py b/charmcraft/parts/reactive.py index 9f7b54bd4..1203f79a1 100644 --- a/charmcraft/parts/reactive.py +++ b/charmcraft/parts/reactive.py @@ -26,7 +26,7 @@ from craft_parts.errors import PluginEnvironmentValidationError -class ReactivePluginProperties(plugins.PluginProperties, plugins.PluginModel): +class ReactivePluginProperties(plugins.PluginProperties, frozen=True): """Properties used to pack reactive charms using charm-tools.""" source: str diff --git a/charmcraft/services/package.py b/charmcraft/services/package.py index fa7950a4f..28caaa9ee 100644 --- a/charmcraft/services/package.py +++ b/charmcraft/services/package.py @@ -116,7 +116,7 @@ def get_charm_path(self, dest_dir: pathlib.Path) -> pathlib.Path: """Get a charm file name for the appropriate set of run-on bases.""" if self._platform: return dest_dir / f"{self._project.name}_{self._platform}.charm" - build_plan = models.CharmcraftBuildPlanner.parse_obj( + build_plan = models.CharmcraftBuildPlanner.model_validate( self._project.marshal() ).get_build_plan() platform = utils.get_os_platform() diff --git a/tests/extensions/test_extensions.py b/tests/extensions/test_extensions.py index 906e3af69..2fd5ce162 100644 --- a/tests/extensions/test_extensions.py +++ b/tests/extensions/test_extensions.py @@ -21,7 +21,7 @@ from overrides import override from charmcraft import const, errors, extensions -from charmcraft.config import load +# from charmcraft.config import load from charmcraft.extensions.extension import Extension diff --git a/tests/test_models.py b/tests/test_models.py index 6542a32dd..9f9437b44 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -20,7 +20,7 @@ from craft_application import util from craft_cli import CraftError from pydantic import AnyHttpUrl -from pydantic.tools import parse_obj_as +from pydantic.tools import max_length_as from charmcraft.config import load from charmcraft.metafiles.metadata import parse_charm_metadata_yaml @@ -574,14 +574,14 @@ def test_load_full_metadata_from_charmcraft_yaml(tmp_path, prepare_charmcraft_ya "extra_bindings": {"test-binding-1": "binding-1"}, "links": Links( contact=["https://example.com/contact", "contact@example.com", "IRC #example"], - documentation=parse_obj_as(AnyHttpUrl, "https://example.com/docs"), - issues=parse_obj_as(AnyHttpUrl, "https://example.com/issues"), + documentation=max_length_as(AnyHttpUrl, "https://example.com/docs"), + issues=max_length_as(AnyHttpUrl, "https://example.com/issues"), source=[ - parse_obj_as(AnyHttpUrl, "https://example.com/source"), - parse_obj_as(AnyHttpUrl, "https://example.com/source2"), - parse_obj_as(AnyHttpUrl, "https://example.com/source3"), + max_length_as(AnyHttpUrl, "https://example.com/source"), + max_length_as(AnyHttpUrl, "https://example.com/source2"), + max_length_as(AnyHttpUrl, "https://example.com/source3"), ], - website=[parse_obj_as(AnyHttpUrl, "https://example.com/")], + website=[max_length_as(AnyHttpUrl, "https://example.com/")], ), "metadata_legacy": False, } @@ -801,15 +801,15 @@ def test_load_full_metadata_from_metadata_yaml( "subordinate": True, "terms": ["https://example.com/terms", "https://example.com/terms2"], "extra_bindings": {"test-binding-1": "binding-1"}, - "docs": parse_obj_as(AnyHttpUrl, "https://example.com/docs"), - "issues": parse_obj_as(AnyHttpUrl, "https://example.com/issues"), + "docs": max_length_as(AnyHttpUrl, "https://example.com/docs"), + "issues": max_length_as(AnyHttpUrl, "https://example.com/issues"), "maintainers": ["https://example.com/contact", "contact@example.com", "IRC #example"], "source": [ - parse_obj_as(AnyHttpUrl, "https://example.com/source"), - parse_obj_as(AnyHttpUrl, "https://example.com/source2"), - parse_obj_as(AnyHttpUrl, "https://example.com/source3"), + max_length_as(AnyHttpUrl, "https://example.com/source"), + max_length_as(AnyHttpUrl, "https://example.com/source2"), + max_length_as(AnyHttpUrl, "https://example.com/source3"), ], - "website": [parse_obj_as(AnyHttpUrl, "https://example.com/")], + "website": [max_length_as(AnyHttpUrl, "https://example.com/")], } diff --git a/tests/unit/models/test_config.py b/tests/unit/models/test_config.py index 4f01ecf46..2e020e38f 100644 --- a/tests/unit/models/test_config.py +++ b/tests/unit/models/test_config.py @@ -46,11 +46,11 @@ ], ) def test_valid_config(options): - assert JujuConfig.parse_obj({"options": options}) == JujuConfig(options=options) + assert JujuConfig.model_validate({"options": options}) == JujuConfig(options=options) def test_empty_config(): - JujuConfig.parse_obj({}) + JujuConfig.model_validate({}) @pytest.mark.parametrize( @@ -72,12 +72,12 @@ def test_correct_option_type(option, type_): @pytest.mark.parametrize( ("option", "match"), [ - (None, "none is not an allowed value"), - ({}, "Discriminator 'type' is missing"), - ({"type": "stargate"}, "No match for discriminator 'type' and value 'stargate'"), - ({"type": "int", "default": 3.14}, "value is not a valid integer"), - ({"type": "float", "default": "pi"}, "value is not a valid float"), - ({"type": "boolean", "default": "maybe"}, "value could not be parsed to a boolean"), + (None, "Input should be a valid dict"), + ({}, "Unable to extract tag using discriminator 'type'"), + ({"type": "stargate"}, "Input tag 'stargate' found using 'type' does not match any of the expected tags:"), + ({"type": "int", "default": 3.14}, "Input should be a valid integer"), + ({"type": "float", "default": "pi"}, "Input should be a valid number"), + ({"type": "boolean", "default": "maybe"}, "Input should be a valid boolean"), ], ) def test_invalid_options(option, match): diff --git a/tests/unit/models/test_metadata.py b/tests/unit/models/test_metadata.py index 062622f75..2da3f3e82 100644 --- a/tests/unit/models/test_metadata.py +++ b/tests/unit/models/test_metadata.py @@ -46,8 +46,8 @@ [ (BASIC_CHARM_DICT, BASIC_CHARM_METADATA_DICT), ( - dict(**BASIC_CHARM_DICT, links={"documentation": "https://docs.url"}), - {**BASIC_CHARM_METADATA_DICT, "docs": "https://docs.url"}, + dict(**BASIC_CHARM_DICT, links={"documentation": "https://docs.url/"}), + {**BASIC_CHARM_METADATA_DICT, "docs": "https://docs.url/"}, ), ( dict(**BASIC_CHARM_DICT, links={"contact": "someone@company.com"}), diff --git a/tests/unit/models/test_project.py b/tests/unit/models/test_project.py index 4ff7a00fc..f57aeee0b 100644 --- a/tests/unit/models/test_project.py +++ b/tests/unit/models/test_project.py @@ -119,12 +119,12 @@ ), ], ) -def test_platform_from_bases_backwards_compatible(bases, expected): +def test_platform_from_bases_backwards_compatible(bases: list[Base], expected: str): """Replicates the format_charm_file_name tests in test_package.py. This ensures that charm names remain consistent as we move to platforms. """ - assert project.CharmPlatform.from_bases(bases) == expected + assert project.get_charm_file_platform_str(bases) == expected @pytest.mark.parametrize("base", [*SIMPLE_BASES, *COMPLEX_BASES]) @@ -133,7 +133,7 @@ def test_platform_from_single_base(base): expected_architectures = "-".join(base.architectures) expected = f"{base.name}-{base.channel}-{expected_architectures}" - actual = project.CharmPlatform.from_bases([base]) + actual = project.get_charm_file_platform_str([base]) assert actual == expected @@ -149,7 +149,7 @@ def test_platform_from_single_base(base): ], ) def test_platform_from_multiple_bases(bases, expected): - assert project.CharmPlatform.from_bases(bases) == expected + assert project.get_charm_file_platform_str(bases) == expected # endregion @@ -165,7 +165,7 @@ def test_platform_from_multiple_bases(bases, expected): @pytest.mark.parametrize("build_on", VALID_PLATFORM_ARCHITECTURES) @pytest.mark.parametrize("build_for", [[arch] for arch in (*const.CharmArch, "all")]) def test_platform_validation_lists(build_on, build_for): - platform = project.Platform.parse_obj({"build-on": build_on, "build-for": build_for}) + platform = project.Platform.model_validate({"build-on": build_on, "build-for": build_for}) assert platform.build_for == build_for assert platform.build_on == build_on @@ -174,7 +174,7 @@ def test_platform_validation_lists(build_on, build_for): @pytest.mark.parametrize("build_on", const.CharmArch) @pytest.mark.parametrize("build_for", [*const.CharmArch, "all"]) def test_platform_validation_strings(build_on, build_for): - platform = project.Platform.parse_obj({"build-on": build_on, "build-for": build_for}) + platform = project.Platform.model_validate({"build-on": build_on, "build-for": build_for}) assert platform.build_for == [build_for] assert platform.build_on == [build_on] @@ -468,7 +468,7 @@ def test_build_info_generator(given, expected): ], ) def test_build_planner_correct(data, expected): - planner = project.CharmcraftBuildPlanner.parse_obj(data) + planner = project.CharmcraftBuildPlanner.model_validate(data) assert planner.get_build_plan() == expected @@ -744,7 +744,7 @@ def test_instantiate_bases_charm_success(values: dict[str, Any], expected_change "description": "This charm has no bases and is thus invalid.", }, pydantic.ValidationError, - r"bases\s+field required", + r"bases\s+Field required", id="no-bases", ), pytest.param( @@ -756,7 +756,7 @@ def test_instantiate_bases_charm_success(values: dict[str, Any], expected_change "bases": [], }, pydantic.ValidationError, - r"bases\s+ensure this value has at least 1 item", + r"bases\s+List should have at least 1 item", id="empty-bases", ), ], @@ -824,7 +824,7 @@ def test_read_charm_from_yaml_file_self_contained_success(tmp_path, filename: st - field 'name' required in top-level configuration - field 'summary' required in top-level configuration - field 'description' required in top-level configuration - - unexpected value; permitted: 'charm' (in field 'type') + - input should be 'charm' (in field 'type') - field 'bases' required in top-level configuration""" ), ), @@ -833,8 +833,8 @@ def test_read_charm_from_yaml_file_self_contained_success(tmp_path, filename: st dedent( """\ Bad invalid-base.yaml content: - - base requires 'platforms' definition: {'name': 'ubuntu', 'channel': '24.04'} (in field 'bases[0]') - - base requires 'platforms' definition: {'name': 'ubuntu', 'channel': 'devel'} (in field 'bases[1]')""" + - value error, Base requires 'platforms' definition: {'name': 'ubuntu', 'channel': '24.04'} (in field 'bases[0]') + - value error, Base requires 'platforms' definition: {'name': 'ubuntu', 'channel': 'devel'} (in field 'bases[1]')""" ), ), ], diff --git a/tests/unit/models/valid_charms_yaml/full-bases.yaml b/tests/unit/models/valid_charms_yaml/full-bases.yaml index d239bde6f..4c467406b 100644 --- a/tests/unit/models/valid_charms_yaml/full-bases.yaml +++ b/tests/unit/models/valid_charms_yaml/full-bases.yaml @@ -11,8 +11,8 @@ analysis: linters: - entrypoint charmhub: - api-url: https://api.staging.charmhub.io - storage-url: https://storage.staging.snapcraftcontent.com + api-url: https://api.staging.charmhub.io/ + storage-url: https://storage.staging.snapcraftcontent.com/ parts: im-not-calling-this-what-you-expect: plugin: charm @@ -24,6 +24,7 @@ parts: charm-strict-dependencies: false another-part: plugin: nil + source: charm-libs: - lib: my-charm.my_lib version: '1' diff --git a/tests/unit/models/valid_charms_yaml/full-platforms.yaml b/tests/unit/models/valid_charms_yaml/full-platforms.yaml index f4963f88c..e1dfba7bc 100644 --- a/tests/unit/models/valid_charms_yaml/full-platforms.yaml +++ b/tests/unit/models/valid_charms_yaml/full-platforms.yaml @@ -11,8 +11,8 @@ analysis: linters: - entrypoint charmhub: - api-url: https://api.staging.charmhub.io - storage-url: https://storage.staging.snapcraftcontent.com + api-url: https://api.staging.charmhub.io/ + storage-url: https://storage.staging.snapcraftcontent.com/ parts: im-not-calling-this-what-you-expect: plugin: charm @@ -24,6 +24,7 @@ parts: charm-strict-dependencies: false another-part: plugin: nil + source: charm-libs: - lib: my-charm.my_lib version: '1' diff --git a/tests/unit/services/test_package.py b/tests/unit/services/test_package.py index c38e09941..75d4d922f 100644 --- a/tests/unit/services/test_package.py +++ b/tests/unit/services/test_package.py @@ -160,7 +160,7 @@ def test_do_not_overwrite_metadata_yaml( ], ) def test_get_manifest_bases_from_bases(fake_path, package_service, bases, expected): - charm = models.BasesCharm.parse_obj( + charm = models.BasesCharm.model_validate( { "name": "my-charm", "description": "", @@ -171,7 +171,7 @@ def test_get_manifest_bases_from_bases(fake_path, package_service, bases, expect ) package_service._project = charm - assert package_service.get_manifest_bases() == [models.Base.parse_obj(b) for b in expected] + assert package_service.get_manifest_bases() == [models.Base.model_validate(b) for b in expected] @pytest.mark.parametrize("base", ["ubuntu@22.04", "almalinux@9"]) @@ -210,7 +210,7 @@ def test_get_manifest_bases_from_bases(fake_path, package_service, bases, expect def test_get_manifest_bases_from_platforms( package_service, base, platforms, selected_platform, expected_architectures ): - charm = models.PlatformCharm.parse_obj( + charm = models.PlatformCharm.model_validate( { "name": "my-charm", "description": "", From aef719481fbb127194bb6ed7d8b0f1c39816f23b Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 30 Jul 2024 23:29:00 -0400 Subject: [PATCH 04/59] fix: fix models unit tests --- charmcraft/metafiles/actions.py | 2 +- charmcraft/metafiles/config.py | 2 +- charmcraft/metafiles/metadata.py | 2 +- charmcraft/models/actions.py | 6 +- charmcraft/models/charmcraft.py | 35 +++++-- charmcraft/models/metadata.py | 4 +- charmcraft/models/project.py | 162 +++++++++++------------------- charmcraft/parts/charm.py | 39 ++++--- tests/unit/models/test_project.py | 24 +---- 9 files changed, 118 insertions(+), 158 deletions(-) diff --git a/charmcraft/metafiles/actions.py b/charmcraft/metafiles/actions.py index a78d1f599..3e28b3887 100644 --- a/charmcraft/metafiles/actions.py +++ b/charmcraft/metafiles/actions.py @@ -104,7 +104,7 @@ def create_actions_yaml( if charmcraft_config.actions: target_file_path.write_text( yaml.dump( - charmcraft_config.actions.dict( + charmcraft_config.actions.model_dump( include={"actions"}, exclude_none=True, by_alias=True )["actions"] ) diff --git a/charmcraft/metafiles/config.py b/charmcraft/metafiles/config.py index 20723286b..842c83a2a 100644 --- a/charmcraft/metafiles/config.py +++ b/charmcraft/metafiles/config.py @@ -98,7 +98,7 @@ def create_config_yaml( if charmcraft_config.config: target_file_path.write_text( yaml.dump( - charmcraft_config.config.dict( + charmcraft_config.config.model_dump( include={"options"}, exclude_none=True, by_alias=True ) ) diff --git a/charmcraft/metafiles/metadata.py b/charmcraft/metafiles/metadata.py index 24107b094..9953c779c 100644 --- a/charmcraft/metafiles/metadata.py +++ b/charmcraft/metafiles/metadata.py @@ -122,7 +122,7 @@ def create_metadata_yaml( shutil.copyfile(original_file_path, target_file_path) else: # metadata.yaml not exists, create it from config - metadata = charmcraft_config.dict( + metadata = charmcraft_config.model_dump( include=const.CHARM_METADATA_KEYS.union(const.CHARM_METADATA_KEYS_ALIAS), exclude_none=True, by_alias=True, diff --git a/charmcraft/models/actions.py b/charmcraft/models/actions.py index 7c0bce730..72af07a29 100644 --- a/charmcraft/models/actions.py +++ b/charmcraft/models/actions.py @@ -32,7 +32,7 @@ class JujuActions(CraftBaseModel): _action_name_regex = re.compile(r"^[a-zA-Z_][a-zA-Z0-9-_]*$") actions: dict[str, dict] | None - @pydantic.validator("actions") + @pydantic.field_validator("actions", mode="after") def validate_actions(cls, actions): """Verify actions names and descriptions.""" if not isinstance(actions, dict): @@ -47,8 +47,8 @@ def validate_actions(cls, actions): return actions - @pydantic.validator("actions", each_item=True) - def validate_each_action(cls, action): + @pydantic.field_validator("actions", mode="after") + def _validate_actions(cls, action): """Verify actions names and descriptions.""" if not isinstance(action, dict): raise TypeError(f"'{action}' is not a dictionary") diff --git a/charmcraft/models/charmcraft.py b/charmcraft/models/charmcraft.py index e11dbcc57..c701ae77c 100644 --- a/charmcraft/models/charmcraft.py +++ b/charmcraft/models/charmcraft.py @@ -18,7 +18,7 @@ import datetime import os import pathlib -from typing import Any, Literal, cast +from typing import Any, Literal, TypedDict, cast from craft_application.models import CraftBaseModel import pydantic @@ -40,6 +40,22 @@ from charmcraft.models.config import JujuConfig +class BaseDict(TypedDict, total=False): + """TypedDict that describes only one base. + + This is equivalent to the short form base definition. + """ + + name: str + channel: str + architectures: list[str] + + +LongFormBasesDict = TypedDict( + "LongFormBasesDict", {"build-on": list[BaseDict], "run-on": list[BaseDict]} +) + + class Charmhub(CraftBaseModel): """Definition of Charmhub endpoint configuration.""" @@ -88,6 +104,13 @@ class BasesConfiguration(CraftBaseModel): build_on: list[Base] run_on: list[Base] + @pydantic.model_validator(mode="before") + def _expand_base(cls, base: BaseDict | LongFormBasesDict) -> LongFormBasesDict: + """Expand short-form bases into long-form bases.""" + if "build-on" in base: # Assume long-form base already. + return cast(LongFormBasesDict, base) + return cast(LongFormBasesDict, {"build-on": [base], "run-on": [base]}) + class Project(CraftBaseModel): """Internal-only project configuration.""" @@ -171,7 +194,7 @@ class Links(CraftBaseModel): # # return name # -# @pydantic.validator("summary", pre=True, always=True) +# @pydantic.field_validator("summary", mode="before") # def validate_summary(cls, summary, values): # """Verify charm summary is valid with exception when instantiated without YAML.""" # if values.get("type") == "charm" and not summary: @@ -179,7 +202,7 @@ class Links(CraftBaseModel): # # return summary # -# @pydantic.validator("description", pre=True, always=True) +# @pydantic.field_validator("description", mode="before") # def validate_description(cls, description, values): # """Verify charm name is valid with exception when instantiated without YAML.""" # if values.get("type") == "charm" and not description: @@ -187,7 +210,7 @@ class Links(CraftBaseModel): # # return description # -# @pydantic.validator("parts", pre=True, always=True) +# @pydantic.field_validator("parts", mode="before") # def validate_special_parts(cls, parts, values): # """Verify parts type (craft-parts will re-validate the schemas for the plugins).""" # if "type" not in values: @@ -237,7 +260,7 @@ class Links(CraftBaseModel): # raise ValueError("Field not allowed when type=bundle") # return bases # -# @pydantic.validator("actions", pre=True, always=True) +# @pydantic.field_validator("actions", mode="before") # def validate_actions(cls, actions, values): # """Verify 'actions' in charms. # @@ -257,7 +280,7 @@ class Links(CraftBaseModel): # # return JujuActions.model_validate({"actions": actions}) # -# @pydantic.validator("juju_config", pre=True, always=True) +# @pydantic.field_validator("juju_config", mode="before") # def validate_config(cls, config, values): # """Verify 'actions' in charms. # diff --git a/charmcraft/models/metadata.py b/charmcraft/models/metadata.py index 2078f3850..6db1f40db 100644 --- a/charmcraft/models/metadata.py +++ b/charmcraft/models/metadata.py @@ -64,8 +64,8 @@ def from_charm(cls, charm: Charm) -> Self: Performs the necessary renaming and reorganisation. """ - charm_dict = charm.dict( - include=const.METADATA_YAML_KEYS | {"title"}, + charm_dict = charm.model_dump( + include={"title"} | const.METADATA_YAML_KEYS, exclude_none=True, by_alias=True, ) diff --git a/charmcraft/models/project.py b/charmcraft/models/project.py index 470af6b07..955aa6f96 100644 --- a/charmcraft/models/project.py +++ b/charmcraft/models/project.py @@ -53,20 +53,6 @@ from charmcraft.parts import process_part_config -class BaseDict(TypedDict, total=False): - """TypedDict that describes only one base. - - This is equivalent to the short form base definition. - """ - - name: str - channel: str - architectures: list[str] - - -LongFormBasesDict = TypedDict( - "LongFormBasesDict", {"build-on": list[BaseDict], "run-on": list[BaseDict]} -) CharmcraftSummaryStr = Annotated[ str, models.SummaryStr, @@ -97,19 +83,6 @@ def get_charm_file_platform_str(bases: Iterable[charmcraft.Base]) -> str: ] -class Platform(models.CraftBaseModel): - """Project platform definition.""" - - build_on: list[CharmArch] = pydantic.Field(min_length=1) - build_for: list[CharmArch | Literal["all"]] = pydantic.Field(min_length=1, max_length=1) - - @pydantic.validator("build_on", "build_for", pre=True) - def _listify_architectures(cls, value: str | list[str]) -> list[str]: - if isinstance(value, str): - return [value] - return value - - class CharmLib(models.CraftBaseModel): """A Charm library dependency for this charm.""" @@ -122,7 +95,7 @@ class CharmLib(models.CraftBaseModel): pattern=r"[0-9]+(\.[0-9]+)?", ) - @pydantic.validator("lib", pre=True) + @pydantic.field_validator("lib", mode="before") def _validate_name(cls, value: str) -> str: """Validate the lib field, providing a useful error message on failure.""" charm_name, _, lib_name = str(value).partition(".") @@ -143,7 +116,7 @@ def _validate_name(cls, value: str) -> str: ) return f"{charm_name}.{lib_name}" - @pydantic.validator("version", pre=True) + @pydantic.field_validator("version", mode="before") def _validate_api_version(cls, value: str) -> str: """Validate the API version field, providing a useful error message on failure.""" api, *_ = str(value).partition(".") @@ -153,7 +126,7 @@ def _validate_api_version(cls, value: str) -> str: raise ValueError(f"API version not valid. Expected an integer, got {api!r}") from None return str(value) - @pydantic.validator("version", pre=True) + @pydantic.field_validator("version", mode="before") def _validate_patch_version(cls, value: str) -> str: """Validate the optional patch version, providing a useful error message.""" api, separator, patch = value.partition(".") @@ -344,14 +317,7 @@ class CharmcraftBuildPlanner(models.BuildPlanner): bases: list[BasesConfiguration] = pydantic.Field(default_factory=list) base: str | None = None build_base: str | None = None - platforms: dict[str, Platform | None] | None = None # type: ignore[assignment] - - @pydantic.validator("bases", pre=True, each_item=True, allow_reuse=True) - def expand_base(cls, base: BaseDict | LongFormBasesDict) -> LongFormBasesDict: - """Expand short-form bases into long-form bases.""" - if "name" not in base: # Assume long-form base already. - return cast(LongFormBasesDict, base) - return cast(LongFormBasesDict, {"build-on": [base], "run-on": [base]}) + platforms: dict[str, models.Platform | None] | None = None # type: ignore[assignment] def get_build_plan(self) -> list[models.BuildInfo]: """Get build bases for this charm. @@ -528,24 +494,26 @@ def from_yaml_file(cls, path: pathlib.Path) -> Self: return project - @pydantic.root_validator(pre=True, allow_reuse=True) - def preprocess(cls, values: dict[str, Any]) -> dict[str, Any]: + @pydantic.model_validator(mode="before") + @classmethod + def _preprocess(cls, values: dict[str, Any]) -> dict[str, Any]: """Preprocess any values that charmcraft infers, before attribute validation.""" if "type" not in values: raise ValueError("Project type must be declared in charmcraft.yaml.") return values - @pydantic.validator("parts", pre=True, always=True, allow_reuse=True) - def preprocess_parts( - cls, parts: dict[str, dict[str, Any]] | None, values: dict[str, Any] + @pydantic.field_validator("parts", mode="before") + @classmethod + def _preprocess_parts( + cls, parts: dict[str, dict[str, Any]] | None, info: pydantic.ValidationInfo ) -> dict[str, dict[str, Any]]: """Preprocess parts object for a charm or bundle, creating an implicit part if needed.""" if parts is not None and not isinstance(parts, dict): raise TypeError("'parts' in charmcraft.yaml must conform to the charmcraft.yaml spec.") if not parts: - if "type" in values: - parts = {values["type"]: {"plugin": values["type"]}} + if "type" in info.data: + parts = {info.data["type"]: {"plugin": info.data["type"]}} else: parts = {} for name, part in parts.items(): @@ -563,10 +531,13 @@ def preprocess_parts( part.setdefault("source", ".") return parts - @pydantic.validator("parts", each_item=True, allow_reuse=True) - def validate_each_part(cls, item): + @pydantic.field_validator("parts", mode="before") + def _validate_parts(cls, parts: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: """Verify each part in the parts section. Craft-parts will re-validate them.""" - return process_part_config(item) + return { + name: process_part_config(part) + for name, part in parts.items() + } class CharmProject(CharmcraftProject): @@ -990,6 +961,34 @@ class CharmProject(CharmcraftProject): ], ) +def _check_base_is_legacy(base: charmcraft.BaseDict) -> bool: + """Check that the given base is a legacy base, usable with 'bases'.""" + # This pyright ignore can go away once we're on Python minimum version 3.11. + # At that point we can mark items as required or not required. + # https://docs.python.org/3/library/typing.html#typing.Required + if ( + base["name"] == "ubuntu" # pyright: ignore[reportTypedDictNotRequiredAccess] + and base["channel"] < "24.04" # pyright: ignore[reportTypedDictNotRequiredAccess] + ): + return True + return base in ({"name": "centos", "channel": "7"}, {"name": "almalinux", "channel": "9"}) + + +def _validate_base(base: charmcraft.BaseDict | charmcraft.LongFormBasesDict) -> charmcraft.LongFormBasesDict: + if "name" in base: # Convert short form to long form + base = cast(charmcraft.LongFormBasesDict, {"build-on": [base], "run-on": [base]}) + else: # Cast to long form since we know it is one. + base = cast(charmcraft.LongFormBasesDict, base) + + # Ensure we're only allowing legacy bases. + for build_base in base["build-on"]: + if not _check_base_is_legacy(build_base): + raise ValueError(f"Base requires 'platforms' definition: {build_base}") + for run_base in base["run-on"]: + if not _check_base_is_legacy(run_base): + raise ValueError(f"Base requires 'platforms' definition: {run_base}") + return base + class BasesCharm(CharmProject): """A charm using the deprecated ``bases`` keyword. @@ -1007,75 +1006,32 @@ class BasesCharm(CharmProject): # This is defined this way because using conlist makes mypy sad and using # a ConstrainedList child class has pydantic issues. This appears to be # solved with Pydantic 2. - bases: list[BasesConfiguration] = pydantic.Field(min_length=1) + bases: list[ + Annotated[ + BasesConfiguration, + pydantic.BeforeValidator(_validate_base) + ] + ] = pydantic.Field(min_length=1) base: None = None - - @pydantic.validator("bases", pre=True, each_item=True, allow_reuse=True) - def _validate_base(cls, base: BaseDict | LongFormBasesDict) -> LongFormBasesDict: - """Expand short-form bases into long-form bases.""" - if "name" in base: # Convert short form to long form - base = cast(LongFormBasesDict, {"build-on": [base], "run-on": [base]}) - else: # Cast to long form since we know it is one. - base = cast(LongFormBasesDict, base) - - # Ensure we're only allowing legacy bases. - for build_base in base["build-on"]: - if not cls._check_base_is_legacy(build_base): - raise ValueError(f"Base requires 'platforms' definition: {build_base}") - for run_base in base["run-on"]: - if not cls._check_base_is_legacy(run_base): - raise ValueError(f"Base requires 'platforms' definition: {run_base}") - - return base - - @staticmethod - def _check_base_is_legacy(base: BaseDict) -> bool: - """Check that the given base is a legacy base, usable with 'bases'.""" - # This pyright ignore can go away once we're on Python minimum version 3.11. - # At that point we can mark items as required or not required. - # https://docs.python.org/3/library/typing.html#typing.Required - if ( - base["name"] == "ubuntu" # pyright: ignore[reportTypedDictNotRequiredAccess] - and base["channel"] < "24.04" # pyright: ignore[reportTypedDictNotRequiredAccess] - ): - return True - return base in ({"name": "centos", "channel": "7"}, {"name": "almalinux", "channel": "9"}) - - class PlatformCharm(CharmProject): """Model for defining a charm using Platforms.""" # Silencing pyright because it complains about missing default value base: BaseStr # pyright: ignore[reportGeneralTypeIssues] build_base: BuildBaseStr | None = None - platforms: dict[str, Platform | None] # type: ignore[assignment] + platforms: dict[str, models.Platform | None] # type: ignore[assignment] parts: dict[str, dict[str, Any]] # pyright: ignore[reportGeneralTypeIssues] - @staticmethod - def _check_base_is_legacy(base: BaseDict) -> bool: - """Check that the given base is a legacy base, usable with 'bases'.""" - # This pyright ignore can go away once we're on Python minimum version 3.11. - # At that point we can mark items as required or not required. - # https://docs.python.org/3/library/typing.html#typing.Required - if ( - base["name"] == "ubuntu" # pyright: ignore[reportTypedDictNotRequiredAccess] - and base["channel"] < "24.04" # pyright: ignore[reportTypedDictNotRequiredAccess] - ): - return True - return base in ({"name": "centos", "channel": "7"}, {"name": "almalinux", "channel": "9"}) - - @pydantic.validator("build_base", always=True) - def _validate_dev_base_needs_build_base( - cls, build_base: str | None, values: dict[str, Any] - ) -> str | None: - if not build_base and (base := values["base"]) in const.DEVEL_BASE_STRINGS: + @pydantic.model_validator(mode="after") + def _validate_dev_base_needs_build_base(self) -> Self: + if not self.build_base and self.base in const.DEVEL_BASE_STRINGS: raise ValueError( - f"Base {base} requires a build-base (recommended: 'build-base: ubuntu@devel')" + f"Base {self.base} requires a build-base (recommended: 'build-base: ubuntu@devel')" ) - return build_base + return self Charm = BasesCharm | PlatformCharm diff --git a/charmcraft/parts/charm.py b/charmcraft/parts/charm.py index 379fc78e6..f95cfc750 100644 --- a/charmcraft/parts/charm.py +++ b/charmcraft/parts/charm.py @@ -21,6 +21,7 @@ import sys from contextlib import suppress from typing import Any, cast +from typing_extensions import Self import overrides import pydantic @@ -57,15 +58,15 @@ class CharmPluginProperties(plugins.PluginProperties, frozen=True): ``charm-strict-dependencies`` is mutually exclusive with ``charm-python-packages``. """ - @pydantic.validator("charm_entrypoint") - def validate_entry_point(cls, charm_entrypoint, values): + @pydantic.field_validator("charm_entrypoint", mode="after") + def _validate_entrypoint(cls, charm_entrypoint: str, info: pydantic.ValidationInfo) -> str: """Validate the entry point.""" # the location of the project is needed - if "source" not in values: + if "source" not in info.data: raise ValueError( "cannot validate 'charm-entrypoint' because invalid 'source' configuration" ) - project_dirpath = pathlib.Path(values["source"]).resolve() + project_dirpath = pathlib.Path(info.data["source"]).resolve() # check that the entrypoint is inside the project filepath = (project_dirpath / charm_entrypoint).resolve() @@ -77,19 +78,19 @@ def validate_entry_point(cls, charm_entrypoint, values): rel_entrypoint = (project_dirpath / charm_entrypoint).relative_to(project_dirpath) return rel_entrypoint.as_posix() - @pydantic.validator("charm_requirements", always=True) - def validate_requirements(cls, charm_requirements, values): + @pydantic.field_validator("charm_requirements", mode="after") + def _validate_requirements(cls, charm_requirements: list[str], info: pydantic.ValidationInfo) -> list[str]: """Validate the specified requirement or dynamically default it. The default is dynamic because it's only requirements.txt if the file is there. """ # the location of the project is needed - if "source" not in values: + if "source" not in info.data: raise ValueError( "cannot validate 'charm-requirements' because invalid 'source' configuration" ) - project_dirpath = pathlib.Path(values["source"]) + project_dirpath = pathlib.Path(info.data["source"]) # check that all indicated files are present for reqs_filename in charm_requirements: @@ -104,30 +105,28 @@ def validate_requirements(cls, charm_requirements, values): return charm_requirements - @pydantic.validator("charm_strict_dependencies") - def validate_strict_dependencies( - cls, charm_strict_dependencies: bool, values: dict[str, Any] - ) -> bool: + @pydantic.model_validator(mode="after") + def _validate_strict_dependencies(self) -> Self: """Validate basic requirements if strict dependencies are enabled. Full validation that the requirements file contains all dependencies is done later, but we can fail early if the strict dependencies setting causes the charm to be invalid. """ - if not charm_strict_dependencies: - return charm_strict_dependencies + if not self.charm_strict_dependencies: + return self - if values.get("charm_python_packages"): + if self.charm_python_packages: raise ValueError( "'charm-python-packages' must not be set if 'charm-strict-dependencies' is enabled" ) - if not values.get("charm_requirements"): + if not self.charm_requirements: raise ValueError( "'charm-strict-dependencies' requires at least one requirements file." ) invalid_binaries = set() - for binary_package in values.get("charm_binary_python_packages", []): + for binary_package in self.charm_binary_python_packages: if not PACKAGE_NAME_REGEX.fullmatch(binary_package): invalid_binaries.add(binary_package) @@ -141,16 +140,16 @@ def validate_strict_dependencies( try: validate_strict_dependencies( get_requirements_file_package_names( - *(pathlib.Path(r) for r in values["charm_requirements"]) + *(pathlib.Path(r) for r in self.charm_requirements) ), - values.get("charm_binary_python_packages", []), + self.charm_binary_python_packages, ) except DependencyError as e: raise ValueError( "All dependencies must be specified in requirements files for strict dependencies." ) from e - return charm_strict_dependencies + return self class CharmPlugin(plugins.Plugin): diff --git a/tests/unit/models/test_project.py b/tests/unit/models/test_project.py index f57aeee0b..ea571a5bb 100644 --- a/tests/unit/models/test_project.py +++ b/tests/unit/models/test_project.py @@ -162,24 +162,6 @@ def test_platform_from_multiple_bases(bases, expected): ] -@pytest.mark.parametrize("build_on", VALID_PLATFORM_ARCHITECTURES) -@pytest.mark.parametrize("build_for", [[arch] for arch in (*const.CharmArch, "all")]) -def test_platform_validation_lists(build_on, build_for): - platform = project.Platform.model_validate({"build-on": build_on, "build-for": build_for}) - - assert platform.build_for == build_for - assert platform.build_on == build_on - - -@pytest.mark.parametrize("build_on", const.CharmArch) -@pytest.mark.parametrize("build_for", [*const.CharmArch, "all"]) -def test_platform_validation_strings(build_on, build_for): - platform = project.Platform.model_validate({"build-on": build_on, "build-for": build_for}) - - assert platform.build_for == [build_for] - assert platform.build_on == [build_on] - - # endregion # region CharmBuildInfo tests @pytest.mark.parametrize("build_on_base", [SIMPLE_BASE, BASE_WITH_ONE_ARCH, BASE_WITH_MULTIARCH]) @@ -367,7 +349,7 @@ def test_build_info_generator(given, expected): "build-on": ["amd64", "arm64", "riscv64"], "build-for": ["s390x"], }, - "crossy": {"build-on": "s390x", "build-for": "ppc64el"}, + "crossy": {"build-on": ["s390x"], "build-for": ["ppc64el"]}, "amd64": None, "arm64": None, "riscv64": None, @@ -768,7 +750,7 @@ def test_instantiate_bases_charm_error( project.BasesCharm(**values) -@pytest.mark.parametrize("base", ["ubuntu@24.04"]) +@pytest.mark.parametrize("base", ["ubuntu@18.04", "ubuntu@24.04"]) def test_devel_bases(monkeypatch, base): monkeypatch.setattr(const, "DEVEL_BASE_STRINGS", [base]) @@ -864,7 +846,7 @@ def test_read_charm_from_yaml_file_error(filename, errors): ], ) def test_check_legacy_bases(base, expected): - assert project.BasesCharm._check_base_is_legacy(base) == expected + assert project._check_base_is_legacy(base) == expected # endregion From 05ea547be8ede7b38cf97476ad718f35c8b0bb91 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 30 Jul 2024 23:33:56 -0400 Subject: [PATCH 05/59] chore: fix some pydantic warnings --- charmcraft/application/commands/store.py | 2 +- charmcraft/config.py | 2 +- charmcraft/models/project.py | 2 +- charmcraft/parts/__init__.py | 2 +- tests/conftest.py | 4 +-- tests/integration/services/test_package.py | 6 ++-- tests/test_models.py | 36 ++++++++++------------ 7 files changed, 26 insertions(+), 28 deletions(-) diff --git a/charmcraft/application/commands/store.py b/charmcraft/application/commands/store.py index ec0a4bd16..7807b00c8 100644 --- a/charmcraft/application/commands/store.py +++ b/charmcraft/application/commands/store.py @@ -1683,7 +1683,7 @@ def run(self, parsed_args: argparse.Namespace) -> None: declared_libs = {lib.lib: lib for lib in charm_libs} missing_store_libs = declared_libs.keys() - libs_metadata.keys() if missing_store_libs: - missing_libs_source = [declared_libs[lib].dict() for lib in sorted(missing_store_libs)] + missing_libs_source = [declared_libs[lib].model_dump() for lib in sorted(missing_store_libs)] libs_yaml = util.dump_yaml(missing_libs_source) raise errors.CraftError( f"Could not find the following libraries on charmhub:\n{libs_yaml}", diff --git a/charmcraft/config.py b/charmcraft/config.py index d434ae104..54ce9fb1c 100644 --- a/charmcraft/config.py +++ b/charmcraft/config.py @@ -92,7 +92,7 @@ # else: # path = pathlib.Path(dirpath).expanduser().resolve() # -# now = datetime.datetime.utcnow() +# now = datetime.datetime.now(tz=datetime.timezone.utc) # # content = load_yaml(path / const.CHARMCRAFT_FILENAME) # if content is None: diff --git a/charmcraft/models/project.py b/charmcraft/models/project.py index 955aa6f96..26ab3371d 100644 --- a/charmcraft/models/project.py +++ b/charmcraft/models/project.py @@ -427,7 +427,7 @@ class CharmcraftProject(models.Project, metaclass=abc.ABCMeta): # These private attributes are not part of the project model but are attached here # because Charmcraft uses this metadata. - _started_at: datetime.datetime = pydantic.PrivateAttr(default_factory=datetime.datetime.utcnow) + _started_at: datetime.datetime = pydantic.PrivateAttr(default_factory=lambda: datetime.datetime.now(tz=datetime.timezone.utc)) _valid: bool = pydantic.PrivateAttr(default=False) @property diff --git a/charmcraft/parts/__init__.py b/charmcraft/parts/__init__.py index 0e885564a..d7883ab26 100644 --- a/charmcraft/parts/__init__.py +++ b/charmcraft/parts/__init__.py @@ -71,7 +71,7 @@ def process_part_config(data: dict[str, Any]) -> dict[str, Any]: # get plugin properties data if it's model based (otherwise it's empty), and # update with the received config if isinstance(plugin_properties, plugins.PluginProperties): - full_config = plugin_properties.dict(by_alias=True) + full_config = plugin_properties.model_dump(by_alias=True) else: full_config = {} full_config.update(data) diff --git a/tests/conftest.py b/tests/conftest.py index 43c422af3..9fc496b2d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -176,7 +176,7 @@ def set(self, prime=None, **kwargs): project = config_module.Project( dirpath=tmp_path, - started_at=datetime.datetime.utcnow(), + started_at=datetime.datetime.now(tz=datetime.timezone.utc), config_provided=True, ) @@ -212,7 +212,7 @@ def set(self, prime=None, **kwargs): project = config_module.Project( dirpath=tmp_path, - started_at=datetime.datetime.utcnow(), + started_at=datetime.datetime.now(tz=datetime.timezone.utc), config_provided=True, ) diff --git a/tests/integration/services/test_package.py b/tests/integration/services/test_package.py index 9e261e852..6035e315e 100644 --- a/tests/integration/services/test_package.py +++ b/tests/integration/services/test_package.py @@ -58,7 +58,7 @@ def test_write_metadata(monkeypatch, fs, package_service, project_path): expected_prime_dir = project_path / "prime" project = models.CharmcraftProject.from_yaml_file(project_path / "project" / "charmcraft.yaml") - project._started_at = datetime.datetime.utcnow() + project._started_at = datetime.datetime.now(tz=datetime.timezone.utc) package_service._project = project package_service.write_metadata(test_prime_dir) @@ -88,7 +88,7 @@ def test_overwrite_metadata(monkeypatch, fs, package_service, project_path): expected_prime_dir = project_path / "prime" project = models.CharmcraftProject.from_yaml_file(project_path / "project" / "charmcraft.yaml") - project._started_at = datetime.datetime.utcnow() + project._started_at = datetime.datetime.now(tz=datetime.timezone.utc) package_service._project = project fs.create_file(test_prime_dir / const.METADATA_FILENAME, contents="INVALID!!") @@ -116,7 +116,7 @@ def test_no_overwrite_reactive_metadata(monkeypatch, fs, package_service): fs.create_file(test_stage_dir / const.METADATA_FILENAME, contents="INVALID!!") project = models.CharmcraftProject.from_yaml_file(project_path / "project" / "charmcraft.yaml") - project._started_at = datetime.datetime.utcnow() + project._started_at = datetime.datetime.now(tz=datetime.timezone.utc) package_service._project = project package_service.write_metadata(test_prime_dir) diff --git a/tests/test_models.py b/tests/test_models.py index 9f9437b44..44c21f42c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -484,7 +484,7 @@ def test_load_full_metadata_from_charmcraft_yaml(tmp_path, prepare_charmcraft_ya ) config = load(tmp_path) - config_dict = config.dict() + config_dict = config.model_dump() # remove unrelated keys. but they should exist in the config @@ -501,24 +501,22 @@ def test_load_full_metadata_from_charmcraft_yaml(tmp_path, prepare_charmcraft_ya "summary": "test-summary", "description": "test-description", "bases": [ - BasesConfiguration( - **{ - "build-on": [ - Base( - name="test-name", - channel="test-channel", - architectures=[util.get_host_architecture()], - ) - ], - "run-on": [ - Base( - name="test-name", - channel="test-channel", - architectures=[util.get_host_architecture()], - ) - ], - } - ) + { + "build-on": [ + Base( + name="test-name", + channel="test-channel", + architectures=[util.get_host_architecture()], + ).model_dump() + ], + "run-on": [ + Base( + name="test-name", + channel="test-channel", + architectures=[util.get_host_architecture()], + ) + ], + } ], "assumes": [ "test-feature", From 0a4c156a7b454bd871d972e6da2f9073e49160ab Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 30 Jul 2024 23:55:51 -0400 Subject: [PATCH 06/59] fix: all unit tests but package service --- charmcraft/parts/bundle.py | 18 ++---------------- charmcraft/parts/charm.py | 16 +++++++++------- charmcraft/parts/reactive.py | 18 ++---------------- .../models/valid_charms_yaml/full-bases.yaml | 2 +- .../valid_charms_yaml/full-platforms.yaml | 2 +- tests/unit/parts/test_bundle.py | 11 ----------- tests/unit/parts/test_charm.py | 18 ++++++++++-------- tests/unit/parts/test_reactive.py | 9 --------- tests/unit/test_parts.py | 8 ++++---- 9 files changed, 29 insertions(+), 73 deletions(-) diff --git a/charmcraft/parts/bundle.py b/charmcraft/parts/bundle.py index 8a7adf876..d22f3180e 100644 --- a/charmcraft/parts/bundle.py +++ b/charmcraft/parts/bundle.py @@ -15,7 +15,7 @@ # For further info, check https://github.com/canonical/charmcraft """Bundle plugin for craft-parts.""" import sys -from typing import Any +from typing import Any, Literal import overrides from craft_parts import plugins @@ -24,23 +24,9 @@ class BundlePluginProperties(plugins.PluginProperties, frozen=True): """Properties used to pack bundles.""" + plugin: Literal["bundle"] = "bundle" source: str - @classmethod - def unmarshal(cls, data: dict[str, Any]): - """Populate bundle properties from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise pydantic.ValidationError: If validation fails. - """ - plugin_data = plugins.extract_plugin_properties( - data, plugin_name="bundle", required=["source"] - ) - return cls(**plugin_data) - class BundlePlugin(plugins.Plugin): """Prepare a bundle for packing. diff --git a/charmcraft/parts/charm.py b/charmcraft/parts/charm.py index f95cfc750..65dd84485 100644 --- a/charmcraft/parts/charm.py +++ b/charmcraft/parts/charm.py @@ -20,7 +20,7 @@ import shlex import sys from contextlib import suppress -from typing import Any, cast +from typing import Any, Literal, cast from typing_extensions import Self import overrides @@ -43,6 +43,7 @@ class CharmPluginProperties(plugins.PluginProperties, frozen=True): """Properties used in charm building.""" + plugin: Literal["charm"] = "charm" source: str charm_entrypoint: str = "src/charm.py" charm_binary_python_packages: list[str] = [] @@ -78,19 +79,20 @@ def _validate_entrypoint(cls, charm_entrypoint: str, info: pydantic.ValidationIn rel_entrypoint = (project_dirpath / charm_entrypoint).relative_to(project_dirpath) return rel_entrypoint.as_posix() - @pydantic.field_validator("charm_requirements", mode="after") - def _validate_requirements(cls, charm_requirements: list[str], info: pydantic.ValidationInfo) -> list[str]: + @pydantic.model_validator(mode="before") + def _validate_requirements(cls, values: dict[str, Any]) -> dict[str, Any]: """Validate the specified requirement or dynamically default it. The default is dynamic because it's only requirements.txt if the file is there. """ # the location of the project is needed - if "source" not in info.data: + if "source" not in values: raise ValueError( "cannot validate 'charm-requirements' because invalid 'source' configuration" ) - project_dirpath = pathlib.Path(info.data["source"]) + project_dirpath = pathlib.Path(values["source"]) + charm_requirements = values.setdefault("charm-requirements", []) # check that all indicated files are present for reqs_filename in charm_requirements: @@ -103,7 +105,7 @@ def _validate_requirements(cls, charm_requirements: list[str], info: pydantic.Va if not charm_requirements and (project_dirpath / default_reqs_name).is_file(): charm_requirements.append(default_reqs_name) - return charm_requirements + return values @pydantic.model_validator(mode="after") def _validate_strict_dependencies(self) -> Self: @@ -146,7 +148,7 @@ def _validate_strict_dependencies(self) -> Self: ) except DependencyError as e: raise ValueError( - "All dependencies must be specified in requirements files for strict dependencies." + "all dependencies must be specified in requirements files for strict dependencies." ) from e return self diff --git a/charmcraft/parts/reactive.py b/charmcraft/parts/reactive.py index 1203f79a1..49ac4a99e 100644 --- a/charmcraft/parts/reactive.py +++ b/charmcraft/parts/reactive.py @@ -19,7 +19,7 @@ import subprocess import sys from pathlib import Path -from typing import Any, cast +from typing import Any, Literal, cast import overrides from craft_parts import plugins @@ -29,24 +29,10 @@ class ReactivePluginProperties(plugins.PluginProperties, frozen=True): """Properties used to pack reactive charms using charm-tools.""" + plugin: Literal["reactive"] = "reactive" source: str reactive_charm_build_arguments: list[str] = [] - @classmethod - def unmarshal(cls, data: dict[str, Any]): - """Populate reactive plugin properties from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise pydantic.ValidationError: If validation fails. - """ - plugin_data = plugins.extract_plugin_properties( - data, plugin_name="reactive", required=["source"] - ) - return cls(**plugin_data) - class ReactivePluginEnvironmentValidator(plugins.validator.PluginEnvironmentValidator): """Check the execution environment for the Reactive plugin. diff --git a/tests/unit/models/valid_charms_yaml/full-bases.yaml b/tests/unit/models/valid_charms_yaml/full-bases.yaml index 4c467406b..4e3663c3e 100644 --- a/tests/unit/models/valid_charms_yaml/full-bases.yaml +++ b/tests/unit/models/valid_charms_yaml/full-bases.yaml @@ -20,7 +20,7 @@ parts: charm-entrypoint: src/charm.py charm-binary-python-packages: [] charm-python-packages: [] - charm-requirements: [] + charm-requirements: [requirements.txt] charm-strict-dependencies: false another-part: plugin: nil diff --git a/tests/unit/models/valid_charms_yaml/full-platforms.yaml b/tests/unit/models/valid_charms_yaml/full-platforms.yaml index e1dfba7bc..03dca4686 100644 --- a/tests/unit/models/valid_charms_yaml/full-platforms.yaml +++ b/tests/unit/models/valid_charms_yaml/full-platforms.yaml @@ -20,7 +20,7 @@ parts: charm-entrypoint: src/charm.py charm-binary-python-packages: [] charm-python-packages: [] - charm-requirements: [] + charm-requirements: [requirements.txt] charm-strict-dependencies: false another-part: plugin: nil diff --git a/tests/unit/parts/test_bundle.py b/tests/unit/parts/test_bundle.py index 7a87554b6..30ef5d797 100644 --- a/tests/unit/parts/test_bundle.py +++ b/tests/unit/parts/test_bundle.py @@ -46,14 +46,3 @@ def test_bundleplugin_get_build_commands(bundle_plugin, tmp_path): f'mkdir -p "{str(tmp_path)}/parts/foo/install"', f'cp -R -p -P {tmp_path}/parts/foo/build/* "{str(tmp_path)}/parts/foo/install"', ] - - -def test_bundleplugin_invalid_properties(): - with pytest.raises(pydantic.ValidationError) as raised: - charmcraft.parts.bundle.BundlePlugin.properties_class.unmarshal( - {"source": ".", "bundle-invalid": True} - ) - err = raised.value.errors() - assert len(err) == 1 - assert err[0]["loc"] == ("bundle-invalid",) - assert err[0]["type"] == "value_error.extra" diff --git a/tests/unit/parts/test_charm.py b/tests/unit/parts/test_charm.py index 00bcb3f36..937dab48d 100644 --- a/tests/unit/parts/test_charm.py +++ b/tests/unit/parts/test_charm.py @@ -14,6 +14,7 @@ # # For further info, check https://github.com/canonical/charmcraft """Unit tests for charm plugin.""" +import pathlib import sys from unittest.mock import patch @@ -205,9 +206,10 @@ def test_charmpluginproperties_invalid_properties(): with pytest.raises(pydantic.ValidationError) as raised: parts.CharmPlugin.properties_class.unmarshal(content) err = raised.value.errors() + assert len(err) == 1 assert err[0]["loc"] == ("charm-invalid",) - assert err[0]["type"] == "value_error.extra" + assert err[0]["type"] == "extra_forbidden" def test_charmpluginproperties_entrypoint_ok(): @@ -241,7 +243,7 @@ def test_charmpluginproperties_entrypoint_outside_project_absolute(tmp_path): err = raised.value.errors() assert len(err) == 1 assert err[0]["loc"] == ("charm-entrypoint",) - assert err[0]["msg"] == f"charm entry point must be inside the project: {str(outside_path)!r}" + assert err[0]["msg"] == f"Value error, charm entry point must be inside the project: {str(outside_path)!r}" def test_charmpluginproperties_entrypoint_outside_project_relative(tmp_path): @@ -253,7 +255,7 @@ def test_charmpluginproperties_entrypoint_outside_project_relative(tmp_path): err = raised.value.errors() assert len(err) == 1 assert err[0]["loc"] == ("charm-entrypoint",) - assert err[0]["msg"] == f"charm entry point must be inside the project: {str(outside_path)!r}" + assert err[0]["msg"] == f"Value error, charm entry point must be inside the project: {str(outside_path)!r}" def test_charmpluginproperties_requirements_default(tmp_path): @@ -266,20 +268,20 @@ def test_charmpluginproperties_requirements_default(tmp_path): def test_charmpluginproperties_requirements_must_exist(tmp_path): """The configured files must be present.""" reqs_path = tmp_path / "reqs.txt" # not in disk, really - content = {"source": str(tmp_path), "charm-requirements": [str(reqs_path)]} + content = {"source": str(tmp_path), "charm-requirements": ["reqs.txt"]} with pytest.raises(pydantic.ValidationError) as raised: parts.CharmPlugin.properties_class.unmarshal(content) err = raised.value.errors() assert len(err) == 1 - assert err[0]["loc"] == ("charm-requirements",) - assert err[0]["msg"] == f"requirements file {str(reqs_path)!r} not found" + assert err[0]["loc"] == () + assert err[0]["msg"] == f"Value error, requirements file {str(reqs_path)!r} not found" -def test_charmpluginproperties_requirements_filepresent_ok(tmp_path): +def test_charmpluginproperties_requirements_filepresent_ok(tmp_path: pathlib.Path): """If a specific file is present in disk it's used.""" (tmp_path / "requirements.txt").write_text("somedep") content = {"source": str(tmp_path)} - properties = parts.CharmPlugin.properties_class.unmarshal(content) + properties = parts.CharmPluginProperties.unmarshal(content) assert properties.charm_requirements == ["requirements.txt"] diff --git a/tests/unit/parts/test_reactive.py b/tests/unit/parts/test_reactive.py index 2dc55f30b..e4d152a22 100644 --- a/tests/unit/parts/test_reactive.py +++ b/tests/unit/parts/test_reactive.py @@ -111,15 +111,6 @@ def test_get_build_commands(plugin, tmp_path): ] -def test_invalid_properties(plugin): - with pytest.raises(pydantic.ValidationError) as raised: - plugin.properties_class.unmarshal({"source": ".", "reactive-invalid": True}) - err = raised.value.errors() - assert len(err) == 1 - assert err[0]["loc"] == ("reactive-invalid",) - assert err[0]["type"] == "value_error.extra" - - def test_validate_environment(plugin, plugin_properties, charm_exe): validator = plugin.validator_class( part_name="my-part", diff --git a/tests/unit/test_parts.py b/tests/unit/test_parts.py index 5d6859d83..d4e70dc0f 100644 --- a/tests/unit/test_parts.py +++ b/tests/unit/test_parts.py @@ -68,19 +68,19 @@ def test_partconfig_strict_dependencies_success(fs: FakeFilesystem, part_config, [ ( {"charm-requirements": ["req.txt"], "charm-python-packages": ["ops"]}, - "'charm-python-packages' must not be set if 'charm-strict-dependencies' is enabled", + "Value error, 'charm-python-packages' must not be set if 'charm-strict-dependencies' is enabled", ), ( {"charm-requirements": ["req.txt"], "charm-binary-python-packages": ["not-here"]}, - "All dependencies must be specified in requirements files for strict dependencies.", + "Value error, all dependencies must be specified in requirements files for strict dependencies.", ), ( {"charm-requirements": ["req.txt"], "charm-binary-python-packages": ["ops>=2.6"]}, - "'charm-binary-python-packages' may contain only package names allowed to be " + "Value error, 'charm-binary-python-packages' may contain only package names allowed to be " "installed from binary if 'charm-strict-dependencies' is enabled. Invalid " "package names: ['ops>=2.6']", ), - ({}, "'charm-strict-dependencies' requires at least one requirements file."), + ({}, "Value error, 'charm-strict-dependencies' requires at least one requirements file."), ], ) def test_partconfig_strict_dependencies_failure(fs: FakeFilesystem, part_config, message): From a917c95f3597b0e740630bdc7e786923cc72967d Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 12:45:51 -0400 Subject: [PATCH 07/59] fix(tests)!: deprecate `build-for: all` `build-for` now has to be a list, even with `all`. So `build-for: all` in yaml becomes `build-for: [all]` --- tests/unit/services/test_package.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/services/test_package.py b/tests/unit/services/test_package.py index 75d4d922f..5c6cbcb0d 100644 --- a/tests/unit/services/test_package.py +++ b/tests/unit/services/test_package.py @@ -183,7 +183,7 @@ def test_get_manifest_bases_from_bases(fake_path, package_service, bases, expect { "anything": { "build-on": [*const.SUPPORTED_ARCHITECTURES], - "build-for": "all", + "build-for": ["all"], } }, "anything", @@ -193,7 +193,7 @@ def test_get_manifest_bases_from_bases(fake_path, package_service, bases, expect { "anything": { "build-on": [*const.SUPPORTED_ARCHITECTURES], - "build-for": "all", + "build-for": ["all"], }, "amd64": None, "riscy": { From ed71a5b6bdd75138d69a74efcf2161b416b31e05 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 13:01:49 -0400 Subject: [PATCH 08/59] replace `dict()` with `model_dump()` in two places --- charmcraft/application/commands/store.py | 2 +- charmcraft/services/package.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/charmcraft/application/commands/store.py b/charmcraft/application/commands/store.py index 7807b00c8..63e1c151a 100644 --- a/charmcraft/application/commands/store.py +++ b/charmcraft/application/commands/store.py @@ -2205,7 +2205,7 @@ def run(self, parsed_args): "revision": item.revision, "created at": item.created_at.isoformat(), "size": item.size, - "bases": [base.dict() for base in item.bases], + "bases": [base.model_dump() for base in item.bases], } for item in result ] diff --git a/charmcraft/services/package.py b/charmcraft/services/package.py index 28caaa9ee..b6388a3e1 100644 --- a/charmcraft/services/package.py +++ b/charmcraft/services/package.py @@ -233,7 +233,7 @@ def write_metadata(self, path: pathlib.Path) -> None: # crystal wine glass. (path / "manifest.yaml").write_text( utils.dump_yaml( - manifest.dict(by_alias=True, exclude_unset=False, exclude_none=True) + manifest.model_dump(mode="json",by_alias=True, exclude_unset=False, exclude_none=True) ) ) From 6eb20f38b7c2e8dd8419528e68e6b9326e28ab06 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 14:32:21 -0400 Subject: [PATCH 09/59] chore: remove unused package module The only thing that remained using this module was its set of tests. --- charmcraft/package.py | 568 ------------ tests/test_package.py | 1909 ----------------------------------------- 2 files changed, 2477 deletions(-) delete mode 100644 charmcraft/package.py delete mode 100644 tests/test_package.py diff --git a/charmcraft/package.py b/charmcraft/package.py deleted file mode 100644 index 6ed3d8fd4..000000000 --- a/charmcraft/package.py +++ /dev/null @@ -1,568 +0,0 @@ -# Copyright 2020-2023 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 -"""Infrastructure for the 'pack' command.""" -import dataclasses -import os -import pathlib -import shutil -import subprocess -import tempfile -import zipfile -from collections.abc import Collection, Mapping, Sequence - -import craft_parts -import yaml -from craft_application import util -from craft_cli import CraftError, emit -from craft_providers.bases import get_base_alias - -import charmcraft.env -import charmcraft.instrum -import charmcraft.linters -import charmcraft.providers -from charmcraft import const, env, errors, parts -from charmcraft.metafiles.actions import create_actions_yaml -from charmcraft.metafiles.config import create_config_yaml -from charmcraft.metafiles.manifest import create_manifest -from charmcraft.metafiles.metadata import create_metadata_yaml -from charmcraft.models.charmcraft import Base, BasesConfiguration -from charmcraft.models.lint import LintResult -from charmcraft.utils import ( - build_zip, - collect_charmlib_pydeps, - humanize_list, - load_yaml, -) - - -@dataclasses.dataclass(frozen=True) -class OutputFiles: - """Collection of output files, separated into charms and an optional bundle.""" - - charms: Sequence[pathlib.Path] = () - bundles: Sequence[pathlib.Path] = () - - -def _format_run_on_base(base: Base) -> str: - """Formulate charm string for base section.""" - return "-".join([base.name, base.channel, *base.architectures]) - - -def _format_bases_config(bases_config: BasesConfiguration) -> str: - """Formulate charm string for bases configuration section.""" - return "_".join([_format_run_on_base(r) for r in bases_config.run_on]) - - -def format_charm_file_name(charm_name: str, bases_config: BasesConfiguration) -> str: - """Formulate charm file name. - - :param charm_name: Name of charm. - :param bases_config: Bases configuration for charm. - - :returns: File name string, including .charm extension. - """ - return "_".join([charm_name, _format_bases_config(bases_config)]) + ".charm" - - -def launch_shell(*, cwd: pathlib.Path | None = None) -> None: - """Launch a user shell for debugging environment. - - :param cwd: Working directory to start user in. - """ - with emit.pause(): - subprocess.run(["bash"], check=False, cwd=cwd) - - -class Builder: - """The package builder.""" - - def __init__( - self, - *, - config, - force, - debug, - shell, - shell_after, - measure, - ): - self.force_packing = force - self.debug = debug - self.shell = shell - self.shell_after = shell_after - self.measure = measure - - self.charmdir = config.project.dirpath - self.buildpath = self.charmdir / const.BUILD_DIRNAME - self.shared_cache_path = charmcraft.env.get_host_shared_cache_path() - - self.config = config - if self.config.parts: - self._parts = self.config.parts.copy() - else: - self._parts = None - - # a part named "charm" using plugin "charm" is special and has - # predefined values set automatically. - charm_part = self._parts.get("charm") - if charm_part and charm_part.get("plugin") == "charm": - self._special_charm_part = charm_part - else: - self._special_charm_part = None - - self.provider = charmcraft.providers.get_provider() - - def show_linting_results(self, linting_results): - """Manage the linters results, show some in different conditions, decide if continue.""" - attribute_results = [] - lint_results_by_outcome = {} - for result in linting_results: - if result.result == LintResult.IGNORED: - continue - if result.check_type == charmcraft.linters.CheckType.ATTRIBUTE: - attribute_results.append(result) - else: - lint_results_by_outcome.setdefault(result.result, []).append(result) - - # show attribute results - for result in attribute_results: - emit.verbose( - f"Check result: {result.name} [{result.check_type.value}] {result.result} " - f"({result.text}; see more at {result.url}).", - ) - - # show warnings (if any), then errors (if any) - template = "- {0.name}: {0.text} ({0.url})" - if LintResult.WARNING in lint_results_by_outcome: - emit.progress("Lint Warnings:", permanent=True) - for result in lint_results_by_outcome[LintResult.WARNING]: - emit.progress(template.format(result), permanent=True) - if LintResult.ERROR in lint_results_by_outcome: - emit.progress("Lint Errors:", permanent=True) - for result in lint_results_by_outcome[LintResult.ERROR]: - emit.progress(template.format(result), permanent=True) - if self.force_packing: - emit.progress("Packing anyway as requested.", permanent=True) - else: - raise CraftError( - "Aborting due to lint errors (use --force to override).", retcode=2 - ) - - def build_charm(self, bases_config: BasesConfiguration) -> str: - """Build the charm. - - :param bases_config: Bases configuration to use for build. - - :returns: File name of charm. - - :raises CraftError: on lifecycle exception. - :raises RuntimeError: on unexpected lifecycle exception. - """ - if charmcraft.env.is_charmcraft_running_in_managed_mode(): - work_dir = charmcraft.env.get_managed_environment_home_path() - else: - work_dir = self.buildpath - - emit.progress(f"Building charm in {str(work_dir)!r}") - - if self._special_charm_part: - # add charm files to the prime filter - # XXX Facundo 2022-07-18: we need to move this also to the plugin config - self._set_prime_filter() - - # run the parts lifecycle - emit.debug(f"Parts definition: {self._parts}") - lifecycle = charmcraft.parts.PartsLifecycle( - self._parts, - work_dir=work_dir, - project_dir=self.charmdir, - project_name=self.config.name, - ignore_local_sources=["*.charm"], - ) - with charmcraft.instrum.Timer("Lifecycle run"): - lifecycle.run(craft_parts.Step.PRIME) - - # skip creating yaml files if using reactive, reactive will create them - # in a incompatible way - if "reactive" not in {value.get("plugin") for value in self._parts.values()}: - create_actions_yaml(lifecycle.prime_dir, self.config) - create_config_yaml(lifecycle.prime_dir, self.config) - create_metadata_yaml(lifecycle.prime_dir, self.config) - - # run linters and show the results - linting_results = charmcraft.linters.analyze(self.config, lifecycle.prime_dir) - self.show_linting_results(linting_results) - - create_manifest( - lifecycle.prime_dir, - self.config.project.started_at, - bases_config, - linting_results, - ) - - zipname = self.handle_package(lifecycle.prime_dir, bases_config) - emit.progress(f"Created '{zipname}'.", permanent=True) - return zipname - - def _set_prime_filter(self): - """Add mandatory and optional charm files to the prime filter. - - The prime filter should contain: - - The charm entry point, or the directory containing it if it's - not directly in the project dir. - - A set of mandatory charm files, including metadata.yaml, the - dispatcher and the hooks directory. - - A set of optional charm files. - """ - charm_part_prime = self._special_charm_part.setdefault("prime", []) - - # add entrypoint - entrypoint = pathlib.Path(self._special_charm_part["charm-entrypoint"]) - if str(entrypoint) == entrypoint.name: - # the entry point is in the root of the project, just include it - charm_part_prime.append(str(entrypoint)) - else: - # the entry point is in a subdir, include the whole subtree - charm_part_prime.append(str(entrypoint.parts[0])) - - # add venv if there are requirements - charmlib_pydeps = collect_charmlib_pydeps(self.charmdir) - if ( - self._special_charm_part.get("charm-requirements") - or self._special_charm_part.get("charm-binary-python-packages") - or self._special_charm_part.get("charm-python-packages") - or charmlib_pydeps - ): - charm_part_prime.append(const.VENV_DIRNAME) - - # add mandatory and optional charm files - charm_part_prime.extend(const.CHARM_MANDATORY_FILES) - for fn in const.CHARM_OPTIONAL_FILES: - path = self.charmdir / fn - if path.exists(): - charm_part_prime.append(fn) - - @charmcraft.instrum.Timer("Builder run") - def run( - self, bases_indices: list[int] | None = None, destructive_mode: bool = False - ) -> list[str]: - """Run build process. - - In managed-mode or destructive-mode, build for each bases configuration - which has a matching build-on to the host we are executing on. Warn for - each base configuration that is incompatible. Error if unable to - produce any builds for any bases configuration. - - :returns: List of charm files created. - """ - charms: list[str] = [] - - managed_mode = charmcraft.env.is_charmcraft_running_in_managed_mode() - if not managed_mode and not destructive_mode: - charmcraft.providers.ensure_provider_is_available(self.provider) - - build_plan = charmcraft.providers.create_build_plan( - bases=self.config.bases, - bases_indices=bases_indices, - destructive_mode=destructive_mode, - managed_mode=managed_mode, - provider=self.provider, - ) - if not build_plan: - raise CraftError( - "No suitable 'build-on' environment found in any 'bases' configuration." - ) - - charms = [] - for plan in build_plan: - emit.debug(f"Building for 'bases[{plan.bases_index:d}][{plan.build_on_index:d}]'.") - if managed_mode or destructive_mode: - if self.shell: - # Execute shell in lieu of build. - launch_shell() - continue - - try: - with charmcraft.instrum.Timer("Building the charm"): - charm_name = self.build_charm(plan.bases_config) - except (CraftError, RuntimeError) as error: - if self.debug: - emit.debug(f"Launching shell as charm building ended in error: {error}") - launch_shell() - raise - - if self.shell_after: - launch_shell() - else: - charm_name = self.pack_charm_in_instance( - bases_index=plan.bases_index, - build_on=plan.build_on, - build_on_index=plan.build_on_index, - ) - charms.append(charm_name) - - return charms - - def pack_charm_in_instance( - self, *, bases_index: int, build_on: Base, build_on_index: int - ) -> str: - """Pack instance in Charm.""" - charm_name = format_charm_file_name(self.config.name, self.config.bases[bases_index]) - - # If building in project directory, use the project path as the working - # directory. The output charms will be placed in the correct directory - # without needing retrieval. If outputting to a directory other than the - # charm project directory, we need to output the charm outside the - # project directory and can retrieve it when complete. - cwd = pathlib.Path.cwd() - if cwd == self.charmdir: - instance_output_dir = charmcraft.env.get_managed_environment_project_path() - pull_charm = False - else: - instance_output_dir = charmcraft.env.get_managed_environment_home_path() - pull_charm = True - - mode = emit.get_mode().name.lower() - cmd = ["charmcraft", "pack", "--bases-index", str(bases_index), f"--verbosity={mode}"] - - if self.debug: - cmd.append("--debug") - - if self.shell: - cmd.append("--shell") - - if self.shell_after: - cmd.append("--shell-after") - - if self.force_packing: - cmd.append("--force") - - if self.measure: - instance_metrics = charmcraft.env.get_managed_environment_metrics_path() - cmd.append(f"--measure={str(instance_metrics)}") - else: - instance_metrics = None - - emit.progress( - f"Launching environment to pack for base {build_on} " - "(may take a while the first time but it's reusable)" - ) - - build_base_alias = get_base_alias((build_on.name, build_on.channel)) - instance_name = charmcraft.providers.get_instance_name( - bases_index=bases_index, - build_on_index=build_on_index, - project_name=self.config.name, - project_path=self.charmdir, - target_arch=util.get_host_architecture(), - ) - base_configuration = charmcraft.providers.get_base_configuration( - alias=build_base_alias, - instance_name=instance_name, - shared_cache_path=self.shared_cache_path, - ) - - if build_on.name == "ubuntu": - if build_on.channel in const.UBUNTU_LTS_STABLE: - allow_unstable = False - else: - allow_unstable = True - emit.progress( - f"Warning: non-LTS Ubuntu releases {build_on.channel} are " - "intended for experimental use only.", - permanent=True, - ) - else: - allow_unstable = True - emit.message( - f"Warning: Base {build_on.name} {build_on.channel} daily image may be unstable." - ) - - with self.provider.launched_environment( - project_name=self.config.name, - project_path=self.charmdir, - base_configuration=base_configuration, - instance_name=instance_name, - allow_unstable=allow_unstable, - ) as instance: - emit.debug("Mounting directory inside the instance") - with charmcraft.instrum.Timer("Mounting directory"): - instance.mount( - host_source=self.charmdir, - target=charmcraft.env.get_managed_environment_project_path(), - ) - - emit.progress("Packing the charm") - emit.debug(f"Running {cmd}") - try: - with charmcraft.instrum.Timer("Execution inside instance"): - with emit.pause(): - instance.execute_run(cmd, check=True, cwd=instance_output_dir) - if self.measure: - with instance.temporarily_pull_file(instance_metrics) as local_filepath: - charmcraft.instrum.merge_from(local_filepath) - except subprocess.CalledProcessError as error: - raise CraftError( - f"Failed to build charm for bases index '{bases_index}'." - ) from error - finally: - charmcraft.providers.capture_logs_from_instance(instance) - - if pull_charm: - try: - instance.pull_file( - source=instance_output_dir / charm_name, - destination=cwd / charm_name, - ) - except FileNotFoundError as error: - raise CraftError("Unexpected error retrieving charm from instance.") from error - - emit.progress("Charm packed ok") - return charm_name - - def handle_package(self, prime_dir, bases_config: BasesConfiguration): - """Handle the final package creation.""" - emit.progress("Creating the package itself") - zipname = format_charm_file_name(self.config.name, bases_config) - zipfh = zipfile.ZipFile(zipname, "w", zipfile.ZIP_DEFLATED) - for dirpath, _dirnames, filenames in os.walk(prime_dir, followlinks=True): - dirpath = pathlib.Path(dirpath) - for filename in filenames: - filepath = dirpath / filename - zipfh.write(str(filepath), str(filepath.relative_to(prime_dir))) - - zipfh.close() - return zipname - - def _get_charm_pack_args(self, base_indeces: list[str], destructive_mode: bool) -> list[str]: - """Get the arguments for a charmcraft pack subprocess to run.""" - args = ["charmcraft", "pack", "--verbose"] - if destructive_mode: - args.append("--destructive-mode") - for base in base_indeces: - args.append(f"--bases-index={base}") - if self.force_packing: - args.append("--force") - return args - - def pack_bundle( - self, - *, - charms: dict[str, pathlib.Path], - base_indeces: list[str], - destructive_mode: bool, - overwrite: bool = False, - ) -> OutputFiles: - """Pack a bundle.""" - if self._parts is None: - self._parts = {"bundle": {"plugin": "bundle"}} - - if env.is_charmcraft_running_in_managed_mode(): - work_dir = env.get_managed_environment_home_path() - else: - work_dir = self.config.project.dirpath / const.BUILD_DIRNAME - - # get the config files - bundle_filepath = self.config.project.dirpath / const.BUNDLE_FILENAME - bundle = load_yaml(bundle_filepath) - bundle_name = bundle.get("name") - if not bundle_name: - raise CraftError( - "Invalid bundle config; missing a 'name' field indicating the bundle's name in " - f"file {str(bundle_filepath)!r}." - ) - - if charms: - bundle_charms = bundle.get("applications", {}) - command_args = self._get_charm_pack_args(base_indeces, destructive_mode) - charms = _subprocess_pack_charms(charms, command_args) - for name, value in bundle_charms.items(): - if name in charms: - value["charm"] = charms[name] - else: - charms = {} - - lifecycle = parts.PartsLifecycle( - self._parts, - work_dir=work_dir, - project_dir=self.config.project.dirpath, - project_name=bundle_name, - ignore_local_sources=[bundle_name + ".zip"], - ) - - lifecycle.run(craft_parts.Step.PRIME) - - # pack everything - create_manifest( - lifecycle.prime_dir, - self.config.project.started_at, - bases_config=None, - linting_results=[], - ) - zipname = self.config.project.dirpath / (bundle_name + ".zip") - if overwrite: - primed_bundle_path = lifecycle.prime_dir / const.BUNDLE_FILENAME - with primed_bundle_path.open("w") as bundle_file: - yaml.safe_dump(bundle, bundle_file) - build_zip(zipname, lifecycle.prime_dir) - - return OutputFiles(charms=list(charms.values()), bundles=[zipname]) - - -def _subprocess_pack_charms( - charms: Mapping[str, pathlib.Path], - command_args: Collection[str], -) -> dict[str, pathlib.Path]: - """Pack the given charms for a bundle in subprocesses. - - :param command_args: The initial arguments - :param charms: A mapping of charm name to charm path - :returns: A mapping of charm names to the generated charm. - """ - if charms: - charm_str = humanize_list(charms.keys(), "and") - emit.progress(f"Packing charms: {charm_str}...") - cwd = pathlib.Path(os.getcwd()).resolve() - generated_charms = {} - with tempfile.TemporaryDirectory(prefix="charmcraft-bundle-", dir=cwd) as temp_dir: - temp_dir = pathlib.Path(temp_dir) - try: - # Put all the charms in this temporary directory. - os.chdir(temp_dir) - for charm, project_dir in charms.items(): - full_command = [*command_args, f"--project-dir={project_dir}"] - with emit.open_stream(f"Packing charm {charm}...") as stream: - subprocess.check_call(full_command, stdout=stream, stderr=stream) - duplicate_charms = {} - for charm_file in temp_dir.glob("*.charm"): - charm_name = charm_file.name.partition("_")[0] - if charm_name not in charms: - emit.debug(f"Unknown charm file generated: {charm_file.name}") - continue - if charm_name in generated_charms: - if charm_name not in duplicate_charms: - duplicate_charms[charm_name] = temp_dir.glob(f"{charm_name}_*.charm") - continue - generated_charms[charm_name] = charm_file - if duplicate_charms: - raise errors.DuplicateCharmsError(duplicate_charms) - for charm, charm_file in generated_charms.items(): - destination = cwd / charm_file.name - destination.unlink(missing_ok=True) - generated_charms[charm] = shutil.move(charm_file, destination) - finally: - os.chdir(cwd) - return generated_charms diff --git a/tests/test_package.py b/tests/test_package.py deleted file mode 100644 index 776e7c5bc..000000000 --- a/tests/test_package.py +++ /dev/null @@ -1,1909 +0,0 @@ -# Copyright 2020-2023 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 -import contextlib -import pathlib -import re -import subprocess -import sys -import zipfile -from textwrap import dedent -from unittest import mock -from unittest.mock import ANY, MagicMock, call, patch - -import pytest -import yaml -from craft_application import util -from craft_cli import CraftError, EmitterMode, emit -from craft_providers import bases - -from charmcraft import const, instrum, linters -from charmcraft.bases import get_host_as_base -from charmcraft.charm_builder import relativise -from charmcraft.config import load -from charmcraft.models.charmcraft import Base, BasesConfiguration -from charmcraft.models.lint import LintResult -from charmcraft.package import ( - Builder, - _subprocess_pack_charms, - format_charm_file_name, - launch_shell, -) -from charmcraft.providers import get_base_configuration - - -def get_builder( - config, - *, - project_dir=None, - force=False, - debug=False, - shell=False, - shell_after=False, - measure=None, -): - if project_dir is None: - project_dir = config.project.dirpath - - return Builder( - config=config, - debug=debug, - force=force, - shell=shell, - shell_after=shell_after, - measure=measure, - ) - - -@pytest.fixture() -def basic_project(tmp_path, monkeypatch, prepare_charmcraft_yaml, prepare_metadata_yaml): - """Create a basic Charmcraft project.""" - build_dir = tmp_path / const.BUILD_DIRNAME - build_dir.mkdir() - - # a lib dir - lib_dir = tmp_path / "lib" - lib_dir.mkdir() - ops_lib_dir = lib_dir / "ops" - ops_lib_dir.mkdir() - ops_stuff = ops_lib_dir / "stuff.txt" - ops_stuff.write_bytes(b"ops stuff") - - # simple source code - src_dir = tmp_path / "src" - src_dir.mkdir() - charm_script = src_dir / "charm.py" - charm_script.write_bytes(b"all the magic") - charm_script.chmod(0o755) - - # the license file - license = tmp_path / "LICENSE" - license.write_text("license content") - - # other optional assets - icon = tmp_path / "icon.svg" - icon.write_text("icon content") - - # README - readme = tmp_path / "README.md" - readme.write_text("README content") - - # the config - host_base = get_host_as_base() - prepare_charmcraft_yaml( - dedent( - f""" - type: charm - bases: - - name: {host_base.name} - channel: "{host_base.channel}" - architectures: {host_base.architectures!r} - parts: # just to avoid "default charm parts" sneaking in - foo: - plugin: nil - """ - ) - ) - prepare_metadata_yaml( - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ) - # paths are relative, make all tests to run in the project's directory - monkeypatch.chdir(tmp_path) - - return tmp_path - - -@pytest.fixture() -def basic_project_builder(basic_project, prepare_charmcraft_yaml): - def _basic_project_builder(bases_configs: list[BasesConfiguration], **builder_kwargs): - charmcraft_yaml = dedent( - """ - type: charm - bases: - """ - ) - - for bases_config in bases_configs: - charmcraft_yaml += " - build-on:\n" - for base in bases_config.build_on: - charmcraft_yaml += ( - f" - name: {base.name!r}\n" - f" channel: {base.channel!r}\n" - f" architectures: {base.architectures!r}\n" - ) - - charmcraft_yaml += " run-on:\n" - for base in bases_config.run_on: - charmcraft_yaml += ( - f" - name: {base.name!r}\n" - f" channel: {base.channel!r}\n" - f" architectures: {base.architectures!r}\n" - ) - charmcraft_yaml += dedent( - """ - parts: - foo: - plugin: nil - """ - ) - - prepare_charmcraft_yaml(charmcraft_yaml) - - config = load(basic_project) - return get_builder(config, **builder_kwargs) - - return _basic_project_builder - - -@pytest.fixture() -def mock_capture_logs_from_instance(): - with patch("charmcraft.providers.capture_logs_from_instance") as mock_capture: - yield mock_capture - - -@pytest.fixture() -def mock_launch_shell(): - with patch("charmcraft.package.launch_shell") as mock_shell: - yield mock_shell - - -@pytest.fixture() -def mock_linters(): - with patch("charmcraft.linters") as mock_linters: - mock_linters.analyze.return_value = [] - yield mock_linters - - -@pytest.fixture() -def mock_parts(): - with patch("charmcraft.parts") as mock_parts: - yield mock_parts - - -@pytest.fixture(autouse=True) -def mock_provider(mock_instance, fake_provider): - mock_provider = mock.Mock(wraps=fake_provider) - with patch("charmcraft.providers.get_provider", return_value=mock_provider): - yield mock_provider - - -@pytest.fixture() -def mock_ubuntu_buildd_base_configuration(): - with mock.patch("craft_providers.bases.ubuntu.BuilddBase", autospec=True) as mock_base_config: - yield mock_base_config - - -@pytest.fixture() -def mock_centos_base_configuration(): - with mock.patch("craft_providers.bases.centos.CentOSBase", autospec=True) as mock_base_config: - yield mock_base_config - - -@pytest.fixture() -def mock_instance_name(): - with mock.patch( - "charmcraft.providers.get_instance_name", return_value="test-instance-name" - ) as patched: - yield patched - - -@pytest.fixture() -def mock_is_base_available(): - with mock.patch( - "charmcraft.providers.is_base_available", - return_value=(True, None), - ) as mock_is_base_available: - yield mock_is_base_available - - -# --- (real) build tests - - -def test_build_error_without_metadata_yaml(basic_project): - """Validate error if trying to build project without metadata.yaml.""" - metadata = basic_project / const.METADATA_FILENAME - metadata.unlink() - - with pytest.raises(CraftError) as exc_info: - config = load(basic_project) - - get_builder(config) - - assert str(exc_info.value) == dedent( - """\ - Bad charmcraft.yaml content: - - needs value in field 'name' - - needs value in field 'summary' - - needs value in field 'description'""" - ) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_build_with_charmcraft_yaml_destructive_mode(basic_project_builder, emitter, monkeypatch): - host_base = get_host_as_base() - builder = basic_project_builder( - [BasesConfiguration(**{"build-on": [host_base], "run-on": [host_base]})], - force=True, # to ignore any linter issue - ) - - zipnames = builder.run(destructive_mode=True) - - host_arch = host_base.architectures[0] - assert zipnames == [ - "test-charm-name-from-metadata-yaml_" - f"{host_base.name}-{host_base.channel}-{host_arch}.charm" - ] - - emitter.assert_debug("Building for 'bases[0]' as host matches 'build-on[0]'.") - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_build_with_charmcraft_yaml_managed_mode( - basic_project_builder, emitter, monkeypatch, tmp_path -): - monkeypatch.setenv(const.MANAGED_MODE_ENV_VAR, "1") - host_base = get_host_as_base() - builder = basic_project_builder( - [BasesConfiguration(**{"build-on": [host_base], "run-on": [host_base]})], - force=True, # to ignore any linter issue - ) - - with patch("charmcraft.env.get_managed_environment_home_path", return_value=tmp_path / "root"): - zipnames = builder.run() - - host_arch = host_base.architectures[0] - assert zipnames == [ - "test-charm-name-from-metadata-yaml_" - f"{host_base.name}-{host_base.channel}-{host_arch}.charm" - ] - - emitter.assert_debug("Building for 'bases[0]' as host matches 'build-on[0]'.") - - -def test_build_checks_provider(basic_project, mock_provider, mock_capture_logs_from_instance): - """Test cases for base-index parameter.""" - config = load(basic_project) - builder = get_builder(config) - - with contextlib.suppress(CraftError): - # 'No suitable 'build-on' environment...' error will be raised on some test platforms - builder.run() - - mock_provider.ensure_provider_is_available.assert_called_once() - - -def test_build_with_debug_no_error( - basic_project_builder, - mock_linters, - mock_parts, - mock_launch_shell, -): - host_base = get_host_as_base() - builder = basic_project_builder( - [BasesConfiguration(**{"build-on": [host_base], "run-on": [host_base]})], - debug=True, - ) - - charms = builder.run(destructive_mode=True) - - assert len(charms) == 1 - assert mock_launch_shell.mock_calls == [] - - -def test_build_with_debug_with_error( - basic_project_builder, - mock_linters, - mock_parts, - mock_launch_shell, -): - mock_parts.PartsLifecycle.return_value.run.side_effect = CraftError("fail") - host_base = get_host_as_base() - builder = basic_project_builder( - [BasesConfiguration(**{"build-on": [host_base], "run-on": [host_base]})], - debug=True, - ) - - with pytest.raises(CraftError): - builder.run(destructive_mode=True) - - assert mock_launch_shell.mock_calls == [mock.call()] - - -def test_build_with_shell(basic_project_builder, mock_parts, mock_provider, mock_launch_shell): - host_base = get_host_as_base() - builder = basic_project_builder( - [BasesConfiguration(**{"build-on": [host_base], "run-on": [host_base]})], - shell=True, - ) - - charms = builder.run(destructive_mode=True) - - assert charms == [] - assert mock_launch_shell.mock_calls == [mock.call()] - - -def test_build_with_shell_after( - basic_project_builder, - mock_linters, - mock_parts, - mock_launch_shell, -): - host_base = get_host_as_base() - builder = basic_project_builder( - [BasesConfiguration(**{"build-on": [host_base], "run-on": [host_base]})], - shell_after=True, - ) - - charms = builder.run(destructive_mode=True) - - assert len(charms) == 1 - assert mock_launch_shell.mock_calls == [mock.call()] - - -def test_build_checks_provider_error(basic_project, mock_provider): - """Test cases for base-index parameter.""" - mock_provider.ensure_provider_is_available.side_effect = RuntimeError("foo") - config = load(basic_project) - builder = get_builder(config) - - with pytest.raises(RuntimeError, match="foo"): - builder.run() - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_build_multiple_with_charmcraft_yaml_destructive_mode(basic_project_builder, emitter): - """Build multiple charms for multiple matching bases, skipping one unmatched config.""" - host_base = get_host_as_base() - unmatched_base = Base( - name="unmatched-name", - channel="unmatched-channel", - architectures=["unmatched-arch1"], - ) - matched_cross_base = Base( - name="cross-name", - channel="cross-channel", - architectures=["cross-arch1"], - ) - builder = basic_project_builder( - [ - BasesConfiguration(**{"build-on": [host_base], "run-on": [host_base]}), - BasesConfiguration(**{"build-on": [unmatched_base], "run-on": [unmatched_base]}), - BasesConfiguration(**{"build-on": [host_base], "run-on": [matched_cross_base]}), - ], - force=True, - ) - - zipnames = builder.run(destructive_mode=True) - - host_arch = host_base.architectures[0] - assert zipnames == [ - "test-charm-name-from-metadata-yaml_" - f"{host_base.name}-{host_base.channel}-{host_arch}.charm", - "test-charm-name-from-metadata-yaml_cross-name-cross-channel-cross-arch1.charm", - ] - - reason = f"name 'unmatched-name' does not match host {host_base.name!r}." - emitter.assert_interactions( - [ - call("debug", "Building for 'bases[0]' as host matches 'build-on[0]'."), - call("progress", f"Skipping 'bases[1].build-on[0]': {reason}"), - call( - "progress", - "No suitable 'build-on' environment found in 'bases[1]' configuration.", - permanent=True, - ), - call("debug", "Building for 'bases[2]' as host matches 'build-on[0]'."), - ] - ) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_build_multiple_with_charmcraft_yaml_managed_mode( - basic_project_builder, monkeypatch, emitter, tmp_path -): - """Build multiple charms for multiple matching bases, skipping one unmatched config.""" - host_base = get_host_as_base() - unmatched_base = Base( - name="unmatched-name", - channel="unmatched-channel", - architectures=["unmatched-arch1"], - ) - matched_cross_base = Base( - name="cross-name", - channel="cross-channel", - architectures=["cross-arch1"], - ) - builder = basic_project_builder( - [ - BasesConfiguration(**{"build-on": [host_base], "run-on": [host_base]}), - BasesConfiguration(**{"build-on": [unmatched_base], "run-on": [unmatched_base]}), - BasesConfiguration(**{"build-on": [host_base], "run-on": [matched_cross_base]}), - ], - force=True, - ) - - monkeypatch.setenv(const.MANAGED_MODE_ENV_VAR, "1") - with patch("charmcraft.env.get_managed_environment_home_path", return_value=tmp_path / "root"): - zipnames = builder.run() - - host_arch = host_base.architectures[0] - assert zipnames == [ - "test-charm-name-from-metadata-yaml_" - f"{host_base.name}-{host_base.channel}-{host_arch}.charm", - "test-charm-name-from-metadata-yaml_cross-name-cross-channel-cross-arch1.charm", - ] - - reason = f"name 'unmatched-name' does not match host {host_base.name!r}." - emitter.assert_interactions( - [ - call("debug", "Building for 'bases[0]' as host matches 'build-on[0]'."), - call("progress", f"Skipping 'bases[1].build-on[0]': {reason}"), - call( - "progress", - "No suitable 'build-on' environment found in 'bases[1]' configuration.", - permanent=True, - ), - call("debug", "Building for 'bases[2]' as host matches 'build-on[0]'."), - ] - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml_template", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: ubuntu - channel: "18.04" - architectures: {arch} - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - ], -) -def test_build_project_is_cwd( - basic_project, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml_template, - metadata_yaml, - emitter, - mock_capture_logs_from_instance, - mock_instance, - mock_provider, - mock_instance_name, - mock_ubuntu_buildd_base_configuration, - mock_is_base_available, - mocker, -): - """Test cases for base-index parameter.""" - emit.set_mode(EmitterMode.BRIEF) - host_base = get_host_as_base() - host_arch = host_base.architectures[0] - prepare_charmcraft_yaml(charmcraft_yaml_template.format(arch=host_base.architectures)) - prepare_metadata_yaml(metadata_yaml) - - config = load(basic_project) - project_managed_path = pathlib.Path("/root/project") - builder = get_builder(config) - base_configuration = get_base_configuration( - alias=bases.ubuntu.BuilddBaseAlias.BIONIC, instance_name=mock_instance_name() - ) - mock_base_get_base_configuration = mocker.patch("charmcraft.providers.get_base_configuration") - mock_base_get_base_configuration.return_value = base_configuration - - zipnames = builder.run([0]) - - assert zipnames == [ - f"test-charm-name-from-metadata-yaml_ubuntu-18.04-{host_arch}.charm", - ] - assert mock_provider.mock_calls == [ - call.is_provider_installed(), - call.ensure_provider_is_available(), - call.launched_environment( - project_name="test-charm-name-from-metadata-yaml", - project_path=basic_project, - base_configuration=base_configuration, - instance_name=mock_instance_name(), - allow_unstable=False, - ), - ] - assert mock_instance.mock_calls == [ - call.mount(host_source=basic_project, target=project_managed_path), - call.execute_run( - ["charmcraft", "pack", "--bases-index", "0", "--verbosity=brief"], - check=True, - cwd=project_managed_path, - ), - ] - assert mock_is_base_available.mock_calls == [ - call.is_base_available( - Base(name="ubuntu", channel="18.04", architectures=[host_arch]), - ) - ] - - -@pytest.mark.parametrize( - ("charmcraft_yaml_template", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: ubuntu - channel: "18.04" - architectures: {arch} - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - ], -) -def test_build_project_is_not_cwd( - basic_project, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml_template, - metadata_yaml, - mock_capture_logs_from_instance, - mock_instance, - mock_provider, - monkeypatch, - mock_instance_name, - mock_ubuntu_buildd_base_configuration, - mock_is_base_available, - mocker, -): - """Test cases for base-index parameter.""" - emit.set_mode(EmitterMode.BRIEF) - host_base = get_host_as_base() - host_arch = host_base.architectures[0] - prepare_charmcraft_yaml(charmcraft_yaml_template.format(arch=host_base.architectures)) - prepare_metadata_yaml(metadata_yaml) - - config = load(basic_project) - builder = get_builder(config) - base_configuration = get_base_configuration( - alias=bases.ubuntu.BuilddBaseAlias.BIONIC, instance_name=mock_instance_name() - ) - mock_base_get_base_configuration = mocker.patch("charmcraft.providers.get_base_configuration") - mock_base_get_base_configuration.return_value = base_configuration - - monkeypatch.chdir("/") # make the working directory NOT the project's one - zipnames = builder.run([0]) - - assert zipnames == [ - f"test-charm-name-from-metadata-yaml_ubuntu-18.04-{host_arch}.charm", - ] - assert mock_provider.mock_calls == [ - call.is_provider_installed(), - call.ensure_provider_is_available(), - call.launched_environment( - project_name="test-charm-name-from-metadata-yaml", - project_path=basic_project, - base_configuration=base_configuration, - instance_name=mock_instance_name(), - allow_unstable=False, - ), - ] - assert mock_instance.mock_calls == [ - call.mount(host_source=basic_project, target=pathlib.Path("/root/project")), - call.execute_run( - ["charmcraft", "pack", "--bases-index", "0", "--verbosity=brief"], - check=True, - cwd=pathlib.Path("/root"), - ), - call.pull_file( - source=pathlib.Path("/root") / zipnames[0], - destination=pathlib.Path.cwd() / zipnames[0], - ), - ] - assert mock_is_base_available.mock_calls == [ - call.is_base_available( - Base(name="ubuntu", channel="18.04", architectures=[host_arch]), - ) - ] - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -@pytest.mark.parametrize( - ("mode", "cmd_flags"), - [ - (EmitterMode.VERBOSE, ["--verbosity=verbose"]), - (EmitterMode.QUIET, ["--verbosity=quiet"]), - (EmitterMode.DEBUG, ["--verbosity=debug"]), - (EmitterMode.TRACE, ["--verbosity=trace"]), - (EmitterMode.BRIEF, ["--verbosity=brief"]), - ], -) -@pytest.mark.parametrize( - ("charmcraft_yaml_template", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: ubuntu - channel: "18.04" - architectures: {arch} - - name: ubuntu - channel: "20.04" - architectures: {arch} - - name: centos - channel: "7" - architectures: {arch} - - name: ubuntu - channel: "unsupported-channel" - architectures: {arch} - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - ], -) -def test_build_bases_index_scenarios_provider( - mode, - cmd_flags, - basic_project, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml_template, - metadata_yaml, - emitter, - mock_capture_logs_from_instance, - mock_instance, - mock_provider, - mock_instance_name, - mock_ubuntu_buildd_base_configuration, - mock_centos_base_configuration, - mock_is_base_available, - mocker, -): - """Test cases for base-index parameter.""" - emit.set_mode(mode) - host_base = get_host_as_base() - host_arch = host_base.architectures[0] - project_managed_path = pathlib.Path("/root/project") - prepare_charmcraft_yaml(charmcraft_yaml_template.format(arch=host_base.architectures)) - config = load(basic_project) - builder = get_builder(config) - base_bionic_configuration = get_base_configuration( - alias=bases.ubuntu.BuilddBaseAlias.BIONIC, instance_name=mock_instance_name() - ) - base_focal_configuration = get_base_configuration( - alias=bases.ubuntu.BuilddBaseAlias.FOCAL, instance_name=mock_instance_name() - ) - base_centos_configuration = get_base_configuration( - alias=bases.centos.CentOSBaseAlias.SEVEN, instance_name=mock_instance_name() - ) - mock_base_get_base_configuration = mocker.patch("charmcraft.providers.get_base_configuration") - mock_base_get_base_configuration.side_effect = [ - base_bionic_configuration, - base_focal_configuration, - base_centos_configuration, - ] - - zipnames = builder.run([0]) - assert zipnames == [ - f"test-charm-name-from-metadata-yaml_ubuntu-18.04-{host_arch}.charm", - ] - - assert mock_provider.mock_calls == [ - call.is_provider_installed(), - call.ensure_provider_is_available(), - call.launched_environment( - project_name="test-charm-name-from-metadata-yaml", - project_path=basic_project, - base_configuration=base_bionic_configuration, - instance_name=mock_instance_name(), - allow_unstable=False, - ), - ] - assert mock_instance.mock_calls == [ - call.mount(host_source=basic_project, target=project_managed_path), - call.execute_run( - ["charmcraft", "pack", "--bases-index", "0", *cmd_flags], - check=True, - cwd=project_managed_path, - ), - ] - assert mock_is_base_available.mock_calls == [ - call(Base(name="ubuntu", channel="18.04", architectures=[host_arch])) - ] - emitter.assert_progress( - "Launching environment to pack for base " - "name='ubuntu' channel='18.04' architectures=['amd64'] " - "(may take a while the first time but it's reusable)" - ) - emitter.assert_progress("Packing the charm") - mock_provider.reset_mock() - mock_instance.reset_mock() - mock_is_base_available.reset_mock() - mock_base_get_base_configuration.reset_mock() - mock_base_get_base_configuration.side_effect = [ - base_bionic_configuration, - base_focal_configuration, - base_centos_configuration, - ] - - zipnames = builder.run([1]) - assert zipnames == [ - f"test-charm-name-from-metadata-yaml_ubuntu-20.04-{host_arch}.charm", - ] - assert mock_provider.mock_calls == [ - call.is_provider_installed(), - call.ensure_provider_is_available(), - call.launched_environment( - project_name="test-charm-name-from-metadata-yaml", - project_path=basic_project, - base_configuration=base_focal_configuration, - instance_name=mock_instance_name(), - allow_unstable=False, - ), - ] - assert mock_instance.mock_calls == [ - call.mount(host_source=basic_project, target=project_managed_path), - call.execute_run( - ["charmcraft", "pack", "--bases-index", "1", *cmd_flags], - check=True, - cwd=project_managed_path, - ), - ] - assert mock_is_base_available.mock_calls == [ - call(Base(name="ubuntu", channel="20.04", architectures=[host_arch])) - ] - mock_provider.reset_mock() - mock_instance.reset_mock() - mock_is_base_available.reset_mock() - mock_base_get_base_configuration.reset_mock() - mock_base_get_base_configuration.side_effect = [ - base_bionic_configuration, - base_focal_configuration, - base_centos_configuration, - ] - - zipnames = builder.run([0, 1]) - assert zipnames == [ - f"test-charm-name-from-metadata-yaml_ubuntu-18.04-{host_arch}.charm", - f"test-charm-name-from-metadata-yaml_ubuntu-20.04-{host_arch}.charm", - ] - assert mock_provider.mock_calls == [ - call.is_provider_installed(), - call.ensure_provider_is_available(), - call.launched_environment( - project_name="test-charm-name-from-metadata-yaml", - project_path=basic_project, - base_configuration=base_bionic_configuration, - instance_name=mock_instance_name(), - allow_unstable=False, - ), - call.launched_environment( - project_name="test-charm-name-from-metadata-yaml", - project_path=basic_project, - base_configuration=base_focal_configuration, - instance_name=mock_instance_name(), - allow_unstable=False, - ), - ] - assert mock_instance.mock_calls == [ - call.mount(host_source=basic_project, target=project_managed_path), - call.execute_run( - ["charmcraft", "pack", "--bases-index", "0", *cmd_flags], - check=True, - cwd=project_managed_path, - ), - call.mount(host_source=basic_project, target=project_managed_path), - call.execute_run( - ["charmcraft", "pack", "--bases-index", "1", *cmd_flags], - check=True, - cwd=project_managed_path, - ), - ] - assert mock_is_base_available.mock_calls == [ - call(Base(name="ubuntu", channel="18.04", architectures=[host_arch])), - call(Base(name="ubuntu", channel="20.04", architectures=[host_arch])), - ] - - mock_provider.reset_mock() - mock_instance.reset_mock() - mock_is_base_available.reset_mock() - mock_base_get_base_configuration.reset_mock() - mock_base_get_base_configuration.side_effect = [ - base_focal_configuration, - base_centos_configuration, - ] - - zipnames = builder.run([1, 2]) - assert zipnames == [ - f"test-charm-name-from-metadata-yaml_ubuntu-20.04-{host_arch}.charm", - f"test-charm-name-from-metadata-yaml_centos-7-{host_arch}.charm", - ] - assert mock_provider.mock_calls == [ - call.is_provider_installed(), - call.ensure_provider_is_available(), - call.launched_environment( - project_name="test-charm-name-from-metadata-yaml", - project_path=basic_project, - base_configuration=base_focal_configuration, - instance_name=mock_instance_name(), - allow_unstable=False, - ), - call.launched_environment( - project_name="test-charm-name-from-metadata-yaml", - project_path=basic_project, - base_configuration=base_centos_configuration, - instance_name=mock_instance_name(), - allow_unstable=True, - ), - ] - assert mock_instance.mock_calls == [ - call.mount(host_source=basic_project, target=project_managed_path), - call.execute_run( - ["charmcraft", "pack", "--bases-index", "1", *cmd_flags], - check=True, - cwd=project_managed_path, - ), - call.mount(host_source=basic_project, target=project_managed_path), - call.execute_run( - ["charmcraft", "pack", "--bases-index", "2", *cmd_flags], - check=True, - cwd=project_managed_path, - ), - ] - assert mock_is_base_available.mock_calls == [ - call(Base(name="ubuntu", channel="20.04", architectures=[host_arch])), - call(Base(name="centos", channel="7", architectures=[host_arch])), - ] - mock_provider.reset_mock() - mock_instance.reset_mock() - mock_is_base_available.reset_mock() - mock_base_get_base_configuration.reset_mock() - mock_base_get_base_configuration.side_effect = [ - base_bionic_configuration, - base_focal_configuration, - base_centos_configuration, - ] - - with pytest.raises( - CraftError, - match=r"No suitable 'build-on' environment found in any 'bases' configuration.", - ): - builder.run([4]) - - mock_provider.reset_mock() - mock_instance.reset_mock() - mock_is_base_available.reset_mock() - mock_base_get_base_configuration.reset_mock() - mock_base_get_base_configuration.side_effect = [ - base_bionic_configuration, - base_focal_configuration, - ] - - expected_msg = re.escape("Failed to build charm for bases index '0'.") - with pytest.raises( - CraftError, - match=expected_msg, - ): - mock_instance.execute_run.side_effect = subprocess.CalledProcessError( - -1, - ["charmcraft", "pack", "..."], - "some output", - "some error", - ) - builder.run([0]) - - assert mock_instance.mock_calls == [ - call.mount(host_source=basic_project, target=project_managed_path), - call.execute_run( - ["charmcraft", "pack", "--bases-index", "0", *cmd_flags], - check=True, - cwd=project_managed_path, - ), - ] - # it was called seven times, for success and errors - assert mock_capture_logs_from_instance.mock_calls == [call(mock_instance)] * 7 - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_build_bases_index_scenarios_managed_mode(basic_project_builder, monkeypatch, tmp_path): - """Test cases for base-index parameter.""" - host_base = get_host_as_base() - host_arch = host_base.architectures[0] - host_base = get_host_as_base() - unmatched_base = Base( - name="unmatched-name", - channel="unmatched-channel", - architectures=["unmatched-arch1"], - ) - matched_cross_base = Base( - name="cross-name", - channel="cross-channel", - architectures=["cross-arch1"], - ) - builder = basic_project_builder( - [ - BasesConfiguration(**{"build-on": [host_base], "run-on": [host_base]}), - BasesConfiguration(**{"build-on": [unmatched_base], "run-on": [unmatched_base]}), - BasesConfiguration(**{"build-on": [host_base], "run-on": [matched_cross_base]}), - ], - force=True, - ) - - monkeypatch.setenv(const.MANAGED_MODE_ENV_VAR, "1") - with patch("charmcraft.env.get_managed_environment_home_path", return_value=tmp_path / "root"): - zipnames = builder.run([0]) - assert zipnames == [ - "test-charm-name-from-metadata-yaml_" - f"{host_base.name}-{host_base.channel}-{host_arch}.charm", - ] - - with pytest.raises( - CraftError, - match=r"No suitable 'build-on' environment found in any 'bases' configuration.", - ): - builder.run([1]) - - with patch("charmcraft.env.get_managed_environment_home_path", return_value=tmp_path / "root"): - zipnames = builder.run([2]) - assert zipnames == [ - "test-charm-name-from-metadata-yaml_cross-name-cross-channel-cross-arch1.charm", - ] - - -@patch( - "charmcraft.bases.get_host_as_base", - return_value=Base(name="xname", channel="xchannel", architectures=["xarch"]), -) -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: unmatched-name - channel: xchannel - architectures: [xarch] - - name: xname - channel: unmatched-channel - architectures: [xarch] - - name: xname - channel: xchannel - architectures: [unmatched-arch1, unmatched-arch2] - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - ], -) -def test_build_error_no_match_with_charmcraft_yaml( - mock_host_base, - basic_project, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml, - metadata_yaml, - monkeypatch, - emitter, -): - """Error when no charms are buildable with host base, verifying each mismatched reason.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(basic_project) - builder = get_builder(config) - - # Managed bases build. - monkeypatch.setenv(const.MANAGED_MODE_ENV_VAR, "1") - with pytest.raises( - CraftError, - match=r"No suitable 'build-on' environment found in any 'bases' configuration.", - ): - builder.run() - - emitter.assert_interactions( - [ - call( - "progress", - "Skipping 'bases[0].build-on[0]': " - "name 'unmatched-name' does not match host 'xname'.", - ), - call( - "progress", - "No suitable 'build-on' environment found in 'bases[0]' configuration.", - permanent=True, - ), - call( - "progress", - "Skipping 'bases[1].build-on[0]': " - "channel 'unmatched-channel' does not match host 'xchannel'.", - ), - call( - "progress", - "No suitable 'build-on' environment found in 'bases[1]' configuration.", - permanent=True, - ), - call( - "progress", - "Skipping 'bases[2].build-on[0]': " - "host architecture 'xarch' not in base architectures " - "['unmatched-arch1', 'unmatched-arch2'].", - ), - call( - "progress", - "No suitable 'build-on' environment found in 'bases[2]' configuration.", - permanent=True, - ), - ] - ) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -@pytest.mark.parametrize( - ("builder_flag", "cmd_flag"), - [ - ("debug", "--debug"), - ("shell", "--shell"), - ("shell_after", "--shell-after"), - ("force", "--force"), - ], -) -def test_build_arguments_managed_charmcraft_simples( - builder_flag, - cmd_flag, - mock_capture_logs_from_instance, - mock_instance, - basic_project_builder, -): - """Check that the command to run charmcraft inside the environment is properly built.""" - emit.set_mode(EmitterMode.BRIEF) - host_base = Base(name="ubuntu", channel="18.04", architectures=[util.get_host_architecture()]) - bases_config = [BasesConfiguration(**{"build-on": [host_base], "run-on": [host_base]})] - project_managed_path = pathlib.Path("/root/project") - - kwargs = {builder_flag: True} - builder = basic_project_builder(bases_config, **kwargs) - builder.pack_charm_in_instance( - build_on=bases_config[0].build_on[0], - bases_index=0, - build_on_index=0, - ) - expected_cmd = ["charmcraft", "pack", "--bases-index", "0", "--verbosity=brief", cmd_flag] - assert mock_instance.mock_calls == [ - call.mount(host_source=builder.config.project.dirpath, target=project_managed_path), - call.execute_run(expected_cmd, check=True, cwd=project_managed_path), - ] - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_build_arguments_managed_charmcraft_measure( - mock_capture_logs_from_instance, - mock_instance, - basic_project_builder, - tmp_path, -): - """Check that the command to run charmcraft inside the environment is properly built.""" - emit.set_mode(EmitterMode.BRIEF) - host_base = Base(name="ubuntu", channel="18.04", architectures=[util.get_host_architecture()]) - bases_config = [BasesConfiguration(**{"build-on": [host_base], "run-on": [host_base]})] - project_managed_path = pathlib.Path("/root/project") - - # fake a dumped measure to be pulled from the instance - fake_local_m = tmp_path / "local.json" - instrum._Measurements().dump(fake_local_m) - - # specially patch the context manager - fake_inst_m = tmp_path / "inst.json" - mock_instance.temporarily_pull_file = MagicMock() - mock_instance.temporarily_pull_file.return_value = fake_local_m - - builder = basic_project_builder(bases_config, measure=tmp_path) - with patch("charmcraft.env.get_managed_environment_metrics_path", return_value=fake_inst_m): - builder.pack_charm_in_instance( - build_on=bases_config[0].build_on[0], - bases_index=0, - build_on_index=0, - ) - cmd_flag = f"--measure={str(fake_inst_m)}" - expected_cmd = ["charmcraft", "pack", "--bases-index", "0", "--verbosity=brief", cmd_flag] - assert mock_instance.mock_calls == [ - call.mount(host_source=builder.config.project.dirpath, target=project_managed_path), - call.execute_run(expected_cmd, check=True, cwd=project_managed_path), - call.temporarily_pull_file(fake_inst_m), - ] - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_build_package_tree_structure(new_path, config): - """The zip file is properly built internally.""" - # the metadata - metadata_data = {"name": "test-charm-name-from-metadata-yaml"} - metadata_file = new_path / const.METADATA_FILENAME - with metadata_file.open("wt", encoding="ascii") as fh: - yaml.dump(metadata_data, fh) - - # create some dirs and files! a couple of files outside, and the dir we'll zip... - file_outside_1 = new_path / "file_outside_1" - with file_outside_1.open("wb") as fh: - fh.write(b"content_out_1") - file_outside_2 = new_path / "file_outside_2" - with file_outside_2.open("wb") as fh: - fh.write(b"content_out_2") - to_be_zipped_dir = new_path / const.BUILD_DIRNAME - to_be_zipped_dir.mkdir() - - # ...also outside a dir with a file... - dir_outside = new_path / "extdir" - dir_outside.mkdir() - file_ext = dir_outside / "file_ext" - with file_ext.open("wb") as fh: - fh.write(b"external file") - - # ...then another file inside, and another dir... - file_inside = to_be_zipped_dir / "file_inside" - with file_inside.open("wb") as fh: - fh.write(b"content_in") - dir_inside = to_be_zipped_dir / "somedir" - dir_inside.mkdir() - - # ...also inside, a link to the external dir... - dir_linked_inside = to_be_zipped_dir / "linkeddir" - dir_linked_inside.symlink_to(dir_outside) - - # ...and finally another real file, and two symlinks - file_deep_1 = dir_inside / "file_deep_1" - with file_deep_1.open("wb") as fh: - fh.write(b"content_deep") - file_deep_2 = dir_inside / "file_deep_2" - file_deep_2.symlink_to(file_inside) - file_deep_3 = dir_inside / "file_deep_3" - file_deep_3.symlink_to(file_outside_1) - - # zip it - bases_config = BasesConfiguration( - **{ - "build-on": [], - "run-on": [Base(name="xname", channel="xchannel", architectures=["xarch1"])], - } - ) - builder = get_builder(config) - zipname = builder.handle_package(to_be_zipped_dir, bases_config) - - # check the stuff outside is not in the zip, the stuff inside is zipped (with - # contents!), and all relative to build dir - zf = zipfile.ZipFile(zipname) - assert "file_outside_1" not in [x.filename for x in zf.infolist()] - assert "file_outside_2" not in [x.filename for x in zf.infolist()] - assert zf.read("file_inside") == b"content_in" - assert zf.read("somedir/file_deep_1") == b"content_deep" # own - assert zf.read("somedir/file_deep_2") == b"content_in" # from file inside - assert zf.read("somedir/file_deep_3") == b"content_out_1" # from file outside 1 - assert zf.read("linkeddir/file_ext") == b"external file" # from file in the outside linked dir - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml", "expected_zipname"), - [ - [ - dedent( - """\ - type: charm - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - "test-charm-name-from-metadata-yaml_xname-xchannel-xarch1.charm", - ], - [ - dedent( - """\ - name: test-charm-name-from-charmcraft-yaml - type: charm - summary: test summary - description: test description - """ - ), - None, - "test-charm-name-from-charmcraft-yaml_xname-xchannel-xarch1.charm", - ], - ], -) -def test_build_package_name( - new_path, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml, - metadata_yaml, - expected_zipname, -): - """The zip file name comes from the config.""" - to_be_zipped_dir = new_path / const.BUILD_DIRNAME - to_be_zipped_dir.mkdir() - - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - # zip it - bases_config = BasesConfiguration( - **{ - "build-on": [], - "run-on": [Base(name="xname", channel="xchannel", architectures=["xarch1"])], - } - ) - - config = load(new_path) - builder = get_builder(config) - zipname = builder.handle_package(to_be_zipped_dir, bases_config) - - assert zipname == expected_zipname - - -@pytest.mark.parametrize( - ("charmcraft_yaml_template", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - build-on: - - name: {base_name} - channel: "{base_channel}" - run-on: - - name: {base_name} - channel: "{base_channel}" - parts: - charm: - charm-entrypoint: my_entrypoint.py - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - ], -) -def test_build_postlifecycle_validation_is_properly_called( - basic_project, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml_template, - metadata_yaml, - monkeypatch, -): - """Check how the entrypoint validation helper is called.""" - host_base = get_host_as_base() - prepare_charmcraft_yaml( - charmcraft_yaml_template.format(base_name=host_base.name, base_channel=host_base.channel) - ) - config = load(basic_project) - builder = get_builder(config) - - entrypoint = basic_project / "my_entrypoint.py" - entrypoint.touch(mode=0o700) - - monkeypatch.setenv(const.MANAGED_MODE_ENV_VAR, "1") - with patch("charmcraft.parts.PartsLifecycle") as mock_lifecycle: - mock_lifecycle.return_value = mock_lifecycle_instance = MagicMock() - mock_lifecycle_instance.prime_dir = basic_project - mock_lifecycle_instance.run().return_value = None - with patch("charmcraft.linters.analyze"): - with patch.object(Builder, "show_linting_results"): - builder.run([0]) - - -@pytest.mark.parametrize( - ("charmcraft_yaml_template", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - build-on: - - name: {base_name} - channel: "{base_channel}" - run-on: - - name: {base_name} - channel: "{base_channel}" - - parts: - charm: - charm-entrypoint: src/charm.py - charm-python-packages: ["foo", "bar"] - charm-binary-python-packages: ["baz"] - charm-requirements: ["reqs.txt"] - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - ], -) -def test_build_part_from_config( - basic_project, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml_template, - metadata_yaml, - monkeypatch, -): - """Check that the "parts" are passed to the lifecycle correctly.""" - host_base = get_host_as_base() - prepare_charmcraft_yaml( - charmcraft_yaml_template.format(base_name=host_base.name, base_channel=host_base.channel) - ) - prepare_metadata_yaml(metadata_yaml) - - reqs_file = basic_project / "reqs.txt" - reqs_file.write_text("somedep") - config = load(basic_project) - builder = get_builder(config, force=True) - - monkeypatch.setenv(const.MANAGED_MODE_ENV_VAR, "1") - with patch("charmcraft.parts.PartsLifecycle", autospec=True) as mock_lifecycle: - mock_lifecycle.side_effect = SystemExit() - with pytest.raises(SystemExit): - builder.run([0]) - mock_lifecycle.assert_has_calls( - [ - call( - { - "charm": { - "plugin": "charm", - "prime": ANY, - "charm-entrypoint": "src/charm.py", - "charm-python-packages": ["foo", "bar"], - "charm-binary-python-packages": ["baz"], - "source": str(basic_project), - "charm-requirements": ["reqs.txt"], - "charm-strict-dependencies": False, - } - }, - work_dir=pathlib.Path("/root"), - project_dir=basic_project, - project_name="test-charm-name-from-metadata-yaml", - ignore_local_sources=["*.charm"], - ) - ] - ) - assert set(mock_lifecycle.call_args_list[0][0][0]["charm"]["prime"]) == { - "src", - const.VENV_DIRNAME, - const.HOOKS_DIRNAME, - const.DISPATCH_FILENAME, - "LICENSE", - "README.md", - "icon.svg", - "lib", - const.METADATA_FILENAME, - } - - -@pytest.mark.parametrize( - ("charmcraft_yaml_template", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - build-on: - - name: {base_name} - channel: "{base_channel}" - run-on: - - name: {base_name} - channel: "{base_channel}" - - parts: - charm: - charm-entrypoint: src/charm.py - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - ], -) -def test_build_part_include_venv_pydeps( - basic_project, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml_template, - metadata_yaml, - monkeypatch, -): - """Include the venv directory even if only charmlib python dependencies exist.""" - host_base = get_host_as_base() - prepare_charmcraft_yaml( - charmcraft_yaml_template.format(base_name=host_base.name, base_channel=host_base.channel) - ) - prepare_metadata_yaml(metadata_yaml) - - charmlib = basic_project / "lib" / "charms" / "somecharm" / "v1" / "somelib.py" - charmlib.parent.mkdir(parents=True) - charmlib.write_text( - dedent( - """ - LIBID = "asdasds" - LIBAPI = 1 - LIBPATCH = 1 - PYDEPS = ["foo"] - """ - ) - ) - config = load(basic_project) - builder = get_builder(config, force=True) - - monkeypatch.setenv(const.MANAGED_MODE_ENV_VAR, "1") - with patch("charmcraft.parts.PartsLifecycle", autospec=True) as mock_lifecycle: - mock_lifecycle.side_effect = SystemExit() - with pytest.raises(SystemExit): - builder.run([0]) - mock_lifecycle.assert_has_calls( - [ - call( - { - "charm": { - "plugin": "charm", - "prime": ANY, - "charm-entrypoint": "src/charm.py", - "charm-python-packages": [], - "charm-binary-python-packages": [], - "source": str(basic_project), - "charm-requirements": [], - "charm-strict-dependencies": False, - } - }, - work_dir=pathlib.Path("/root"), - project_dir=basic_project, - project_name="test-charm-name-from-metadata-yaml", - ignore_local_sources=["*.charm"], - ) - ] - ) - - assert set(mock_lifecycle.call_args_list[0][0][0]["charm"]["prime"]) == { - "src", - const.VENV_DIRNAME, - const.HOOKS_DIRNAME, - const.DISPATCH_FILENAME, - "LICENSE", - "README.md", - "icon.svg", - "lib", - const.METADATA_FILENAME, - } - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_build_using_linters_attributes(basic_project_builder, monkeypatch, tmp_path): - """Generic use of linters, pass them ok to their processor and save them in the manifest.""" - host_base = get_host_as_base() - builder = basic_project_builder( - [BasesConfiguration(**{"build-on": [host_base], "run-on": [host_base]})], - force=True, # to ignore any linter issue - ) - - # the results from the analyzer - linting_results = [ - linters.CheckResult( - name="check-name-1", - check_type=linters.CheckType.ATTRIBUTE, - url="url", - text="text", - result="check-result-1", - ), - linters.CheckResult( - name="check-name-2", - check_type=linters.CheckType.ATTRIBUTE, - url="url", - text="text", - result=LintResult.IGNORED, - ), - ] - - monkeypatch.setenv(const.MANAGED_MODE_ENV_VAR, "1") - with patch("charmcraft.env.get_managed_environment_home_path", return_value=tmp_path / "root"): - with patch("charmcraft.linters.analyze") as mock_analyze: - with patch.object(Builder, "show_linting_results") as mock_show_lint: - mock_analyze.return_value = linting_results - zipnames = builder.run() - - # check the analyze and processing functions were called properly - mock_analyze.assert_called_with(builder.config, tmp_path / "root" / "prime") - mock_show_lint.assert_called_with(linting_results) - - # the manifest should have all the results (including the ignored one) - zf = zipfile.ZipFile(zipnames[0]) - manifest = yaml.safe_load(zf.read(const.MANIFEST_FILENAME)) - expected = { - "attributes": [ - {"name": "check-name-1", "result": "check-result-1"}, - {"name": "check-name-2", "result": "ignored"}, - ] - } - assert manifest["analysis"] == expected - - -def test_show_linters_attributes(basic_project, emitter, config): - """Show the linting results, only attributes, one ignored.""" - builder = get_builder(config) - - # fake results from the analyzer - linting_results = [ - linters.CheckResult( - name="check-name-1", - check_type=linters.CheckType.ATTRIBUTE, - url="url", - text="text", - result="check-result-1", - ), - linters.CheckResult( - name="check-name-2", - check_type=linters.CheckType.ATTRIBUTE, - url="url", - text="text", - result=LintResult.IGNORED, - ), - ] - - builder.show_linting_results(linting_results) - - expected = "Check result: check-name-1 [attribute] check-result-1 (text; see more at url)." - emitter.assert_verbose(expected) - - -def test_show_linters_lint_warnings(basic_project, emitter, config): - """Show the linting results, some warnings.""" - builder = get_builder(config) - - # fake result from the analyzer - linting_results = [ - linters.CheckResult( - name="check-name", - check_type=linters.CheckType.LINT, - url="check-url", - text="Some text", - result=LintResult.WARNING, - ), - ] - - builder.show_linting_results(linting_results) - - emitter.assert_interactions( - [ - call("progress", "Lint Warnings:", permanent=True), - call("progress", "- check-name: Some text (check-url)", permanent=True), - ] - ) - - -def test_show_linters_lint_errors_normal(basic_project, emitter, config): - """Show the linting results, have errors.""" - builder = get_builder(config) - - # fake result from the analyzer - linting_results = [ - linters.CheckResult( - name="check-name", - check_type=linters.CheckType.LINT, - url="check-url", - text="Some text", - result=LintResult.ERROR, - ), - ] - - with pytest.raises(CraftError) as cm: - builder.show_linting_results(linting_results) - exc = cm.value - assert str(exc) == "Aborting due to lint errors (use --force to override)." - assert exc.retcode == 2 - - emitter.assert_interactions( - [ - call("progress", "Lint Errors:", permanent=True), - call("progress", "- check-name: Some text (check-url)", permanent=True), - ] - ) - - -def test_show_linters_lint_errors_forced(basic_project, emitter, config): - """Show the linting results, have errors but the packing is forced.""" - builder = get_builder(config, force=True) - - # fake result from the analyzer - linting_results = [ - linters.CheckResult( - name="check-name", - check_type=linters.CheckType.LINT, - url="check-url", - text="Some text", - result=LintResult.ERROR, - ), - ] - - builder.show_linting_results(linting_results) - - emitter.assert_interactions( - [ - call("progress", "Lint Errors:", permanent=True), - call("progress", "- check-name: Some text (check-url)", permanent=True), - call("progress", "Packing anyway as requested.", permanent=True), - ] - ) - - -@pytest.mark.parametrize("force", [True, False]) -@pytest.mark.parametrize("destructive_mode", [True, False]) -@pytest.mark.parametrize("base_indeces", [[], [1], [1, 2, 3, 4, 5]]) -def test_get_charm_pack_args(config, force, base_indeces, destructive_mode): - builder = get_builder(config, force=force) - - actual = builder._get_charm_pack_args(base_indeces, destructive_mode) - - assert actual[:3] == ["charmcraft", "pack", "--verbose"] - assert ("--force" in actual) == force - assert ("--destructive-mode" in actual) == destructive_mode - for index in base_indeces: - assert f"--bases-index={index}" in actual - - -# --- tests for relativise helper - - -def test_relativise_sameparent(): - """Two files in the same dir.""" - src = pathlib.Path("/tmp/foo/bar/src.txt") - dst = pathlib.Path("/tmp/foo/bar/dst.txt") - rel = relativise(src, dst) - assert rel == pathlib.Path("dst.txt") - - -def test_relativise_src_under(): - """The src is in subdirectory of dst's parent.""" - src = pathlib.Path("/tmp/foo/bar/baz/src.txt") - dst = pathlib.Path("/tmp/foo/dst.txt") - rel = relativise(src, dst) - assert rel == pathlib.Path("../../dst.txt") - - -def test_relativise_dst_under(): - """The dst is in subdirectory of src's parent.""" - src = pathlib.Path("/tmp/foo/src.txt") - dst = pathlib.Path("/tmp/foo/bar/baz/dst.txt") - rel = relativise(src, dst) - assert rel == pathlib.Path("bar/baz/dst.txt") - - -def test_relativise_different_parents_shallow(): - """Different parents for src and dst, but shallow.""" - src = pathlib.Path("/tmp/foo/bar/src.txt") - dst = pathlib.Path("/tmp/foo/baz/dst.txt") - rel = relativise(src, dst) - assert rel == pathlib.Path("../baz/dst.txt") - - -def test_relativise_different_parents_deep(): - """Different parents for src and dst, in a deep structure.""" - src = pathlib.Path("/tmp/foo/bar1/bar2/src.txt") - dst = pathlib.Path("/tmp/foo/baz1/baz2/baz3/dst.txt") - rel = relativise(src, dst) - assert rel == pathlib.Path("../../baz1/baz2/baz3/dst.txt") - - -def test_format_charm_file_name_basic(): - """Basic entry.""" - bases_config = BasesConfiguration( - **{ - "build-on": [], - "run-on": [Base(name="xname", channel="xchannel", architectures=["xarch1"])], - } - ) - - assert ( - format_charm_file_name("charm-name", bases_config) - == "charm-name_xname-xchannel-xarch1.charm" - ) - - -def test_format_charm_file_name_multi_arch(): - """Multiple architectures.""" - bases_config = BasesConfiguration( - **{ - "build-on": [], - "run-on": [ - Base( - name="xname", - channel="xchannel", - architectures=["xarch1", "xarch2", "xarch3"], - ) - ], - } - ) - - assert ( - format_charm_file_name("charm-name", bases_config) - == "charm-name_xname-xchannel-xarch1-xarch2-xarch3.charm" - ) - - -def test_format_charm_file_name_multi_run_on(): - """Multiple run-on entries.""" - bases_config = BasesConfiguration( - **{ - "build-on": [], - "run-on": [ - Base(name="x1name", channel="x1channel", architectures=["x1arch"]), - Base( - name="x2name", - channel="x2channel", - architectures=["x2arch1", "x2arch2"], - ), - ], - } - ) - - assert ( - format_charm_file_name("charm-name", bases_config) - == "charm-name_x1name-x1channel-x1arch_x2name-x2channel-x2arch1-x2arch2.charm" - ) - - -def test_launch_shell(emitter): - """Check bash is called while Emitter is paused.""" - - def fake_run(command, check, cwd): - """MITM to verify parameters and that emitter is paused when it's called.""" - assert command == ["bash"] - assert check is False - assert cwd is None - assert emitter.paused - - with mock.patch("subprocess.run", fake_run): - launch_shell() - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -@pytest.mark.parametrize( - ("charms", "command_args", "charm_files", "expected_calls", "expected"), - [ - pytest.param({}, [], [], [], {}, id="empty"), - pytest.param( - {"test": pathlib.Path("charms/test")}, - ["pack_cmd"], - [], - [ - mock.call( - ["pack_cmd", "--project-dir=charms/test"], stdout=mock.ANY, stderr=mock.ANY - ) - ], - {}, - id="no_outputs", - ), - pytest.param( - {"test": pathlib.Path("charms/test")}, - ["pack_cmd"], - ["test_amd64.charm"], - [ - mock.call( - ["pack_cmd", "--project-dir=charms/test"], stdout=mock.ANY, stderr=mock.ANY - ) - ], - {"test": pathlib.Path("test_amd64.charm")}, - id="one_correct_charm", - ), - pytest.param( - {"test": pathlib.Path("charms/test")}, - ["pack_cmd"], - ["test_amd64.charm", "where-did-this-come-from_riscv.charm"], - [ - mock.call( - ["pack_cmd", "--project-dir=charms/test"], stdout=mock.ANY, stderr=mock.ANY - ) - ], - {"test": pathlib.Path("test_amd64.charm")}, - id="one_correct_charm", - ), - ], -) -@pytest.mark.usefixtures("new_path") -def test_subprocess_pack_charms_success( - mocker, check, charms, charm_files, command_args, expected_calls, expected -): - expected = {name: path.resolve() for name, path in expected.items()} - mock_check_call = mocker.patch("subprocess.check_call") - mock_check_call.side_effect = lambda *_, **__: [pathlib.Path(f).touch() for f in charm_files] - - actual = _subprocess_pack_charms(charms, command_args) - - check.equal(actual, expected) - check.equal(mock_check_call.mock_calls, expected_calls) From b9389e8a71a7228f4db9f0884da3b3075201587f Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 14:49:21 -0400 Subject: [PATCH 10/59] chore: remove unused providers module Also removes the related unused test fixtures. --- charmcraft/providers.py | 310 ---------------- tests/conftest.py | 59 +-- tests/test_providers.py | 783 ---------------------------------------- 3 files changed, 2 insertions(+), 1150 deletions(-) delete mode 100644 charmcraft/providers.py delete mode 100644 tests/test_providers.py diff --git a/charmcraft/providers.py b/charmcraft/providers.py deleted file mode 100644 index e5ecc4b6e..000000000 --- a/charmcraft/providers.py +++ /dev/null @@ -1,310 +0,0 @@ -# Copyright 2022 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 - -"""Charmcraft-specific code to interface with craft-providers.""" - -import enum -import os -import pathlib -import sys -from typing import NamedTuple - -from craft_application import util -from craft_cli import CraftError, emit -from craft_providers import Base, Executor, Provider, ProviderError, lxd, multipass -from craft_providers.actions.snap_installer import Snap -from craft_providers.bases import ( - BASE_NAME_TO_BASE_ALIAS, - get_base_alias, - get_base_from_alias, -) -from craft_providers.errors import BaseConfigurationError - -from charmcraft import const -from charmcraft.bases import check_if_base_matches_host -from charmcraft.const import EXPERIMENTAL_EXTENSIONS_ENV_VAR -from charmcraft.env import ( - get_managed_environment_log_path, - get_managed_environment_snap_channel, - is_charmcraft_running_from_snap, - is_charmcraft_running_in_developer_mode, -) -from charmcraft.models.charmcraft import BasesConfiguration -from charmcraft.snap import get_snap_configuration -from charmcraft.utils import confirm_with_user - - -class Plan(NamedTuple): - """A build plan for a particular base. - - :param bases_config: The BasesConfiguration object, which contains a list of Bases to - build on and a list of Bases to run on. - :param build_on: The Base to build on. - :param bases_index: Index of the BasesConfiguration in bases_config containing the - Base to build on. - :param build_on_index: Index of the Base to build on in the BasesConfiguration's build_on list. - """ - - bases_config: BasesConfiguration - build_on: Base - bases_index: int - build_on_index: int - - -def create_build_plan( - *, - bases: list[BasesConfiguration] | None, - bases_indices: list[int] | None, - destructive_mode: bool, - managed_mode: bool, - provider: "Provider", -) -> list[Plan]: - """Determine the build plan based on user inputs and host environment. - - Provide a list of bases that are buildable and scoped according to user - configuration. Provide all relevant details including the applicable - bases configuration and the indices of the entries to build for. - - :param bases: List of BaseConfigurations - :param bases_indices: List of indices of which BaseConfigurations to consider when creating - the build plan. If None, then all BaseConfigurations are considered - :param destructive_mode: True is charmcraft is running in destructive mode - :param managed_mode: True is charmcraft is running in managed mode - :param provider: Provider object to check for base availability - - :returns: List of Plans - :raises CraftError: if no bases are provided. - """ - build_plan: list[Plan] = [] - - if not bases: - raise CraftError("Cannot create build plan because no bases were provided.") - - for bases_index, bases_config in enumerate(bases): - if bases_indices and bases_index not in bases_indices: - emit.debug(f"Skipping 'bases[{bases_index:d}]' due to --base-index usage.") - continue - - for build_on_index, build_on in enumerate(bases_config.build_on): - if managed_mode or destructive_mode: - matches, reason = check_if_base_matches_host(build_on) - else: - matches, reason = is_base_available(build_on) - - if matches: - emit.debug( - f"Building for 'bases[{bases_index:d}]' " - f"as host matches 'build-on[{build_on_index:d}]'.", - ) - build_plan.append(Plan(bases_config, build_on, bases_index, build_on_index)) - break - else: - emit.progress( - f"Skipping 'bases[{bases_index:d}].build-on[{build_on_index:d}]': " - f"{reason}.", - ) - else: - emit.progress( - "No suitable 'build-on' environment found " - f"in 'bases[{bases_index:d}]' configuration.", - permanent=True, - ) - - return build_plan - - -def get_command_environment(base: Base) -> dict[str, str | None]: - """Construct the required environment.""" - env = base.default_command_environment() - env[const.MANAGED_MODE_ENV_VAR] = "1" - - # Pass-through host environment that target may need. - for env_key in ["http_proxy", "https_proxy", "no_proxy", EXPERIMENTAL_EXTENSIONS_ENV_VAR]: - if env_key in os.environ: - env[env_key] = os.environ[env_key] - - return env - - -def get_instance_name( - *, - bases_index: int, - build_on_index: int, - project_name: str, - project_path: pathlib.Path, - target_arch: str, -) -> str: - """Formulate the name for an instance using each of the given parameters. - - Incorporate each of the parameters into the name to come up with a - predictable naming schema that avoids name collisions across multiple, - potentially complex, projects. - - :param bases_index: Index of `bases:` entry. - :param build_on_index: Index of `build-on` within bases entry. - :param project_name: Name of charm project. - :param project_path: Directory of charm project. - :param target_arch: Targeted architecture, used in the name to prevent - collisions should future work enable multiple architectures on the same - platform. - """ - return "-".join( - [ - "charmcraft", - project_name, - str(project_path.stat().st_ino), - str(bases_index), - str(build_on_index), - target_arch, - ] - ) - - -def get_base_configuration( - *, - alias: enum.Enum, - instance_name: str, - shared_cache_path: pathlib.Path | None = None, -) -> Base: - """Create a Base configuration.""" - # injecting a snap on a non-linux system is not supported, so default to - # install charmcraft from the store's stable channel - snap_channel = get_managed_environment_snap_channel() - if snap_channel is None and sys.platform != "linux": - snap_channel = "stable" - - base = get_base_from_alias(alias) - charmcraft_snap = Snap(name="charmcraft", channel=snap_channel, classic=True) - environment = get_command_environment(base) - return base( - alias=alias, - environment=environment, - hostname=instance_name, - snaps=[charmcraft_snap], - compatibility_tag=f"charmcraft-{base.compatibility_tag}.0", - cache_path=shared_cache_path, - ) - - -def capture_logs_from_instance(instance: Executor) -> None: - """Retrieve logs from instance. - - :param instance: Instance to retrieve logs from. - """ - source_log_path = get_managed_environment_log_path() - with instance.temporarily_pull_file(source=source_log_path, missing_ok=True) as local_log_path: - if local_log_path: - emit.debug("Logs captured from managed instance:") - with open(local_log_path, encoding="utf8") as fh: - for line in fh: - emit.debug(f":: {line.rstrip()}") - else: - emit.debug("No logs found in instance.") - return - - -def ensure_provider_is_available(provider: "Provider") -> None: - """Ensure provider is installed, running, and properly configured. - - If the provider is not installed, the user is prompted to install it. - - :param instance: the provider to ensure is available - :raises ProviderError: if provider is not available, or if the user chooses not - to install the provider. - """ - # TODO: add provider.name and provider.install_recommendations to craft-providers - confirm_msg = ( - "Provider is required but not installed. Do you wish to " - "install provider and configure it with the defaults?" - ) - if not provider.is_provider_installed() and not confirm_with_user(confirm_msg, default=False): - raise ProviderError("Provider is required, but not installed.") - provider.ensure_provider_is_available() - - -def is_base_available(base: Base) -> tuple[bool, str | None]: - """Check if provider can provide an environment matching given base. - - :param base: Base to check. - - :returns: Tuple of bool indicating whether it is a match, with optional - reason if not a match. - """ - arch = util.get_host_architecture() - if arch not in base.architectures: - return ( - False, - f"host architecture {arch!r} not in base architectures {base.architectures!r}", - ) - - if base.name not in ("ubuntu", "centos", "almalinux"): - return ( - False, - f"name {base.name!r} is not yet supported (must be 'ubuntu', 'almalinux', " - "or 'centos')", - ) - - try: - get_base_alias((base.name, base.channel)) - except BaseConfigurationError: - *firsts, last = sorted(" ".join(s) for s in BASE_NAME_TO_BASE_ALIAS) - allowed = f"{', '.join(map(repr, firsts))} or {last!r}" - return ( - False, - f"base {base.name!r} channel {base.channel!r} is not yet supported " - f"(must be {allowed})", - ) - - return True, None - - -def _get_platform_default_provider() -> str: - if sys.platform == "linux": - return "lxd" - - return "multipass" - - -def get_provider(): - """Get the configured or appropriate provider for the host OS. - - If platform is not Linux, use Multipass. - - If platform is Linux: - (1) use provider specified with CHARMCRAFT_PROVIDER if running in developer mode, - (2) use provider specified with snap configuration if running as a snap, - (3) default to platform default (LXD on Linux). - - :return: Provider instance. - """ - provider = None - - if is_charmcraft_running_in_developer_mode(): - provider = os.getenv(const.PROVIDER_ENV_VAR) - - if provider is None and is_charmcraft_running_from_snap(): - snap_config = get_snap_configuration() - provider = snap_config.provider if snap_config else None - - if provider is None: - provider = _get_platform_default_provider() - - if provider == "lxd": - return lxd.LXDProvider(lxd_project="charmcraft") - elif provider == "multipass": - return multipass.MultipassProvider() - - raise CraftError(f"Unsupported provider specified {provider!r}.") diff --git a/tests/conftest.py b/tests/conftest.py index 43c422af3..ed18e8530 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,7 +23,6 @@ import tempfile import types from unittest import mock -from unittest.mock import Mock import craft_parts import pytest @@ -31,7 +30,7 @@ import yaml from craft_application import models, util from craft_parts import callbacks, plugins -from craft_providers import Executor, Provider, bases +from craft_providers import bases import charmcraft.parts from charmcraft import const, deprecations, instrum, parts, services, store @@ -39,7 +38,7 @@ from charmcraft.bases import get_host_as_base from charmcraft.models import charmcraft as config_module from charmcraft.models import project -from charmcraft.models.charmcraft import Base, BasesConfiguration +from charmcraft.models.charmcraft import BasesConfiguration @pytest.fixture() @@ -254,60 +253,6 @@ def responses(): yield rsps -@pytest.fixture() -def mock_instance(): - """Provide a mock instance (Executor).""" - return Mock(spec=Executor) - - -@pytest.fixture(autouse=True) -def fake_provider(mock_instance): - """Provide a minimal/fake provider.""" - - class FakeProvider(Provider): - name = "TestProvider" - install_recommendation = "Insert floppy disk." - - def clean_project_environments(self, *, instance_name: str) -> None: - pass - - @classmethod - def ensure_provider_is_available(cls) -> None: - pass - - def environment( - self, - *, - instance_name: str, - ) -> Executor: - return mock_instance - - def create_environment(self, *, instance_name: str): - yield mock_instance - - @contextlib.contextmanager - def launched_environment( - self, - *, - project_name: str, - project_path: pathlib.Path, - base_configuration: Base, - instance_name: str, - allow_unstable: bool = False, - ): - yield mock_instance - - @classmethod - def is_provider_installed(cls) -> bool: - """Check if provider is installed. - - :returns: True if installed. - """ - return True - - return FakeProvider() - - @pytest.fixture() def prepare_charmcraft_yaml(tmp_path: pathlib.Path): """Helper to create a charmcraft.yaml file in disk. diff --git a/tests/test_providers.py b/tests/test_providers.py deleted file mode 100644 index defa3607c..000000000 --- a/tests/test_providers.py +++ /dev/null @@ -1,783 +0,0 @@ -# Copyright 2022 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 - -import contextlib -import pathlib -import re -import sys -from unittest.mock import Mock, call, patch - -import pytest -from craft_cli import CraftError -from craft_providers import ProviderError, bases, lxd, multipass -from craft_providers.actions.snap_installer import Snap - -from charmcraft import const, providers -from charmcraft.models.charmcraft import Base, BasesConfiguration -from charmcraft.snap import CharmcraftSnapConfiguration - - -@pytest.fixture() -def mock_provider(mock_instance, fake_provider): - mock_provider = Mock(wraps=fake_provider) - with patch("charmcraft.providers.get_provider", return_value=mock_provider): - yield mock_provider - - -@pytest.fixture() -def mock_is_base_available(): - with patch( - "charmcraft.providers.is_base_available", return_value=(True, None) - ) as mock_is_base_available: - yield mock_is_base_available - - -@pytest.fixture() -def mock_check_if_base_matches_host(): - with patch( - "charmcraft.providers.check_if_base_matches_host", return_value=(True, None) - ) as mock_check_if_base_matches_host: - yield mock_check_if_base_matches_host - - -@pytest.fixture() -def mock_get_host_architecture(): - with patch( - "craft_application.util.get_host_architecture", return_value="host-arch" - ) as mock_arch: - yield mock_arch - - -@pytest.fixture() -def mock_snap_config(): - with patch("charmcraft.providers.get_snap_configuration", return_value=None) as mock_snap: - yield mock_snap - - -@pytest.fixture() -def mock_is_developer_mode(): - with patch( - "charmcraft.providers.is_charmcraft_running_in_developer_mode", - return_value=False, - ) as mock_is_dev_mode: - yield mock_is_dev_mode - - -@pytest.fixture() -def mock_is_snap(): - with patch( - "charmcraft.providers.is_charmcraft_running_from_snap", return_value=False - ) as mock_is_snap: - yield mock_is_snap - - -@pytest.fixture() -def simple_base_config(): - """Yields a simple BaseConfiguration object.""" - return [ - BasesConfiguration( - **{ - "build-on": [ - Base(name="x1name", channel="x1channel", architectures=["x1arch"]), - ], - "run-on": [ - Base(name="x2name", channel="x2channel", architectures=["x2arch"]), - ], - } - ), - ] - - -@pytest.fixture() -def complex_base_config(): - """Yields a complex list of BaseConfiguration objects.""" - return [ - # 1 build-on and 1 run-on - BasesConfiguration( - **{ - "build-on": [ - Base(name="x1name", channel="x1channel", architectures=["x1arch"]), - ], - "run-on": [ - Base(name="x2name", channel="x2channel", architectures=["x2arch"]), - ], - } - ), - # 2 build-on and 1 run-on - BasesConfiguration( - **{ - "build-on": [ - Base(name="x3name", channel="x3channel", architectures=["x3arch"]), - Base(name="x4name", channel="x4channel", architectures=["x4arch"]), - ], - "run-on": [ - Base(name="x5name", channel="x5channel", architectures=["x5arch"]), - ], - } - ), - # 1 build-on and 2 run-on with multiple architectures - BasesConfiguration( - **{ - "build-on": [ - Base(name="x6name", channel="x6channel", architectures=["x6arch"]), - ], - "run-on": [ - Base(name="x7name", channel="x7channel", architectures=["x7arch"]), - Base( - name="x8name", - channel="x8channel", - architectures=["x8arch1", "x8arch2"], - ), - ], - } - ), - ] - - -def test_create_build_plan_simple( - emitter, mock_provider, mock_is_base_available, simple_base_config -): - """Verify creation of a simple build plan.""" - build_plan = providers.create_build_plan( - bases=simple_base_config, - bases_indices=None, - destructive_mode=False, - managed_mode=False, - provider=mock_provider, - ) - - assert build_plan == [ - providers.Plan( - bases_config=BasesConfiguration( - **{ - "build-on": [ - Base(name="x1name", channel="x1channel", architectures=["x1arch"]), - ], - "run-on": [ - Base(name="x2name", channel="x2channel", architectures=["x2arch"]), - ], - } - ), - build_on=Base(name="x1name", channel="x1channel", architectures=["x1arch"]), - bases_index=0, - build_on_index=0, - ), - ] - emitter.assert_interactions( - [ - call("debug", "Building for 'bases[0]' as host matches 'build-on[0]'."), - ] - ) - - -def test_create_build_plan_complex( - emitter, complex_base_config, mock_provider, mock_is_base_available -): - """Verify creation of a complex build plan.""" - - build_plan = providers.create_build_plan( - bases=complex_base_config, - bases_indices=None, - destructive_mode=False, - managed_mode=False, - provider=mock_provider, - ) - - assert build_plan == [ - providers.Plan( - bases_config=BasesConfiguration( - **{ - "build-on": [ - Base(name="x1name", channel="x1channel", architectures=["x1arch"]), - ], - "run-on": [ - Base(name="x2name", channel="x2channel", architectures=["x2arch"]), - ], - } - ), - build_on=Base(name="x1name", channel="x1channel", architectures=["x1arch"]), - bases_index=0, - build_on_index=0, - ), - providers.Plan( - bases_config=BasesConfiguration( - **{ - "build-on": [ - Base(name="x3name", channel="x3channel", architectures=["x3arch"]), - Base(name="x4name", channel="x4channel", architectures=["x4arch"]), - ], - "run-on": [ - Base(name="x5name", channel="x5channel", architectures=["x5arch"]), - ], - } - ), - build_on=Base(name="x3name", channel="x3channel", architectures=["x3arch"]), - bases_index=1, - build_on_index=0, - ), - providers.Plan( - bases_config=BasesConfiguration( - **{ - "build-on": [ - Base(name="x6name", channel="x6channel", architectures=["x6arch"]), - ], - "run-on": [ - Base(name="x7name", channel="x7channel", architectures=["x7arch"]), - Base( - name="x8name", - channel="x8channel", - architectures=["x8arch1", "x8arch2"], - ), - ], - } - ), - build_on=Base(name="x6name", channel="x6channel", architectures=["x6arch"]), - bases_index=2, - build_on_index=0, - ), - ] - emitter.assert_interactions( - [ - call("debug", "Building for 'bases[0]' as host matches 'build-on[0]'."), - call("debug", "Building for 'bases[1]' as host matches 'build-on[0]'."), - call("debug", "Building for 'bases[2]' as host matches 'build-on[0]'."), - ] - ) - - -@pytest.mark.parametrize(("destructive_mode", "managed_mode"), [(True, False), (False, True)]) -def test_create_build_plan_base_matches_host( - emitter, - destructive_mode, - managed_mode, - mock_check_if_base_matches_host, - mock_provider, - simple_base_config, -): - """Verify the first `build_on` Base that matches the host is used for the build plan - when building in managed mode or destructive mode.""" - build_plan = providers.create_build_plan( - bases=simple_base_config, - bases_indices=None, - destructive_mode=destructive_mode, - managed_mode=managed_mode, - provider=mock_provider, - ) - - assert build_plan == [ - providers.Plan( - bases_config=BasesConfiguration( - **{ - "build-on": [ - Base(name="x1name", channel="x1channel", architectures=["x1arch"]), - ], - "run-on": [ - Base(name="x2name", channel="x2channel", architectures=["x2arch"]), - ], - } - ), - build_on=Base(name="x1name", channel="x1channel", architectures=["x1arch"]), - bases_index=0, - build_on_index=0, - ), - ] - emitter.assert_interactions( - [ - call("debug", "Building for 'bases[0]' as host matches 'build-on[0]'."), - ] - ) - - -def test_create_build_plan_is_base_available(emitter, mock_is_base_available, mock_provider): - """Verify the first available `build_on` Base that is used for the build plan.""" - base = [ - BasesConfiguration( - **{ - "build-on": [ - Base(name="x1name", channel="x1channel", architectures=["x1arch"]), - Base(name="x2name", channel="x2channel", architectures=["x2arch"]), - ], - "run-on": [ - Base(name="x3name", channel="x3channel", architectures=["x3arch"]), - ], - } - ) - ] - - # the first Base is not available, but the second Base is available - mock_is_base_available.side_effect = [(False, "test error message"), (True, None)] - - build_plan = providers.create_build_plan( - bases=base, - bases_indices=None, - destructive_mode=False, - managed_mode=False, - provider=mock_provider, - ) - - # verify charmcraft will build on the second Base - assert build_plan[0].build_on == Base( - name="x2name", channel="x2channel", architectures=["x2arch"] - ) - assert build_plan[0].build_on_index == 1 - - emitter.assert_interactions( - [ - call("progress", "Skipping 'bases[0].build-on[0]': test error message."), - call("debug", "Building for 'bases[0]' as host matches 'build-on[1]'."), - ] - ) - - -def test_create_build_plan_base_index_usage( - complex_base_config, - emitter, - mock_is_base_available, - mock_provider, -): - """Verify `bases_indices` argument causes build plan to only contain matching bases.""" - build_plan = providers.create_build_plan( - bases=complex_base_config, - bases_indices=[1, 2], - destructive_mode=False, - managed_mode=False, - provider=mock_provider, - ) - - assert build_plan == [ - providers.Plan( - bases_config=BasesConfiguration( - **{ - "build-on": [ - Base(name="x3name", channel="x3channel", architectures=["x3arch"]), - Base(name="x4name", channel="x4channel", architectures=["x4arch"]), - ], - "run-on": [ - Base(name="x5name", channel="x5channel", architectures=["x5arch"]), - ], - } - ), - build_on=Base(name="x3name", channel="x3channel", architectures=["x3arch"]), - bases_index=1, - build_on_index=0, - ), - providers.Plan( - bases_config=BasesConfiguration( - **{ - "build-on": [ - Base(name="x6name", channel="x6channel", architectures=["x6arch"]), - ], - "run-on": [ - Base(name="x7name", channel="x7channel", architectures=["x7arch"]), - Base( - name="x8name", - channel="x8channel", - architectures=["x8arch1", "x8arch2"], - ), - ], - } - ), - build_on=Base(name="x6name", channel="x6channel", architectures=["x6arch"]), - bases_index=2, - build_on_index=0, - ), - ] - - emitter.assert_interactions( - [ - call("debug", "Skipping 'bases[0]' due to --base-index usage."), - call("debug", "Building for 'bases[1]' as host matches 'build-on[0]'."), - call("debug", "Building for 'bases[2]' as host matches 'build-on[0]'."), - ] - ) - - -def test_create_build_plan_no_suitable_bases( - emitter, complex_base_config, mock_is_base_available, mock_provider -): - """Verify an empty build plan is returned when no bases are available.""" - mock_is_base_available.return_value = (False, "test error message") - - build_plan = providers.create_build_plan( - bases=complex_base_config, - bases_indices=None, - destructive_mode=False, - managed_mode=False, - provider=mock_provider, - ) - - assert build_plan == [] - - emitter.assert_interactions( - [ - call("progress", "Skipping 'bases[0].build-on[0]': test error message."), - call( - "progress", - "No suitable 'build-on' environment found in 'bases[0]' configuration.", - permanent=True, - ), - call("progress", "Skipping 'bases[1].build-on[0]': test error message."), - call("progress", "Skipping 'bases[1].build-on[1]': test error message."), - call( - "progress", - "No suitable 'build-on' environment found in 'bases[1]' configuration.", - permanent=True, - ), - call("progress", "Skipping 'bases[2].build-on[0]': test error message."), - call( - "progress", - "No suitable 'build-on' environment found in 'bases[2]' configuration.", - permanent=True, - ), - ] - ) - - -def test_create_build_plan_no_bases_error(mock_provider): - """Verify an error is raised when no bases are passed.""" - with pytest.raises(CraftError) as error: - providers.create_build_plan( - bases=None, - bases_indices=None, - destructive_mode=False, - managed_mode=False, - provider=mock_provider, - ) - - assert str(error.value) == "Cannot create build plan because no bases were provided." - - -def test_get_command_environment_minimal(monkeypatch): - monkeypatch.setenv("IGNORE_ME", "or-im-failing") - monkeypatch.setenv("PATH", "not-using-host-path") - - env = providers.get_command_environment(bases.ubuntu.BuilddBase) - - assert env == { - const.MANAGED_MODE_ENV_VAR: "1", - "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin", - } - - -def test_get_command_environment_all_opts(monkeypatch): - monkeypatch.setenv("IGNORE_ME", "or-im-failing") - monkeypatch.setenv("PATH", "not-using-host-path") - monkeypatch.setenv("http_proxy", "test-http-proxy") - monkeypatch.setenv("https_proxy", "test-https-proxy") - monkeypatch.setenv("no_proxy", "test-no-proxy") - - env = providers.get_command_environment(bases.ubuntu.BuilddBase) - - assert env == { - const.MANAGED_MODE_ENV_VAR: "1", - "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin", - "http_proxy": "test-http-proxy", - "https_proxy": "test-https-proxy", - "no_proxy": "test-no-proxy", - } - - -@pytest.mark.parametrize( - ("bases_index", "build_on_index", "project_name", "target_arch", "expected"), - [ - (0, 0, "mycharm", "test-arch1", "charmcraft-mycharm-{inode}-0-0-test-arch1"), - ( - 1, - 2, - "my-other-charm", - "test-arch2", - "charmcraft-my-other-charm-{inode}-1-2-test-arch2", - ), - ], -) -def test_get_instance_name( - bases_index, build_on_index, project_name, target_arch, expected, tmp_path -): - assert providers.get_instance_name( - bases_index=bases_index, - build_on_index=build_on_index, - project_name=project_name, - project_path=tmp_path, - target_arch=target_arch, - ) == expected.format(inode=tmp_path.stat().st_ino) - - -@pytest.mark.parametrize( - "cache_path", - [ - None, - pathlib.Path("charmcraft-cache"), - ], -) -@pytest.mark.parametrize( - ("platform", "snap_channel", "expected_snap_channel"), - [ - ("linux", None, None), - ("linux", "edge", "edge"), - ("darwin", "edge", "edge"), - # default to stable on non-linux system - ("darwin", None, "stable"), - ], -) -@pytest.mark.parametrize( - "alias_ubuntu", - [ - bases.ubuntu.BuilddBaseAlias.BIONIC, - bases.ubuntu.BuilddBaseAlias.FOCAL, - bases.ubuntu.BuilddBaseAlias.JAMMY, - bases.ubuntu.BuilddBaseAlias.NOBLE, - ], -) -def test_get_base_configuration_ubuntu( - platform, - snap_channel, - expected_snap_channel, - alias_ubuntu, - cache_path, - mocker, - tmp_path, -): - """Verify the snapcraft snap is installed from the correct channel.""" - mocker.patch("sys.platform", platform) - mocker.patch( - "charmcraft.providers.get_managed_environment_snap_channel", - return_value=snap_channel, - ) - mocker.patch("charmcraft.providers.get_command_environment", return_value="test-env") - mocker.patch("charmcraft.providers.get_instance_name", return_value="test-instance-name") - mock_buildd_base = mocker.patch("craft_providers.bases.ubuntu.BuilddBase") - mock_buildd_base.compatibility_tag = "buildd-base-v0" - - if cache_path: - cache_path = tmp_path / cache_path - cache_path.mkdir(parents=True, exist_ok=True) - - providers.get_base_configuration( - alias=alias_ubuntu, instance_name="test-instance-name", shared_cache_path=cache_path - ) - - mock_buildd_base.assert_called_with( - alias=alias_ubuntu, - environment="test-env", - hostname="test-instance-name", - snaps=[Snap(name="charmcraft", channel=expected_snap_channel, classic=True)], - compatibility_tag="charmcraft-buildd-base-v0.0", - cache_path=cache_path, - ) - - -@pytest.mark.parametrize( - "cache_path", - [ - None, - pathlib.Path("charmcraft-cache"), - ], -) -@pytest.mark.parametrize( - ("platform", "snap_channel", "expected_snap_channel"), - [ - ("linux", None, None), - ("linux", "edge", "edge"), - ("darwin", "edge", "edge"), - # default to stable on non-linux system - ("darwin", None, "stable"), - ], -) -def test_get_base_configuration_centos( - platform, - snap_channel, - expected_snap_channel, - cache_path, - mocker, - tmp_path, -): - """Verify the snapcraft snap is installed from the correct channel.""" - mocker.patch("sys.platform", platform) - mocker.patch( - "charmcraft.providers.get_managed_environment_snap_channel", - return_value=snap_channel, - ) - mocker.patch("charmcraft.providers.get_command_environment", return_value="test-env") - mocker.patch("charmcraft.providers.get_instance_name", return_value="test-instance-name") - mock_centos_base = mocker.patch("craft_providers.bases.centos.CentOSBase") - mock_centos_base.compatibility_tag = "centos-base-v0" - - if cache_path: - cache_path = tmp_path / cache_path - cache_path.mkdir(parents=True, exist_ok=True) - - providers.get_base_configuration( - alias=bases.centos.CentOSBaseAlias.SEVEN, - instance_name="test-instance-name", - shared_cache_path=cache_path, - ) - - mock_centos_base.assert_called_with( - alias=bases.centos.CentOSBaseAlias.SEVEN, - environment="test-env", - hostname="test-instance-name", - snaps=[Snap(name="charmcraft", channel=expected_snap_channel, classic=True)], - compatibility_tag="charmcraft-centos-base-v0.0", - cache_path=cache_path, - ) - - -def test_capture_logs_from_instance_ok(emitter, mock_instance, tmp_path, mocker): - @contextlib.contextmanager - def fake_pull(source, missing_ok): - assert source == pathlib.Path("/tmp/charmcraft.log") - assert missing_ok is True - fake_file = tmp_path / "fake.file" - fake_file.write_text("some\nlog data\nhere") - yield fake_file - - mocker.patch.object(mock_instance, "temporarily_pull_file", fake_pull) - providers.capture_logs_from_instance(mock_instance) - - emitter.assert_interactions( - [ - call("debug", "Logs captured from managed instance:"), - call("debug", ":: some"), - call("debug", ":: log data"), - call("debug", ":: here"), - ] - ) - - -def test_capture_logs_from_instance_not_found(emitter, mock_instance, tmp_path, mocker): - @contextlib.contextmanager - def fake_pull(source, missing_ok): - yield None # didn't find the indicated file - - mocker.patch.object(mock_instance, "temporarily_pull_file", fake_pull) - providers.capture_logs_from_instance(mock_instance) - - emitter.assert_debug("No logs found in instance.") - - -def test_ensure_provider_is_available_installed_yes(mocker, fake_provider): - """Verify provider is ensured to be available when installed (fake_provider's default).""" - confirmation_mock = mocker.patch("charmcraft.providers.confirm_with_user") - available_mock = mocker.patch.object(fake_provider, "ensure_provider_is_available") - - providers.ensure_provider_is_available(fake_provider) - - confirmation_mock.assert_not_called() - available_mock.assert_called_once() - - -def test_ensure_provider_is_available_installed_no_user_confirms_yes(mocker, fake_provider): - """Verify provider is ensured to be available, not installed but user chooses to install.""" - confirmation_mock = mocker.patch("charmcraft.providers.confirm_with_user", return_value=True) - mocker.patch.object(fake_provider, "is_provider_installed", return_value=False) - available_mock = mocker.patch.object(fake_provider, "ensure_provider_is_available") - - providers.ensure_provider_is_available(fake_provider) - - message = ( - "Provider is required but not installed. Do you wish to " - "install provider and configure it with the defaults?" - ) - confirmation_mock.assert_called_with(message, default=False) - available_mock.assert_called_once() - - -def test_ensure_provider_is_available_installed_no_user_confirms_no(mocker, fake_provider): - """Raise an error if not installed and the user does not choose to install it.""" - mocker.patch("charmcraft.providers.confirm_with_user", return_value=False) - mocker.patch.object(fake_provider, "is_provider_installed", return_value=False) - - with pytest.raises(ProviderError) as error: - providers.ensure_provider_is_available(fake_provider) - - assert error.value.brief == "Provider is required, but not installed." - - -@pytest.mark.parametrize( - ("name", "channel", "architectures", "expected_valid", "expected_reason"), - [ - ("ubuntu", "18.04", ["host-arch"], True, None), - ("ubuntu", "20.04", ["host-arch"], True, None), - ("ubuntu", "22.04", ["host-arch"], True, None), - ("ubuntu", "20.04", ["extra-arch", "host-arch"], True, None), - ( - "not-ubuntu", - "20.04", - ["host-arch"], - False, - r"name 'not-ubuntu' is not yet supported \(must be 'ubuntu', .*\)", - ), - ( - "ubuntu", - "10.04", - ["host-arch"], - False, - r"base 'ubuntu' channel '10.04' is not yet supported \(must be .*\)", - ), - ( - "ubuntu", - "20.04", - ["other-arch"], - False, - r"host architecture 'host-arch' not in base architectures \['other-arch'\]", - ), - ], -) -def test_is_base_available( - mock_get_host_architecture, name, channel, architectures, expected_valid, expected_reason -): - base = Base(name=name, channel=channel, architectures=architectures) - valid, reason = providers.is_base_available(base) - - assert valid == expected_valid - if reason is None: - assert expected_reason == reason - else: - assert re.fullmatch(expected_reason, reason) - - -def test_get_provider_default(mock_snap_config, mock_is_developer_mode, mock_is_snap): - if sys.platform == "linux": - provider = providers.get_provider() - assert isinstance(provider, lxd.LXDProvider) - assert provider.lxd_project == "charmcraft" - else: - assert isinstance(providers.get_provider(), multipass.MultipassProvider) - - -def test_get_provider_developer_mode_env( - mock_snap_config, mock_is_developer_mode, mock_is_snap, monkeypatch -): - mock_is_developer_mode.return_value = True - monkeypatch.setenv(const.PROVIDER_ENV_VAR, "lxd") - provider = providers.get_provider() - assert isinstance(provider, lxd.LXDProvider) - assert provider.lxd_project == "charmcraft" - - monkeypatch.setenv(const.PROVIDER_ENV_VAR, "multipass") - assert isinstance(providers.get_provider(), multipass.MultipassProvider) - - -def test_get_provider_snap_config(mock_is_snap, mock_is_developer_mode, mock_snap_config): - mock_is_snap.return_value = True - - mock_snap_config.return_value = CharmcraftSnapConfiguration(provider="lxd") - provider = providers.get_provider() - assert isinstance(provider, lxd.LXDProvider) - assert provider.lxd_project == "charmcraft" - - mock_snap_config.return_value = CharmcraftSnapConfiguration(provider="multipass") - assert isinstance(providers.get_provider(), multipass.MultipassProvider) From 9da46c37c9e7abba4edcb356d4562bd668ef3418 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 14:54:28 -0400 Subject: [PATCH 11/59] chore: remove unused store command implementations These are leftover from before the craft-application move, but were used for ensuring compatibility of the commands. Now that they're no longer needed, we can eliminate them. --- charmcraft/commands/store.py | 1772 ----------- tests/commands/test_store_commands.py | 4134 ------------------------- 2 files changed, 5906 deletions(-) delete mode 100644 charmcraft/commands/store.py delete mode 100644 tests/commands/test_store_commands.py diff --git a/charmcraft/commands/store.py b/charmcraft/commands/store.py deleted file mode 100644 index ecf7c4c87..000000000 --- a/charmcraft/commands/store.py +++ /dev/null @@ -1,1772 +0,0 @@ -# Copyright 2020-2023 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 - -"""Commands related to Charmhub.""" -import collections -import os -import pathlib -import shutil -import string -import tempfile -import textwrap -import typing -import zipfile -from operator import attrgetter -from typing import TYPE_CHECKING - -import yaml -from craft_cli import ArgumentParsingError, emit -from craft_cli.errors import CraftError -from craft_parts import Step -from craft_store import attenuations -from craft_store.errors import CredentialsUnavailable -from tabulate import tabulate - -from charmcraft import const, parts, utils -from charmcraft.cmdbase import BaseCommand -from charmcraft.store.registry import ImageHandler, LocalDockerdInterface, OCIRegistry -from charmcraft.store.store import Entity, Store - -if TYPE_CHECKING: - from argparse import ArgumentParser, Namespace - - -# some types -class _EntityType(typing.NamedTuple): - charm: str = "charm" - bundle: str = "bundle" - - -class _ResourceType(typing.NamedTuple): - file: str = "file" - oci_image: str = "oci-image" - - -EntityType = _EntityType() -ResourceType = _ResourceType() - -# the list of valid attenuations to restrict login credentials -VALID_ATTENUATIONS = {getattr(attenuations, x) for x in dir(attenuations) if x.isupper()} - - -class LoginCommand(BaseCommand): - """Login to Charmhub.""" - - name = "login" - help_msg = "Login to Charmhub" - overview = textwrap.dedent( - """ - Login to Charmhub. - - Charmcraft will provide a URL for the Charmhub login. When you have - successfully logged in, charmcraft will store a token for ongoing - access to Charmhub at the CLI (if `--export` option was not used - otherwise it will only save the credentials in the indicated file). - - If `--export ` option is used, a secret credentials file will - be created. And the file can be used to set `CHARMCRAFT_AUTH` - environment variable. - - export CHARMCRAFT_AUTH=$(cat secret) - - This is suitable for Linux environments without a Vault, such as - remote servers and CI/CD pipelines. - - Please ensure the secret file and environment variable are secured. - - Remember to `charmcraft logout` if you want to remove that token - from your local system, especially in a shared environment. - - If the credentials are exported, they can also be attenuated in - several ways specifying their time-to-live (`--ttl`), on which - channels would work (`--channel`), what actions will be able to - do (`--permission`), and on which packages they will work - (using `--charm` or `--bundle`). - - See also `charmcraft whoami` to verify that you are logged in. - """ - ) - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - parser.add_argument( - "--export", - type=pathlib.Path, - help=("Export the Charmhub unencrypted secret credentials to a file"), - ) - parser.add_argument( - "--charm", - action="append", - help=( - "The charm(s) on which the required credentials would work " - "(this option can be indicated multiple times; defaults to all)" - ), - ) - parser.add_argument( - "--bundle", - action="append", - help=( - "The bundle(s) on which the required credentials would work " - "(this option can be indicated multiple times; defaults to all)" - ), - ) - parser.add_argument( - "--channel", - action="append", - help=( - "The channel(s) on which the required credentials would work " - "(this option can be indicated multiple times, defaults to any channel)" - ), - ) - parser.add_argument( - "--permission", - action="append", - help=( - "The permission(s) that the required credentials will have " - "(this option can be indicated multiple times, defaults to all permissions)" - ), - ) - parser.add_argument( - "--ttl", - type=int, - help=( - "The time-to-live (in seconds) of the required credentials (defaults to 30 hours)" - ), - ) - - def run(self, parsed_args): - """Run the command.""" - # validate that restrictions are only used if credentials are exported - restrictive_options = ["charm", "bundle", "channel", "permission", "ttl"] - if any(getattr(parsed_args, option) is not None for option in restrictive_options): - if parsed_args.export is None: - raise ArgumentParsingError( - "The restrictive options 'bundle', 'channel', 'charm', 'permission' or 'ttl' " - "can only be used when credentials are exported." - ) - if parsed_args.permission is not None: - invalid = set(parsed_args.permission) - VALID_ATTENUATIONS - if invalid: - invalid_text = ", ".join(map(repr, sorted(invalid))) - details = ( - "Explore the documentation to learn about valid permissions: " - "https://juju.is/docs/sdk/remote-env-auth" - ) - raise CraftError(f"Invalid permission: {invalid_text}.", details=details) - - # restrictive options, mapping the names between what is used in Namespace (singular, - # even if it ends up being a list) and the more natural ones used in the Store layer - restrictive_options_map = [ - ("ttl", parsed_args.ttl), - ("channels", parsed_args.channel), - ("charms", parsed_args.charm), - ("bundles", parsed_args.bundle), - ("permissions", parsed_args.permission), - ] - kwargs = {} - for arg_name, namespace_value in restrictive_options_map: - if namespace_value is not None: - kwargs[arg_name] = namespace_value - - ephemeral = parsed_args.export is not None - store = Store(self.config.charmhub, ephemeral=ephemeral) - credentials = store.login(**kwargs) - if parsed_args.export is None: - macaroon_info = store.whoami() - emit.message(f"Logged in as '{macaroon_info.account.username}'.") - else: - parsed_args.export.write_text(credentials) - emit.message(f"Login successful. Credentials exported to {str(parsed_args.export)!r}.") - - -class LogoutCommand(BaseCommand): - """Clear Charmhub token.""" - - name = "logout" - help_msg = "Logout from Charmhub and remove token" - overview = textwrap.dedent( - """ - Clear the Charmhub token. - - Charmcraft will remove the local token used for Charmhub access. - This is important on any shared system because the token allows - manipulation of your published charms. - - See also `charmcraft whoami` to verify that you are logged in, - and `charmcraft login`. - """ - ) - - def run(self, parsed_args): - """Run the command.""" - store = Store(self.config.charmhub) - try: - store.logout() - emit.message("Charmhub token cleared.") - except CredentialsUnavailable: - emit.message("You are not logged in to Charmhub.") - - -class WhoamiCommand(BaseCommand): - """Show login information.""" - - name = "whoami" - help_msg = "Show your Charmhub login status" - overview = textwrap.dedent( - """ - Show your Charmhub login status. - - See also `charmcraft login` and `charmcraft logout`. - """ - ) - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - self.include_format_option(parser) - - def run(self, parsed_args): - """Run the command.""" - store = Store(self.config.charmhub) - try: - macaroon_info = store.whoami() - except CredentialsUnavailable: - if parsed_args.format: - info = {"logged": False} - emit.message(self.format_content(parsed_args.format, info)) - else: - emit.message("You are not logged in to Charmhub.") - return - - human_msgs = [] - prog_info = {"logged": True} - - human_msgs.append(f"name: {macaroon_info.account.name}") - prog_info["name"] = macaroon_info.account.name - human_msgs.append(f"username: {macaroon_info.account.username}") - prog_info["username"] = macaroon_info.account.username - human_msgs.append(f"id: {macaroon_info.account.id}") - prog_info["id"] = macaroon_info.account.id - - if macaroon_info.permissions: - human_msgs.append("permissions:") - for item in macaroon_info.permissions: - human_msgs.append(f"- {item}") - prog_info["permissions"] = macaroon_info.permissions - - if macaroon_info.packages: - grouped = {} - for package in macaroon_info.packages: - grouped.setdefault(package.type, []).append(package) - for package_type, title in [("charm", "charms"), ("bundle", "bundles")]: - if package_type in grouped: - human_msgs.append(f"{title}:") - pkg_info = [] - for item in grouped[package_type]: - if item.name is not None: - human_msgs.append(f"- name: {item.name}") - pkg_info.append({"name": item.name}) - elif item.id is not None: - human_msgs.append(f"- id: {item.id}") - pkg_info.append({"id": item.id}) - prog_info[title] = pkg_info - - if macaroon_info.channels: - human_msgs.append("channels:") - for item in macaroon_info.channels: - human_msgs.append(f"- {item}") - prog_info["channels"] = macaroon_info.channels - - if parsed_args.format: - emit.message(self.format_content(parsed_args.format, prog_info)) - else: - for msg in human_msgs: - emit.message(msg) - - -class RegisterCharmNameCommand(BaseCommand): - """Register a charm name in Charmhub.""" - - name = "register" - help_msg = "Register a charm name in Charmhub" - overview = textwrap.dedent( - """ - Register a charm name in Charmhub. - - Claim a name for your operator in Charmhub. Once you have registered - a name, you can upload charm operator packages for that name and - release them for wider consumption. - - Charmhub operates on the 'principle of least surprise' with regard - to naming. A charm with a well-known name should provide the best - operator for the microservice most people associate with that name. - Charms can be renamed in the Charmhub, but we would nonetheless ask - you to use a qualified name, such as `yourname-charmname` if you are - in any doubt about your ability to meet that standard. - - We discuss registrations on Charmhub's Discourse: - - https://discourse.charmhub.io/c/charm - - Registration will take you through login if needed. - """ - ) - common = True - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - parser.add_argument("name", help="The name to register in Charmhub") - - def run(self, parsed_args): - """Run the command.""" - store = Store(self.config.charmhub) - store.register_name(parsed_args.name, EntityType.charm) - emit.message(f"You are now the publisher of charm {parsed_args.name!r} in Charmhub.") - - -class RegisterBundleNameCommand(BaseCommand): - """Register a bundle name in the Store.""" - - name = "register-bundle" - help_msg = "Register a bundle name in the Store" - overview = textwrap.dedent( - """ - Register a bundle name in the Store. - - Claim a name for your bundle in Charmhub. Once you have registered - a name, you can upload bundle packages for that name and - release them for wider consumption. - - Charmhub operates on the 'principle of least surprise' with regard - to naming. A bundle with a well-known name should provide the best - system for the service most people associate with that name. Bundles - can be renamed in the Charmhub, but we would nonetheless ask - you to use a qualified name, such as `yourname-bundlename` if you are - in any doubt about your ability to meet that standard. - - We discuss registrations on Charmhub's Discourse: - - https://discourse.charmhub.io/c/charm - - Registration will take you through login if needed. - """ - ) - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - parser.add_argument("name", help="The name to register in Charmhub") - - def run(self, parsed_args): - """Run the command.""" - store = Store(self.config.charmhub) - store.register_name(parsed_args.name, EntityType.bundle) - emit.message(f"You are now the publisher of bundle {parsed_args.name!r} in Charmhub.") - - -class UnregisterNameCommand(BaseCommand): - """Unregister a name in the Store.""" - - name = "unregister" - help_msg = "Unregister a name in the Store" - overview = textwrap.dedent( - """ - Unregister a name in the Store. - - Unregister a name from Charmhub if no revisions have been uploaded. - - A package cannot be unregistered if something has been uploaded to - the name. This command is only for unregistering names that have - never been used. Unregistering must be done by the publisher. - Attempting to unregister a charm or bundle as a collaborator will - fail. - - We discuss registrations on Charmhub's Discourse: - - https://discourse.charmhub.io/c/charm - """ - ) - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - parser.add_argument("name", help="The name to unregister from Charmhub") - - def run(self, parsed_args): - """Run the command.""" - store = Store(self.config.charmhub, needs_auth=True) - store.unregister_name(parsed_args.name) - emit.message(f"Name {parsed_args.name!r} has been removed from Charmhub.") - - -class ListNamesCommand(BaseCommand): - """List the entities registered in Charmhub.""" - - name = "names" - help_msg = "List your registered charm and bundle names in Charmhub" - overview = textwrap.dedent( - """ - An overview of names you have registered to publish in Charmhub. - - $ charmcraft names - Name Type Visibility Status - sabdfl-hello-world charm public registered - - Visibility and status are shown for each name. `public` items can be - seen by any user, while `private` items are only for you and the - other accounts with permission to collaborate on that specific name. - - The --include-collaborations option can be included to also list those - names you collaborate with; in that case the publisher will be included - in the output. - - Listing names will take you through login if needed. - """ - ) - common = True - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - self.include_format_option(parser) - parser.add_argument( - "--include-collaborations", - action="store_true", - help="Include the names you are a collaborator of", - ) - - def run(self, parsed_args): - """Run the command.""" - store = Store(self.config.charmhub) - with_collab = parsed_args.include_collaborations - result = store.list_registered_names(include_collaborations=with_collab) - - # build the structure that we need for both human and programmatic output - headers = ["Name", "Type", "Visibility", "Status"] - prog_keys = ["name", "type", "visibility", "status"] - if with_collab: - headers.append("Publisher") - prog_keys.append("publisher") - data = [] - for item in result: - visibility = "private" if item.private else "public" - datum = [ - item.name, - item.entity_type, - visibility, - item.status, - ] - if with_collab: - datum.append(item.publisher_display_name) - data.append(datum) - - if parsed_args.format: - info = [dict(zip(prog_keys, item)) for item in data] - emit.message(self.format_content(parsed_args.format, info)) - return - - if not result: - emit.message("No charms or bundles registered.") - return - - table = tabulate(data, headers=headers, tablefmt="plain") - for line in table.splitlines(): - emit.message(line) - - -def get_name_from_zip(filepath): - """Get the charm/bundle name from a zip file.""" - try: - zf = zipfile.ZipFile(str(filepath)) - except zipfile.BadZipFile as err: - raise CraftError(f"Cannot open {str(filepath)!r} (bad zip file).") from err - - # get the name from the given file (trying first if it's a charm, then a bundle, - # otherwise it's an error) - if const.METADATA_FILENAME in zf.namelist(): - try: - name = yaml.safe_load(zf.read(const.METADATA_FILENAME))["name"] - except Exception as err: - raise CraftError( - f"Bad 'metadata.yaml' file inside charm zip {str(filepath)!r}: must be a valid YAML with " - "a 'name' key." - ) from err - elif const.BUNDLE_FILENAME in zf.namelist(): - try: - name = yaml.safe_load(zf.read(const.BUNDLE_FILENAME))["name"] - except Exception as err: - raise CraftError( - f"Bad 'bundle.yaml' file inside bundle zip {str(filepath)!r}: must be a valid YAML with " - "a 'name' key." - ) from err - else: - raise CraftError( - f"The indicated zip file {str(filepath)!r} is not a charm ('metadata.yaml' not found) " - "nor a bundle ('bundle.yaml' not found)." - ) - - return name - - -class UploadCommand(BaseCommand): - """Upload a charm or bundle to Charmhub.""" - - name = "upload" - help_msg = "Upload a charm or bundle to Charmhub" - overview = textwrap.dedent( - """ - Upload a charm or bundle to Charmhub. - - Push a charm or bundle to Charmhub where it will be verified. - This command will finish successfully once the package is - approved by Charmhub. - - In the event of a failure in the verification process, charmcraft - will report details of the failure, otherwise it will give you the - new charm or bundle revision. - - Upload will take you through login if needed. - """ - ) - common = True - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - self.include_format_option(parser) - - parser.add_argument( - "filepath", type=utils.useful_filepath, help="The charm or bundle to upload" - ) - parser.add_argument( - "--release", - action="append", - help="The channel(s) to release to (this option can be indicated multiple times)", - ) - parser.add_argument( - "--name", - type=str, - help="Name of the charm or bundle on Charmhub to upload to", - ) - parser.add_argument( - "--resource", - action="append", - type=utils.ResourceOption(), - default=[], - help=( - "The resource(s) to attach to the release, in the : format " - "(this option can be indicated multiple times)" - ), - ) - - def run(self, parsed_args): - """Run the command.""" - if parsed_args.name: - name = parsed_args.name - else: - name = get_name_from_zip(parsed_args.filepath) - store = Store(self.config.charmhub) - result = store.upload(name, parsed_args.filepath) - - if not result.ok: - if parsed_args.format: - errors = [{"code": err.code, "message": err.message} for err in result.errors] - info = {"errors": errors} - emit.message(self.format_content(parsed_args.format, info)) - else: - emit.message(f"Upload failed with status {result.status!r}:") - for error in result.errors: - emit.message(f"- {error.code}: {error.message}") - return 1 - - if parsed_args.release: - # also release! - store.release(name, result.revision, parsed_args.release, parsed_args.resource) - - if parsed_args.format: - info = {"revision": result.revision} - emit.message(self.format_content(parsed_args.format, info)) - else: - emit.message(f"Revision {result.revision} of {str(name)!r} created") - if parsed_args.release: - msg = "Revision released to {}" - args = [", ".join(parsed_args.release)] - if parsed_args.resource: - msg += " (attaching resources: {})" - args.append( - ", ".join(f"{r.name!r} r{r.revision}" for r in parsed_args.resource) - ) - emit.message(msg.format(*args)) - return 0 - - -class ListRevisionsCommand(BaseCommand): - """List revisions for a charm or a bundle.""" - - name = "revisions" - help_msg = "List revisions for a charm or a bundle in Charmhub" - overview = textwrap.dedent( - """ - Show version, date and status for each revision in Charmhub. - - For example: - - $ charmcraft revisions mycharm - Revision Version Created at Status - 1 1 2020-11-15T11:13:15Z released - - Listing revisions will take you through login if needed. - """ - ) - common = True - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - self.include_format_option(parser) - parser.add_argument("name", help="The name of the charm or bundle") - - def run(self, parsed_args): - """Run the command.""" - store = Store(self.config.charmhub) - result = store.list_revisions(parsed_args.name) - - # build the structure that we need for both human and programmatic output - headers = ["Revision", "Version", "Created at", "Status"] - human_data = [] - prog_data = [] - for item in sorted(result, key=attrgetter("revision"), reverse=True): - # use just the status or include error message/code in it (if exist) - if item.errors: - errors = (f"{e.message} [{e.code}]" for e in item.errors) - status = "{}: {}".format(item.status, "; ".join(errors)) - else: - status = item.status - - tstamp = utils.format_timestamp(item.created_at) - human_data.append( - [ - item.revision, - item.version, - tstamp, - status, - ] - ) - - prog_info = { - "revision": item.revision, - "version": item.version, - "created_at": tstamp, - "status": item.status, - } - if item.errors: - prog_info["errors"] = [{"message": e.message, "code": e.code} for e in item.errors] - prog_data.append(prog_info) - - if parsed_args.format: - emit.message(self.format_content(parsed_args.format, prog_data)) - return - - if not result: - emit.message("No revisions found.") - return - - table = tabulate(human_data, headers=headers, tablefmt="plain", numalign="left") - for line in table.splitlines(): - emit.message(line) - - -class ReleaseCommand(BaseCommand): - """Release a charm or bundle revision to specific channels.""" - - name = "release" - help_msg = "Release a charm or bundle revision in one or more channels" - overview = textwrap.dedent( - """ - Release a charm or bundle revision in the channel(s) provided. - - Charm or bundle revisions are not published for anybody else until you - release them in a channel. When you release a revision into a channel, - users who deploy the charm or bundle from that channel will get see - the new revision as a potential update. - - A channel is made up of `track/risk/branch` with both the track and - the branch as optional items, so formally: - - [track/]risk[/branch] - - Channel risk must be one of stable, candidate, beta or edge. The - track defaults to `latest` and branch has no default. - - It is enough just to provide a channel risk, like `stable` because - the track will be assumed to be `latest` and branch is not required. - - Some channel examples: - - stable - edge - 2.0/candidate - beta/hotfix-23425 - 1.3/beta/feature-foo - - When releasing a charm, one or more resources can be attached to that - release, using the `--resource` option, indicating in each case the - resource name and specific revision. For example, to include the - resource `thedb` revision 4 in the charm release, do: - - charmcraft release mycharm --revision=14 \\ - --channel=beta --resource=thedb:4 - - Releasing a revision will take you through login if needed. - """ - ) - common = True - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - parser.add_argument("name", help="The name of charm or bundle") - parser.add_argument( - "-r", - "--revision", - type=utils.SingleOptionEnsurer(int), - required=True, - help="The revision to release", - ) - parser.add_argument( - "-c", - "--channel", - action="append", - required=True, - help="The channel(s) to release to (this option can be indicated multiple times)", - ) - parser.add_argument( - "--resource", - action="append", - type=utils.ResourceOption(), - default=[], - help=( - "The resource(s) to attach to the release, in the : format " - "(this option can be indicated multiple times)" - ), - ) - - def run(self, parsed_args): - """Run the command.""" - store = Store(self.config.charmhub) - store.release( - parsed_args.name, - parsed_args.revision, - parsed_args.channel, - parsed_args.resource, - ) - - msg = "Revision {:d} of {!r} released to {}" - args = [parsed_args.revision, parsed_args.name, ", ".join(parsed_args.channel)] - if parsed_args.resource: - msg += " (attaching resources: {})" - args.append(", ".join(f"{r.name!r} r{r.revision}" for r in parsed_args.resource)) - emit.message(msg.format(*args)) - - -class PromoteBundleCommand(BaseCommand): - """Promote a bundle in the Store.""" - - name = "promote-bundle" - help_msg = "Promote a bundle to another channel in the Store" - overview = textwrap.dedent( - """ - Promote a bundle to another channel in the Store. - - This command must be run from the bundle project directory to be - promoted. - """ - ) - - def fill_parser(self, parser: "ArgumentParser") -> None: - """Add promote-bundle parameters to the general parser.""" - parser.add_argument( - "--from-channel", - type=utils.SingleOptionEnsurer(str), - required=True, - help="The channel from which to promote the bundle", - ) - parser.add_argument( - "--to-channel", - type=utils.SingleOptionEnsurer(str), - required=True, - help="The target channel for the promoted bundle", - ) - parser.add_argument( - "--output-bundle", - type=pathlib.Path, - help="A path where the created bundle.yaml file can be written", - ) - parser.add_argument( - "--exclude", - action="append", - default=[], - help="Any charms to exclude from the promotion process", - ) - - def run(self, parsed_args: "Namespace") -> None: - """Run the command.""" - self._check_config(config_file=True) - # Validation... - if self.config.type != EntityType.bundle: - raise CraftError("promote-bundle must be run on a bundle.") - - # Check snapcraft for equiv logic - from_channel = utils.ChannelData.from_str(parsed_args.from_channel) - to_channel = utils.ChannelData.from_str(parsed_args.to_channel) - - if to_channel == from_channel: - raise CraftError("Cannot promote from a channel to the same channel.") - if to_channel.risk > from_channel.risk: - command_parts = [ - "charmcraft", - "promote-bundle", - to_channel.name, - from_channel.name, - ] - if parsed_args.output_bundle: - command_parts.extend(["--output-bundle", parsed_args.output_bundle]) - for exclusion in parsed_args.exclude: - command_parts.extend(["--exclude", exclusion]) - command = " ".join(command_parts) - raise CraftError( - f"Target channel ({to_channel.name}) must be lower risk " - f"than the source channel ({from_channel.name}).\n" - f"Did you mean: {command}" - ) - if to_channel.track != from_channel.track: - emit.message( - "Promoting to a different track (from " - f"{from_channel.track} to {to_channel.track})" - ) - - output_bundle: pathlib.Path | None = parsed_args.output_bundle - if output_bundle is not None and output_bundle.exists(): - if output_bundle.is_file() or output_bundle.is_symlink(): - emit.verbose(f"Overwriting existing bundle file: {str(output_bundle)}") - elif output_bundle.is_dir(): - emit.debug(f"Creating bundle file in {str(output_bundle)}") - output_bundle /= const.BUNDLE_FILENAME - else: - raise CraftError(f"Not a valid bundle output path: {str(output_bundle)}") - elif output_bundle is not None: - if not output_bundle.suffix: - output_bundle /= const.BUNDLE_FILENAME - for parent in output_bundle.parents: - if parent.exists(): - if os.access(parent, os.W_OK): - break - raise CraftError(f"Bundle output directory not writable: {str(parent)}") - - # Load bundle - bundle_path = self.config.project.dirpath / const.BUNDLE_FILENAME - bundle_config = utils.load_yaml(bundle_path) - if bundle_config is None: - raise CraftError(f"Missing or invalid main bundle file: {(str(bundle_path))}") - bundle_name = bundle_config.get("name") - if not bundle_name: - raise CraftError( - "Invalid bundle config; missing a 'name' field indicating the bundle's name in " - f"file {str(bundle_path)!r}." - ) - emit.progress("Determining charms to promote") - charms = [c["charm"] for c in bundle_config.get("applications", {}).values()] - errant_excludes = [] - for excluded in parsed_args.exclude: - try: - charms.remove(excluded) - except ValueError: - errant_excludes.append(excluded) - if errant_excludes: - bad_charms = utils.humanize_list(errant_excludes, "and") - raise CraftError( - f"Bundle does not contain the following excluded charms: {bad_charms}" - ) - - store = Store(self.config.charmhub) - registered_names: list[Entity] = store.list_registered_names(include_collaborations=True) - name_map = {entity.name: entity for entity in registered_names} - - if bundle_name not in name_map: - raise CraftError( - f"Cannot modify bundle {bundle_name}. Ensure the bundle exists and that you have " - "been made a collaborator." - ) - elif name_map[bundle_name].entity_type != EntityType.bundle: - entity_type = name_map[bundle_name].entity_type - raise CraftError(f"Store Entity {bundle_name} is a {entity_type}, not a bundle.") - - invalid_charms = [] - non_charms = [] - for charm_name in charms: - if charm_name not in name_map: - invalid_charms.append(charm_name) - elif name_map[charm_name].entity_type != EntityType.charm: - non_charms.append(charm_name) - if invalid_charms: - charm_list = utils.humanize_list(invalid_charms, "and") - raise CraftError( - "The following entities do not exist or you are not a collaborator on them: " - f"{charm_list}" - ) - if non_charms: - non_charm_list = utils.humanize_list(non_charms, "and") - raise CraftError(f"The following store entities are not charms: {non_charm_list}") - - # Revision in the source channel - channel_map, *_ = store.list_releases(bundle_name) - bundle_revision = None - for release in channel_map: - if release.channel == from_channel.name: - bundle_revision = release.revision - break - if bundle_revision is None: - raise CraftError("Cannot find a bundle released to the given source channel.") - - # Get source channel charms - charm_revisions: dict[str, int] = {} - charm_resources: dict[str, list[str]] = collections.defaultdict(list) - error_charms = [] - for charm_name in charms: - channel_map, *_ = store.list_releases(charm_name) - for release in channel_map: - if release.channel == from_channel.name: - charm_revisions[charm_name] = release.revision - if release.resources: - charm_resources[charm_name] = release.resources - break - else: - error_charms.append(charm_name) - if error_charms: - charm_list = utils.humanize_list(error_charms, "and") - raise CraftError(f"Not found in channel {from_channel.name}: {charm_list}") - - for application in bundle_config.get("applications", {}).values(): - application["channel"] = to_channel.name - - if parsed_args.output_bundle: - with parsed_args.output_bundle.open("w+") as output_bundle: - yaml.dump(bundle_config, stream=output_bundle) # pyright: ignore[reportCallIssue] - - for charm_name, charm_revision in charm_revisions.items(): - store.release( - charm_name, - charm_revision, - channels=[to_channel.name], - resources=charm_resources[charm_name], - ) - - # Export a temporary bundle file with the charms in the target channel - with tempfile.TemporaryDirectory(prefix="charmcraft-") as bundle_dir: - bundle_dir_path = pathlib.Path(bundle_dir) / bundle_name - shutil.copytree(self.config.project.dirpath, bundle_dir_path) - bundle_path = bundle_dir_path / const.BUNDLE_FILENAME - with bundle_path.open("w+") as bundle_file: - yaml.dump(bundle_config, bundle_file) - - # Pack the bundle using the modified bundle file - emit.verbose(f"Packing temporary bundle in {bundle_dir}...") - lifecycle = parts.PartsLifecycle( - {}, - work_dir=bundle_dir_path / const.BUILD_DIRNAME, - project_dir=bundle_dir_path, - project_name=bundle_name, - ignore_local_sources=[bundle_name + ".zip"], - ) - try: - lifecycle.run(Step.PRIME) - except (RuntimeError, CraftError) as error: - emit.debug(f"Error when running PRIME step: {error}") - raise - - from charmcraft.metafiles.manifest import create_manifest - from charmcraft.metafiles.metadata import create_metadata_yaml - - create_metadata_yaml(lifecycle.prime_dir, self.config) - create_manifest(lifecycle.prime_dir, self.config.project.started_at, None, []) - zipname = bundle_dir_path / (bundle_name + ".zip") - utils.build_zip(zipname, lifecycle.prime_dir) - - # Upload the bundle and release it to the target channel. - store.upload(bundle_name, zipname) - release_info = store.release(bundle_name, bundle_revision, [parsed_args.to_channel], []) - - # There should only be one revision. - release_info = release_info["released"][0] - emit.message( - f"Created revision {release_info['revision']!r} and " - f"released it to the {release_info['channel']!r} channel" - ) - - -class CloseCommand(BaseCommand): - """Close a channel for a charm or bundle.""" - - name = "close" - help_msg = "Close a channel for a charm or bundle" - overview = textwrap.dedent( - """ - Close the specified channel for a charm or bundle. - - The channel is made up of `track/risk/branch` with both the track and - the branch as optional items, so formally: - - [track/]risk[/branch] - - Channel risk must be one of stable, candidate, beta or edge. The - track defaults to `latest` and branch has no default. - - Closing a channel will take you through login if needed. - """ - ) - common = True - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - parser.add_argument("name", help="The name of charm or bundle") - parser.add_argument("channel", help="The channel to close") - - def run(self, parsed_args): - """Run the command.""" - store = Store(self.config.charmhub) - revision = None # revision None will actually close the channel - channels = [parsed_args.channel] # the API accepts multiple channels, we have only one - resources = [] # not really used when closing channels - store.release(parsed_args.name, revision, channels, resources) - emit.message(f"Closed {parsed_args.channel!r} channel for {parsed_args.name!r}.") - - -class StatusCommand(BaseCommand): - """Show channel status for a charm or bundle.""" - - name = "status" - help_msg = "Show channel and released revisions" - overview = textwrap.dedent( - """ - Show channels and released revisions in Charmhub. - - Charm revisions are not available to users until they are released - into a channel. This command shows the various channels for a charm - and whether there is a charm released. - - For example: - - $ charmcraft status - Track Base Channel Version Revision - latest ubuntu 20.04 (amd64) stable - - - candidate - - - beta - - - edge 1 1 - - Showing channels will take you through login if needed. - """ - ) - common = True - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - self.include_format_option(parser) - parser.add_argument("name", help="The name of the charm or bundle") - - def _build_resources_repr(self, resources): - """Build a representation of a list of resources.""" - if resources: - result = ", ".join(f"{r.name} (r{r.revision})" for r in resources) - else: - result = "-" - return result - - def _build_resources_prog(self, resources): - """Build the programmatic object for a list of resources.""" - return [{"name": res.name, "revision": res.revision} for res in resources] - - def run(self, parsed_args): - """Run the command.""" - store = Store(self.config.charmhub) - channel_map, channels, revisions = store.list_releases(parsed_args.name) - if not channel_map: - if parsed_args.format: - emit.message(self.format_content(parsed_args.format, {})) - else: - emit.message("Nothing has been released yet.") - return - - # group released revision by track and base - releases_by_track = {} - for item in channel_map: - track = item.channel.split("/")[0] - by_base = releases_by_track.setdefault(track, {}) - by_channel = by_base.setdefault(item.base, {}) - by_channel[item.channel] = item - - # group revision objects by revision number - revisions_by_revno = {item.revision: item for item in revisions} - - # process and order the channels, while preserving the tracks order - per_track = {} - branch_present = False - for channel in channels: - # the branches list is really a dict just to deduplicate them (sometimes they come - # repeated because of a Charmhub bug) without losing their order (that's why - # a set is not used) - nonbranches_list, branches = per_track.setdefault(channel.track, ([], {})) - if channel.branch is None: - # insert branch right after its fallback - for idx, stored in enumerate(nonbranches_list, 1): - if stored.name == channel.fallback: - nonbranches_list.insert(idx, channel) - break - else: - nonbranches_list.append(channel) - else: - branches[channel] = None - branch_present = True - - headers = ["Track", "Base", "Channel", "Version", "Revision"] - resources_present = any(release.resources for release in channel_map) - if resources_present: - headers.append("Resources") - if branch_present: - headers.append("Expires at") - - # show everything, grouped by tracks and bases, with regular channels at first and - # branches (if any) after those - human_data = [] - prog_data = [] - unreleased_track = {None: {}} # base in None with no releases at all - for track, (channels, branches) in per_track.items(): - prog_channels_info = [] - prog_data.append({"track": track, "mappings": prog_channels_info}) - - releases_by_base = releases_by_track.get(track, unreleased_track) - shown_track = track - - # bases are shown alphabetically ordered - sorted_bases = sorted( - releases_by_base, key=lambda b: b and (b.name, b.channel, b.architecture) - ) - for base in sorted_bases: - releases_by_channel = releases_by_base[base] - if base is None: - shown_base = "-" - prog_base = None - else: - shown_base = f"{base.name} {base.channel} ({base.architecture})" - prog_base = { - "name": base.name, - "channel": base.channel, - "architecture": base.architecture, - } - - prog_releases_info = [] - prog_channels_info.append({"base": prog_base, "releases": prog_releases_info}) - - release_shown_for_this_track_base = False - - for channel in channels: - # get the release of the channel, fallbacking accordingly - release = releases_by_channel.get(channel.name) - if release is None: - version = revno = resources = ( - "↑" if release_shown_for_this_track_base else "-" - ) - prog_version = prog_revno = prog_resources = None - prog_status = "tracking" if release_shown_for_this_track_base else "closed" - else: - release_shown_for_this_track_base = True - revno = prog_revno = release.revision - revision = revisions_by_revno[revno] - version = prog_version = revision.version - resources = self._build_resources_repr(release.resources) - prog_resources = self._build_resources_prog(release.resources) - prog_status = "open" - - datum = [shown_track, shown_base, channel.risk, version, revno] - if resources_present: - datum.append(resources) - human_data.append(datum) - - prog_releases_info.append( - { - "status": prog_status, - "channel": channel.name, - "version": prog_version, - "revision": prog_revno, - "resources": prog_resources, - "expires_at": None, - } - ) - - # stop showing the track and base for the rest of the struct - shown_track = "" - shown_base = "" - - for branch in branches: - release = releases_by_channel.get(branch.name) - if release is None: - # not for this base! - continue - description = "/".join((branch.risk, branch.branch)) - expiration = utils.format_timestamp(release.expires_at) - revision = revisions_by_revno[release.revision] - datum = ["", "", description, revision.version, release.revision] - if resources_present: - datum.append(self._build_resources_repr(release.resources)) - datum.append(expiration) - human_data.append(datum) - - prog_releases_info.append( - { - "status": "open", - "channel": branch.name, - "version": revision.version, - "revision": release.revision, - "resources": self._build_resources_prog(release.resources), - "expires_at": expiration, - } - ) - - if parsed_args.format: - emit.message(self.format_content(parsed_args.format, prog_data)) - else: - table = tabulate(human_data, headers=headers, tablefmt="plain", numalign="left") - for line in table.splitlines(): - emit.message(line) - - -class CreateLibCommand(BaseCommand): - """Create a charm library.""" - - name = "create-lib" - help_msg = "Create a charm library" - overview = textwrap.dedent( - """ - Create a charm library. - - Charmcraft manages charm libraries, which are published by charmers - to help other charmers integrate their charms. This command creates - a new library in your charm which you are publishing for others. - - This command MUST be run inside your charm directory with a valid - metadata.yaml. It will create the Python library with API version 0 - initially: - - lib/charms//v0/.py - - Each library has a unique identifier assigned by Charmhub that - supports accurate updates of libraries even if charms are renamed. - Charmcraft will request a unique ID from Charmhub and initialise a - template Python library. - - Creating a charm library will take you through login if needed. - """ - ) - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - self.include_format_option(parser) - parser.add_argument("name", help="The name of the library file (e.g. 'db')") - - def run(self, parsed_args): - """Run the command.""" - lib_name = parsed_args.name - valid_all_chars = set(string.ascii_lowercase + string.digits + "_") - valid_first_char = string.ascii_lowercase - if set(lib_name) - valid_all_chars or not lib_name or lib_name[0] not in valid_first_char: - raise CraftError( - "Invalid library name. Must only use lowercase alphanumeric " - "characters and underscore, starting with alpha." - ) - - charm_name = self.config.name or utils.get_name_from_metadata() - if charm_name is None: - raise CraftError( - "Cannot find a valid charm name in charm definition. " - "Check that you are using the correct project directory." - ) - - # '-' is valid in charm names, but not in a python import - # mutate the name so the path is a valid import - importable_charm_name = utils.create_importable_name(charm_name) - - # all libraries born with API version 0 - full_name = f"charms.{importable_charm_name}.v0.{lib_name}" - lib_data = utils.get_lib_info(full_name=full_name) - lib_path = lib_data.path - if lib_path.exists(): - raise CraftError(f"This library already exists: {str(lib_path)!r}.") - - emit.progress(f"Creating library {lib_name}.") - store = Store(self.config.charmhub) - lib_id = store.create_library_id(charm_name, lib_name) - - # create the new library file from the template - env = utils.get_templates_environment("charmlibs") - template = env.get_template("new_library.py.j2") - context = {"lib_id": lib_id} - try: - lib_path.parent.mkdir(parents=True, exist_ok=True) - lib_path.write_text(template.render(context)) - except OSError as exc: - raise CraftError(f"Error writing the library in {str(lib_path)!r}: {exc!r}.") - - if parsed_args.format: - info = {"library_id": lib_id} - emit.message(self.format_content(parsed_args.format, info)) - else: - emit.message(f"Library {full_name} created with id {lib_id}.") - emit.message(f"Consider 'git add {lib_path}'.") - - -class PublishLibCommand(BaseCommand): - """Publish one or more charm libraries.""" - - name = "publish-lib" - help_msg = "Publish one or more charm libraries" - overview = textwrap.dedent( - """ - Publish charm libraries. - - Upload and release in Charmhub the new api/patch version of the - indicated library, or all the charm libraries if is not - provided. - - It will automatically take you through the login process if - your credentials are missing or too old. - - Note that in order to be able to publish a charm library, you need - to be signed into Charmcraft as a user that has permissions to - publish libraries to this charm. In particular you need to be the - owner of this charm or registered as a contributor to the - charm (a status that can be requested via Discourse). - """ - ) - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - self.include_format_option(parser) - parser.add_argument( - "library", - nargs="?", - help="Library to publish (e.g. charms.mycharm.v2.foo.); optional, default to all", - ) - - def run(self, parsed_args): - """Run the command.""" - charm_name = self.config.name or utils.get_name_from_metadata() - if charm_name is None: - raise CraftError( - "Cannot find a valid charm name in charm definition. " - "Check that you are using the correct project directory." - ) - - if parsed_args.library: - lib_data = utils.get_lib_info(full_name=parsed_args.library) - if not lib_data.path.exists(): - raise CraftError( - f"The specified library was not found at path {str(lib_data.path)!r}." - ) - if lib_data.charm_name != charm_name: - raise CraftError( - f"The library {lib_data.full_name} does not belong to this charm {charm_name!r}." - ) - local_libs_data = [lib_data] - else: - local_libs_data = utils.get_libs_from_tree(charm_name) - found_libs = [lib_data.full_name for lib_data in local_libs_data] - (charmlib_path,) = {lib_data.path.parent.parent for lib_data in local_libs_data} - emit.debug(f"Libraries found under {str(charmlib_path)!r}: {found_libs}") - - # check if something needs to be done - store = Store(self.config.charmhub) - to_query = [{"lib_id": lib.lib_id, "api": lib.api} for lib in local_libs_data] - libs_tips = store.get_libraries_tips(to_query) - analysis = [] - for lib_data in local_libs_data: - emit.debug(f"Verifying local lib {lib_data}") - tip = libs_tips.get((lib_data.lib_id, lib_data.api)) - emit.debug(f"Store tip: {tip}") - - # big decision branch to analyse if the library needs publishing or there is a reason - # not to (to be actioned later in consideration of having a error situation or not) - error_message = None - if tip is None: - # needs to first publish - pass - elif tip.patch > lib_data.patch: - # the store is more advanced than local - error_message = ( - f"Library {lib_data.full_name} is out-of-date locally, Charmhub has " - f"version {tip.api:d}.{tip.patch:d}, please " - "fetch the updates before publishing." - ) - elif tip.patch == lib_data.patch: - # the store has same version numbers than local - if tip.content_hash == lib_data.content_hash: - error_message = f"Library {lib_data.full_name} is already updated in Charmhub." - else: - # but shouldn't as hash is different! - error_message = ( - f"Library {lib_data.full_name} version {tip.api:d}.{tip.patch:d} " - "is the same than in Charmhub but content is different" - ) - elif tip.patch + 1 == lib_data.patch: - # local is correctly incremented - if tip.content_hash == lib_data.content_hash: - # but shouldn't as hash is the same! - error_message = ( - f"Library {lib_data.full_name} LIBPATCH number was incorrectly " - "incremented, Charmhub has the " - f"same content in version {tip.api:d}.{tip.patch:d}." - ) - else: - error_message = ( - f"Library {lib_data.full_name} has a wrong LIBPATCH number, it's too high " - "and needs to be consecutive, Charmhub " - f"highest version is {tip.api:d}.{tip.patch:d}." - ) - analysis.append((lib_data, error_message)) - - # work on the analysis result, showing messages to the user if not programmatic output - for lib_data, error_message in analysis: - if error_message is None: - store.create_library_revision( - lib_data.charm_name, - lib_data.lib_id, - lib_data.api, - lib_data.patch, - lib_data.content, - lib_data.content_hash, - ) - message = ( - f"Library {lib_data.full_name} sent to the store with " - f"version {lib_data.api:d}.{lib_data.patch:d}" - ) - else: - message = error_message - if not parsed_args.format: - emit.message(message) - - if parsed_args.format: - output_data = [] - for lib_data, error_message in analysis: - datum = { - "charm_name": lib_data.charm_name, - "library_name": lib_data.lib_name, - "library_id": lib_data.lib_id, - "api": lib_data.api, - } - if error_message is None: - datum["published"] = { - "patch": lib_data.patch, - "content_hash": lib_data.content_hash, - } - else: - datum["error_message"] = error_message - output_data.append(datum) - emit.message(self.format_content(parsed_args.format, output_data)) - - -class ListLibCommand(BaseCommand): - """List all libraries belonging to a charm.""" - - name = "list-lib" - help_msg = "List all libraries from a charm" - overview = textwrap.dedent( - """ - List all libraries from a charm. - - For each library, it will show the name and the api and patch versions - for its tip. - - For example: - - $ charmcraft list-lib my-charm - Library name API Patch - my_great_lib 0 3 - my_great_lib 1 0 - other_lib 0 5 - - To fetch one of the shown libraries you can use the `fetch-lib` command. - """ - ) - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - self.include_format_option(parser) - parser.add_argument( - "name", - nargs="?", - help=( - "The name of the charm (optional, will get the name from" - "metadata.yaml if not given)" - ), - ) - - def run(self, parsed_args): - """Run the command.""" - if parsed_args.name: - charm_name = parsed_args.name - else: - charm_name = utils.get_name_from_metadata() - if charm_name is None: - raise CraftError( - "Can't access name in 'metadata.yaml' file. The 'list-lib' command must " - "either be executed from a valid project directory, or specify a charm " - "name using the --charm-name option." - ) - - # get tips from the Store - store = Store(self.config.charmhub, needs_auth=False) - to_query = [{"charm_name": charm_name}] - libs_tips = store.get_libraries_tips(to_query) - - # order it - libs_data = sorted(libs_tips.values(), key=attrgetter("lib_name", "api", "patch")) - - if parsed_args.format: - info = [ - { - "charm_name": item.charm_name, - "library_name": item.lib_name, - "library_id": item.lib_id, - "api": item.api, - "patch": item.patch, - "content_hash": item.content_hash, - } - for item in libs_data - ] - emit.message(self.format_content(parsed_args.format, info)) - return - - if not libs_tips: - emit.message(f"No libraries found for charm {charm_name}.") - return - - headers = ["Library name", "API", "Patch"] - data = [(item.lib_name, item.api, item.patch) for item in libs_data] - - table = tabulate(data, headers=headers, tablefmt="plain", numalign="left") - for line in table.splitlines(): - emit.message(line) - - -class ListResourcesCommand(BaseCommand): - """List the resources associated with a given charm in Charmhub.""" - - name = "resources" - help_msg = "List the resources associated with a given charm in Charmhub" - overview = textwrap.dedent( - """ - An overview of the resources associated with a given charm in Charmhub. - - Listing resources will take you through login if needed. - - """ - ) - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - self.include_format_option(parser) - parser.add_argument("charm_name", metavar="charm-name", help="The name of the charm") - - def run(self, parsed_args): - """Run the command.""" - store = Store(self.config.charmhub) - result = store.list_resources(parsed_args.charm_name) - - if parsed_args.format: - info = [ - { - "charm_revision": item.revision, - "name": item.name, - "type": item.resource_type, - "optional": item.optional, - } - for item in result - ] - emit.message(self.format_content(parsed_args.format, info)) - return - - if not result: - emit.message(f"No resources associated to {parsed_args.charm_name}.") - return - - headers = ["Charm Rev", "Resource", "Type", "Optional"] - by_revision = {} - for item in result: - by_revision.setdefault(item.revision, []).append(item) - data = [] - for revision, items in sorted(by_revision.items(), reverse=True): - initial, *rest = sorted(items, key=attrgetter("name")) - data.append((revision, initial.name, initial.resource_type, initial.optional)) - data.extend(("", item.name, item.resource_type, item.optional) for item in rest) - - table = tabulate(data, headers=headers, tablefmt="plain", numalign="left") - for line in table.splitlines(): - emit.message(line) - - -class UploadResourceCommand(BaseCommand): - """Upload a resource to Charmhub.""" - - name = "upload-resource" - help_msg = "Upload a resource to Charmhub" - overview = textwrap.dedent( - """ - Upload a resource to Charmhub. - - Push a resource content to Charmhub, associating it to the - specified charm. This charm needs to have the resource declared - in its metadata (in a previously uploaded to Charmhub revision). - - The resource can be a file from your computer (use the `--filepath` - option) or an OCI Image (use the `--image` option to indicate the - image digest or id), which can be already in Canonical's registry - and used directly, or locally in your computer and will be uploaded - and used. - - Upload will take you through login if needed. - """ - ) - common = True - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - self.include_format_option(parser) - parser.add_argument( - "charm_name", - metavar="charm-name", - help="The charm name to associate the resource", - ) - parser.add_argument("resource_name", metavar="resource-name", help="The resource name") - group = parser.add_mutually_exclusive_group(required=True) - group.add_argument( - "--filepath", - type=utils.SingleOptionEnsurer(utils.useful_filepath), - help="The file path of the resource content to upload", - ) - group.add_argument( - "--image", - type=utils.SingleOptionEnsurer(str), - help=( - 'The digest (remote or local) or id (local, exclude "sha256:") of the OCI image' - ), - ) - - def run(self, parsed_args): - """Run the command.""" - store = Store(self.config.charmhub) - - if parsed_args.filepath: - resource_filepath = parsed_args.filepath - resource_filepath_is_temp = False - resource_type = ResourceType.file - emit.progress(f"Uploading resource directly from file {str(resource_filepath)!r}.") - elif parsed_args.image: - credentials = store.get_oci_registry_credentials( - parsed_args.charm_name, parsed_args.resource_name - ) - - # convert the standard OCI registry image name (which is something like - # 'registry.jujucharms.com/charm/45kk8smbiyn2e/redis-image') to the image - # name that we use internally (just remove the initial "server host" part) - image_name = credentials.image_name.split("/", 1)[1] - emit.progress(f"Uploading resource from image {image_name} @ {parsed_args.image}.") - - # build the image handler and dockerd interface - registry = OCIRegistry( - self.config.charmhub.registry_url, - image_name, - username=credentials.username, - password=credentials.password, - ) - ih = ImageHandler(registry) - dockerd = LocalDockerdInterface() - - server_image_digest = None - if ":" in parsed_args.image: - # the user provided a digest; check if the specific image is - # already in Canonical's registry - already_uploaded = ih.check_in_registry(parsed_args.image) - - if already_uploaded: - emit.progress("Using OCI image from Canonical's registry.", permanent=True) - server_image_digest = parsed_args.image - else: - emit.progress("Remote image not found, getting its info from local registry.") - image_info = dockerd.get_image_info_from_digest(parsed_args.image) - - else: - # the user provided an id, can't search remotely, just get its info locally - emit.progress("Getting image info from local registry.") - image_info = dockerd.get_image_info_from_id(parsed_args.image) - - if server_image_digest is None: - if image_info is None: - raise CraftError("Image not found locally.") - - # upload it from local registry - emit.progress("Uploading from local registry.", permanent=True) - server_image_digest = ih.upload_from_local(image_info) - emit.progress( - f"Image uploaded, new remote digest: {server_image_digest}.", permanent=True - ) - - # all is green, get the blob to upload to Charmhub - content = store.get_oci_image_blob( - parsed_args.charm_name, parsed_args.resource_name, server_image_digest - ) - tfd, tname = tempfile.mkstemp(prefix="image-resource", suffix=".json") - with open(tfd, "w", encoding="utf-8") as fh: # reuse the file descriptor and close it - fh.write(content) - resource_filepath = pathlib.Path(tname) - resource_filepath_is_temp = True - resource_type = ResourceType.oci_image - - result = store.upload_resource( - parsed_args.charm_name, - parsed_args.resource_name, - resource_type, - resource_filepath, - ) - - # clean the filepath if needed - if resource_filepath_is_temp: - resource_filepath.unlink() - - if result.ok: - if parsed_args.format: - info = {"revision": result.revision} - emit.message(self.format_content(parsed_args.format, info)) - else: - emit.message( - f"Revision {result.revision} created of resource " - f"{parsed_args.resource_name!r} for charm {parsed_args.charm_name!r}.", - ) - retcode = 0 - else: - if parsed_args.format: - info = { - "errors": [ - {"code": error.code, "message": error.message} for error in result.errors - ] - } - emit.message(self.format_content(parsed_args.format, info)) - else: - emit.message(f"Upload failed with status {result.status!r}:") - for error in result.errors: - emit.message(f"- {error.code}: {error.message}") - retcode = 1 - return retcode diff --git a/tests/commands/test_store_commands.py b/tests/commands/test_store_commands.py deleted file mode 100644 index 2a85f2718..000000000 --- a/tests/commands/test_store_commands.py +++ /dev/null @@ -1,4134 +0,0 @@ -# Copyright 2020-2023 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 - -"""Tests for the Store commands (code in store/__init__.py).""" - -import base64 -import datetime -import platform -import sys -import zipfile -from argparse import ArgumentParser, Namespace -from unittest.mock import ANY, MagicMock, Mock, call, patch - -import dateutil.parser -import pydantic -import pytest -import yaml -from craft_cli import ArgumentParsingError, CraftError -from craft_store.errors import CredentialsUnavailable, StoreServerError - -from charmcraft import const -from charmcraft.cmdbase import JSON_FORMAT -from charmcraft.commands.store import ( - CloseCommand, - CreateLibCommand, - EntityType, - ListLibCommand, - ListNamesCommand, - ListResourcesCommand, - ListRevisionsCommand, - LoginCommand, - LogoutCommand, - PublishLibCommand, - RegisterBundleNameCommand, - RegisterCharmNameCommand, - ReleaseCommand, - StatusCommand, - UnregisterNameCommand, - UploadCommand, - UploadResourceCommand, - WhoamiCommand, - get_name_from_zip, -) -from charmcraft.models.charmcraft import CharmhubConfig -from charmcraft.store.models import ( - Account, - Base, - Channel, - Entity, - Error, - Library, - MacaroonInfo, - Package, - RegistryCredentials, - Release, - Resource, - Revision, - Uploaded, -) -from charmcraft.utils import ( - ResourceOption, - SingleOptionEnsurer, - get_templates_environment, - useful_filepath, -) -from tests import factory - -# used a lot! -noargs = Namespace() - -# used to flag defaults when None is a real option -DEFAULT = object() - - -def _fake_response(status_code, reason=None, json=None): - response = Mock(spec="requests.Response") - response.status_code = status_code - response.ok = status_code == 200 - response.reason = reason - if json is not None: - response.json = Mock(return_value=json) - return response - - -@pytest.fixture() -def store_mock(): - """The fixture to fake the store layer in all the tests.""" - store_mock = MagicMock() - - def validate_params(config, ephemeral=False, needs_auth=True): - """Check that the store received the Charmhub configuration and ephemeral flag.""" - assert config == CharmhubConfig() - assert isinstance(ephemeral, bool) - assert isinstance(needs_auth, bool) - return store_mock - - with patch("charmcraft.commands.store.Store", validate_params): - yield store_mock - - -@pytest.fixture() -def add_cleanup(): - """Generic cleaning helper.""" - to_cleanup = [] - - def f(func, *args, **kwargs): - """Store the cleaning actions for later.""" - to_cleanup.append((func, args, kwargs)) - - yield f - - for func, args, kwargs in to_cleanup: - func(*args, **kwargs) - - -# -- tests for auth commands - -LOGIN_OPTIONS = dict.fromkeys(["export", "charm", "bundle", "permission", "channel", "ttl"]) - - -def test_login_simple(emitter, store_mock, config): - """Simple login case.""" - store_mock.whoami.return_value = MacaroonInfo( - account=Account(name="John Doe", username="jdoe", id="dlq8hl8hd8qhdl3lhl"), - permissions=["perm1", "perm2"], - channels=None, - packages=None, - ) - - args = Namespace(**LOGIN_OPTIONS) - LoginCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.login(), - call.whoami(), - ] - emitter.assert_message("Logged in as 'jdoe'.") - - -def test_login_exporting(emitter, store_mock, config, tmp_path): - """Login with exported credentials.""" - acquired_credentials = "super secret stuff" - store_mock.login.return_value = acquired_credentials - - credentials_file = tmp_path / "somefile.txt" - args = Namespace(**LOGIN_OPTIONS) - args.export = credentials_file - LoginCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.login(), - ] - emitter.assert_message(f"Login successful. Credentials exported to {str(credentials_file)!r}.") - assert credentials_file.read_text() == acquired_credentials - - -@pytest.mark.parametrize("rest_option", ["charm", "bundle", "permission", "channel", "ttl"]) -def test_login_restrictions_without_export(emitter, store_mock, config, tmp_path, rest_option): - """Login restrictions are not allowed if export option is not used.""" - args = Namespace(**LOGIN_OPTIONS) - setattr(args, rest_option, "used") - with pytest.raises(ArgumentParsingError) as cm: - LoginCommand(config).run(args) - assert str(cm.value) == ( - "The restrictive options 'bundle', 'channel', 'charm', 'permission' or 'ttl' " - "can only be used when credentials are exported." - ) - - -def test_login_restricting_ttl(emitter, store_mock, config, tmp_path): - """Login with a TTL restriction.""" - store_mock.login.return_value = "super secret stuff" - - args = Namespace(**LOGIN_OPTIONS) - args.export = tmp_path / "somefile.txt" - args.ttl = 1000 - LoginCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.login(ttl=1000), - ] - - -def test_login_restricting_channels(emitter, store_mock, config, tmp_path): - """Login with channels restriction.""" - store_mock.login.return_value = "super secret stuff" - - args = Namespace(**LOGIN_OPTIONS) - args.export = tmp_path / "somefile.txt" - args.channel = ["edge", "beta"] - LoginCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.login(channels=["edge", "beta"]), - ] - - -def test_login_restricting_permissions(emitter, store_mock, config, tmp_path): - """Login with permissions restriction.""" - store_mock.login.return_value = "super secret stuff" - - args = Namespace(**LOGIN_OPTIONS) - args.export = tmp_path / "somefile.txt" - args.permission = ["package-view-metadata", "package-manage-metadata"] - LoginCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.login(permissions=["package-view-metadata", "package-manage-metadata"]), - ] - - -def test_login_restricting_permission_invalid(emitter, store_mock, config, tmp_path): - """Login with a permission restriction that is not valid.""" - store_mock.login.return_value = "super secret stuff" - - args = Namespace(**LOGIN_OPTIONS) - args.export = tmp_path / "somefile.txt" - args.permission = ["absolute-power", "package-manage-metadata", "crazy-stuff"] - with pytest.raises(CraftError) as cm: - LoginCommand(config).run(args) - - assert str(cm.value) == "Invalid permission: 'absolute-power', 'crazy-stuff'." - assert cm.value.details == ( - "Explore the documentation to learn about valid permissions: " - "https://juju.is/docs/sdk/remote-env-auth" - ) - - -def test_login_restricting_charms(emitter, store_mock, config, tmp_path): - """Login with charms restriction.""" - store_mock.login.return_value = "super secret stuff" - - args = Namespace(**LOGIN_OPTIONS) - args.export = tmp_path / "somefile.txt" - args.charm = ["charm1", "charm2"] - LoginCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.login(charms=["charm1", "charm2"]), - ] - - -def test_login_restricting_bundles(emitter, store_mock, config, tmp_path): - """Login with bundles restriction.""" - store_mock.login.return_value = "super secret stuff" - - args = Namespace(**LOGIN_OPTIONS) - args.export = tmp_path / "somefile.txt" - args.bundle = ["bundle1", "bundle2"] - LoginCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.login(bundles=["bundle1", "bundle2"]), - ] - - -def test_login_restriction_mix(emitter, store_mock, config, tmp_path): - """Valid case combining several restrictions.""" - store_mock.login.return_value = "super secret stuff" - - args = Namespace( - export=tmp_path / "somefile.txt", - charm=["mycharm"], - bundle=None, - permission=["package-view", "package-manage"], - channel=["edge"], - ttl=259200, - ) - LoginCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.login( - ttl=259200, - channels=["edge"], - charms=["mycharm"], - permissions=["package-view", "package-manage"], - ), - ] - - -def test_logout(emitter, store_mock, config): - """Simple logout case.""" - LogoutCommand(config).run(noargs) - - assert store_mock.mock_calls == [ - call.logout(), - ] - emitter.assert_message("Charmhub token cleared.") - - -def test_logout_but_not_logged_in(emitter, store_mock, config): - """Simple logout case.""" - store_mock.logout.side_effect = CredentialsUnavailable( - application="charmcraft", host="api.charmcraft.io" - ) - - LogoutCommand(config).run(noargs) - - assert store_mock.mock_calls == [ - call.logout(), - ] - emitter.assert_message("You are not logged in to Charmhub.") - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_whoami(emitter, store_mock, config, formatted): - """Simple whoami case.""" - store_response = MacaroonInfo( - account=Account(name="John Doe", username="jdoe", id="dlq8hl8hd8qhdl3lhl"), - permissions=["perm1", "perm2"], - channels=None, - packages=None, - ) - store_mock.whoami.return_value = store_response - - args = Namespace(format=formatted) - WhoamiCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.whoami(), - ] - if formatted: - expected = { - "logged": True, - "name": "John Doe", - "username": "jdoe", - "id": "dlq8hl8hd8qhdl3lhl", - "permissions": ["perm1", "perm2"], - } - emitter.assert_json_output(expected) - else: - expected = [ - "name: John Doe", - "username: jdoe", - "id: dlq8hl8hd8qhdl3lhl", - "permissions:", - "- perm1", - "- perm2", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_whoami_but_not_logged_in(emitter, store_mock, config, formatted): - """Whoami when not logged.""" - store_mock.whoami.side_effect = CredentialsUnavailable( - application="charmcraft", host="api.charmcraft.io" - ) - - args = Namespace(format=formatted) - WhoamiCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.whoami(), - ] - if formatted: - expected = { - "logged": False, - } - emitter.assert_json_output(expected) - else: - emitter.assert_message("You are not logged in to Charmhub.") - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_whoami_with_channels(emitter, store_mock, config, formatted): - """Whoami with channel attenuations.""" - store_response = MacaroonInfo( - account=Account(name="John Doe", username="jdoe", id="dlq8hl8hd8qhdl3lhl"), - permissions=["perm1", "perm2"], - channels=["edge", "beta"], - packages=None, - ) - store_mock.whoami.return_value = store_response - - args = Namespace(format=formatted) - WhoamiCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.whoami(), - ] - if formatted: - expected = { - "logged": True, - "name": "John Doe", - "username": "jdoe", - "id": "dlq8hl8hd8qhdl3lhl", - "permissions": ["perm1", "perm2"], - "channels": ["edge", "beta"], - } - emitter.assert_json_output(expected) - else: - expected = [ - "name: John Doe", - "username: jdoe", - "id: dlq8hl8hd8qhdl3lhl", - "permissions:", - "- perm1", - "- perm2", - "channels:", - "- edge", - "- beta", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_whoami_with_charms(emitter, store_mock, config, formatted): - """Whoami with charms attenuations.""" - store_response = MacaroonInfo( - account=Account(name="John Doe", username="jdoe", id="dlq8hl8hd8qhdl3lhl"), - permissions=["perm1", "perm2"], - channels=None, - packages=[ - Package(type="charm", name="charmname1", id=None), - Package(type="charm", name=None, id="charmid2"), - ], - ) - store_mock.whoami.return_value = store_response - - args = Namespace(format=formatted) - WhoamiCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.whoami(), - ] - if formatted: - expected = { - "logged": True, - "name": "John Doe", - "username": "jdoe", - "id": "dlq8hl8hd8qhdl3lhl", - "permissions": ["perm1", "perm2"], - "charms": [{"name": "charmname1"}, {"id": "charmid2"}], - } - emitter.assert_json_output(expected) - else: - expected = [ - "name: John Doe", - "username: jdoe", - "id: dlq8hl8hd8qhdl3lhl", - "permissions:", - "- perm1", - "- perm2", - "charms:", - "- name: charmname1", - "- id: charmid2", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_whoami_with_bundles(emitter, store_mock, config, formatted): - """Whoami with bundles attenuations.""" - store_response = MacaroonInfo( - account=Account(name="John Doe", username="jdoe", id="dlq8hl8hd8qhdl3lhl"), - permissions=["perm1", "perm2"], - channels=None, - packages=[ - Package(type="bundle", name="bundlename1", id=None), - Package(type="bundle", name=None, id="bundleid2"), - ], - ) - store_mock.whoami.return_value = store_response - - args = Namespace(format=formatted) - WhoamiCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.whoami(), - ] - if formatted: - expected = { - "logged": True, - "name": "John Doe", - "username": "jdoe", - "id": "dlq8hl8hd8qhdl3lhl", - "permissions": ["perm1", "perm2"], - "bundles": [{"name": "bundlename1"}, {"id": "bundleid2"}], - } - emitter.assert_json_output(expected) - else: - expected = [ - "name: John Doe", - "username: jdoe", - "id: dlq8hl8hd8qhdl3lhl", - "permissions:", - "- perm1", - "- perm2", - "bundles:", - "- name: bundlename1", - "- id: bundleid2", - ] - emitter.assert_messages(expected) - - -def test_whoami_comprehensive(emitter, store_mock, config): - """Whoami with ALL attenuations.""" - store_response = MacaroonInfo( - account=Account(name="John Doe", username="jdoe", id="dlq8hl8hd8qhdl3lhl"), - permissions=["perm1", "perm2"], - channels=["edge", "beta"], - packages=[ - Package(type="charm", name="charmname1", id=None), - Package(type="charm", name=None, id="charmid2"), - Package(type="bundle", name="bundlename", id=None), - ], - ) - store_mock.whoami.return_value = store_response - - args = Namespace(format=False) - WhoamiCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.whoami(), - ] - expected = [ - "name: John Doe", - "username: jdoe", - "id: dlq8hl8hd8qhdl3lhl", - "permissions:", - "- perm1", - "- perm2", - "charms:", - "- name: charmname1", - "- id: charmid2", - "bundles:", - "- name: bundlename", - "channels:", - "- edge", - "- beta", - ] - emitter.assert_messages(expected) - - -# -- tests for name-related commands - - -def test_register_charm_name(emitter, store_mock, config): - """Simple register_name case for a charm.""" - args = Namespace(name="testname") - RegisterCharmNameCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.register_name("testname", EntityType.charm), - ] - expected = "You are now the publisher of charm 'testname' in Charmhub." - emitter.assert_message(expected) - - -def test_register_bundle_name(emitter, store_mock, config): - """Simple register_name case for a bundl.""" - args = Namespace(name="testname") - RegisterBundleNameCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.register_name("testname", EntityType.bundle), - ] - expected = "You are now the publisher of bundle 'testname' in Charmhub." - emitter.assert_message(expected) - - -def test_unregister_name(emitter, store_mock, config): - """Simple name unregsitration name.""" - args = Namespace(name="testname") - UnregisterNameCommand(config).run(args) - - assert store_mock.mock_calls == [call.unregister_name("testname")] - emitter.assert_message("Name 'testname' has been removed from Charmhub.") - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_list_registered_empty(emitter, store_mock, config, formatted): - """List registered with empty response.""" - store_response = [] - store_mock.list_registered_names.return_value = store_response - - args = Namespace(format=formatted, include_collaborations=None) - ListNamesCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_registered_names(include_collaborations=None), - ] - if formatted: - emitter.assert_json_output([]) - else: - expected = "No charms or bundles registered." - emitter.assert_message(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_list_registered_one_private(emitter, store_mock, config, formatted): - """List registered with one private item in the response.""" - store_response = [ - Entity( - entity_type="charm", - name="charm", - private=True, - status="status", - publisher_display_name="J. Doe", - ), - ] - store_mock.list_registered_names.return_value = store_response - - args = Namespace(format=formatted, include_collaborations=None) - ListNamesCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_registered_names(include_collaborations=None), - ] - expected = [ - "Name Type Visibility Status", - "charm charm private status", - ] - if formatted: - expected = [ - { - "name": "charm", - "type": "charm", - "visibility": "private", - "status": "status", - }, - ] - emitter.assert_json_output(expected) - else: - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_list_registered_one_public(emitter, store_mock, config, formatted): - """List registered with one public item in the response.""" - store_response = [ - Entity( - entity_type="charm", - name="charm", - private=False, - status="status", - publisher_display_name="J. Doe", - ), - ] - store_mock.list_registered_names.return_value = store_response - - args = Namespace(format=formatted, include_collaborations=None) - ListNamesCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_registered_names(include_collaborations=None), - ] - expected = [ - "Name Type Visibility Status", - "charm charm public status", - ] - if formatted: - expected = [ - { - "name": "charm", - "type": "charm", - "visibility": "public", - "status": "status", - }, - ] - emitter.assert_json_output(expected) - else: - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_list_registered_several(emitter, store_mock, config, formatted): - """List registered with several itemsssssssss in the response.""" - store_response = [ - Entity( - entity_type="charm", - name="charm1", - private=True, - status="simple status", - publisher_display_name="J. Doe", - ), - Entity( - entity_type="charm", - name="charm2-long-name", - private=False, - status="other", - publisher_display_name="J. Doe", - ), - Entity( - entity_type="charm", - name="charm3", - private=True, - status="super long status", - publisher_display_name="J. Doe", - ), - Entity( - entity_type="bundle", - name="somebundle", - private=False, - status="bundle status", - publisher_display_name="J. Doe", - ), - ] - store_mock.list_registered_names.return_value = store_response - - args = Namespace(format=formatted, include_collaborations=None) - ListNamesCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_registered_names(include_collaborations=None), - ] - if formatted: - expected = [ - { - "name": "charm1", - "type": "charm", - "visibility": "private", - "status": "simple status", - }, - { - "name": "charm2-long-name", - "type": "charm", - "visibility": "public", - "status": "other", - }, - { - "name": "charm3", - "type": "charm", - "visibility": "private", - "status": "super long status", - }, - { - "name": "somebundle", - "type": "bundle", - "visibility": "public", - "status": "bundle status", - }, - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Name Type Visibility Status", - "charm1 charm private simple status", - "charm2-long-name charm public other", - "charm3 charm private super long status", - "somebundle bundle public bundle status", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_list_registered_with_collaborations(emitter, store_mock, config, formatted): - """List registered with collaborations flag.""" - store_response = [ - Entity( - entity_type="charm", - name="charm1", - private=True, - status="simple status", - publisher_display_name="J. Doe", - ), - Entity( - entity_type="bundle", - name="somebundle", - private=False, - status="bundle status", - publisher_display_name="Ms. Bundle Publisher", - ), - ] - store_mock.list_registered_names.return_value = store_response - - args = Namespace(format=formatted, include_collaborations=True) - ListNamesCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_registered_names(include_collaborations=True), - ] - if formatted: - expected = [ - { - "name": "charm1", - "type": "charm", - "visibility": "private", - "status": "simple status", - "publisher": "J. Doe", - }, - { - "name": "somebundle", - "type": "bundle", - "visibility": "public", - "status": "bundle status", - "publisher": "Ms. Bundle Publisher", - }, - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Name Type Visibility Status Publisher", - "charm1 charm private simple status J. Doe", - "somebundle bundle public bundle status Ms. Bundle Publisher", - ] - emitter.assert_messages(expected) - - -# -- tests for upload command - - -def _build_zip_with_yaml(zippath, filename, *, content=None, raw_yaml=None): - """Create a yaml named 'filename' with given content, inside a zip file in 'zippath'.""" - if raw_yaml is None: - raw_yaml = yaml.dump(content).encode("ascii") - with zipfile.ZipFile(str(zippath), "w") as zf: - zf.writestr(filename, raw_yaml) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_get_name_bad_zip(tmp_path): - """Get the name from a bad zip file.""" - bad_zip = tmp_path / "badstuff.zip" - bad_zip.write_text("I'm not really a zip file") - - with pytest.raises(CraftError) as cm: - get_name_from_zip(bad_zip) - assert str(cm.value) == f"Cannot open '{bad_zip}' (bad zip file)." - - -def test_get_name_charm_ok(tmp_path): - """Get the name from a charm file, all ok.""" - test_zip = tmp_path / "some.zip" - test_name = "whatever" - _build_zip_with_yaml(test_zip, const.METADATA_FILENAME, content={"name": test_name}) - - name = get_name_from_zip(test_zip) - assert name == test_name - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -@pytest.mark.parametrize( - "yaml_content", - [ - b"=", # invalid yaml - b"foo: bar", # missing 'name' - ], -) -def test_get_name_charm_bad_metadata(tmp_path, yaml_content): - """Get the name from a charm file, but with a wrong metadata.yaml.""" - bad_zip = tmp_path / "badstuff.zip" - _build_zip_with_yaml(bad_zip, const.METADATA_FILENAME, raw_yaml=yaml_content) - - with pytest.raises(CraftError) as cm: - get_name_from_zip(bad_zip) - assert str(cm.value) == ( - "Bad 'metadata.yaml' file inside charm zip " - f"'{bad_zip}': must be a valid YAML with a 'name' key." - ) - assert cm.value.__cause__ is not None - - -def test_get_name_bundle_ok(tmp_path): - """Get the name from a bundle file, all ok.""" - test_zip = tmp_path / "some.zip" - test_name = "whatever" - _build_zip_with_yaml(test_zip, const.BUNDLE_FILENAME, content={"name": test_name}) - - name = get_name_from_zip(test_zip) - assert name == test_name - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -@pytest.mark.parametrize( - "yaml_content", - [ - b"=", # invalid yaml - b"foo: bar", # missing 'name' - ], -) -def test_get_name_bundle_bad_data(tmp_path, yaml_content): - """Get the name from a bundle file, but with a bad bundle.yaml.""" - bad_zip = tmp_path / "badstuff.zip" - _build_zip_with_yaml(bad_zip, const.BUNDLE_FILENAME, raw_yaml=yaml_content) - - with pytest.raises(CraftError) as cm: - get_name_from_zip(bad_zip) - assert str(cm.value) == ( - f"Bad 'bundle.yaml' file inside bundle zip '{bad_zip}': " - "must be a valid YAML with a 'name' key." - ) - assert cm.value.__cause__ is not None - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_get_name_nor_charm_nor_bundle(tmp_path): - """Get the name from a zip that has no metadata.yaml nor bundle.yaml.""" - bad_zip = tmp_path / "bad-stuff.zip" - _build_zip_with_yaml(bad_zip, "whatever.yaml", content={}) - - with pytest.raises(CraftError) as cm: - get_name_from_zip(bad_zip) - assert str(cm.value) == ( - f"The indicated zip file '{bad_zip}' is not a charm ('metadata.yaml' not found) nor a bundle " - "('bundle.yaml' not found)." - ) - - -def test_upload_parameters_filepath_type(config): - """The filepath parameter implies a set of validations.""" - cmd = UploadCommand(config) - parser = ArgumentParser() - cmd.fill_parser(parser) - (action,) = (action for action in parser._actions if action.dest == "filepath") - assert action.type is useful_filepath - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_upload_call_ok(emitter, store_mock, config, tmp_path, formatted): - """Simple upload, success result.""" - store_response = Uploaded(ok=True, status=200, revision=7, errors=[]) - store_mock.upload.return_value = store_response - - test_charm = tmp_path / "mystuff.charm" - _build_zip_with_yaml(test_charm, const.METADATA_FILENAME, content={"name": "mycharm"}) - args = Namespace(filepath=test_charm, release=[], name=None, format=formatted) - retcode = UploadCommand(config).run(args) - assert retcode == 0 - - assert store_mock.mock_calls == [call.upload("mycharm", test_charm)] - if formatted: - expected = {"revision": 7} - emitter.assert_json_output(expected) - else: - expected = "Revision 7 of 'mycharm' created" - emitter.assert_message(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_upload_call_error(emitter, store_mock, config, tmp_path, formatted): - """Simple upload but with a response indicating an error.""" - errors = [ - Error(message="text 1", code="missing-stuff"), - Error(message="other long error text", code="broken"), - ] - store_response = Uploaded(ok=False, status=400, revision=None, errors=errors) - store_mock.upload.return_value = store_response - - test_charm = tmp_path / "mystuff.charm" - _build_zip_with_yaml(test_charm, const.METADATA_FILENAME, content={"name": "mycharm"}) - args = Namespace(filepath=test_charm, release=[], name=None, format=formatted) - retcode = UploadCommand(config).run(args) - assert retcode == 1 - - assert store_mock.mock_calls == [call.upload("mycharm", test_charm)] - if formatted: - expected = { - "errors": [ - {"code": "missing-stuff", "message": "text 1"}, - {"code": "broken", "message": "other long error text"}, - ] - } - emitter.assert_json_output(expected) - else: - expected = [ - "Upload failed with status 400:", - "- missing-stuff: text 1", - "- broken: other long error text", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_upload_call_login_expired(mocker, monkeypatch, config, tmp_path, formatted): - """Simple upload but login expired.""" - monkeypatch.setenv(const.ALTERNATE_AUTH_ENV_VAR, base64.b64encode(b"credentials").decode()) - mock_whoami = mocker.patch("craft_store.base_client.HTTPClient.request") - push_file_mock = mocker.patch("charmcraft.store.Client.push_file") - - mock_whoami.side_effect = StoreServerError(_fake_response(401, json={})) - - test_charm = tmp_path / "mystuff.charm" - _build_zip_with_yaml(test_charm, const.METADATA_FILENAME, content={"name": "mycharm"}) - args = Namespace(filepath=test_charm, release=[], name=None, format=formatted) - - with pytest.raises(CraftError) as cm: - UploadCommand(config).run(args) - assert str(cm.value) == ( - "Provided credentials are no longer valid for Charmhub. Regenerate them and try again." - ) - assert not push_file_mock.called - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_upload_call_ok_including_release(emitter, store_mock, config, tmp_path, formatted): - """Upload with a release included, success result.""" - store_response = Uploaded(ok=True, status=200, revision=7, errors=[]) - store_mock.upload.return_value = store_response - - test_charm = tmp_path / "mystuff.charm" - _build_zip_with_yaml(test_charm, const.METADATA_FILENAME, content={"name": "mycharm"}) - args = Namespace( - filepath=test_charm, release=["edge"], resource=[], name=None, format=formatted - ) - UploadCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.upload("mycharm", test_charm), - call.release("mycharm", 7, ["edge"], []), - ] - if formatted: - expected = {"revision": 7} - emitter.assert_json_output(expected) - else: - expected = [ - "Revision 7 of 'mycharm' created", - "Revision released to edge", - ] - emitter.assert_messages(expected) - - -def test_upload_call_ok_including_release_multiple(emitter, store_mock, config, tmp_path): - """Upload with release to two channels included, success result.""" - store_response = Uploaded(ok=True, status=200, revision=7, errors=[]) - store_mock.upload.return_value = store_response - - test_charm = tmp_path / "mystuff.charm" - _build_zip_with_yaml(test_charm, const.METADATA_FILENAME, content={"name": "mycharm"}) - args = Namespace( - filepath=test_charm, release=["edge", "stable"], resource=[], name=None, format=False - ) - UploadCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.upload("mycharm", test_charm), - call.release("mycharm", 7, ["edge", "stable"], []), - ] - expected = [ - "Revision 7 of 'mycharm' created", - "Revision released to edge, stable", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_upload_including_release_with_resources(emitter, store_mock, config, tmp_path, formatted): - """Releasing with resources.""" - store_response = Uploaded(ok=True, status=200, revision=7, errors=[]) - store_mock.upload.return_value = store_response - - test_charm = tmp_path / "mystuff.charm" - _build_zip_with_yaml(test_charm, const.METADATA_FILENAME, content={"name": "mycharm"}) - r1 = ResourceOption(name="foo", revision=3) - r2 = ResourceOption(name="bar", revision=17) - args = Namespace( - filepath=test_charm, release=["edge"], resource=[r1, r2], name=None, format=formatted - ) - UploadCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.upload("mycharm", test_charm), - call.release("mycharm", 7, ["edge"], [r1, r2]), - ] - if formatted: - expected = {"revision": 7} - emitter.assert_json_output(expected) - else: - expected = [ - "Revision 7 of 'mycharm' created", - "Revision released to edge (attaching resources: 'foo' r3, 'bar' r17)", - ] - emitter.assert_messages(expected) - - -def test_upload_options_resource(config): - """The --resource option implies a set of validations.""" - cmd = UploadCommand(config) - parser = ArgumentParser() - cmd.fill_parser(parser) - (action,) = (action for action in parser._actions if action.dest == "resource") - assert isinstance(action.type, ResourceOption) - - -def test_upload_call_error_including_release(emitter, store_mock, config, tmp_path): - """Upload with a realsea but the upload went wrong, so no release.""" - errors = [Error(message="text", code="problem")] - store_response = Uploaded(ok=False, status=400, revision=None, errors=errors) - store_mock.upload.return_value = store_response - - test_charm = tmp_path / "mystuff.charm" - _build_zip_with_yaml(test_charm, const.METADATA_FILENAME, content={"name": "mycharm"}) - args = Namespace(filepath=test_charm, release=["edge"], name=None, format=False) - UploadCommand(config).run(args) - - # check the upload was attempted, but not the release! - assert store_mock.mock_calls == [call.upload("mycharm", test_charm)] - - -def test_upload_with_different_name_than_in_metadata(emitter, store_mock, config, tmp_path): - """Simple upload to a specific name different from metadata, success result.""" - store_response = Uploaded(ok=True, status=200, revision=7, errors=[]) - store_mock.upload.return_value = store_response - - test_charm = tmp_path / "mystuff.charm" - _build_zip_with_yaml(test_charm, const.METADATA_FILENAME, content={"name": "mycharm"}) - args = Namespace(filepath=test_charm, release=[], name="foo-mycharm", format=False) - retcode = UploadCommand(config).run(args) - assert retcode == 0 - - assert store_mock.mock_calls == [call.upload("foo-mycharm", test_charm)] - expected = "Revision 7 of 'foo-mycharm' created" - emitter.assert_message(expected) - - -# -- tests for list revisions command - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_revisions_simple(emitter, store_mock, config, formatted): - """Happy path of one result from the Store.""" - bases = [Base(architecture="amd64", channel="20.04", name="ubuntu")] - store_response = [ - Revision( - revision=1, - version="v1", - created_at=datetime.datetime(2020, 7, 3, 20, 30, 40), - status="accepted", - errors=[], - bases=bases, - ), - ] - store_mock.list_revisions.return_value = store_response - - args = Namespace(name="testcharm", format=formatted) - ListRevisionsCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_revisions("testcharm"), - ] - if formatted: - expected = [ - { - "revision": 1, - "version": "v1", - "created_at": "2020-07-03T20:30:40Z", - "status": "accepted", - }, - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Revision Version Created at Status", - "1 v1 2020-07-03T20:30:40Z accepted", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_revisions_empty(emitter, store_mock, config, formatted): - """No results from the store.""" - store_response = [] - store_mock.list_revisions.return_value = store_response - - args = Namespace(name="testcharm", format=formatted) - ListRevisionsCommand(config).run(args) - - if formatted: - emitter.assert_json_output([]) - else: - expected = [ - "No revisions found.", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_revisions_ordered_by_revision(emitter, store_mock, config, formatted): - """Results are presented ordered by revision in the table.""" - # three Revisions with all values weirdly similar, the only difference is revision, so - # we really assert later that it was used for ordering - tstamp = datetime.datetime(2020, 7, 3, 20, 30, 40) - bases = [Base(architecture="amd64", channel="20.04", name="ubuntu")] - store_response = [ - Revision( - revision=1, - version="v1", - created_at=tstamp, - status="accepted", - errors=[], - bases=bases, - ), - Revision( - revision=3, - version="v1", - created_at=tstamp, - status="accepted", - errors=[], - bases=bases, - ), - Revision( - revision=2, - version="v1", - created_at=tstamp, - status="accepted", - errors=[], - bases=bases, - ), - ] - store_mock.list_revisions.return_value = store_response - - args = Namespace(name="testcharm", format=formatted) - ListRevisionsCommand(config).run(args) - - if formatted: - expected = [ - { - "revision": 3, - "version": "v1", - "created_at": "2020-07-03T20:30:40Z", - "status": "accepted", - }, - { - "revision": 2, - "version": "v1", - "created_at": "2020-07-03T20:30:40Z", - "status": "accepted", - }, - { - "revision": 1, - "version": "v1", - "created_at": "2020-07-03T20:30:40Z", - "status": "accepted", - }, - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Revision Version Created at Status", - "3 v1 2020-07-03T20:30:40Z accepted", - "2 v1 2020-07-03T20:30:40Z accepted", - "1 v1 2020-07-03T20:30:40Z accepted", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_revisions_version_null(emitter, store_mock, config, formatted): - """Support the case of version being None.""" - bases = [Base(architecture="amd64", channel="20.04", name="ubuntu")] - store_response = [ - Revision( - revision=1, - version=None, - created_at=datetime.datetime(2020, 7, 3, 20, 30, 40), - status="accepted", - errors=[], - bases=bases, - ), - ] - store_mock.list_revisions.return_value = store_response - - args = Namespace(name="testcharm", format=formatted) - ListRevisionsCommand(config).run(args) - - if formatted: - expected = [ - { - "revision": 1, - "version": None, - "created_at": "2020-07-03T20:30:40Z", - "status": "accepted", - }, - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Revision Version Created at Status", - "1 2020-07-03T20:30:40Z accepted", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_revisions_errors_simple(emitter, store_mock, config, formatted): - """Support having one case with a simple error.""" - bases = [Base(architecture="amd64", channel="20.04", name="ubuntu")] - store_response = [ - Revision( - revision=1, - version=None, - created_at=datetime.datetime(2020, 7, 3, 20, 30, 40), - status="rejected", - errors=[Error(message="error text", code="broken")], - bases=bases, - ), - ] - store_mock.list_revisions.return_value = store_response - - args = Namespace(name="testcharm", format=formatted) - ListRevisionsCommand(config).run(args) - - if formatted: - expected = [ - { - "revision": 1, - "version": None, - "created_at": "2020-07-03T20:30:40Z", - "status": "rejected", - "errors": [{"message": "error text", "code": "broken"}], - }, - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Revision Version Created at Status", - "1 2020-07-03T20:30:40Z rejected: error text [broken]", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_revisions_errors_multiple(emitter, store_mock, config, formatted): - """Support having one case with multiple errors.""" - bases = [Base(architecture="amd64", channel="20.04", name="ubuntu")] - store_response = [ - Revision( - revision=1, - version=None, - created_at=datetime.datetime(2020, 7, 3, 20, 30, 40), - status="rejected", - errors=[ - Error(message="text 1", code="missing-stuff"), - Error(message="other long error text", code="broken"), - ], - bases=bases, - ), - ] - store_mock.list_revisions.return_value = store_response - - args = Namespace(name="testcharm", format=formatted) - ListRevisionsCommand(config).run(args) - - if formatted: - expected = [ - { - "revision": 1, - "version": None, - "created_at": "2020-07-03T20:30:40Z", - "status": "rejected", - "errors": [ - {"message": "text 1", "code": "missing-stuff"}, - {"message": "other long error text", "code": "broken"}, - ], - }, - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Revision Version Created at Status", - "1 2020-07-03T20:30:40Z rejected: text 1 [missing-stuff]; other long error text [broken]", - ] - emitter.assert_messages(expected) - - -# -- tests for the release command - - -def test_release_simple_ok(emitter, store_mock, config): - """Simple case of releasing a revision ok.""" - channels = ["somechannel"] - args = Namespace(name="testcharm", revision=7, channel=channels, resource=[]) - ReleaseCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.release("testcharm", 7, channels, []), - ] - - expected = "Revision 7 of 'testcharm' released to somechannel" - emitter.assert_message(expected) - - -def test_release_simple_multiple_channels(emitter, store_mock, config): - """Releasing to multiple channels.""" - args = Namespace( - name="testcharm", - revision=7, - channel=["channel1", "channel2", "channel3"], - resource=[], - ) - ReleaseCommand(config).run(args) - - expected = "Revision 7 of 'testcharm' released to channel1, channel2, channel3" - emitter.assert_message(expected) - - -def test_release_including_resources(emitter, store_mock, config): - """Releasing with resources.""" - r1 = ResourceOption(name="foo", revision=3) - r2 = ResourceOption(name="bar", revision=17) - args = Namespace(name="testcharm", revision=7, channel=["testchannel"], resource=[r1, r2]) - ReleaseCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.release("testcharm", 7, ["testchannel"], [r1, r2]), - ] - - expected = ( - "Revision 7 of 'testcharm' released to testchannel " - "(attaching resources: 'foo' r3, 'bar' r17)" - ) - emitter.assert_message(expected) - - -def test_release_options_resource(config): - """The --resource-file option implies a set of validations.""" - cmd = ReleaseCommand(config) - parser = ArgumentParser() - cmd.fill_parser(parser) - (action,) = (action for action in parser._actions if action.dest == "resource") - assert isinstance(action.type, ResourceOption) - - -@pytest.mark.parametrize( - ("sysargs", "expected_parsed"), - [ - ( - ["somename", "--channel=stable", "--revision=33"], - ("somename", 33, ["stable"], []), - ), - ( - ["somename", "--channel=stable", "-r", "33"], - ("somename", 33, ["stable"], []), - ), - ( - ["somename", "-c", "stable", "--revision=33"], - ("somename", 33, ["stable"], []), - ), - ( - ["-c", "stable", "--revision=33", "somename"], - ("somename", 33, ["stable"], []), - ), - ( - ["-c", "beta", "--revision=1", "--channel=edge", "name"], - ("name", 1, ["beta", "edge"], []), - ), - ( - ["somename", "-c=beta", "-r=3", "--resource=foo:15"], - ("somename", 3, ["beta"], [ResourceOption("foo", 15)]), - ), - ( - ["somename", "-c=beta", "-r=3", "--resource=foo:15", "--resource=bar:99"], - ( - "somename", - 3, - ["beta"], - [ResourceOption("foo", 15), ResourceOption("bar", 99)], - ), - ), - ], -) -def test_release_parameters_ok(config, sysargs, expected_parsed): - """Control of different combination of valid parameters.""" - cmd = ReleaseCommand(config) - parser = ArgumentParser() - cmd.fill_parser(parser) - try: - args = parser.parse_args(sysargs) - except SystemExit: - pytest.fail(f"Parsing of {sysargs} was not ok.") - attribs = ["name", "revision", "channel", "resource"] - assert args == Namespace(**dict(zip(attribs, expected_parsed))) - - -@pytest.mark.parametrize( - "sysargs", - [ - ["somename", "--channel=stable", "--revision=foo"], # revision not an int - ["somename", "--channel=stable"], # missing the revision - ["somename", "--revision=1"], # missing a channel - [ - "somename", - "--channel=stable", - "--revision=1", - "--revision=2", - ], # too many revisions - ["--channel=stable", "--revision=1"], # missing the name - ["somename", "-c=beta", "-r=3", "--resource=foo15"], # bad resource format - ], -) -def test_release_parameters_bad(config, sysargs): - """Control of different option/parameters combinations that are not valid.""" - cmd = ReleaseCommand(config) - parser = ArgumentParser() - cmd.fill_parser(parser) - with pytest.raises(SystemExit): - parser.parse_args(sysargs) - - -# -- tests for the close command - - -def test_close_simple_ok(emitter, store_mock, config): - """Simple case of closing a channel.""" - args = Namespace(name="testcharm", channel="somechannel") - CloseCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.release("testcharm", None, ["somechannel"], []), - ] - - expected = "Closed 'somechannel' channel for 'testcharm'." - emitter.assert_message(expected) - - -# -- tests for the status command - - -def _build_channels(track="latest"): - """Helper to build simple channels structure.""" - channels = [] - risks = ["stable", "candidate", "beta", "edge"] - for risk, fback in zip(risks, [None, *risks]): - name = "/".join((track, risk)) - fallback = None if fback is None else "/".join((track, fback)) - channels.append(Channel(name=name, fallback=fallback, track=track, risk=risk, branch=None)) - return channels - - -def _build_revision(revno, version): - """Helper to build a revision.""" - return Revision( - revision=revno, - version=version, - created_at=datetime.datetime(2020, 7, 3, 20, 30, 40), - status="accepted", - errors=[], - bases=[Base(architecture="amd64", channel="20.04", name="ubuntu")], - ) - - -def _build_release(revision, channel, expires_at=None, resources=None, base=DEFAULT): - """Helper to build a release.""" - if resources is None: - resources = [] - if base is DEFAULT: - base = Base(architecture="amd64", channel="20.04", name="ubuntu") - return Release( - revision=revision, - channel=channel, - expires_at=expires_at, - resources=resources, - base=base, - ) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_status_simple_ok(emitter, store_mock, config, formatted): - """Simple happy case of getting a status.""" - channel_map = [ - _build_release(revision=7, channel="latest/stable"), - _build_release(revision=7, channel="latest/candidate"), - _build_release(revision=80, channel="latest/beta"), - _build_release(revision=156, channel="latest/edge"), - ] - channels = _build_channels() - revisions = [ - _build_revision(revno=7, version="v7"), - _build_revision(revno=80, version="2.0"), - _build_revision(revno=156, version="git-0db35ea1"), - ] - store_mock.list_releases.return_value = (channel_map, channels, revisions) - - args = Namespace(name="testcharm", format=formatted) - StatusCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_releases("testcharm"), - ] - - if formatted: - expected = [ - { - "track": "latest", - "mappings": [ - { - "base": { - "name": "ubuntu", - "channel": "20.04", - "architecture": "amd64", - }, - "releases": [ - { - "status": "open", - "channel": "latest/stable", - "version": "v7", - "revision": 7, - "resources": [], - "expires_at": None, - }, - { - "status": "open", - "channel": "latest/candidate", - "version": "v7", - "revision": 7, - "resources": [], - "expires_at": None, - }, - { - "status": "open", - "channel": "latest/beta", - "version": "2.0", - "revision": 80, - "resources": [], - "expires_at": None, - }, - { - "status": "open", - "channel": "latest/edge", - "version": "git-0db35ea1", - "revision": 156, - "resources": [], - "expires_at": None, - }, - ], - }, - ], - } - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Track Base Channel Version Revision", - "latest ubuntu 20.04 (amd64) stable v7 7", - " candidate v7 7", - " beta 2.0 80", - " edge git-0db35ea1 156", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_status_empty(emitter, store_mock, config, formatted): - """Empty response from the store.""" - store_mock.list_releases.return_value = [], [], [] - args = Namespace(name="testcharm", format=formatted) - StatusCommand(config).run(args) - - if formatted: - emitter.assert_json_output({}) - else: - expected = "Nothing has been released yet." - emitter.assert_message(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_status_channels_not_released_with_fallback(emitter, store_mock, config, formatted): - """Support gaps in channel releases, having fallbacks.""" - channel_map = [ - _build_release(revision=7, channel="latest/stable"), - _build_release(revision=80, channel="latest/edge"), - ] - channels = _build_channels() - revisions = [ - _build_revision(revno=7, version="v7"), - _build_revision(revno=80, version="2.0"), - ] - store_mock.list_releases.return_value = (channel_map, channels, revisions) - - args = Namespace(name="testcharm", format=formatted) - StatusCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_releases("testcharm"), - ] - - if formatted: - expected = [ - { - "track": "latest", - "mappings": [ - { - "base": { - "name": "ubuntu", - "channel": "20.04", - "architecture": "amd64", - }, - "releases": [ - { - "status": "open", - "channel": "latest/stable", - "version": "v7", - "revision": 7, - "resources": [], - "expires_at": None, - }, - { - "status": "tracking", - "channel": "latest/candidate", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "tracking", - "channel": "latest/beta", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "open", - "channel": "latest/edge", - "version": "2.0", - "revision": 80, - "resources": [], - "expires_at": None, - }, - ], - }, - ], - } - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Track Base Channel Version Revision", - "latest ubuntu 20.04 (amd64) stable v7 7", - " candidate ↑ ↑", - " beta ↑ ↑", - " edge 2.0 80", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_status_channels_not_released_without_fallback(emitter, store_mock, config, formatted): - """Support gaps in channel releases, nothing released in more stable ones.""" - channel_map = [ - _build_release(revision=5, channel="latest/beta"), - _build_release(revision=12, channel="latest/edge"), - ] - channels = _build_channels() - revisions = [ - _build_revision(revno=5, version="5.1"), - _build_revision(revno=12, version="almostready"), - ] - store_mock.list_releases.return_value = (channel_map, channels, revisions) - - args = Namespace(name="testcharm", format=formatted) - StatusCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_releases("testcharm"), - ] - - if formatted: - expected = [ - { - "track": "latest", - "mappings": [ - { - "base": { - "name": "ubuntu", - "channel": "20.04", - "architecture": "amd64", - }, - "releases": [ - { - "status": "closed", - "channel": "latest/stable", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "closed", - "channel": "latest/candidate", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "open", - "channel": "latest/beta", - "version": "5.1", - "revision": 5, - "resources": [], - "expires_at": None, - }, - { - "status": "open", - "channel": "latest/edge", - "version": "almostready", - "revision": 12, - "resources": [], - "expires_at": None, - }, - ], - }, - ], - } - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Track Base Channel Version Revision", - "latest ubuntu 20.04 (amd64) stable - -", - " candidate - -", - " beta 5.1 5", - " edge almostready 12", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_status_multiple_tracks(emitter, store_mock, config, formatted): - """Support multiple tracks.""" - channel_map = [ - _build_release(revision=503, channel="latest/stable"), - _build_release(revision=1, channel="2.0/edge"), - ] - channels_latest = _build_channels() - channels_track = _build_channels(track="2.0") - channels = channels_latest + channels_track - revisions = [ - _build_revision(revno=503, version="7.5.3"), - _build_revision(revno=1, version="1"), - ] - store_mock.list_releases.return_value = (channel_map, channels, revisions) - - args = Namespace(name="testcharm", format=formatted) - StatusCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_releases("testcharm"), - ] - - if formatted: - expected = [ - { - "track": "latest", - "mappings": [ - { - "base": { - "name": "ubuntu", - "channel": "20.04", - "architecture": "amd64", - }, - "releases": [ - { - "status": "open", - "channel": "latest/stable", - "version": "7.5.3", - "revision": 503, - "resources": [], - "expires_at": None, - }, - { - "status": "tracking", - "channel": "latest/candidate", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "tracking", - "channel": "latest/beta", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "tracking", - "channel": "latest/edge", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - ], - }, - ], - }, - { - "track": "2.0", - "mappings": [ - { - "base": { - "name": "ubuntu", - "channel": "20.04", - "architecture": "amd64", - }, - "releases": [ - { - "status": "closed", - "channel": "2.0/stable", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "closed", - "channel": "2.0/candidate", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "closed", - "channel": "2.0/beta", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "open", - "channel": "2.0/edge", - "version": "1", - "revision": 1, - "resources": [], - "expires_at": None, - }, - ], - }, - ], - }, - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Track Base Channel Version Revision", - "latest ubuntu 20.04 (amd64) stable 7.5.3 503", - " candidate ↑ ↑", - " beta ↑ ↑", - " edge ↑ ↑", - "2.0 ubuntu 20.04 (amd64) stable - -", - " candidate - -", - " beta - -", - " edge 1 1", - ] - emitter.assert_messages(expected) - - -def test_status_tracks_order(emitter, store_mock, config): - """Respect the track ordering from the store.""" - channel_map = [ - _build_release(revision=1, channel="latest/edge"), - _build_release(revision=2, channel="aaa/edge"), - _build_release(revision=3, channel="2.0/edge"), - _build_release(revision=4, channel="zzz/edge"), - ] - channels_latest = _build_channels() - channels_track_1 = _build_channels(track="zzz") - channels_track_2 = _build_channels(track="2.0") - channels_track_3 = _build_channels(track="aaa") - channels = channels_latest + channels_track_1 + channels_track_2 + channels_track_3 - revisions = [ - _build_revision(revno=1, version="v1"), - _build_revision(revno=2, version="v2"), - _build_revision(revno=3, version="v3"), - _build_revision(revno=4, version="v4"), - ] - store_mock.list_releases.return_value = (channel_map, channels, revisions) - - args = Namespace(name="testcharm", format=False) - StatusCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_releases("testcharm"), - ] - - expected = [ - "Track Base Channel Version Revision", - "latest ubuntu 20.04 (amd64) stable - -", - " candidate - -", - " beta - -", - " edge v1 1", - "zzz ubuntu 20.04 (amd64) stable - -", - " candidate - -", - " beta - -", - " edge v4 4", - "2.0 ubuntu 20.04 (amd64) stable - -", - " candidate - -", - " beta - -", - " edge v3 3", - "aaa ubuntu 20.04 (amd64) stable - -", - " candidate - -", - " beta - -", - " edge v2 2", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_status_with_one_branch(emitter, store_mock, config, formatted): - """Support having one branch.""" - tstamp_with_timezone = dateutil.parser.parse("2020-07-03T20:30:40Z") - channel_map = [ - _build_release(revision=5, channel="latest/beta"), - _build_release( - revision=12, - channel="latest/beta/mybranch", - expires_at=tstamp_with_timezone, - ), - ] - channels = _build_channels() - channels.append( - Channel( - name="latest/beta/mybranch", - fallback="latest/beta", - track="latest", - risk="beta", - branch="mybranch", - ) - ) - revisions = [ - _build_revision(revno=5, version="5.1"), - _build_revision(revno=12, version="ver.12"), - ] - store_mock.list_releases.return_value = (channel_map, channels, revisions) - - args = Namespace(name="testcharm", format=formatted) - StatusCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_releases("testcharm"), - ] - - if formatted: - expected = [ - { - "track": "latest", - "mappings": [ - { - "base": { - "name": "ubuntu", - "channel": "20.04", - "architecture": "amd64", - }, - "releases": [ - { - "status": "closed", - "channel": "latest/stable", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "closed", - "channel": "latest/candidate", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "open", - "channel": "latest/beta", - "version": "5.1", - "revision": 5, - "resources": [], - "expires_at": None, - }, - { - "status": "tracking", - "channel": "latest/edge", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "open", - "channel": "latest/beta/mybranch", - "version": "ver.12", - "revision": 12, - "resources": [], - "expires_at": "2020-07-03T20:30:40Z", - }, - ], - }, - ], - } - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Track Base Channel Version Revision Expires at", - "latest ubuntu 20.04 (amd64) stable - -", - " candidate - -", - " beta 5.1 5", - " edge ↑ ↑", - " beta/mybranch ver.12 12 2020-07-03T20:30:40Z", - ] - emitter.assert_messages(expected) - - -def test_status_with_multiple_branches(emitter, store_mock, config): - """Support having multiple branches.""" - tstamp = dateutil.parser.parse("2020-07-03T20:30:40Z") - channel_map = [ - _build_release(revision=5, channel="latest/beta"), - _build_release(revision=12, channel="latest/beta/branch-1", expires_at=tstamp), - _build_release(revision=15, channel="latest/beta/branch-2", expires_at=tstamp), - ] - channels = _build_channels() - channels.extend( - [ - Channel( - name="latest/beta/branch-1", - fallback="latest/beta", - track="latest", - risk="beta", - branch="branch-1", - ), - Channel( - name="latest/beta/branch-2", - fallback="latest/beta", - track="latest", - risk="beta", - branch="branch-2", - ), - ] - ) - revisions = [ - _build_revision(revno=5, version="5.1"), - _build_revision(revno=12, version="ver.12"), - _build_revision(revno=15, version="15.0.0"), - ] - store_mock.list_releases.return_value = (channel_map, channels, revisions) - - args = Namespace(name="testcharm", format=False) - StatusCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_releases("testcharm"), - ] - - expected = [ - "Track Base Channel Version Revision Expires at", - "latest ubuntu 20.04 (amd64) stable - -", - " candidate - -", - " beta 5.1 5", - " edge ↑ ↑", - " beta/branch-1 ver.12 12 2020-07-03T20:30:40Z", - " beta/branch-2 15.0.0 15 2020-07-03T20:30:40Z", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_status_with_resources(emitter, store_mock, config, formatted): - """Support having multiple branches.""" - res1 = Resource(name="resource1", optional=True, revision=1, resource_type="file") - res2 = Resource(name="resource2", optional=True, revision=54, resource_type="file") - channel_map = [ - _build_release(revision=5, channel="latest/candidate", resources=[res1, res2]), - _build_release(revision=5, channel="latest/beta", resources=[res1]), - ] - channels = _build_channels() - revisions = [ - _build_revision(revno=5, version="5.1"), - ] - store_mock.list_releases.return_value = (channel_map, channels, revisions) - - args = Namespace(name="testcharm", format=formatted) - StatusCommand(config).run(args) - - if formatted: - expected = [ - { - "track": "latest", - "mappings": [ - { - "base": { - "name": "ubuntu", - "channel": "20.04", - "architecture": "amd64", - }, - "releases": [ - { - "status": "closed", - "channel": "latest/stable", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "open", - "channel": "latest/candidate", - "version": "5.1", - "revision": 5, - "resources": [ - {"name": "resource1", "revision": 1}, - {"name": "resource2", "revision": 54}, - ], - "expires_at": None, - }, - { - "status": "open", - "channel": "latest/beta", - "version": "5.1", - "revision": 5, - "resources": [{"name": "resource1", "revision": 1}], - "expires_at": None, - }, - { - "status": "tracking", - "channel": "latest/edge", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - ], - }, - ], - } - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Track Base Channel Version Revision Resources", - "latest ubuntu 20.04 (amd64) stable - - -", - " candidate 5.1 5 resource1 (r1), resource2 (r54)", - " beta 5.1 5 resource1 (r1)", - " edge ↑ ↑ ↑", - ] - emitter.assert_messages(expected) - - -def test_status_with_resources_missing_after_closed_channel(emitter, store_mock, config): - """Specific glitch for a channel without resources after a closed one.""" - resource = Resource(name="resource", optional=True, revision=1, resource_type="file") - channel_map = [ - _build_release(revision=5, channel="latest/stable", resources=[resource]), - _build_release(revision=5, channel="latest/beta", resources=[]), - _build_release(revision=5, channel="latest/edge", resources=[resource]), - ] - channels = _build_channels() - revisions = [ - _build_revision(revno=5, version="5.1"), - ] - store_mock.list_releases.return_value = (channel_map, channels, revisions) - - args = Namespace(name="testcharm", format=False) - StatusCommand(config).run(args) - - expected = [ - "Track Base Channel Version Revision Resources", - "latest ubuntu 20.04 (amd64) stable 5.1 5 resource (r1)", - " candidate ↑ ↑ ↑", - " beta 5.1 5 -", - " edge 5.1 5 resource (r1)", - ] - emitter.assert_messages(expected) - - -def test_status_with_resources_and_branches(emitter, store_mock, config): - """Support having multiple branches.""" - tstamp = dateutil.parser.parse("2020-07-03T20:30:40Z") - res1 = Resource(name="testres", optional=True, revision=1, resource_type="file") - res2 = Resource(name="testres", optional=True, revision=14, resource_type="file") - channel_map = [ - _build_release(revision=23, channel="latest/beta", resources=[res2]), - _build_release( - revision=5, - channel="latest/edge/mybranch", - expires_at=tstamp, - resources=[res1], - ), - ] - channels = _build_channels() - channels.append( - Channel( - name="latest/edge/mybranch", - fallback="latest/edge", - track="latest", - risk="edge", - branch="mybranch", - ) - ) - revisions = [ - _build_revision(revno=5, version="5.1"), - _build_revision(revno=23, version="7.0.0"), - ] - store_mock.list_releases.return_value = (channel_map, channels, revisions) - - args = Namespace(name="testcharm", format=False) - StatusCommand(config).run(args) - - expected = [ - "Track Base Channel Version Revision Resources Expires at", - "latest ubuntu 20.04 (amd64) stable - - -", - " candidate - - -", - " beta 7.0.0 23 testres (r14)", - " edge ↑ ↑ ↑", - " edge/mybranch 5.1 5 testres (r1) 2020-07-03T20:30:40Z", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_status_multiplebases_single_track(emitter, store_mock, config, formatted): - """Multiple bases with one track.""" - other_base = Base(architecture="16b", channel="1", name="xz") - channel_map = [ - _build_release(revision=7, channel="latest/stable", base=other_base), - _build_release(revision=7, channel="latest/candidate"), - _build_release(revision=80, channel="latest/beta", base=other_base), - _build_release(revision=156, channel="latest/edge"), - ] - channels = _build_channels() - revisions = [ - _build_revision(revno=7, version="v7"), - _build_revision(revno=80, version="2.0"), - _build_revision(revno=156, version="git-0db35ea1"), - ] - store_mock.list_releases.return_value = (channel_map, channels, revisions) - - args = Namespace(name="testcharm", format=formatted) - StatusCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_releases("testcharm"), - ] - - if formatted: - expected = [ - { - "track": "latest", - "mappings": [ - { - "base": { - "name": "ubuntu", - "channel": "20.04", - "architecture": "amd64", - }, - "releases": [ - { - "status": "closed", - "channel": "latest/stable", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "open", - "channel": "latest/candidate", - "version": "v7", - "revision": 7, - "resources": [], - "expires_at": None, - }, - { - "status": "tracking", - "channel": "latest/beta", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "open", - "channel": "latest/edge", - "version": "git-0db35ea1", - "revision": 156, - "resources": [], - "expires_at": None, - }, - ], - }, - { - "base": { - "name": "xz", - "channel": "1", - "architecture": "16b", - }, - "releases": [ - { - "status": "open", - "channel": "latest/stable", - "version": "v7", - "revision": 7, - "resources": [], - "expires_at": None, - }, - { - "status": "tracking", - "channel": "latest/candidate", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "open", - "channel": "latest/beta", - "version": "2.0", - "revision": 80, - "resources": [], - "expires_at": None, - }, - { - "status": "tracking", - "channel": "latest/edge", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - ], - }, - ], - } - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Track Base Channel Version Revision", - "latest ubuntu 20.04 (amd64) stable - -", - " candidate v7 7", - " beta ↑ ↑", - " edge git-0db35ea1 156", - " xz 1 (16b) stable v7 7", - " candidate ↑ ↑", - " beta 2.0 80", - " edge ↑ ↑", - ] - emitter.assert_messages(expected) - - -def test_status_multiplebases_multiple_tracks(emitter, store_mock, config): - """Multiple bases with several tracks.""" - other_base = Base(architecture="16b", channel="1", name="xz") - channel_map = [ - _build_release(revision=7, channel="latest/stable", base=other_base), - _build_release(revision=7, channel="latest/candidate"), - _build_release(revision=80, channel="latest/beta", base=other_base), - _build_release(revision=156, channel="latest/edge"), - _build_release(revision=7, channel="2.0/stable", base=other_base), - _build_release(revision=7, channel="2.0/candidate"), - _build_release(revision=80, channel="2.0/beta", base=other_base), - _build_release(revision=156, channel="2.0/edge"), - _build_release(revision=156, channel="3.0/edge"), - ] - channels = _build_channels() + _build_channels(track="2.0") + _build_channels(track="3.0") - revisions = [ - _build_revision(revno=7, version="v7"), - _build_revision(revno=80, version="2.0"), - _build_revision(revno=156, version="git-0db35ea1"), - ] - store_mock.list_releases.return_value = (channel_map, channels, revisions) - - args = Namespace(name="testcharm", format=False) - StatusCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_releases("testcharm"), - ] - - expected = [ - "Track Base Channel Version Revision", - "latest ubuntu 20.04 (amd64) stable - -", - " candidate v7 7", - " beta ↑ ↑", - " edge git-0db35ea1 156", - " xz 1 (16b) stable v7 7", - " candidate ↑ ↑", - " beta 2.0 80", - " edge ↑ ↑", - "2.0 ubuntu 20.04 (amd64) stable - -", - " candidate v7 7", - " beta ↑ ↑", - " edge git-0db35ea1 156", - " xz 1 (16b) stable v7 7", - " candidate ↑ ↑", - " beta 2.0 80", - " edge ↑ ↑", - "3.0 ubuntu 20.04 (amd64) stable - -", - " candidate - -", - " beta - -", - " edge git-0db35ea1 156", - ] - emitter.assert_messages(expected) - - -def test_status_multiplebases_everything_combined(emitter, store_mock, config): - """Validate multiple bases with several other modifiers.""" - other_base = Base(architecture="16b", channel="1", name="xz") - tstamp = dateutil.parser.parse("2020-07-03T20:30:40Z") - resource = Resource(name="testres", optional=True, revision=1, resource_type="file") - channel_map = [ - _build_release(revision=7, channel="latest/candidate"), - _build_release(revision=156, channel="latest/edge"), - _build_release(revision=7, channel="latest/candidate/br1", expires_at=tstamp), - _build_release(revision=7, channel="latest/stable", base=other_base), - _build_release(revision=80, channel="latest/beta", base=other_base), - _build_release( - revision=99, - channel="latest/beta/br2", - base=other_base, - expires_at=tstamp, - resources=[resource], - ), - _build_release(revision=7, channel="2.0/candidate"), - _build_release(revision=80, channel="2.0/beta"), - _build_release(revision=7, channel="2.0/stable", base=other_base), - _build_release(revision=80, channel="2.0/edge", base=other_base), - _build_release(revision=80, channel="2.0/edge/foobar", base=other_base, expires_at=tstamp), - ] - channels = _build_channels() + _build_channels(track="2.0") - channels.extend( - [ - Channel( - name="latest/candidate/br1", - fallback="latest/candidate", - track="latest", - risk="candidate", - branch="br1", - ), - Channel( - name="latest/beta/br2", - fallback="latest/beta", - track="latest", - risk="beta", - branch="br2", - ), - Channel( - name="2.0/edge/foobar", - fallback="2.0/edge", - track="2.0", - risk="edge", - branch="foobar", - ), - ] - ) - revisions = [ - _build_revision(revno=7, version="v7"), - _build_revision(revno=80, version="2.0"), - _build_revision(revno=156, version="git-0db35ea1"), - _build_revision(revno=99, version="weird"), - ] - store_mock.list_releases.return_value = (channel_map, channels, revisions) - - args = Namespace(name="testcharm", format=False) - StatusCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_releases("testcharm"), - ] - - expected = [ - "Track Base Channel Version Revision Resources Expires at", - "latest ubuntu 20.04 (amd64) stable - - -", - " candidate v7 7 -", - " beta ↑ ↑ ↑", - " edge git-0db35ea1 156 -", - " candidate/br1 v7 7 - 2020-07-03T20:30:40Z", - " xz 1 (16b) stable v7 7 -", - " candidate ↑ ↑ ↑", - " beta 2.0 80 -", - " edge ↑ ↑ ↑", - " beta/br2 weird 99 testres (r1) 2020-07-03T20:30:40Z", - "2.0 ubuntu 20.04 (amd64) stable - - -", - " candidate v7 7 -", - " beta 2.0 80 -", - " edge ↑ ↑ ↑", - " xz 1 (16b) stable v7 7 -", - " candidate ↑ ↑ ↑", - " beta ↑ ↑ ↑", - " edge 2.0 80 -", - " edge/foobar 2.0 80 - 2020-07-03T20:30:40Z", - ] - emitter.assert_messages(expected) - - -def test_status_multiplebases_multiplebranches(emitter, store_mock, config): - """Validate specific mix between bases and branches. - - This exposes a bug in Charmhub: https://bugs.launchpad.net/snapstore-server/+bug/1994613 - """ - other_base = Base(architecture="i386", channel="20.04", name="ubuntu") - tstamp = dateutil.parser.parse("2020-07-03T20:30:40Z") - channel_map = [ - _build_release(revision=1, channel="latest/edge"), - _build_release(revision=1, channel="latest/edge/fix", expires_at=tstamp), - _build_release(revision=1, channel="latest/edge", base=other_base), - _build_release(revision=1, channel="latest/edge/fix", expires_at=tstamp, base=other_base), - ] - channels = _build_channels() - extra_channel = Channel( - name="latest/edge/fix", - fallback="latest/edge", - track="latest", - risk="edge", - branch="fix", - ) - channels.extend([extra_channel, extra_channel]) # twice!! this is the reported bug in Charmhub - revisions = [ - _build_revision(revno=1, version="v1"), - ] - store_mock.list_releases.return_value = (channel_map, channels, revisions) - - args = Namespace(name="testcharm", format=False) - StatusCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_releases("testcharm"), - ] - - expected = [ - "Track Base Channel Version Revision Expires at", - "latest ubuntu 20.04 (amd64) stable - -", - " candidate - -", - " beta - -", - " edge v1 1", - " edge/fix v1 1 2020-07-03T20:30:40Z", - " ubuntu 20.04 (i386) stable - -", - " candidate - -", - " beta - -", - " edge v1 1", - " edge/fix v1 1 2020-07-03T20:30:40Z", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_status_with_base_in_none(emitter, store_mock, config, formatted): - """Support the case of base being None.""" - channel_map = [ - _build_release(revision=7, channel="latest/stable", base=None), - _build_release(revision=7, channel="latest/candidate", base=None), - ] - channels = _build_channels() - revisions = [_build_revision(revno=7, version="v7")] - store_mock.list_releases.return_value = (channel_map, channels, revisions) - - args = Namespace(name="testcharm", format=formatted) - StatusCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_releases("testcharm"), - ] - - if formatted: - expected = [ - { - "track": "latest", - "mappings": [ - { - "base": None, - "releases": [ - { - "status": "open", - "channel": "latest/stable", - "version": "v7", - "revision": 7, - "resources": [], - "expires_at": None, - }, - { - "status": "open", - "channel": "latest/candidate", - "version": "v7", - "revision": 7, - "resources": [], - "expires_at": None, - }, - { - "status": "tracking", - "channel": "latest/beta", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - { - "status": "tracking", - "channel": "latest/edge", - "version": None, - "revision": None, - "resources": None, - "expires_at": None, - }, - ], - }, - ], - } - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Track Base Channel Version Revision", - "latest - stable v7 7", - " candidate v7 7", - " beta ↑ ↑", - " edge ↑ ↑", - ] - emitter.assert_messages(expected) - - -def test_status_unreleased_track(emitter, store_mock, config): - """The package has a track, but nothing is released to it.""" - channel_map = [ - _build_release(revision=5, channel="latest/stable"), - ] - channels_latest = _build_channels() - channels_track = _build_channels(track="2.0") - channels = channels_latest + channels_track - revisions = [ - _build_revision(revno=5, version="7.5.3"), - ] - store_mock.list_releases.return_value = (channel_map, channels, revisions) - - args = Namespace(name="testcharm", format=False) - StatusCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_releases("testcharm"), - ] - - expected = [ - "Track Base Channel Version Revision", - "latest ubuntu 20.04 (amd64) stable 7.5.3 5", - " candidate ↑ ↑", - " beta ↑ ↑", - " edge ↑ ↑", - "2.0 - stable - -", - " candidate - -", - " beta - -", - " edge - -", - ] - emitter.assert_messages(expected) - - -# -- tests for create library command - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -@pytest.mark.parametrize("charmcraft_yaml_name", [None, "test-charm"]) -def test_createlib_simple( - emitter, store_mock, tmp_path, monkeypatch, config, formatted, charmcraft_yaml_name -): - """Happy path with result from the Store.""" - if not charmcraft_yaml_name: - pytest.xfail("Store commands need refactoring to not need a project.") - monkeypatch.chdir(tmp_path) - - config.name = charmcraft_yaml_name - - lib_id = "test-example-lib-id" - store_mock.create_library_id.return_value = lib_id - - args = Namespace(name="testlib", format=formatted) - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = "test-charm" - CreateLibCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.create_library_id("test-charm", "testlib"), - ] - if formatted: - expected = {"library_id": lib_id} - emitter.assert_json_output(expected) - else: - expected = [ - "Library charms.test_charm.v0.testlib created with id test-example-lib-id.", - "Consider 'git add lib/charms/test_charm/v0/testlib.py'.", - ] - emitter.assert_messages(expected) - created_lib_file = tmp_path / "lib" / "charms" / "test_charm" / "v0" / "testlib.py" - - env = get_templates_environment("charmlibs") - expected_newlib_content = env.get_template("new_library.py.j2").render(lib_id=lib_id) - assert created_lib_file.read_text() == expected_newlib_content - - -@pytest.mark.xfail( - strict=True, raises=pydantic.ValidationError, reason="Store commands need refactor." -) -def test_createlib_name_from_metadata_problem(store_mock, config): - """The metadata wasn't there to get the name.""" - args = Namespace(name="testlib", format=None) - config.name = None - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = None - with pytest.raises(CraftError) as cm: - CreateLibCommand(config).run(args) - assert str(cm.value) == ( - "Cannot find a valid charm name in charm definition. " - "Check that you are using the correct project directory." - ) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_createlib_name_contains_dash(emitter, store_mock, tmp_path, monkeypatch, config): - """'-' is valid in charm names but can't be imported""" - monkeypatch.chdir(tmp_path) - - lib_id = "test-example-lib-id" - store_mock.create_library_id.return_value = lib_id - - args = Namespace(name="testlib", format=None) - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = "test-charm" - CreateLibCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.create_library_id("test-charm", "testlib"), - ] - expected = [ - "Library charms.test_charm.v0.testlib created with id test-example-lib-id.", - "Consider 'git add lib/charms/test_charm/v0/testlib.py'.", - ] - emitter.assert_messages(expected) - created_lib_file = tmp_path / "lib" / "charms" / "test_charm" / "v0" / "testlib.py" - - env = get_templates_environment("charmlibs") - expected_newlib_content = env.get_template("new_library.py.j2").render(lib_id=lib_id) - assert created_lib_file.read_text() == expected_newlib_content - - -@pytest.mark.parametrize( - "lib_name", - [ - "foo.bar", - "foo/bar", - "Foo", - "123foo", - "_foo", - "", - ], -) -def test_createlib_invalid_name(lib_name, config): - """Verify that it cannot be used with an invalid name.""" - args = Namespace(name=lib_name, format=None) - with pytest.raises(CraftError) as err: - CreateLibCommand(config).run(args) - assert str(err.value) == ( - "Invalid library name. Must only use lowercase alphanumeric " - "characters and underscore, starting with alpha." - ) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_createlib_path_already_there(tmp_path, monkeypatch, config): - """The intended-to-be-created library is already there.""" - monkeypatch.chdir(tmp_path) - - factory.create_lib_filepath("test-charm", "testlib", api=0) - args = Namespace(name="testlib", format=None) - with pytest.raises(CraftError) as err: - CreateLibCommand(config).run(args) - - assert str(err.value) == ( - "This library already exists: 'lib/charms/test_charm/v0/testlib.py'." - ) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_createlib_path_can_not_write(tmp_path, monkeypatch, store_mock, add_cleanup, config): - """Disk error when trying to write the new lib (bad permissions, name too long, whatever).""" - lib_dir = tmp_path / "lib" / "charms" / "test_charm" / "v0" - lib_dir.mkdir(parents=True) - lib_dir.chmod(0o111) - add_cleanup(lib_dir.chmod, 0o777) - monkeypatch.chdir(tmp_path) - - args = Namespace(name="testlib", format=None) - store_mock.create_library_id.return_value = "lib_id" - expected_error = "Error writing the library in .*: PermissionError.*" - with pytest.raises(CraftError, match=expected_error): - CreateLibCommand(config).run(args) - - -def test_createlib_library_template_is_python(emitter, store_mock, tmp_path, monkeypatch): - """Verify that the template used to create a library is valid Python code.""" - env = get_templates_environment("charmlibs") - newlib_content = env.get_template("new_library.py.j2").render(lib_id="test-lib-id") - compile(newlib_content, "test.py", "exec") - - -# -- tests for publish libraries command - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_publishlib_simple(emitter, store_mock, tmp_path, monkeypatch, config, formatted): - """Happy path publishing because no revision at all in the Store.""" - monkeypatch.chdir(tmp_path) - - lib_id = "test-example-lib-id" - content, content_hash = factory.create_lib_filepath( - "test-charm", "testlib", api=0, patch=1, lib_id=lib_id - ) - - store_mock.get_libraries_tips.return_value = {} - args = Namespace(library="charms.test_charm.v0.testlib", format=formatted) - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = "testcharm" - PublishLibCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.get_libraries_tips([{"lib_id": lib_id, "api": 0}]), - call.create_library_revision("test-charm", lib_id, 0, 1, content, content_hash), - ] - if formatted: - expected = [ - { - "charm_name": "test-charm", - "library_name": "testlib", - "library_id": lib_id, - "api": 0, - "published": { - "patch": 1, - "content_hash": content_hash, - }, - }, - ] - emitter.assert_json_output(expected) - else: - expected = "Library charms.test_charm.v0.testlib sent to the store with version 0.1" - emitter.assert_message(expected) - - -def test_publishlib_contains_dash(emitter, store_mock, tmp_path, monkeypatch, config): - """Happy path publishing because no revision at all in the Store.""" - monkeypatch.chdir(tmp_path) - - lib_id = "test-example-lib-id" - content, content_hash = factory.create_lib_filepath( - "test-charm", "testlib", api=0, patch=1, lib_id=lib_id - ) - - store_mock.get_libraries_tips.return_value = {} - args = Namespace(library="charms.test_charm.v0.testlib", format=None) - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = "test-charm" - PublishLibCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.get_libraries_tips([{"lib_id": lib_id, "api": 0}]), - call.create_library_revision("test-charm", lib_id, 0, 1, content, content_hash), - ] - expected = "Library charms.test_charm.v0.testlib sent to the store with version 0.1" - emitter.assert_message(expected) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_publishlib_all(emitter, store_mock, tmp_path, monkeypatch, config, formatted): - """Publish all the libraries found in disk.""" - monkeypatch.chdir(tmp_path) - config.name = "testcharm-1" - - c1, h1 = factory.create_lib_filepath( - "testcharm-1", "testlib-a", api=0, patch=1, lib_id="lib_id_1" - ) - c2, h2 = factory.create_lib_filepath( - "testcharm-1", "testlib-b", api=0, patch=1, lib_id="lib_id_2" - ) - c3, h3 = factory.create_lib_filepath( - "testcharm-1", "testlib-b", api=1, patch=3, lib_id="lib_id_2" - ) - factory.create_lib_filepath("testcharm-2", "testlib", api=0, patch=1, lib_id="lib_id_4") - - store_mock.get_libraries_tips.return_value = {} - args = Namespace(library=None, format=formatted) - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = "testcharm-1" - PublishLibCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.get_libraries_tips( - [ - {"lib_id": "lib_id_1", "api": 0}, - {"lib_id": "lib_id_2", "api": 0}, - {"lib_id": "lib_id_2", "api": 1}, - ] - ), - call.create_library_revision("testcharm-1", "lib_id_1", 0, 1, c1, h1), - call.create_library_revision("testcharm-1", "lib_id_2", 0, 1, c2, h2), - call.create_library_revision("testcharm-1", "lib_id_2", 1, 3, c3, h3), - ] - names = [ - "charms.testcharm_1.v0.testlib-a", - "charms.testcharm_1.v0.testlib-b", - "charms.testcharm_1.v1.testlib-b", - ] - emitter.assert_debug("Libraries found under 'lib/charms/testcharm_1': " + str(names)) - if formatted: - expected = [ - { - "charm_name": "testcharm-1", - "library_name": "testlib-a", - "library_id": "lib_id_1", - "api": 0, - "published": { - "patch": 1, - "content_hash": h1, - }, - }, - { - "charm_name": "testcharm-1", - "library_name": "testlib-b", - "library_id": "lib_id_2", - "api": 0, - "published": { - "patch": 1, - "content_hash": h2, - }, - }, - { - "charm_name": "testcharm-1", - "library_name": "testlib-b", - "library_id": "lib_id_2", - "api": 1, - "published": { - "patch": 3, - "content_hash": h3, - }, - }, - ] - emitter.assert_json_output(expected) - else: - emitter.assert_messages( - [ - "Library charms.testcharm_1.v0.testlib-a sent to the store with version 0.1", - "Library charms.testcharm_1.v0.testlib-b sent to the store with version 0.1", - "Library charms.testcharm_1.v1.testlib-b sent to the store with version 1.3", - ] - ) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_publishlib_not_found(emitter, store_mock, tmp_path, monkeypatch, config): - """The indicated library is not found.""" - monkeypatch.chdir(tmp_path) - - args = Namespace(library="charms.testcharm.v0.testlib", format=None) - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = "testcharm" - with pytest.raises(CraftError) as cm: - PublishLibCommand(config).run(args) - - assert str(cm.value) == ( - "The specified library was not found at path 'lib/charms/testcharm/v0/testlib.py'." - ) - - -def test_publishlib_not_from_current_charm(emitter, store_mock, tmp_path, monkeypatch, config): - """The indicated library to publish does not belong to this charm.""" - monkeypatch.chdir(tmp_path) - config.name = "charm2" - factory.create_lib_filepath("testcharm", "testlib", api=0) - - args = Namespace(library="charms.testcharm.v0.testlib", format=None) - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = "charm2" - with pytest.raises(CraftError) as cm: - PublishLibCommand(config).run(args) - - assert str(cm.value) == ( - "The library charms.testcharm.v0.testlib does not belong to this charm 'charm2'." - ) - - -@pytest.mark.xfail( - strict=True, - raises=pydantic.ValidationError, - reason="Store commands need refactoring to not need a project.", -) -def test_publishlib_name_from_metadata_problem(store_mock, config): - """The metadata wasn't there to get the name.""" - config.name = None - args = Namespace(library="charms.testcharm.v0.testlib", format=None) - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = None - with pytest.raises(CraftError) as cm: - PublishLibCommand(config).run(args) - - assert str(cm.value) == ( - "Cannot find a valid charm name in charm definition. " - "Check that you are using the correct project directory." - ) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_publishlib_store_is_advanced( - emitter, store_mock, tmp_path, monkeypatch, config, formatted -): - """The store has a higher revision number than what we expect.""" - monkeypatch.chdir(tmp_path) - - lib_id = "test-example-lib-id" - factory.create_lib_filepath("test-charm", "testlib", api=0, patch=1, lib_id=lib_id) - - store_mock.get_libraries_tips.return_value = { - (lib_id, 0): Library( - lib_id=lib_id, - content=None, - content_hash="abc", - api=0, - patch=2, - lib_name="testlib", - charm_name="test-charm", - ), - } - args = Namespace(library="charms.test_charm.v0.testlib", format=formatted) - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = "test-charm" - PublishLibCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.get_libraries_tips([{"lib_id": lib_id, "api": 0}]), - ] - error_message = ( - "Library charms.test_charm.v0.testlib is out-of-date locally, Charmhub has version 0.2, " - "please fetch the updates before publishing." - ) - if formatted: - expected = [ - { - "charm_name": "test-charm", - "library_name": "testlib", - "library_id": lib_id, - "api": 0, - "error_message": error_message, - }, - ] - emitter.assert_json_output(expected) - else: - emitter.assert_messages([error_message]) - - -def test_publishlib_store_is_exactly_behind_ok(emitter, store_mock, tmp_path, monkeypatch, config): - """The store is exactly one revision less than local lib, ok.""" - monkeypatch.chdir(tmp_path) - - lib_id = "test-example-lib-id" - content, content_hash = factory.create_lib_filepath( - "test-charm", "testlib", api=0, patch=7, lib_id=lib_id - ) - - store_mock.get_libraries_tips.return_value = { - (lib_id, 0): Library( - lib_id=lib_id, - content=None, - content_hash="abc", - api=0, - patch=6, - lib_name="testlib", - charm_name="test-charm", - ), - } - args = Namespace(library="charms.test_charm.v0.testlib", format=None) - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = "test-charm" - PublishLibCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.get_libraries_tips([{"lib_id": lib_id, "api": 0}]), - call.create_library_revision("test-charm", lib_id, 0, 7, content, content_hash), - ] - expected = "Library charms.test_charm.v0.testlib sent to the store with version 0.7" - emitter.assert_message(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_publishlib_store_is_exactly_behind_same_hash( - emitter, store_mock, tmp_path, monkeypatch, config, formatted -): - """The store is exactly one revision less than local lib, same hash.""" - monkeypatch.chdir(tmp_path) - - lib_id = "test-example-lib-id" - content, content_hash = factory.create_lib_filepath( - "test-charm", "testlib", api=0, patch=7, lib_id=lib_id - ) - - store_mock.get_libraries_tips.return_value = { - (lib_id, 0): Library( - lib_id=lib_id, - content=None, - content_hash=content_hash, - api=0, - patch=6, - lib_name="testlib", - charm_name="test-charm", - ), - } - args = Namespace(library="charms.test_charm.v0.testlib", format=formatted) - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = "test-charm" - PublishLibCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.get_libraries_tips([{"lib_id": lib_id, "api": 0}]), - ] - error_message = ( - "Library charms.test_charm.v0.testlib LIBPATCH number was incorrectly incremented, " - "Charmhub has the same content in version 0.6." - ) - if formatted: - expected = [ - { - "charm_name": "test-charm", - "library_name": "testlib", - "library_id": lib_id, - "api": 0, - "error_message": error_message, - }, - ] - emitter.assert_json_output(expected) - else: - emitter.assert_messages([error_message]) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_publishlib_store_is_too_behind( - emitter, store_mock, tmp_path, monkeypatch, config, formatted -): - """The store is way more behind than what we expected (local lib too high!).""" - monkeypatch.chdir(tmp_path) - - lib_id = "test-example-lib-id" - factory.create_lib_filepath("test-charm", "testlib", api=0, patch=4, lib_id=lib_id) - - store_mock.get_libraries_tips.return_value = { - (lib_id, 0): Library( - lib_id=lib_id, - content=None, - content_hash="abc", - api=0, - patch=2, - lib_name="testlib", - charm_name="test-charm", - ), - } - args = Namespace(library="charms.test_charm.v0.testlib", format=formatted) - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = "test-charm" - PublishLibCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.get_libraries_tips([{"lib_id": lib_id, "api": 0}]), - ] - error_message = ( - "Library charms.test_charm.v0.testlib has a wrong LIBPATCH number, it's too high and needs " - "to be consecutive, Charmhub highest version is 0.2." - ) - if formatted: - expected = [ - { - "charm_name": "test-charm", - "library_name": "testlib", - "library_id": lib_id, - "api": 0, - "error_message": error_message, - }, - ] - emitter.assert_json_output(expected) - else: - emitter.assert_messages([error_message]) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_publishlib_store_has_same_revision_same_hash( - emitter, - store_mock, - tmp_path, - monkeypatch, - config, - formatted, -): - """The store has the same revision we want to publish, with the same hash.""" - monkeypatch.chdir(tmp_path) - - lib_id = "test-example-lib-id" - content, content_hash = factory.create_lib_filepath( - "test-charm", "testlib", api=0, patch=7, lib_id=lib_id - ) - - store_mock.get_libraries_tips.return_value = { - (lib_id, 0): Library( - lib_id=lib_id, - content=None, - content_hash=content_hash, - api=0, - patch=7, - lib_name="testlib", - charm_name="test-charm", - ), - } - args = Namespace(library="charms.test_charm.v0.testlib", format=formatted) - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = "test-charm" - PublishLibCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.get_libraries_tips([{"lib_id": lib_id, "api": 0}]), - ] - error_message = "Library charms.test_charm.v0.testlib is already updated in Charmhub." - if formatted: - expected = [ - { - "charm_name": "test-charm", - "library_name": "testlib", - "library_id": lib_id, - "api": 0, - "error_message": error_message, - }, - ] - emitter.assert_json_output(expected) - else: - emitter.assert_messages([error_message]) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_publishlib_store_has_same_revision_other_hash( - emitter, store_mock, tmp_path, monkeypatch, config, formatted -): - """The store has the same revision we want to publish, but with a different hash.""" - monkeypatch.chdir(tmp_path) - - lib_id = "test-example-lib-id" - factory.create_lib_filepath("test-charm", "testlib", api=0, patch=7, lib_id=lib_id) - - store_mock.get_libraries_tips.return_value = { - (lib_id, 0): Library( - lib_id=lib_id, - content=None, - content_hash="abc", - api=0, - patch=7, - lib_name="testlib", - charm_name="test-charm", - ), - } - args = Namespace(library="charms.test_charm.v0.testlib", format=formatted) - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = "test-charm" - PublishLibCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.get_libraries_tips([{"lib_id": lib_id, "api": 0}]), - ] - error_message = ( - "Library charms.test_charm.v0.testlib version 0.7 is the same than in Charmhub but " - "content is different" - ) - if formatted: - expected = [ - { - "charm_name": "test-charm", - "library_name": "testlib", - "library_id": lib_id, - "api": 0, - "error_message": error_message, - }, - ] - emitter.assert_json_output(expected) - else: - emitter.assert_messages([error_message]) - - -# -- tests for list libraries command - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_listlib_simple(emitter, store_mock, config, formatted): - """Happy path listing simple case.""" - store_mock.get_libraries_tips.return_value = { - ("some-lib-id", 3): Library( - lib_id="some-lib-id", - content=None, - content_hash="abc", - api=3, - patch=7, - lib_name="testlib", - charm_name="testcharm", - ), - } - args = Namespace(name="testcharm", format=formatted) - ListLibCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.get_libraries_tips([{"charm_name": "testcharm"}]), - ] - if formatted: - expected = [ - { - "charm_name": "testcharm", - "library_name": "testlib", - "library_id": "some-lib-id", - "api": 3, - "patch": 7, - "content_hash": "abc", - }, - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Library name API Patch", - "testlib 3 7", - ] - emitter.assert_messages(expected) - - -def test_listlib_charm_from_metadata(emitter, store_mock, config): - """Happy path listing simple case.""" - store_mock.get_libraries_tips.return_value = {} - args = Namespace(name=None, format=None) - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = "testcharm" - ListLibCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.get_libraries_tips([{"charm_name": "testcharm"}]), - ] - - -def test_listlib_name_from_metadata_problem(store_mock, config): - """The metadata wasn't there to get the name.""" - args = Namespace(name=None, format=None) - with patch("charmcraft.utils.get_name_from_metadata") as mock: - mock.return_value = None - with pytest.raises(CraftError) as cm: - ListLibCommand(config).run(args) - - assert str(cm.value) == ( - "Can't access name in 'metadata.yaml' file. The 'list-lib' command must either be " - "executed from a valid project directory, or specify a charm name using " - "the --charm-name option." - ) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_listlib_empty(emitter, store_mock, config, formatted): - """Nothing found in the store for the specified charm.""" - store_mock.get_libraries_tips.return_value = {} - args = Namespace(name="testcharm", format=formatted) - ListLibCommand(config).run(args) - - if formatted: - emitter.assert_json_output([]) - else: - expected = "No libraries found for charm testcharm." - emitter.assert_message(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_listlib_properly_sorted(emitter, store_mock, config, formatted): - """Check the sorting of the list.""" - store_mock.get_libraries_tips.return_value = { - ("lib-id-2", 3): Library( - lib_id="lib-id-2", - content=None, - content_hash="abc", - api=3, - patch=7, - lib_name="testlib-2", - charm_name="testcharm", - ), - ("lib-id-2", 2): Library( - lib_id="lib-id-2", - content=None, - content_hash="abc", - api=2, - patch=8, - lib_name="testlib-2", - charm_name="testcharm", - ), - ("lib-id-1", 5): Library( - lib_id="lib-id-1", - content=None, - content_hash="abc", - api=5, - patch=124, - lib_name="testlib-1", - charm_name="testcharm", - ), - } - args = Namespace(name="testcharm", format=formatted) - ListLibCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.get_libraries_tips([{"charm_name": "testcharm"}]), - ] - if formatted: - expected = [ - { - "charm_name": "testcharm", - "library_name": "testlib-1", - "library_id": "lib-id-1", - "api": 5, - "patch": 124, - "content_hash": "abc", - }, - { - "charm_name": "testcharm", - "library_name": "testlib-2", - "library_id": "lib-id-2", - "api": 2, - "patch": 8, - "content_hash": "abc", - }, - { - "charm_name": "testcharm", - "library_name": "testlib-2", - "library_id": "lib-id-2", - "api": 3, - "patch": 7, - "content_hash": "abc", - }, - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Library name API Patch", - "testlib-1 5 124", - "testlib-2 2 8", - "testlib-2 3 7", - ] - emitter.assert_messages(expected) - - -# -- tests for list resources command - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_resources_simple(emitter, store_mock, config, formatted): - """Happy path of one result from the Store.""" - store_response = [ - Resource(name="testresource", optional=True, revision=1, resource_type="file"), - ] - store_mock.list_resources.return_value = store_response - - args = Namespace(charm_name="testcharm", format=formatted) - ListResourcesCommand(config).run(args) - - assert store_mock.mock_calls == [ - call.list_resources("testcharm"), - ] - if formatted: - expected = [ - { - "charm_revision": 1, - "name": "testresource", - "type": "file", - "optional": True, - } - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Charm Rev Resource Type Optional", - "1 testresource file True", - ] - emitter.assert_messages(expected) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_resources_empty(emitter, store_mock, config, formatted): - """No results from the store.""" - store_response = [] - store_mock.list_resources.return_value = store_response - - args = Namespace(charm_name="testcharm", format=formatted) - ListResourcesCommand(config).run(args) - - if formatted: - emitter.assert_json_output([]) - else: - emitter.assert_message("No resources associated to testcharm.") - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_resources_ordered_and_grouped(emitter, store_mock, config, formatted): - """Results are presented ordered by name in the table.""" - store_response = [ - Resource(name="bbb-resource", optional=True, revision=2, resource_type="file"), - Resource(name="ccc-resource", optional=True, revision=1, resource_type="file"), - Resource(name="bbb-resource", optional=False, revision=3, resource_type="file"), - Resource(name="aaa-resource", optional=True, revision=2, resource_type="oci-image"), - Resource(name="bbb-resource", optional=True, revision=5, resource_type="file"), - ] - store_mock.list_resources.return_value = store_response - - args = Namespace(charm_name="testcharm", format=formatted) - ListResourcesCommand(config).run(args) - - if formatted: - expected = [ - { - "charm_revision": 2, - "name": "bbb-resource", - "type": "file", - "optional": True, - }, - { - "charm_revision": 1, - "name": "ccc-resource", - "type": "file", - "optional": True, - }, - { - "charm_revision": 3, - "name": "bbb-resource", - "type": "file", - "optional": False, - }, - { - "charm_revision": 2, - "name": "aaa-resource", - "type": "oci-image", - "optional": True, - }, - { - "charm_revision": 5, - "name": "bbb-resource", - "type": "file", - "optional": True, - }, - ] - emitter.assert_json_output(expected) - else: - expected = [ - "Charm Rev Resource Type Optional", - "5 bbb-resource file True", - "3 bbb-resource file False", - "2 aaa-resource oci-image True", - " bbb-resource file True", - "1 ccc-resource file True", - ] - emitter.assert_messages(expected) - - -# -- tests for upload resources command - - -def test_uploadresource_options_filepath_type(config): - """The --filepath option implies a set of validations.""" - cmd = UploadResourceCommand(config) - parser = ArgumentParser() - cmd.fill_parser(parser) - (action,) = (action for action in parser._actions if action.dest == "filepath") - assert isinstance(action.type, SingleOptionEnsurer) - assert action.type.converter is useful_filepath - - -def test_uploadresource_options_image_type(config): - """The --image option implies a set of validations.""" - cmd = UploadResourceCommand(config) - parser = ArgumentParser() - cmd.fill_parser(parser) - (action,) = (action for action in parser._actions if action.dest == "image") - assert isinstance(action.type, SingleOptionEnsurer) - assert action.type.converter is str - - -@pytest.mark.parametrize( - "sysargs", - [ - ("c", "r", "--filepath=fpath"), - ("c", "r", "--image=x"), - ], -) -def test_uploadresource_options_good_combinations(tmp_path, config, sysargs, monkeypatch): - """Check the specific rules for filepath and image/[registry] good combinations.""" - # fake the file for filepath - (tmp_path / "fpath").touch() - monkeypatch.chdir(tmp_path) - - cmd = UploadResourceCommand(config) - parser = ArgumentParser() - cmd.fill_parser(parser) - try: - parser.parse_args(sysargs) - except SystemExit: - pytest.fail("Argument parsing expected to succeed but failed") - - -@pytest.mark.parametrize( - "sysargs", - [ - ("c", "r"), # filepath XOR image needs to be specified - ("c", "r", "--filepath=fpath", "--image=y"), # can't specify both - ], -) -def test_uploadresource_options_bad_combinations(config, sysargs, tmp_path, monkeypatch): - """Check the specific rules for filepath and image/[registry] bad combinations.""" - # fake the file for filepath - (tmp_path / "fpath").touch() - monkeypatch.chdir(tmp_path) - - cmd = UploadResourceCommand(config) - parser = ArgumentParser() - cmd.fill_parser(parser) - with pytest.raises(SystemExit): - parsed_args = parser.parse_args(sysargs) - cmd.parsed_args_post_verification(parser, parsed_args) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_uploadresource_filepath_call_ok(emitter, store_mock, config, tmp_path, formatted): - """Simple upload, success result.""" - store_response = Uploaded(ok=True, status=200, revision=7, errors=[]) - store_mock.upload_resource.return_value = store_response - - test_resource = tmp_path / "mystuff.bin" - test_resource.write_text("sample stuff") - args = Namespace( - charm_name="mycharm", - resource_name="myresource", - filepath=test_resource, - image=None, - format=formatted, - ) - retcode = UploadResourceCommand(config).run(args) - assert retcode == 0 - - assert store_mock.mock_calls == [ - call.upload_resource("mycharm", "myresource", "file", test_resource) - ] - if formatted: - expected = {"revision": 7} - emitter.assert_json_output(expected) - else: - emitter.assert_interactions( - [ - call("progress", f"Uploading resource directly from file {str(test_resource)!r}."), - call( - "message", "Revision 7 created of resource 'myresource' for charm 'mycharm'." - ), - ] - ) - assert test_resource.exists() # provided by the user, don't touch it - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_uploadresource_image_digest_already_uploaded(emitter, store_mock, config, formatted): - """Upload an oci-image resource, the image itself already being in the registry.""" - # fake credentials for the charm/resource, and the final json content - store_mock.get_oci_registry_credentials.return_value = RegistryCredentials( - username="testusername", - password="testpassword", - image_name="registry.staging.jujucharms.com/charm/charm-id/test-image-name", - ) - - test_json_content = "from charmhub we came, to charmhub we shall return" - store_mock.get_oci_image_blob.return_value = test_json_content - - # hack into the store mock to save for later the uploaded resource bytes - uploaded_resource_content = None - uploaded_resource_filepath = None - - def interceptor(charm_name, resource_name, resource_type, resource_filepath): - """Intercept the call to save real content (and validate params).""" - nonlocal uploaded_resource_content, uploaded_resource_filepath - - uploaded_resource_filepath = resource_filepath - uploaded_resource_content = resource_filepath.read_text() - - assert charm_name == "mycharm" - assert resource_name == "myresource" - assert resource_type == "oci-image" - return Uploaded(ok=True, status=200, revision=7, errors=[]) - - store_mock.upload_resource.side_effect = interceptor - - # test - original_image_digest = "sha256:test-digest-given-by-user" - args = Namespace( - charm_name="mycharm", - resource_name="myresource", - filepath=None, - image=original_image_digest, - format=formatted, - ) - with patch("charmcraft.commands.store.ImageHandler", autospec=True) as im_class_mock: - with patch("charmcraft.commands.store.OCIRegistry", autospec=True) as reg_class_mock: - reg_class_mock.return_value = reg_mock = MagicMock() - im_class_mock.return_value = im_mock = MagicMock() - im_mock.check_in_registry.return_value = True - UploadResourceCommand(config).run(args) - - # validate how OCIRegistry was instantiated - assert reg_class_mock.mock_calls == [ - call( - config.charmhub.registry_url, - "charm/charm-id/test-image-name", - username="testusername", - password="testpassword", - ) - ] - - # validate how ImageHandler was used - assert im_class_mock.mock_calls == [ - call(reg_mock), - call().check_in_registry(original_image_digest), - ] - - # check that the uploaded file is fine and that was cleaned - assert uploaded_resource_content == test_json_content - assert not uploaded_resource_filepath.exists() # temporary! shall be cleaned - - assert store_mock.mock_calls == [ - call.get_oci_registry_credentials("mycharm", "myresource"), - call.get_oci_image_blob("mycharm", "myresource", original_image_digest), - call.upload_resource("mycharm", "myresource", "oci-image", uploaded_resource_filepath), - ] - - if formatted: - expected = {"revision": 7} - emitter.assert_json_output(expected) - else: - emitter.assert_interactions( - [ - call( - "progress", - "Uploading resource from image " - "charm/charm-id/test-image-name @ sha256:test-digest-given-by-user.", - ), - call("progress", "Using OCI image from Canonical's registry.", permanent=True), - call( - "message", "Revision 7 created of resource 'myresource' for charm 'mycharm'." - ), - ] - ) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_uploadresource_image_digest_upload_from_local(emitter, store_mock, config): - """Upload an oci-image resource, from local to Canonical's registry, specified by digest.""" - # fake credentials for the charm/resource, the final json content, and the upload result - store_mock.get_oci_registry_credentials.return_value = RegistryCredentials( - username="testusername", - password="testpassword", - image_name="registry.staging.jujucharms.com/charm/charm-id/test-image-name", - ) - - test_json_content = "from charmhub we came, to charmhub we shall return" - store_mock.get_oci_image_blob.return_value = test_json_content - - store_mock.upload_resource.return_value = Uploaded(ok=True, status=200, revision=7, errors=[]) - - # test - original_image_digest = "sha256:test-digest-given-by-user" - local_image_info = "local image info" - args = Namespace( - charm_name="mycharm", - resource_name="myresource", - filepath=None, - image=original_image_digest, - format=False, - ) - with patch("charmcraft.commands.store.ImageHandler", autospec=True) as im_class_mock: - with patch( - "charmcraft.commands.store.LocalDockerdInterface", autospec=True - ) as dockerd_class_mock: - im_class_mock.return_value = im_mock = MagicMock() - dockerd_class_mock.return_value = dock_mock = MagicMock() - - # not in the remote registry, found locally, then uploaded ok - im_mock.check_in_registry.return_value = False - dock_mock.get_image_info_from_digest.return_value = local_image_info - new_image_digest = "new-digest-after-upload" - im_mock.upload_from_local.return_value = new_image_digest - - UploadResourceCommand(config).run(args) - - # validate how ImageHandler was used - assert im_mock.mock_calls == [ - call.check_in_registry(original_image_digest), - call.upload_from_local(local_image_info), - ] - - assert store_mock.mock_calls == [ - call.get_oci_registry_credentials("mycharm", "myresource"), - call.get_oci_image_blob("mycharm", "myresource", new_image_digest), - call.upload_resource("mycharm", "myresource", "oci-image", ANY), - ] - - emitter.assert_interactions( - [ - call( - "progress", - "Uploading resource from image " - "charm/charm-id/test-image-name @ sha256:test-digest-given-by-user.", - ), - call("progress", "Remote image not found, getting its info from local registry."), - call("progress", "Uploading from local registry.", permanent=True), - call( - "progress", - "Image uploaded, new remote digest: new-digest-after-upload.", - permanent=True, - ), - call("message", "Revision 7 created of resource 'myresource' for charm 'mycharm'."), - ] - ) - - -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_uploadresource_image_id_upload_from_local(emitter, store_mock, config): - """Upload an oci-image resource, from local to Canonical's registry, specified by id.""" - # fake credentials for the charm/resource, the final json content, and the upload result - store_mock.get_oci_registry_credentials.return_value = RegistryCredentials( - username="testusername", - password="testpassword", - image_name="registry.staging.jujucharms.com/charm/charm-id/test-image-name", - ) - - test_json_content = "from charmhub we came, to charmhub we shall return" - store_mock.get_oci_image_blob.return_value = test_json_content - - store_mock.upload_resource.return_value = Uploaded(ok=True, status=200, revision=7, errors=[]) - - # test - original_image_id = "test-id-given-by-user" - local_image_info = "local image info" - args = Namespace( - charm_name="mycharm", - resource_name="myresource", - filepath=None, - image=original_image_id, - format=False, - ) - with patch("charmcraft.commands.store.ImageHandler", autospec=True) as im_class_mock: - with patch( - "charmcraft.commands.store.LocalDockerdInterface", autospec=True - ) as dockerd_class_mock: - im_class_mock.return_value = im_mock = MagicMock() - dockerd_class_mock.return_value = dock_mock = MagicMock() - - # found locally, then uploaded ok - dock_mock.get_image_info_from_id.return_value = local_image_info - new_image_digest = "new-digest-after-upload" - im_mock.upload_from_local.return_value = new_image_digest - - UploadResourceCommand(config).run(args) - - # validate how ImageHandler was used - assert im_mock.mock_calls == [ - call.upload_from_local(local_image_info), - ] - - assert store_mock.mock_calls == [ - call.get_oci_registry_credentials("mycharm", "myresource"), - call.get_oci_image_blob("mycharm", "myresource", new_image_digest), - call.upload_resource("mycharm", "myresource", "oci-image", ANY), - ] - - emitter.assert_interactions( - [ - call( - "progress", - "Uploading resource from image " - "charm/charm-id/test-image-name @ test-id-given-by-user.", - ), - call("progress", "Getting image info from local registry."), - call("progress", "Uploading from local registry.", permanent=True), - call( - "progress", - "Image uploaded, new remote digest: new-digest-after-upload.", - permanent=True, - ), - call("message", "Revision 7 created of resource 'myresource' for charm 'mycharm'."), - ] - ) - - -@pytest.mark.skipif(platform.system() == "Windows", reason="No skopeo") -def test_uploadresource_image_digest_missing_everywhere(emitter, store_mock, config): - """Upload an oci-image resource by digest, but the image is not found remote nor locally.""" - # fake credentials for the charm/resource, the final json content, and the upload result - store_mock.get_oci_registry_credentials.return_value = RegistryCredentials( - username="testusername", - password="testpassword", - image_name="registry.staging.jujucharms.com/charm/charm-id/test-image-name", - ) - - # test - original_image_digest = "sha256:test-digest-given-by-user" - args = Namespace( - charm_name="mycharm", - resource_name="myresource", - filepath=None, - image=original_image_digest, - format=False, - ) - with patch("charmcraft.commands.store.ImageHandler", autospec=True) as im_class_mock: - with patch( - "charmcraft.commands.store.LocalDockerdInterface", autospec=True - ) as dockerd_class_mock: - im_class_mock.return_value = im_mock = MagicMock() - dockerd_class_mock.return_value = dock_mock = MagicMock() - - # not in the remote registry, not locally either - im_mock.check_in_registry.return_value = False - dock_mock.get_image_info_from_digest.return_value = None - - with pytest.raises(CraftError) as cm: - UploadResourceCommand(config).run(args) - - assert str(cm.value) == "Image not found locally." - - # validate how local interfaces and store was used - assert im_mock.mock_calls == [ - call.check_in_registry(original_image_digest), - ] - assert dock_mock.mock_calls == [ - call.get_image_info_from_digest(original_image_digest), - ] - assert store_mock.mock_calls == [ - call.get_oci_registry_credentials("mycharm", "myresource"), - ] - - emitter.assert_interactions( - [ - call( - "progress", - "Uploading resource from " - "image charm/charm-id/test-image-name @ sha256:test-digest-given-by-user.", - ), - call("progress", "Remote image not found, getting its info from local registry."), - ] - ) - - -@pytest.mark.skipif(platform.system() == "Windows", reason="No skopeo") -def test_uploadresource_image_id_missing(emitter, store_mock, config): - """Upload an oci-image resource by id, but the image is not found locally.""" - # fake credentials for the charm/resource, the final json content, and the upload result - store_mock.get_oci_registry_credentials.return_value = RegistryCredentials( - username="testusername", - password="testpassword", - image_name="registry.staging.jujucharms.com/charm/charm-id/test-image-name", - ) - - # test - original_image_id = "test-id-given-by-user" - args = Namespace( - charm_name="mycharm", - resource_name="myresource", - filepath=None, - image=original_image_id, - format=False, - ) - with patch( - "charmcraft.commands.store.LocalDockerdInterface", autospec=True - ) as dockerd_class_mock: - dockerd_class_mock.return_value = dock_mock = MagicMock() - - # not present locally - dock_mock.get_image_info_from_id.return_value = None - - with pytest.raises(CraftError) as cm: - UploadResourceCommand(config).run(args) - - assert str(cm.value) == "Image not found locally." - - assert dock_mock.mock_calls == [ - call.get_image_info_from_id(original_image_id), - ] - assert store_mock.mock_calls == [ - call.get_oci_registry_credentials("mycharm", "myresource"), - ] - - emitter.assert_interactions( - [ - call( - "progress", - "Uploading resource from " - "image charm/charm-id/test-image-name @ test-id-given-by-user.", - ), - call("progress", "Getting image info from local registry."), - ] - ) - - -@pytest.mark.parametrize("formatted", [None, JSON_FORMAT]) -def test_uploadresource_call_error(emitter, store_mock, config, tmp_path, formatted): - """Simple upload but with a response indicating an error.""" - errors = [ - Error(message="text 1", code="missing-stuff"), - Error(message="other long error text", code="broken"), - ] - store_response = Uploaded(ok=False, status=400, revision=None, errors=errors) - store_mock.upload_resource.return_value = store_response - - test_resource = tmp_path / "mystuff.bin" - test_resource.write_text("sample stuff") - args = Namespace( - charm_name="mycharm", resource_name="myresource", filepath=test_resource, format=formatted - ) - retcode = UploadResourceCommand(config).run(args) - assert retcode == 1 - - if formatted: - expected = { - "errors": [ - {"code": "missing-stuff", "message": "text 1"}, - {"code": "broken", "message": "other long error text"}, - ] - } - emitter.assert_json_output(expected) - else: - emitter.assert_messages( - [ - "Upload failed with status 400:", - "- missing-stuff: text 1", - "- broken: other long error text", - ] - ) From edde43ae3a8b0e84be02f4515a933093389e66c1 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 14:59:02 -0400 Subject: [PATCH 12/59] chore: remove unused version command craft-application provides this so this one is redundant --- charmcraft/commands/__init__.py | 17 --------- charmcraft/commands/version.py | 62 --------------------------------- tests/commands/test_version.py | 36 ------------------- 3 files changed, 115 deletions(-) delete mode 100644 charmcraft/commands/__init__.py delete mode 100644 charmcraft/commands/version.py delete mode 100644 tests/commands/test_version.py diff --git a/charmcraft/commands/__init__.py b/charmcraft/commands/__init__.py deleted file mode 100644 index 85360b378..000000000 --- a/charmcraft/commands/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright 2020-2021 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 - -"""Flag the sub package for files to be included when distributing.""" diff --git a/charmcraft/commands/version.py b/charmcraft/commands/version.py deleted file mode 100644 index ce6c7ec26..000000000 --- a/charmcraft/commands/version.py +++ /dev/null @@ -1,62 +0,0 @@ -# Copyright 2020-2022 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 - -"""Infrastructure for the 'version' command.""" - -from craft_cli import emit - -from charmcraft import __version__ -from charmcraft.cmdbase import BaseCommand - -_overview = """ -Show charmcraft version. - -The output has the following format: `X.Y.Z[+N.gHASH[.dirty]]` - -Where: - -- `X`, `Y` and `Z` are the major, minor and patch version numbers, - upgraded when a release is done - -- `+N.gHASH` is present if using charmcraft from the project (how many - commits after last release, and last commit's hash) - -- `.dirty` is present if the branch you're executing charmcraft from has - modifications - -Example: `0.3.1+40.g883455b.dirty` -""" - - -class VersionCommand(BaseCommand): - """Show the charmcraft version.""" - - name = "version" - help_msg = "Show charmcraft version" - overview = _overview - common = True - - def fill_parser(self, parser): - """Add own parameters to the general parser.""" - self.include_format_option(parser) - - def run(self, parsed_args): - """Run the command.""" - if parsed_args.format: - info = {"version": __version__} - emit.message(self.format_content(parsed_args.format, info)) - else: - emit.message(__version__) diff --git a/tests/commands/test_version.py b/tests/commands/test_version.py deleted file mode 100644 index 7a23051b0..000000000 --- a/tests/commands/test_version.py +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright 2020-2022 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 - -from argparse import Namespace - -from charmcraft import __version__ -from charmcraft.commands.version import VersionCommand - - -def test_version_result(emitter): - """Check it produces the right version.""" - cmd = VersionCommand("config") - args = Namespace(format=None) - cmd.run(args) - emitter.assert_message(__version__) - - -def test_version_result_formatjson(emitter): - """Format the output.""" - cmd = VersionCommand("config") - args = Namespace(format="json") - cmd.run(args) - emitter.assert_json_output({"version": __version__}) From debf4b2549ad7f21d34b8594083ba3223b82b79d Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 15:21:22 -0400 Subject: [PATCH 13/59] chore: use upstream `format_pydantic_errors` Also removes the now-unused `format` module. --- charmcraft/format.py | 105 ------------------------------- charmcraft/metafiles/actions.py | 2 +- charmcraft/metafiles/config.py | 2 +- charmcraft/metafiles/metadata.py | 2 +- charmcraft/models/charmcraft.py | 6 +- tests/test_config.py | 42 ++++++------- tests/test_models.py | 8 +-- 7 files changed, 31 insertions(+), 136 deletions(-) delete mode 100644 charmcraft/format.py diff --git a/charmcraft/format.py b/charmcraft/format.py deleted file mode 100644 index b7bbad43c..000000000 --- a/charmcraft/format.py +++ /dev/null @@ -1,105 +0,0 @@ -# Copyright 2023 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 - -"""Reformat pydantic errors.""" - -from charmcraft import const - - -def format_pydantic_error_location(loc) -> str: - """Format location.""" - loc_parts = [] - for loc_part in loc: - if isinstance(loc_part, str): - loc_parts.append(loc_part) - elif isinstance(loc_part, int): - # Integer indicates an index. Go - # back and fix up previous part. - previous_part = loc_parts.pop() - previous_part += f"[{loc_part}]" - loc_parts.append(previous_part) - else: - raise RuntimeError(f"unhandled loc: {loc_part}") # noqa: TRY004 - - loc = ".".join(loc_parts) - - # Filter out internal __root__ detail. - return loc.replace(".__root__", "") - - -def format_pydantic_error_message(msg: str) -> str: - """Format pydantic's error message field.""" - # Replace shorthand "str" with "string". - return msg.replace("str type expected", "string type expected") - - -def printable_field_location_split(location: str) -> tuple[str, str]: - """Return split field location. - - If top-level, location is returned as unquoted "top-level". - If not top-level, location is returned as quoted location. - - Examples - -------- - (1) field1[idx].foo => 'foo', 'field1[idx]' - (2) field2 => 'field2', top-level - - :returns: Tuple of , as printable representations. - - """ - loc_split = location.split(".") - field_name = repr(loc_split.pop()) - - if loc_split: - return field_name, repr(".".join(loc_split)) - - return field_name, "top-level" - - -def format_pydantic_errors(errors, *, file_name: str = const.CHARMCRAFT_FILENAME) -> str: - """Format errors. - - Example 1: Single error:: - - Bad charmcraft.yaml content: - - field: - reason: - - Example 2: Multiple errors:: - - Bad charmcraft.yaml content: - - field: - reason: - - field: - reason: - """ - combined = [f"Bad {file_name} content:"] - for error in errors: - formatted_loc = format_pydantic_error_location(error["loc"]) - formatted_msg = format_pydantic_error_message(error["msg"]) - - if formatted_msg == "field required": - field_name, location = printable_field_location_split(formatted_loc) - combined.append(f"- field {field_name} required in {location} configuration") - elif formatted_msg == "extra fields not permitted": - field_name, location = printable_field_location_split(formatted_loc) - combined.append( - f"- extra field {field_name} not permitted in {location} configuration" - ) - else: - combined.append(f"- {formatted_msg} in field {formatted_loc!r}") - - return "\n".join(combined) diff --git a/charmcraft/metafiles/actions.py b/charmcraft/metafiles/actions.py index bbb248646..148d38514 100644 --- a/charmcraft/metafiles/actions.py +++ b/charmcraft/metafiles/actions.py @@ -29,7 +29,7 @@ from craft_cli import CraftError, emit from charmcraft import const -from charmcraft.format import format_pydantic_errors +from craft_application.util.error_formatting import format_pydantic_errors from charmcraft.models.actions import JujuActions if TYPE_CHECKING: diff --git a/charmcraft/metafiles/config.py b/charmcraft/metafiles/config.py index c61b4e9e5..8eef7727b 100644 --- a/charmcraft/metafiles/config.py +++ b/charmcraft/metafiles/config.py @@ -27,7 +27,7 @@ from craft_cli import CraftError, emit from charmcraft import const -from charmcraft.format import format_pydantic_errors +from craft_application.util.error_formatting import format_pydantic_errors from charmcraft.metafiles import read_yaml from charmcraft.models.config import JujuConfig diff --git a/charmcraft/metafiles/metadata.py b/charmcraft/metafiles/metadata.py index 24107b094..960ca23f7 100644 --- a/charmcraft/metafiles/metadata.py +++ b/charmcraft/metafiles/metadata.py @@ -27,7 +27,7 @@ from craft_cli import CraftError, emit from charmcraft import const -from charmcraft.format import format_pydantic_errors +from craft_application.util.error_formatting import format_pydantic_errors from charmcraft.models.metadata import BundleMetadata, CharmMetadataLegacy from charmcraft.utils.yaml import dump_yaml diff --git a/charmcraft/models/charmcraft.py b/charmcraft/models/charmcraft.py index 24e6de03e..fd6e2a7c0 100644 --- a/charmcraft/models/charmcraft.py +++ b/charmcraft/models/charmcraft.py @@ -27,7 +27,7 @@ from charmcraft import const, parts from charmcraft.extensions import apply_extensions -from charmcraft.format import format_pydantic_errors +from craft_application.util.error_formatting import format_pydantic_errors from charmcraft.metafiles.actions import parse_actions_yaml from charmcraft.metafiles.config import parse_config_yaml from charmcraft.metafiles.metadata import ( @@ -306,7 +306,7 @@ def expand_short_form_bases(cls, bases: list[dict[str, Any]]) -> None: for pydantic_error in pydantic_errors: pydantic_error["loc"] = ("bases", index, pydantic_error["loc"][0]) - raise CraftError(format_pydantic_errors(pydantic_errors)) + raise CraftError(format_pydantic_errors(pydantic_errors, file_name="charmcraft.yaml")) base.clear() base["build-on"] = [converted_base.dict()] @@ -382,7 +382,7 @@ def unmarshal( # pyright: ignore[reportIncompatibleMethodOverride] return cls.parse_obj({"project": project, **obj}) except pydantic.ValidationError as error: - raise CraftError(format_pydantic_errors(error.errors())) + raise CraftError(format_pydantic_errors(error.errors(), file_name="charmcraft.yaml")) @classmethod def schema( # pyright: ignore[reportIncompatibleMethodOverride] diff --git a/tests/test_config.py b/tests/test_config.py index b837dca1d..b4cd4c140 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -450,7 +450,7 @@ def test_schema_type_bad_type( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - unexpected value; permitted: 'bundle', 'charm' in field 'type'""" + - unexpected value; permitted: 'bundle', 'charm' (in field 'type')""" ) @@ -496,7 +496,7 @@ def test_schema_type_limited_values( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - unexpected value; permitted: 'bundle', 'charm' in field 'type'""" + - unexpected value; permitted: 'bundle', 'charm' (in field 'type')""" ) @@ -546,7 +546,7 @@ def test_schema_charmhub_api_url_bad_type( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - invalid or missing URL scheme in field 'charmhub.api-url'""" + - invalid or missing URL scheme (in field 'charmhub.api-url')""" ) @@ -596,7 +596,7 @@ def test_schema_charmhub_api_url_bad_format( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - invalid or missing URL scheme in field 'charmhub.api-url'""" + - invalid or missing URL scheme (in field 'charmhub.api-url')""" ) @@ -646,7 +646,7 @@ def test_schema_charmhub_storage_url_bad_type( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - invalid or missing URL scheme in field 'charmhub.storage-url'""" + - invalid or missing URL scheme (in field 'charmhub.storage-url')""" ) @@ -696,7 +696,7 @@ def test_schema_charmhub_storage_url_bad_format( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - invalid or missing URL scheme in field 'charmhub.storage-url'""" + - invalid or missing URL scheme (in field 'charmhub.storage-url')""" ) @@ -746,7 +746,7 @@ def test_schema_charmhub_registry_url_bad_type( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - invalid or missing URL scheme in field 'charmhub.registry-url'""" + - invalid or missing URL scheme (in field 'charmhub.registry-url')""" ) @@ -796,7 +796,7 @@ def test_schema_charmhub_registry_url_bad_format( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - invalid or missing URL scheme in field 'charmhub.registry-url'""" + - invalid or missing URL scheme (in field 'charmhub.registry-url')""" ) @@ -896,7 +896,7 @@ def test_schema_basicprime_bad_init_structure( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - value must be a dictionary in field 'parts'""" + - value must be a dictionary (in field 'parts')""" ) @@ -946,7 +946,7 @@ def test_schema_basicprime_bad_bundle_structure( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - part 'charm' must be a dictionary in field 'parts'""" + - part 'charm' must be a dictionary (in field 'parts')""" ) @@ -998,7 +998,7 @@ def test_schema_basicprime_bad_prime_structure( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - value is not a valid list in field 'parts.charm.prime'""" + - value is not a valid list (in field 'parts.charm.prime')""" ) @@ -1050,7 +1050,7 @@ def test_schema_basicprime_bad_prime_type( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - string type expected in field 'parts.charm.prime[0]'""" + - string type expected (in field 'parts.charm.prime[0]')""" ) @@ -1103,7 +1103,7 @@ def test_schema_basicprime_bad_prime_type_empty( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - path cannot be empty in field 'parts.charm.prime[0]'""" + - path cannot be empty (in field 'parts.charm.prime[0]')""" ) @@ -1138,7 +1138,7 @@ def test_schema_basicprime_bad_content_format( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - '/bar/foo' must be a relative path (cannot start with '/') in field 'parts.charm.prime[0]'""" + - '/bar/foo' must be a relative path (cannot start with '/') (in field 'parts.charm.prime[0]')""" ) @@ -1192,7 +1192,7 @@ def test_schema_additional_part( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - part 'other-part' must be a dictionary in field 'parts'""" + - part 'other-part' must be a dictionary (in field 'parts')""" ) @@ -1259,7 +1259,7 @@ def test_schema_other_charm_part_no_source( """\ Bad charmcraft.yaml content: - field 'source' required in 'parts.other-part' configuration - - cannot validate 'charm-requirements' because invalid 'source' configuration in field 'parts.other-part.charm-requirements'""" + - cannot validate 'charm-requirements' because invalid 'source' configuration (in field 'parts.other-part.charm-requirements')""" ) @@ -1933,7 +1933,7 @@ def test_bases_forbidden_for_bundles( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - Field not allowed when type=bundle in field 'bases'""" + - field not allowed when type=bundle (in field 'bases')""" ) @@ -2169,7 +2169,7 @@ def test_channel_is_yaml_number( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - string type expected in field 'bases[0].build-on[0].channel'""" + - string type expected (in field 'bases[0].build-on[0].channel')""" ) @@ -2938,7 +2938,7 @@ def test_schema_analysis_ignore_attribute_missing( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - Bad attribute name 'check_missing' in field 'analysis.ignore.attributes[1]'""" + - bad attribute name 'check_missing' (in field 'analysis.ignore.attributes[1]')""" ) @@ -2999,7 +2999,7 @@ def test_schema_analysis_ignore_linter_missing( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - Bad lint name 'check_missing' in field 'analysis.ignore.linters[1]'""" + - bad lint name 'check_missing' (in field 'analysis.ignore.linters[1]')""" ) @@ -3313,7 +3313,7 @@ def test_actions_defined_in_charmcraft_yaml_and_actions_yaml( assert str(cm.value) == dedent( """\ Bad charmcraft.yaml content: - - 'actions.yaml' file not allowed when an 'actions' section is defined in 'charmcraft.yaml' in field 'actions'""" + - 'actions.yaml' file not allowed when an 'actions' section is defined in 'charmcraft.yaml' (in field 'actions')""" ) diff --git a/tests/test_models.py b/tests/test_models.py index 6542a32dd..614d27803 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -95,7 +95,7 @@ def test_load_minimal_metadata_from_charmcraft_yaml_missing_name( ) ) - with pytest.raises(CraftError, match="needs value in field 'name'"): + with pytest.raises(CraftError, match=r"needs value \(in field 'name'\)"): load(tmp_path) @@ -139,7 +139,7 @@ def test_load_minimal_metadata_from_charmcraft_yaml_missing_summary( ), ) - with pytest.raises(CraftError, match="needs value in field 'summary'"): + with pytest.raises(CraftError, match=r"needs value \(in field 'summary'\)"): load(tmp_path) @@ -161,7 +161,7 @@ def test_load_minimal_metadata_from_charmcraft_yaml_missing_description( ), ) - with pytest.raises(CraftError, match="needs value in field 'description'"): + with pytest.raises(CraftError, match=r"needs value \(in field 'description'\)"): load(tmp_path) @@ -1099,7 +1099,7 @@ def test_load_actions_in_charmcraft_yaml_and_actions_yaml( msg = ( "'actions.yaml' file not allowed when an 'actions' section " - "is defined in 'charmcraft.yaml' in field 'actions'" + r"is defined in 'charmcraft.yaml' \(in field 'actions'\)" ) with pytest.raises(CraftError, match=msg): From ae66cdf9393ec942d5b434cc2cb1511585464367 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 15:24:44 -0400 Subject: [PATCH 14/59] chore: remove unused deprecations module --- charmcraft/deprecations.py | 58 --------------------------- tests/conftest.py | 3 +- tests/test_deprecations.py | 80 -------------------------------------- 3 files changed, 1 insertion(+), 140 deletions(-) delete mode 100644 charmcraft/deprecations.py delete mode 100644 tests/test_deprecations.py diff --git a/charmcraft/deprecations.py b/charmcraft/deprecations.py deleted file mode 100644 index e3e74d07e..000000000 --- a/charmcraft/deprecations.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright 2021-2022 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 - -"""Handle surfacing deprecation notices. - -When a new deprecation has occurred, write a Deprecation Notice for it here -(assigning it the next DN ID): - - https://discourse.charmhub.io/t/4652 - -Then add that ID along with the deprecation title in the list below. -""" - -from craft_cli import emit - -from charmcraft.env import is_charmcraft_running_in_managed_mode - -# the message to show for each deprecation ID (this needs to be in sync with the -# documentation) -_DEPRECATION_MESSAGES: dict[str, str] = { - # example of item in this structure when something is deprecated: -} - -# the URL to point to the deprecation entry in the documentation -_DEPRECATION_URL_FMT = "https://discourse.charmhub.io/t/4652#heading--{deprecation_id}" - -# already-notified deprecations will be stored here to not log them twice -_ALREADY_NOTIFIED = set() - - -def notify_deprecation(deprecation_id): - """Present proper messages to the user for the indicated deprecation id. - - Prevent issuing duplicate warnings to the user by ignoring notifications if: - - running in managed-mode - - already issued by running process - """ - if is_charmcraft_running_in_managed_mode() or deprecation_id in _ALREADY_NOTIFIED: - return - - message = _DEPRECATION_MESSAGES[deprecation_id] - emit.progress(f"DEPRECATED: {message}", permanent=True) - url = _DEPRECATION_URL_FMT.format(deprecation_id=deprecation_id) - emit.progress(f"See {url} for more information.", permanent=True) - _ALREADY_NOTIFIED.add(deprecation_id) diff --git a/tests/conftest.py b/tests/conftest.py index ed18e8530..1e196f827 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,7 +33,7 @@ from craft_providers import bases import charmcraft.parts -from charmcraft import const, deprecations, instrum, parts, services, store +from charmcraft import const, instrum, parts, services, store from charmcraft.application.main import APP_METADATA from charmcraft.bases import get_host_as_base from charmcraft.models import charmcraft as config_module @@ -242,7 +242,6 @@ def intertests_cleanups(): """ importlib.reload(instrum) yield - deprecations._ALREADY_NOTIFIED.clear() callbacks.unregister_all() diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py deleted file mode 100644 index 5f9f9763b..000000000 --- a/tests/test_deprecations.py +++ /dev/null @@ -1,80 +0,0 @@ -# Copyright 2021-2022 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 - -import re -from unittest.mock import call, patch - -import pytest - -from charmcraft import deprecations -from charmcraft.deprecations import _DEPRECATION_MESSAGES, notify_deprecation - - -def test_notice_ok(monkeypatch, emitter): - """Present proper messages to the user.""" - monkeypatch.setitem(_DEPRECATION_MESSAGES, "dn666", "Test message for the user.") - monkeypatch.setattr(deprecations, "_DEPRECATION_URL_FMT", "http://docs.com/#{deprecation_id}") - - notify_deprecation("dn666") - emitter.assert_interactions( - [ - call("progress", "DEPRECATED: Test message for the user.", permanent=True), - call("progress", "See http://docs.com/#dn666 for more information.", permanent=True), - ] - ) - - -def test_notice_skipped_in_managed_mode(monkeypatch, emitter): - """Present proper messages to the user.""" - monkeypatch.setitem(_DEPRECATION_MESSAGES, "dn666", "Test message for the user.") - monkeypatch.setattr(deprecations, "_DEPRECATION_URL_FMT", "http://docs.com/#{deprecation_id}") - - with patch( - "charmcraft.deprecations.is_charmcraft_running_in_managed_mode", - return_value=True, - ): - notify_deprecation("dn666") - - emitter.assert_interactions(None) - - -@pytest.mark.parametrize("deprecation_id", _DEPRECATION_MESSAGES.keys()) -def test_check_real_deprecation_ids(deprecation_id): - """Verify all the real IDs used have the correct form.""" - assert re.match(r"dn\d\d", deprecation_id) - - -@pytest.mark.parametrize("message", _DEPRECATION_MESSAGES.values()) -def test_check_real_deprecation_messages(message): - """Verify all the real messages conform some rules.""" - assert message[0].isupper() - assert message[-1] == "." - - -def test_log_deprecation_only_once(monkeypatch, emitter): - """Show the message only once even if it was called several times.""" - monkeypatch.setitem(_DEPRECATION_MESSAGES, "dn666", "Test message for the user.") - monkeypatch.setattr(deprecations, "_DEPRECATION_URL_FMT", "http://docs.com/#{deprecation_id}") - - # call twice, log once - notify_deprecation("dn666") - notify_deprecation("dn666") - emitter.assert_interactions( - [ - call("progress", "DEPRECATED: Test message for the user.", permanent=True), - call("progress", "See http://docs.com/#dn666 for more information.", permanent=True), - ] - ) From 1eacdcdff734cb92302881a97ab710c0a8160b0e Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 15:40:07 -0400 Subject: [PATCH 15/59] chore: remove unused env functions --- charmcraft/const.py | 1 - charmcraft/env.py | 19 ------------------- tests/test_env.py | 41 ----------------------------------------- 3 files changed, 61 deletions(-) diff --git a/charmcraft/const.py b/charmcraft/const.py index 3033f17e4..b2b7ff878 100644 --- a/charmcraft/const.py +++ b/charmcraft/const.py @@ -32,7 +32,6 @@ STORE_REGISTRY_ENV_VAR = "CHARMCRAFT_REGISTRY_URL" # These are only for use within the managed environment MANAGED_MODE_ENV_VAR = "CHARMCRAFT_MANAGED_MODE" -SNAP_CHANNEL_ENV_VAR = "CHARMCRAFT_INSTALL_SNAP_CHANNEL" # endregion # region Project files and directories CHARMCRAFT_FILENAME = "charmcraft.yaml" diff --git a/charmcraft/env.py b/charmcraft/env.py index a330a228c..df3f112b9 100644 --- a/charmcraft/env.py +++ b/charmcraft/env.py @@ -46,11 +46,6 @@ def get_managed_environment_log_path() -> pathlib.Path: return pathlib.Path("/tmp/charmcraft.log") -def get_managed_environment_metrics_path() -> pathlib.Path: - """Path for charmcraft metrics when running in managed environment.""" - return pathlib.Path("/tmp/metrics.json") - - def get_charm_builder_metrics_path() -> pathlib.Path: """Path for charmcraft metrics when running charm_builder.""" return pathlib.Path("/tmp/charm_builder_metrics.json") @@ -61,25 +56,11 @@ def get_managed_environment_project_path() -> pathlib.Path: return get_managed_environment_home_path() / "project" -def get_managed_environment_snap_channel() -> str | None: - """User-specified channel to use when installing Charmcraft snap from Snap Store. - - :returns: Channel string if specified, else None. - """ - return os.getenv(const.SNAP_CHANNEL_ENV_VAR) - - def is_charmcraft_running_from_snap() -> bool: """Check if charmcraft is running from the snap.""" return os.getenv("SNAP_NAME") == "charmcraft" and os.getenv("SNAP") is not None -def is_charmcraft_running_in_developer_mode() -> bool: - """Check if Charmcraft is running under developer mode.""" - developer_flag = os.getenv(const.DEVELOPER_MODE_ENV_VAR, "n") - return strtobool(developer_flag) - - def is_charmcraft_running_in_managed_mode() -> bool: """Check if charmcraft is running in a managed environment.""" managed_flag = os.getenv(const.MANAGED_MODE_ENV_VAR, os.getenv("CRAFT_MANAGED_MODE", "n")) diff --git a/tests/test_env.py b/tests/test_env.py index 65521f0aa..d964a2e32 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -39,18 +39,6 @@ def test_get_managed_environment_project_path(): assert dirpath == pathlib.Path("/root/project") -def test_get_managed_environment_snap_channel_none(monkeypatch): - monkeypatch.delenv(const.SNAP_CHANNEL_ENV_VAR, raising=False) - - assert env.get_managed_environment_snap_channel() is None - - -def test_get_managed_environment_snap_channel(monkeypatch): - monkeypatch.setenv(const.SNAP_CHANNEL_ENV_VAR, "latest/edge") - - assert env.get_managed_environment_snap_channel() == "latest/edge" - - @pytest.mark.parametrize( ("snap_name", "snap", "result"), [ @@ -74,35 +62,6 @@ def test_is_charmcraft_running_from_snap(monkeypatch, snap_name, snap, result): assert env.is_charmcraft_running_from_snap() == result -@pytest.mark.parametrize( - ("developer", "result"), - [ - (None, False), - ("y", True), - ("n", False), - ("Y", True), - ("N", False), - ("true", True), - ("false", False), - ("TRUE", True), - ("FALSE", False), - ("yes", True), - ("no", False), - ("YES", True), - ("NO", False), - ("1", True), - ("0", False), - ], -) -def test_is_charmcraft_running_in_developer_mode(monkeypatch, developer, result): - if developer is None: - monkeypatch.delenv(const.DEVELOPER_MODE_ENV_VAR, raising=False) - else: - monkeypatch.setenv(const.DEVELOPER_MODE_ENV_VAR, developer) - - assert env.is_charmcraft_running_in_developer_mode() == result - - @pytest.mark.parametrize( ("managed", "result"), [ From c14865867c354585133b01438c3bb79d53fd52b5 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 15:43:13 -0400 Subject: [PATCH 16/59] chore: remove unused error classe --- charmcraft/errors.py | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/charmcraft/errors.py b/charmcraft/errors.py index e2b5f8d84..6764a50e3 100644 --- a/charmcraft/errors.py +++ b/charmcraft/errors.py @@ -31,22 +31,6 @@ CheckResult = "CheckResult" -class InvalidEnvironmentVariableError(CraftError): - """A Charmcraft-related environment variable value is invalid.""" - - def __init__( - self, variable: str, *, details: str, resolution: str, docs_url: str | None = None - ): - super().__init__( - f"Environment variable {variable!r} contains an invalid value.", - details=details, - resolution=resolution, - docs_url=docs_url, - reportable=False, - retcode=65, # Data format error - ) - - class LibraryError(CraftError): """Errors related to charm libraries.""" @@ -125,32 +109,10 @@ def _format_details(charms: Mapping[str, Iterable[pathlib.Path]]) -> str: return details.getvalue() -class LintingError(CraftError): - """Lint failures.""" - - def __init__(self, errors: list[CheckResult], warnings: list[CheckResult]): - self.errors = errors - self.warnings = warnings - detail_lines = ["ERRORS:"] - for err in errors: - detail_lines.append(f"- {err.name}: {err.text} ({err.url})") - for warning in warnings: - detail_lines.append(f"- {warning.name}: {warning.text} ({warning.url})") - - super().__init__( - f"There were {len(errors)} linting errors and {len(warnings)} warnings." - "\n".join(detail_lines) - ) - - class DependencyError(CraftError): """Errors related to dependencies.""" -class InvalidDependenciesError(DependencyError): - """In strict dependencies mode, some binary dependencies.""" - - class MissingDependenciesError(DependencyError): """In strict dependencies mode, some dependencies are missing from requirements files.""" From 7d10f6e7b93aa6dff75eefd61ebf62c6696f5274 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 15:44:08 -0400 Subject: [PATCH 17/59] chore: autoformat --- charmcraft/metafiles/actions.py | 2 +- charmcraft/metafiles/config.py | 2 +- charmcraft/metafiles/metadata.py | 2 +- charmcraft/models/charmcraft.py | 6 ++++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/charmcraft/metafiles/actions.py b/charmcraft/metafiles/actions.py index 148d38514..4752c4c60 100644 --- a/charmcraft/metafiles/actions.py +++ b/charmcraft/metafiles/actions.py @@ -26,10 +26,10 @@ import pydantic import yaml from craft_application import util +from craft_application.util.error_formatting import format_pydantic_errors from craft_cli import CraftError, emit from charmcraft import const -from craft_application.util.error_formatting import format_pydantic_errors from charmcraft.models.actions import JujuActions if TYPE_CHECKING: diff --git a/charmcraft/metafiles/config.py b/charmcraft/metafiles/config.py index 8eef7727b..be8f6d047 100644 --- a/charmcraft/metafiles/config.py +++ b/charmcraft/metafiles/config.py @@ -24,10 +24,10 @@ import pydantic import yaml +from craft_application.util.error_formatting import format_pydantic_errors from craft_cli import CraftError, emit from charmcraft import const -from craft_application.util.error_formatting import format_pydantic_errors from charmcraft.metafiles import read_yaml from charmcraft.models.config import JujuConfig diff --git a/charmcraft/metafiles/metadata.py b/charmcraft/metafiles/metadata.py index 960ca23f7..8f394cdf9 100644 --- a/charmcraft/metafiles/metadata.py +++ b/charmcraft/metafiles/metadata.py @@ -24,10 +24,10 @@ import pydantic import yaml +from craft_application.util.error_formatting import format_pydantic_errors from craft_cli import CraftError, emit from charmcraft import const -from craft_application.util.error_formatting import format_pydantic_errors from charmcraft.models.metadata import BundleMetadata, CharmMetadataLegacy from charmcraft.utils.yaml import dump_yaml diff --git a/charmcraft/models/charmcraft.py b/charmcraft/models/charmcraft.py index fd6e2a7c0..881abc41d 100644 --- a/charmcraft/models/charmcraft.py +++ b/charmcraft/models/charmcraft.py @@ -22,12 +22,12 @@ import pydantic from craft_application import util +from craft_application.util.error_formatting import format_pydantic_errors from craft_cli import CraftError from typing_extensions import Self from charmcraft import const, parts from charmcraft.extensions import apply_extensions -from craft_application.util.error_formatting import format_pydantic_errors from charmcraft.metafiles.actions import parse_actions_yaml from charmcraft.metafiles.config import parse_config_yaml from charmcraft.metafiles.metadata import ( @@ -306,7 +306,9 @@ def expand_short_form_bases(cls, bases: list[dict[str, Any]]) -> None: for pydantic_error in pydantic_errors: pydantic_error["loc"] = ("bases", index, pydantic_error["loc"][0]) - raise CraftError(format_pydantic_errors(pydantic_errors, file_name="charmcraft.yaml")) + raise CraftError( + format_pydantic_errors(pydantic_errors, file_name="charmcraft.yaml") + ) base.clear() base["build-on"] = [converted_base.dict()] From 4b8139b09e361415ca377f4c31769f4463fd61cf Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 16:00:00 -0400 Subject: [PATCH 18/59] chore: remove unused manifest code --- charmcraft/metafiles/manifest.py | 91 --------------- tests/test_manifest.py | 185 ------------------------------- 2 files changed, 276 deletions(-) delete mode 100644 charmcraft/metafiles/manifest.py delete mode 100644 tests/test_manifest.py diff --git a/charmcraft/metafiles/manifest.py b/charmcraft/metafiles/manifest.py deleted file mode 100644 index 538e5ee8a..000000000 --- a/charmcraft/metafiles/manifest.py +++ /dev/null @@ -1,91 +0,0 @@ -# Copyright 2023 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 - -"""Handlers for manifest.yaml file.""" - -import datetime -import json -import logging -import os -import pathlib -from typing import Any - -import yaml -from craft_cli import CraftError - -import charmcraft.linters -import charmcraft.models.charmcraft -from charmcraft import const - -logger = logging.getLogger(__name__) - - -def create_manifest( - basedir: pathlib.Path, - started_at: datetime.datetime, - bases_config: charmcraft.models.charmcraft.BasesConfiguration | None, - linting_results: list[charmcraft.linters.CheckResult], -) -> pathlib.Path: - """Create manifest.yaml in basedir for given base configuration. - - For packing bundles, `bases` will be skipped when bases_config is None. - Charms should always include a valid bases_config. - - :param basedir: Directory to create Charm in. - :param started_at: Build start time. - :param bases_config: Relevant bases configuration, if any. - - :returns: Path to created manifest.yaml. - """ - content: dict[str, Any] = { - "charmcraft-version": charmcraft.__version__, - "charmcraft-started-at": started_at.isoformat() + "Z", - } - - # Annotate bases only if bases_config is not None. - if bases_config is not None: - bases = [ - { - "name": r.name, - "channel": r.channel, - "architectures": r.architectures, - } - for r in bases_config.run_on - ] - content["bases"] = bases - - # include the linters results (only for attributes) - attributes_info = [ - {"name": result.name, "result": result.result} - for result in linting_results - if result.check_type == charmcraft.linters.CheckType.ATTRIBUTE - ] - content["analysis"] = {"attributes": attributes_info} - - # include the image info, if present - image_info_raw = os.environ.get(const.IMAGE_INFO_ENV_VAR) - if image_info_raw: - try: - image_info = json.loads(image_info_raw) - except json.decoder.JSONDecodeError as exc: - raise CraftError( - f"Failed to parse the content of {const.IMAGE_INFO_ENV_VAR} environment variable" - ) from exc - content["image-info"] = image_info - - filepath = basedir / const.MANIFEST_FILENAME - filepath.write_text(yaml.dump(content)) - return filepath diff --git a/tests/test_manifest.py b/tests/test_manifest.py deleted file mode 100644 index 2227adbd3..000000000 --- a/tests/test_manifest.py +++ /dev/null @@ -1,185 +0,0 @@ -# Copyright 2020-2022 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 - -import datetime -import json -from unittest.mock import patch - -import pytest -import yaml -from craft_cli import CraftError - -from charmcraft import __version__, const, linters -from charmcraft.metafiles.manifest import create_manifest -from charmcraft.models.charmcraft import Base, BasesConfiguration -from charmcraft.utils import OSPlatform - - -def test_manifest_simple_ok(tmp_path): - """Simple construct.""" - bases_config = BasesConfiguration( - **{ - "build-on": [ - Base( - name="test-name", - channel="test-channel", - ), - ], - "run-on": [ - Base( - name="test-name", - channel="test-channel", - architectures=["arch1"], - ), - Base( - name="test-name2", - channel="test-channel2", - architectures=["arch1", "arch2"], - ), - ], - } - ) - - linting_results = [ - linters.CheckResult( - name="check-name", - check_type=linters.CheckType.ATTRIBUTE, - url="url", - text="text", - result="check-result", - ), - ] - - tstamp = datetime.datetime(2020, 2, 1, 15, 40, 33) - os_platform = OSPlatform(system="SuperUbuntu", release="40.10", machine="SomeRISC") - with patch("charmcraft.utils.get_os_platform", return_value=os_platform): - result_filepath = create_manifest(tmp_path, tstamp, bases_config, linting_results) - - assert result_filepath == tmp_path / const.MANIFEST_FILENAME - saved = yaml.safe_load(result_filepath.read_text()) - expected = { - "charmcraft-started-at": "2020-02-01T15:40:33Z", - "charmcraft-version": __version__, - "bases": [ - { - "name": "test-name", - "channel": "test-channel", - "architectures": ["arch1"], - }, - { - "name": "test-name2", - "channel": "test-channel2", - "architectures": ["arch1", "arch2"], - }, - ], - "analysis": { - "attributes": [ - { - "name": "check-name", - "result": "check-result", - }, - ], - }, - } - assert saved == expected - - -def test_manifest_no_bases(tmp_path): - """Manifest without bases (used for bundles).""" - tstamp = datetime.datetime(2020, 2, 1, 15, 40, 33) - os_platform = OSPlatform(system="SuperUbuntu", release="40.10", machine="SomeRISC") - with patch("charmcraft.utils.get_os_platform", return_value=os_platform): - result_filepath = create_manifest(tmp_path, tstamp, None, []) - - saved = yaml.safe_load(result_filepath.read_text()) - - assert result_filepath == tmp_path / const.MANIFEST_FILENAME - assert saved == { - "charmcraft-started-at": "2020-02-01T15:40:33Z", - "charmcraft-version": __version__, - "analysis": {"attributes": []}, - } - - -def test_manifest_checkers_multiple(tmp_path): - """Multiple checkers, attributes and a linter.""" - linting_results = [ - linters.CheckResult( - name="attrib-name-1", - check_type=linters.CheckType.ATTRIBUTE, - url="url", - text="text", - result="result-1", - ), - linters.CheckResult( - name="attrib-name-2", - check_type=linters.CheckType.ATTRIBUTE, - url="url", - text="text", - result="result-2", - ), - linters.CheckResult( - name="warning-name", - check_type=linters.CheckType.LINT, - url="url", - text="text", - result="result", - ), - ] - - tstamp = datetime.datetime(2020, 2, 1, 15, 40, 33) - os_platform = OSPlatform(system="SuperUbuntu", release="40.10", machine="SomeRISC") - with patch("charmcraft.utils.get_os_platform", return_value=os_platform): - result_filepath = create_manifest(tmp_path, tstamp, None, linting_results) - - assert result_filepath == tmp_path / const.MANIFEST_FILENAME - saved = yaml.safe_load(result_filepath.read_text()) - expected = [ - { - "name": "attrib-name-1", - "result": "result-1", - }, - { - "name": "attrib-name-2", - "result": "result-2", - }, - ] - assert saved["analysis"]["attributes"] == expected - - -def test_manifest_image_info_ok(tmp_path, monkeypatch): - """Include the image info in the manifest.""" - test_image_content = {"some info": ["whatever", 123]} - monkeypatch.setenv(const.IMAGE_INFO_ENV_VAR, json.dumps(test_image_content)) - - tstamp = datetime.datetime(2020, 2, 1, 15, 40, 33) - os_platform = OSPlatform(system="SuperUbuntu", release="40.10", machine="SomeRISC") - with patch("charmcraft.utils.get_os_platform", return_value=os_platform): - result_filepath = create_manifest(tmp_path, tstamp, None, []) - - saved = yaml.safe_load(result_filepath.read_text()) - assert saved["image-info"] == test_image_content - - -def test_manifest_image_info_bad(tmp_path, monkeypatch): - """The format of the image info environment variable is wrong.""" - monkeypatch.setenv(const.IMAGE_INFO_ENV_VAR, "this is not a json") - tstamp = datetime.datetime(2020, 2, 1, 15, 40, 33) - with pytest.raises(CraftError) as cm: - create_manifest(tmp_path, tstamp, None, []) - exc = cm.value - assert str(exc) == "Failed to parse the content of CHARMCRAFT_IMAGE_INFO environment variable" - assert isinstance(exc.__cause__, json.JSONDecodeError) From eb8efea0b23adc1541c0225558acdcd6b58e1a1b Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 16:01:39 -0400 Subject: [PATCH 19/59] chore: remove unused create_config_yaml --- charmcraft/metafiles/config.py | 34 ----------- tests/test_metafiles.py | 103 --------------------------------- 2 files changed, 137 deletions(-) diff --git a/charmcraft/metafiles/config.py b/charmcraft/metafiles/config.py index be8f6d047..c7db20671 100644 --- a/charmcraft/metafiles/config.py +++ b/charmcraft/metafiles/config.py @@ -73,37 +73,3 @@ def parse_config_yaml(charm_dir: pathlib.Path, allow_broken=False) -> JujuConfig emit.debug(f"Ignoring {const.JUJU_CONFIG_FILENAME}") return None raise - - -def create_config_yaml( - basedir: pathlib.Path, - charmcraft_config: "CharmcraftConfig", -) -> pathlib.Path | None: - """Create actions.yaml in basedir for given project configuration. - - :param basedir: Directory to create Charm in. - :param charmcraft_config: Charmcraft configuration object. - - :returns: Path to created config.yaml. - """ - original_file_path = charmcraft_config.project.dirpath / const.JUJU_CONFIG_FILENAME - target_file_path = basedir / const.JUJU_CONFIG_FILENAME - - # Copy config.yaml if it exists, otherwise create it from CharmcraftConfig. - if original_file_path.exists(): - # In the build / test process, the original file may be the same as the target file. - with contextlib.suppress(shutil.SameFileError): - shutil.copyfile(original_file_path, target_file_path) - else: - if charmcraft_config.config: - target_file_path.write_text( - yaml.dump( - charmcraft_config.config.dict( - include={"options"}, exclude_none=True, by_alias=True - ) - ) - ) - else: - return None - - return target_file_path diff --git a/tests/test_metafiles.py b/tests/test_metafiles.py index c38113b00..8afd965e2 100644 --- a/tests/test_metafiles.py +++ b/tests/test_metafiles.py @@ -21,7 +21,6 @@ from charmcraft import const from charmcraft.config import load from charmcraft.metafiles.actions import create_actions_yaml -from charmcraft.metafiles.config import create_config_yaml from charmcraft.metafiles.metadata import create_metadata_yaml @@ -959,105 +958,3 @@ def test_copy_actions_from_actions_yaml(tmp_path, prepare_charmcraft_yaml, prepa "additionalProperties": False, }, } - - -def test_dump_config_from_charmcraft_yaml(tmp_path, prepare_charmcraft_yaml): - """Dump a config.yaml from charmcraft.yaml.""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - description: test-description - - config: - options: - test-int: - default: 123 - description: test-1 - type: int - test-string: - description: test-2 - type: string - test-float: - default: 1.23 - type: float - test-bool: - default: true - type: boolean - """ - ), - ) - - config = load(tmp_path) - - create_config_yaml(tmp_path, config) - - config_data = yaml.safe_load((tmp_path / const.JUJU_CONFIG_FILENAME).read_text()) - - assert config_data == { - "options": { - "test-int": {"default": 123, "description": "test-1", "type": "int"}, - "test-string": {"description": "test-2", "type": "string"}, - "test-float": {"default": 1.23, "type": "float"}, - "test-bool": {"default": True, "type": "boolean"}, - }, - } - - -def test_copy_config_from_config_yaml(tmp_path, prepare_charmcraft_yaml, prepare_config_yaml): - """Dump a actions.yaml from charmcraft.yaml.""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - description: test-description - """ - ), - ) - prepare_config_yaml( - dedent( - """ - options: - test-int: - default: 123 - description: test-1 - type: int - test-string: - description: test-2 - type: string - test-float: - default: 1.23 - type: float - test-bool: - default: true - type: boolean - #### TEST-COPY #### - """ - ), - ) - - config = load(tmp_path) - - os.mkdir(tmp_path / "new") - - create_config_yaml(tmp_path / "new", config) - - config_yaml = (tmp_path / "new" / const.JUJU_CONFIG_FILENAME).read_text() - - # Copy will preserve the TEST-COPY comment - assert "TEST-COPY" in config_yaml - - config_data = yaml.safe_load(config_yaml) - - assert config_data == { - "options": { - "test-int": {"default": 123, "description": "test-1", "type": "int"}, - "test-string": {"description": "test-2", "type": "string"}, - "test-float": {"default": 1.23, "type": "float"}, - "test-bool": {"default": True, "type": "boolean"}, - }, - } From 309a69d589a6f7c06c428f8e998508b1f21e9dba Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 16:04:08 -0400 Subject: [PATCH 20/59] chore: remove unused create_actions_yaml --- charmcraft/metafiles/actions.py | 34 ------- tests/test_actions.py | 96 ------------------ tests/test_metafiles.py | 167 -------------------------------- 3 files changed, 297 deletions(-) delete mode 100644 tests/test_actions.py diff --git a/charmcraft/metafiles/actions.py b/charmcraft/metafiles/actions.py index 4752c4c60..ac7cacd38 100644 --- a/charmcraft/metafiles/actions.py +++ b/charmcraft/metafiles/actions.py @@ -79,37 +79,3 @@ def parse_actions_yaml(charm_dir, allow_broken=False): emit.debug(f"Ignoring {const.JUJU_ACTIONS_FILENAME}") return None raise - - -def create_actions_yaml( - basedir: pathlib.Path, - charmcraft_config: "CharmcraftConfig", -) -> pathlib.Path | None: - """Create actions.yaml in basedir for given project configuration. - - :param basedir: Directory to create Charm in. - :param charmcraft_config: Charmcraft configuration object. - - :returns: Path to created actions.yaml. - """ - original_file_path = charmcraft_config.project.dirpath / const.JUJU_ACTIONS_FILENAME - target_file_path = basedir / const.JUJU_ACTIONS_FILENAME - - # Copy actions.yaml if it exists, otherwise create it from CharmcraftConfig. - if original_file_path.exists(): - # In the build / test process, the original file may be the same as the target file. - with contextlib.suppress(shutil.SameFileError): - shutil.copyfile(original_file_path, target_file_path) - else: - if charmcraft_config.actions: - target_file_path.write_text( - yaml.dump( - charmcraft_config.actions.dict( - include={"actions"}, exclude_none=True, by_alias=True - )["actions"] - ) - ) - else: - return None - - return target_file_path diff --git a/tests/test_actions.py b/tests/test_actions.py deleted file mode 100644 index 00cda6b1a..000000000 --- a/tests/test_actions.py +++ /dev/null @@ -1,96 +0,0 @@ -# Copyright 2023 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 - -from textwrap import dedent - -import yaml - -from charmcraft import const -from charmcraft.config import load -from charmcraft.metafiles.actions import create_actions_yaml - - -def test_create_actions_yaml(tmp_path, prepare_charmcraft_yaml): - """create actions.yaml.""" - actions = { - "actions": { - "pause": {"description": "Pause the database."}, - "resume": {"description": "Resume a paused database."}, - "snapshot": { - "description": "Take a snapshot of the database.", - "params": { - "filename": { - "type": "string", - "description": "The name of the snapshot file.", - }, - "compression": { - "type": "object", - "description": "The type of compression to use.", - "properties": { - "kind": {"type": "string", "enum": ["gzip", "bzip2", "xz"]}, - "quality": { - "description": "Compression quality", - "type": "integer", - "minimum": 0, - "maximum": 9, - }, - }, - }, - }, - "required": ["filename"], - "additionalProperties": False, - }, - } - } - - yaml_data = yaml.safe_dump(actions) - - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - description: test-description - """ - ) - + yaml_data - ) - - config = load(tmp_path) - - actions_file = create_actions_yaml(tmp_path, config) - - assert yaml.safe_load(actions_file.read_text()) == actions["actions"] - - -def test_create_actions_yaml_none(tmp_path, prepare_charmcraft_yaml): - """create actions.yaml with None, the file should not exist.""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - description: test-description - """ - ) - ) - config = load(tmp_path) - actions_file = create_actions_yaml(tmp_path, config) - - assert actions_file is None - assert not (tmp_path / const.JUJU_ACTIONS_FILENAME).exists() diff --git a/tests/test_metafiles.py b/tests/test_metafiles.py index 8afd965e2..b5916ee22 100644 --- a/tests/test_metafiles.py +++ b/tests/test_metafiles.py @@ -20,7 +20,6 @@ from charmcraft import const from charmcraft.config import load -from charmcraft.metafiles.actions import create_actions_yaml from charmcraft.metafiles.metadata import create_metadata_yaml @@ -792,169 +791,3 @@ def test_copy_bundle_metadata_from_metadata_yaml( # Copy will preserve the TEST-COPY comment assert "TEST-COPY" in metadata_yaml - - -def test_dump_actions_from_charmcraft_yaml(tmp_path, prepare_charmcraft_yaml): - """Dump a actions.yaml from charmcraft.yaml.""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - description: test-description - - bases: - - name: test-name - channel: test-channel - - actions: - pause: - description: Pause the database. - resume: - description: Resume a paused database. - snapshot: - description: Take a snapshot of the database. - params: - filename: - type: string - description: The name of the snapshot file. - compression: - type: object - description: The type of compression to use. - properties: - kind: - type: string - enum: [gzip, bzip2, xz] - quality: - description: Compression quality - type: integer - minimum: 0 - maximum: 9 - required: [filename] - additionalProperties: false - """ - ), - ) - - config = load(tmp_path) - - create_actions_yaml(tmp_path, config) - - actions = yaml.safe_load((tmp_path / const.JUJU_ACTIONS_FILENAME).read_text()) - - assert actions == { - "pause": {"description": "Pause the database."}, - "resume": {"description": "Resume a paused database."}, - "snapshot": { - "description": "Take a snapshot of the database.", - "params": { - "filename": { - "type": "string", - "description": "The name of the snapshot file.", - }, - "compression": { - "type": "object", - "description": "The type of compression to use.", - "properties": { - "kind": {"type": "string", "enum": ["gzip", "bzip2", "xz"]}, - "quality": { - "description": "Compression quality", - "type": "integer", - "minimum": 0, - "maximum": 9, - }, - }, - }, - }, - "required": ["filename"], - "additionalProperties": False, - }, - } - - -def test_copy_actions_from_actions_yaml(tmp_path, prepare_charmcraft_yaml, prepare_actions_yaml): - """Dump a actions.yaml from charmcraft.yaml.""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - description: test-description - """ - ), - ) - prepare_actions_yaml( - dedent( - """ - pause: - description: Pause the database. - resume: - description: Resume a paused database. - snapshot: - description: Take a snapshot of the database. - params: - filename: - type: string - description: The name of the snapshot file. - compression: - type: object - description: The type of compression to use. - properties: - kind: - type: string - enum: [gzip, bzip2, xz] - quality: - description: Compression quality - type: integer - minimum: 0 - maximum: 9 - required: [filename] - additionalProperties: false - #### TEST-COPY #### - """ - ), - ) - - config = load(tmp_path) - - os.mkdir(tmp_path / "new") - - create_actions_yaml(tmp_path / "new", config) - - actions_yaml = (tmp_path / "new" / const.JUJU_ACTIONS_FILENAME).read_text() - - # Copy will preserve the TEST-COPY comment - assert "TEST-COPY" in actions_yaml - - actions = yaml.safe_load(actions_yaml) - - assert actions == { - "pause": {"description": "Pause the database."}, - "resume": {"description": "Resume a paused database."}, - "snapshot": { - "description": "Take a snapshot of the database.", - "params": { - "filename": { - "type": "string", - "description": "The name of the snapshot file.", - }, - "compression": { - "type": "object", - "description": "The type of compression to use.", - "properties": { - "kind": {"type": "string", "enum": ["gzip", "bzip2", "xz"]}, - "quality": { - "description": "Compression quality", - "type": "integer", - "minimum": 0, - "maximum": 9, - }, - }, - }, - }, - "required": ["filename"], - "additionalProperties": False, - }, - } From 1b41f1c689ff0a27b4aa8a184633e75476b15118 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 16:05:56 -0400 Subject: [PATCH 21/59] chore: remove unused create_metadata_yaml function --- charmcraft/metafiles/metadata.py | 50 -- tests/test_metafiles.py | 793 ------------------------------- 2 files changed, 843 deletions(-) delete mode 100644 tests/test_metafiles.py diff --git a/charmcraft/metafiles/metadata.py b/charmcraft/metafiles/metadata.py index 8f394cdf9..276f63898 100644 --- a/charmcraft/metafiles/metadata.py +++ b/charmcraft/metafiles/metadata.py @@ -97,53 +97,3 @@ def parse_bundle_metadata_yaml(charm_dir: pathlib.Path) -> BundleMetadata: emit.debug("Validating metadata keys") return BundleMetadata.unmarshal(metadata) - - -def create_metadata_yaml( - charm_dir: pathlib.Path, - charmcraft_config: "CharmcraftConfig", -) -> pathlib.Path: - """Create metadata.yaml in charm_dir for given project configuration. - - Use CHARM_METADATA_KEYS and CHARM_METADATA_KEYS_ALIAS to filter the keys. - - :param charm_dir: Directory to create Charm in. - :param charmcraft_config: Charmcraft configuration object. - - :returns: Path to created metadata.yaml. - """ - original_file_path = charmcraft_config.project.dirpath / const.METADATA_FILENAME - target_file_path = charm_dir / const.METADATA_FILENAME - - # Copy metadata.yaml if it exists, otherwise create it from CharmcraftConfig. - if original_file_path.exists(): - # In the build / test process, the original file may be the same as the target file. - with contextlib.suppress(shutil.SameFileError): - shutil.copyfile(original_file_path, target_file_path) - else: - # metadata.yaml not exists, create it from config - metadata = charmcraft_config.dict( - include=const.CHARM_METADATA_KEYS.union(const.CHARM_METADATA_KEYS_ALIAS), - exclude_none=True, - by_alias=True, - ) - - # convert to legacy metadata format - if (title := metadata.pop("title", None)) is not None: - metadata["display-name"] = title - - if (links := metadata.pop("links", None)) is not None: - if (documentation := links.pop("documentation", None)) is not None: - metadata["docs"] = documentation - if (issues := links.pop("issues", None)) is not None: - metadata["issues"] = issues - if (contact := links.pop("contact", None)) is not None: - metadata["maintainers"] = contact - if (source := links.pop("source", None)) is not None: - metadata["source"] = source - if (website := links.pop("website", None)) is not None: - metadata["website"] = website - - target_file_path.write_text(dump_yaml(metadata)) - - return target_file_path diff --git a/tests/test_metafiles.py b/tests/test_metafiles.py deleted file mode 100644 index b5916ee22..000000000 --- a/tests/test_metafiles.py +++ /dev/null @@ -1,793 +0,0 @@ -# Copyright 2023 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 -import os -from textwrap import dedent - -import yaml - -from charmcraft import const -from charmcraft.config import load -from charmcraft.metafiles.metadata import create_metadata_yaml - - -def test_dump_metadata_from_charmcraft_yaml(tmp_path, prepare_charmcraft_yaml): - """Dump a metadata.yaml with full metadata. (Spec ST087)""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - description: test-description - - bases: - - name: test-name - channel: test-channel - - assumes: - - test-feature - - any-of: - - extra-feature-1 - - extra-feature-2 - - all-of: - - juju >= 2.9.44 - - juju < 3 - - all-of: - - juju >= 3.1.6 - - juju < 4 - - all-of: - - test-feature-1 - - test-feature-2 - - containers: - container-1: - resource: resource-1 - bases: - - name: ubuntu - channel: 22.04 - architectures: - - x86_64 - mounts: - - storage: storage-1 - location: /var/lib/storage-1 - container-2: - resource: resource-2 - bases: - - name: ubuntu - channel: 22.04 - architectures: - - x86_64 - mounts: - - storage: storage-2 - location: /var/lib/storage-2 - - devices: - test-device-1: - type: gpu - description: gpu - countmin: 1 - countmax: 10 - - title: test-title - - extra-bindings: - test-binding-1: binding-1 - - links: - issues: https://example.com/issues - contact: - - https://example.com/contact - - contact@example.com - - "IRC #example" - documentation: https://example.com/docs - source: - - https://example.com/source - - https://example.com/source2 - - https://example.com/source3 - website: - - https://example.com/ - - peers: - peer-1: - interface: eth0 - limit: 1 - optional: true - scope: global - - provides: - provide-1: - interface: eht1 - limit: 1 - optional: true - scope: global - - requires: - peer-1: - interface: eth0 - limit: 1 - optional: true - scope: global - - resources: - resource-1: - type: file - description: resource-1 - filename: /path/to/resource-1 - - storage: - storage-1: - type: filesystem - description: storage-1 - location: /var/lib/storage-1 - shared: true - read-only: false - multiple: 5G - minimum-size: 5G - properties: - - transient - - subordinate: true - - terms: - - https://example.com/terms - - https://example.com/terms2 - """ - ), - ) - - config = load(tmp_path) - - create_metadata_yaml(tmp_path, config) - - metadata = yaml.safe_load((tmp_path / const.METADATA_FILENAME).read_text()) - - assert metadata == { - "name": "test-charm-name", - "summary": "test-summary", - "description": "test-description", - "assumes": [ - "test-feature", - { - "any-of": [ - "extra-feature-1", - "extra-feature-2", - {"all-of": ["juju >= 2.9.44", "juju < 3"]}, - {"all-of": ["juju >= 3.1.6", "juju < 4"]}, - ] - }, - {"all-of": ["test-feature-1", "test-feature-2"]}, - ], - "containers": { - "container-1": { - "resource": "resource-1", - "bases": [{"name": "ubuntu", "channel": 22.04, "architectures": ["x86_64"]}], - "mounts": [{"storage": "storage-1", "location": "/var/lib/storage-1"}], - }, - "container-2": { - "resource": "resource-2", - "bases": [{"name": "ubuntu", "channel": 22.04, "architectures": ["x86_64"]}], - "mounts": [{"storage": "storage-2", "location": "/var/lib/storage-2"}], - }, - }, - "devices": { - "test-device-1": {"type": "gpu", "description": "gpu", "countmin": 1, "countmax": 10} - }, - "display-name": "test-title", - "peers": { - "peer-1": {"interface": "eth0", "limit": 1, "optional": True, "scope": "global"} - }, - "provides": { - "provide-1": {"interface": "eht1", "limit": 1, "optional": True, "scope": "global"} - }, - "requires": { - "peer-1": {"interface": "eth0", "limit": 1, "optional": True, "scope": "global"} - }, - "resources": { - "resource-1": None, - "type": "file", - "description": "resource-1", - "filename": "/path/to/resource-1", - }, - "storage": { - "storage-1": { - "type": "filesystem", - "description": "storage-1", - "location": "/var/lib/storage-1", - "shared": True, - "read-only": False, - "multiple": "5G", - "minimum-size": "5G", - "properties": ["transient"], - } - }, - "subordinate": True, - "terms": ["https://example.com/terms", "https://example.com/terms2"], - "extra-bindings": {"test-binding-1": "binding-1"}, - "docs": "https://example.com/docs", - "issues": "https://example.com/issues", - "maintainers": ["https://example.com/contact", "contact@example.com", "IRC #example"], - "source": [ - "https://example.com/source", - "https://example.com/source2", - "https://example.com/source3", - ], - "website": ["https://example.com/"], - } - - -def test_copy_metadata_from_metadata_yaml( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml -): - """Copy a metadata.yaml with full metadata. (Spec ST087)""" - prepare_charmcraft_yaml( - dedent( - """ - type: charm - bases: - - name: test-name - channel: test-channel - """ - ) - ) - prepare_metadata_yaml( - dedent( - """ - name: test-charm-name - summary: test-summary - description: test-description - assumes: - - test-feature - - any-of: - - extra-feature-1 - - extra-feature-2 - - all-of: - - juju >= 2.9.44 - - juju < 3 - - all-of: - - juju >= 3.1.6 - - juju < 4 - - all-of: - - test-feature-1 - - test-feature-2 - - containers: - container-1: - resource: resource-1 - bases: - - name: ubuntu - channel: 22.04 - architectures: - - x86_64 - mounts: - - storage: storage-1 - location: /var/lib/storage-1 - container-2: - resource: resource-2 - bases: - - name: ubuntu - channel: 22.04 - architectures: - - x86_64 - mounts: - - storage: storage-2 - location: /var/lib/storage-2 - - devices: - test-device-1: - type: gpu - description: gpu - countmin: 1 - countmax: 10 - - display-name: test-title - - docs: https://example.com/docs - - extra-bindings: - test-binding-1: binding-1 - - issues: https://example.com/issues - - maintainers: - - https://example.com/contact - - contact@example.com - - "IRC #example" - - peers: - peer-1: - interface: eth0 - limit: 1 - optional: true - scope: global - - provides: - provide-1: - interface: eht1 - limit: 1 - optional: true - scope: global - - requires: - peer-1: - interface: eth0 - limit: 1 - optional: true - scope: global - - resources: - resource-1: - type: file - description: resource-1 - filename: /path/to/resource-1 - - source: - - https://example.com/source - - https://example.com/source2 - - https://example.com/source3 - - storage: - storage-1: - type: filesystem - description: storage-1 - location: /var/lib/storage-1 - shared: true - read-only: false - multiple: 5G - minimum-size: 5G - properties: - - transient - - subordinate: true - - terms: - - https://example.com/terms - - https://example.com/terms2 - - website: - - https://example.com/ - - #### TEST-COPY #### - """ - ), - ) - - config = load(tmp_path) - - os.mkdir(tmp_path / "new") - - create_metadata_yaml(tmp_path / "new", config) - - metadata_yaml = (tmp_path / "new" / const.METADATA_FILENAME).read_text() - - # Copy will preserve the TEST-COPY comment - assert "TEST-COPY" in metadata_yaml - - metadata = yaml.safe_load(metadata_yaml) - - assert metadata == { - "name": "test-charm-name", - "summary": "test-summary", - "description": "test-description", - "assumes": [ - "test-feature", - { - "any-of": [ - "extra-feature-1", - "extra-feature-2", - {"all-of": ["juju >= 2.9.44", "juju < 3"]}, - {"all-of": ["juju >= 3.1.6", "juju < 4"]}, - ] - }, - {"all-of": ["test-feature-1", "test-feature-2"]}, - ], - "containers": { - "container-1": { - "resource": "resource-1", - "bases": [{"name": "ubuntu", "channel": 22.04, "architectures": ["x86_64"]}], - "mounts": [{"storage": "storage-1", "location": "/var/lib/storage-1"}], - }, - "container-2": { - "resource": "resource-2", - "bases": [{"name": "ubuntu", "channel": 22.04, "architectures": ["x86_64"]}], - "mounts": [{"storage": "storage-2", "location": "/var/lib/storage-2"}], - }, - }, - "devices": { - "test-device-1": {"type": "gpu", "description": "gpu", "countmin": 1, "countmax": 10} - }, - "display-name": "test-title", - "peers": { - "peer-1": {"interface": "eth0", "limit": 1, "optional": True, "scope": "global"} - }, - "provides": { - "provide-1": {"interface": "eht1", "limit": 1, "optional": True, "scope": "global"} - }, - "requires": { - "peer-1": {"interface": "eth0", "limit": 1, "optional": True, "scope": "global"} - }, - "resources": { - "resource-1": None, - "type": "file", - "description": "resource-1", - "filename": "/path/to/resource-1", - }, - "storage": { - "storage-1": { - "type": "filesystem", - "description": "storage-1", - "location": "/var/lib/storage-1", - "shared": True, - "read-only": False, - "multiple": "5G", - "minimum-size": "5G", - "properties": ["transient"], - } - }, - "subordinate": True, - "terms": ["https://example.com/terms", "https://example.com/terms2"], - "extra-bindings": {"test-binding-1": "binding-1"}, - "docs": "https://example.com/docs", - "issues": "https://example.com/issues", - "maintainers": ["https://example.com/contact", "contact@example.com", "IRC #example"], - "source": [ - "https://example.com/source", - "https://example.com/source2", - "https://example.com/source3", - ], - "website": ["https://example.com/"], - } - - -def test_copy_metadata_from_metadata_yaml_with_arbitrary_keys( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml -): - """Copy a metadata.yaml with full metadata. (Spec ST087)""" - prepare_charmcraft_yaml( - dedent( - """ - type: charm - bases: - - name: test-name - channel: test-channel - """ - ) - ) - prepare_metadata_yaml( - dedent( - """ - name: test-charm-name - summary: test-summary - description: test-description - assumes: - - test-feature - - any-of: - - extra-feature-1 - - extra-feature-2 - - all-of: - - test-feature-1 - - test-feature-2 - - containers: - container-1: - resource: resource-1 - bases: - - name: ubuntu - channel: 22.04 - architectures: - - x86_64 - mounts: - - storage: storage-1 - location: /var/lib/storage-1 - container-2: - resource: resource-2 - bases: - - name: ubuntu - channel: 22.04 - architectures: - - x86_64 - mounts: - - storage: storage-2 - location: /var/lib/storage-2 - - devices: - test-device-1: - type: gpu - description: gpu - countmin: 1 - countmax: 10 - - display-name: test-title - - docs: https://example.com/docs - - extra-bindings: - test-binding-1: binding-1 - - issues: https://example.com/issues - - maintainers: - - https://example.com/contact - - contact@example.com - - "IRC #example" - - peers: - peer-1: - interface: eth0 - limit: 1 - optional: true - scope: global - - provides: - provide-1: - interface: eht1 - limit: 1 - optional: true - scope: global - - requires: - peer-1: - interface: eth0 - limit: 1 - optional: true - scope: global - - resources: - resource-1: - type: file - description: resource-1 - filename: /path/to/resource-1 - - source: - - https://example.com/source - - https://example.com/source2 - - https://example.com/source3 - - storage: - storage-1: - type: filesystem - description: storage-1 - location: /var/lib/storage-1 - shared: true - read-only: false - multiple: 5G - minimum-size: 5G - properties: - - transient - - subordinate: true - - terms: - - https://example.com/terms - - https://example.com/terms2 - - website: - - https://example.com/ - - test-arbitrary-key: test-arbitrary-value - test-arbitrary-key-2: test-arbitrary-value-2 - - #### TEST-COPY #### - """ - ), - ) - - config = load(tmp_path) - - os.mkdir(tmp_path / "new") - - create_metadata_yaml(tmp_path / "new", config) - - metadata_yaml = (tmp_path / "new" / const.METADATA_FILENAME).read_text() - - # Copy will preserve the TEST-COPY comment - assert "TEST-COPY" in metadata_yaml - - metadata = yaml.safe_load(metadata_yaml) - - assert metadata == { - "name": "test-charm-name", - "summary": "test-summary", - "description": "test-description", - "assumes": [ - "test-feature", - {"any-of": ["extra-feature-1", "extra-feature-2"]}, - {"all-of": ["test-feature-1", "test-feature-2"]}, - ], - "containers": { - "container-1": { - "resource": "resource-1", - "bases": [{"name": "ubuntu", "channel": 22.04, "architectures": ["x86_64"]}], - "mounts": [{"storage": "storage-1", "location": "/var/lib/storage-1"}], - }, - "container-2": { - "resource": "resource-2", - "bases": [{"name": "ubuntu", "channel": 22.04, "architectures": ["x86_64"]}], - "mounts": [{"storage": "storage-2", "location": "/var/lib/storage-2"}], - }, - }, - "devices": { - "test-device-1": {"type": "gpu", "description": "gpu", "countmin": 1, "countmax": 10} - }, - "display-name": "test-title", - "peers": { - "peer-1": {"interface": "eth0", "limit": 1, "optional": True, "scope": "global"} - }, - "provides": { - "provide-1": {"interface": "eht1", "limit": 1, "optional": True, "scope": "global"} - }, - "requires": { - "peer-1": {"interface": "eth0", "limit": 1, "optional": True, "scope": "global"} - }, - "resources": { - "resource-1": None, - "type": "file", - "description": "resource-1", - "filename": "/path/to/resource-1", - }, - "storage": { - "storage-1": { - "type": "filesystem", - "description": "storage-1", - "location": "/var/lib/storage-1", - "shared": True, - "read-only": False, - "multiple": "5G", - "minimum-size": "5G", - "properties": ["transient"], - } - }, - "subordinate": True, - "terms": ["https://example.com/terms", "https://example.com/terms2"], - "extra-bindings": {"test-binding-1": "binding-1"}, - "docs": "https://example.com/docs", - "issues": "https://example.com/issues", - "maintainers": ["https://example.com/contact", "contact@example.com", "IRC #example"], - "source": [ - "https://example.com/source", - "https://example.com/source2", - "https://example.com/source3", - ], - "website": ["https://example.com/"], - "test-arbitrary-key": "test-arbitrary-value", - "test-arbitrary-key-2": "test-arbitrary-value-2", - } - - -def test_copy_bundle_metadata_from_metadata_yaml( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml -): - """Copy a metadata.yaml when type is bundle.""" - prepare_charmcraft_yaml( - dedent( - """ - type: bundle - """ - ), - ) - prepare_metadata_yaml( - dedent( - """ - name: test-charm-name - description: This is a test bundle. - - variables: - data-port: &data-port br-ex:eno2 - worker-multiplier: &worker-multiplier 0.25 - - - series: bionic - - tags: [monitoring] - - tags: [database, utility] - - - applications: - easyrsa: - charm: containers-easyrsa - revision: 8 - channel: latest/edge - series: bionic - - resources: - easyrsa: 5 - - resources: - easyrsa: ./relative/path/to/file - - resources: - easyrsa: /absolute/path/to/file - - num_units: 2 - - to: 3, new - to: ["django/0", "django/1", "django/2"] - to: ["django"] - to: ["lxd"] - to: ["lxd:2", "lxd:3"] - to: ["lxd:nova-compute/2", "lxd:glance/3"] - - expose: true - - offers: - my-offer: - endpoints: - - apache-website - acl: - admin: admin - user1: read - - options: - osd-devices: /dev/sdb - worker-multiplier: *worker-multiplier - - annotations: - gui-x: 450 - gui-y: 550 - - constraints: root-disk=8G - - constraints: cores=4 mem=4G root-disk=16G - - constraints: zones=us-east-1a - - storage: - database: ebs,10G,1 - - bindings: - "": alpha - kube-api-endpoint: internal - loadbalancer: dmz - - plan: acme-support/default - - machines: - "1": - "2": - series: bionic - constraints: cores=2 mem=2G - "3": - constraints: cores=3 root-disk=1T - - relations: - - - kubernetes-master:kube-api-endpoint - - kubeapi-load-balancer:apiserver - - - kubernetes-master:loadbalancer - - kubeapi-load-balancer:loadbalancer - - saas: - svc1: - url: localoffer.svc1 - svc2: - url: admin/localoffer.svc2 - svc3: - url: othercontroller:admin/offer.svc3 - - #### TEST-COPY #### - """ - ), - ) - - config = load(tmp_path) - - os.mkdir(tmp_path / "new") - - create_metadata_yaml(tmp_path / "new", config) - - metadata_yaml = (tmp_path / "new" / const.METADATA_FILENAME).read_text() - - # Copy will preserve the TEST-COPY comment - assert "TEST-COPY" in metadata_yaml From 99c33ee8b710df79e2a00742163d3b0449d52689 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 16:11:54 -0400 Subject: [PATCH 22/59] chore: remove the unused linters.analyze function Everything from this function now exists in the AnalysisService. --- charmcraft/linters.py | 49 +---- charmcraft/metafiles/actions.py | 8 +- charmcraft/metafiles/config.py | 7 - charmcraft/metafiles/metadata.py | 8 +- tests/integration/commands/test_analyse.py | 10 +- tests/test_linters.py | 216 --------------------- 6 files changed, 4 insertions(+), 294 deletions(-) diff --git a/charmcraft/linters.py b/charmcraft/linters.py index 87e73a913..1ce0aa42b 100644 --- a/charmcraft/linters.py +++ b/charmcraft/linters.py @@ -26,7 +26,7 @@ import yaml -from charmcraft import config, const, utils +from charmcraft import const, utils from charmcraft.metafiles.metadata import parse_charm_metadata_yaml, read_metadata_yaml from charmcraft.models.lint import CheckResult, CheckType, LintResult @@ -585,50 +585,3 @@ def run(self, basedir: pathlib.Path) -> str: Entrypoint, AdditionalFiles, ] - - -def analyze( - config: config.CharmcraftConfig, - basedir: pathlib.Path, - *, - override_ignore_config: bool = False, -) -> list[CheckResult]: - """Run all checkers and linters.""" - all_results = [] - for cls in CHECKERS: - # do not run the ignored ones - if cls.check_type == CheckType.ATTRIBUTE: - ignore_list = config.analysis.ignore.attributes - else: - ignore_list = config.analysis.ignore.linters - if cls.name in ignore_list and not override_ignore_config: - all_results.append( - CheckResult( - check_type=cls.check_type, - name=cls.name, - result=LintResult.IGNORED, - url=cls.url, - text="", - ) - ) - continue - - checker = cls() - try: - result = checker.run(basedir) - except Exception: - result = ( - LintResult.UNKNOWN - if checker.check_type == CheckType.ATTRIBUTE - else LintResult.FATAL - ) - all_results.append( - CheckResult( - check_type=checker.check_type, - name=checker.name, - url=checker.url, - text=checker.text or "n/a", - result=result, - ) - ) - return all_results diff --git a/charmcraft/metafiles/actions.py b/charmcraft/metafiles/actions.py index ac7cacd38..e78a8df41 100644 --- a/charmcraft/metafiles/actions.py +++ b/charmcraft/metafiles/actions.py @@ -16,15 +16,12 @@ """Charmcraft project handle actions.yaml file.""" -import contextlib import logging import pathlib -import shutil import typing -from typing import TYPE_CHECKING, Literal +from typing import Literal import pydantic -import yaml from craft_application import util from craft_application.util.error_formatting import format_pydantic_errors from craft_cli import CraftError, emit @@ -32,9 +29,6 @@ from charmcraft import const from charmcraft.models.actions import JujuActions -if TYPE_CHECKING: - from charmcraft.models.charmcraft import CharmcraftConfig - logger = logging.getLogger(__name__) diff --git a/charmcraft/metafiles/config.py b/charmcraft/metafiles/config.py index c7db20671..8a821e944 100644 --- a/charmcraft/metafiles/config.py +++ b/charmcraft/metafiles/config.py @@ -16,14 +16,10 @@ """Charmcraft project handle config.yaml file.""" -import contextlib import logging import pathlib -import shutil -from typing import TYPE_CHECKING import pydantic -import yaml from craft_application.util.error_formatting import format_pydantic_errors from craft_cli import CraftError, emit @@ -31,9 +27,6 @@ from charmcraft.metafiles import read_yaml from charmcraft.models.config import JujuConfig -if TYPE_CHECKING: - from charmcraft.models.charmcraft import CharmcraftConfig - logger = logging.getLogger(__name__) diff --git a/charmcraft/metafiles/metadata.py b/charmcraft/metafiles/metadata.py index 276f63898..2be4cbc57 100644 --- a/charmcraft/metafiles/metadata.py +++ b/charmcraft/metafiles/metadata.py @@ -16,11 +16,9 @@ """Handlers for metadata.yaml file.""" -import contextlib import logging import pathlib -import shutil -from typing import TYPE_CHECKING, Any +from typing import Any import pydantic import yaml @@ -29,10 +27,6 @@ from charmcraft import const from charmcraft.models.metadata import BundleMetadata, CharmMetadataLegacy -from charmcraft.utils.yaml import dump_yaml - -if TYPE_CHECKING: - from charmcraft.models.charmcraft import CharmcraftConfig logger = logging.getLogger(__name__) diff --git a/tests/integration/commands/test_analyse.py b/tests/integration/commands/test_analyse.py index 1398815b7..36e00fc49 100644 --- a/tests/integration/commands/test_analyse.py +++ b/tests/integration/commands/test_analyse.py @@ -49,14 +49,6 @@ def test_expanded_charm_permissions(config, fake_project_dir, monkeypatch, modeb with zipfile.ZipFile(str(charm_file), "w") as zf: zf.write(str(payload_file), payload_file.name) - def fake_analyze(passed_config, passed_basedir, *, override_ignore_config): - """Check payload content and attributes.""" - unzipped_payload = passed_basedir / "payload.txt" - assert unzipped_payload.read_bytes() == b"123" - assert unzipped_payload.stat().st_mode & 0o777 == modebits - return [] - - monkeypatch.setattr(linters, "analyze", fake_analyze) args = Namespace(filepath=charm_file, force=None, format=None, ignore=None) Analyse(config).run(args) @@ -82,7 +74,7 @@ def create_a_valid_zip(tmp_path): def test_integration_linters(fake_project_dir, emitter, config, monkeypatch): - """Integration test with the real linters.analyze function (as other tests fake it).""" + """Integration test with a real analysis.""" fake_charm = create_a_valid_zip(fake_project_dir) args = Namespace(filepath=fake_charm, force=None, format=None, ignore=None) Analyse(config).run(args) diff --git a/tests/test_linters.py b/tests/test_linters.py index b3e2155e3..6d40ca095 100644 --- a/tests/test_linters.py +++ b/tests/test_linters.py @@ -25,10 +25,7 @@ from charmcraft import const from charmcraft.linters import ( - CHECKERS, AdditionalFiles, - BaseChecker, - CheckType, Entrypoint, Framework, JujuActions, @@ -36,7 +33,6 @@ JujuMetadata, Language, NamingConventions, - analyze, check_dispatch_with_python_entrypoint, get_entrypoint_from_dispatch, ) @@ -671,218 +667,6 @@ def test_jujumetadata_series_is_deprecated(tmp_path): ) -# --- tests for analyze function - - -def create_fake_checker(**kwargs): - """Create a fake Checker. - - Receive generic kwargs and process them as a dict for the defaults, as we can't declare - the name in the function definition and then use it in the class definition. - """ - params = { - "check_type": "type", - "name": "name", - "url": "url", - "text": "text", - "result": "result", - } - params.update(kwargs) - - class FakeChecker(BaseChecker): - check_type = params["check_type"] - name = params["name"] - url = params["url"] - text = params["text"] - - def run(self, basedir): - return params["result"] - - return FakeChecker - - -def test_analyze_run_everything(config): - """Check that analyze runs all and collect the results.""" - FakeChecker1 = create_fake_checker( - check_type=CheckType.ATTRIBUTE, name="name1", url="url1", text="text1", result="result1" - ) - FakeChecker2 = create_fake_checker( - check_type=CheckType.LINT, name="name2", url="url2", text="text2", result="result2" - ) - FakeChecker3 = create_fake_checker( - check_type=CheckType.LINT, name="returns_none", url="url3", text=None, result="result3" - ) - - # hack the first fake checker to validate that it receives the indicated path - def dir_validator(self, basedir): - assert basedir == pathlib.Path("test-buildpath") - return "result1" - - FakeChecker1.run = dir_validator - - with patch("charmcraft.linters.CHECKERS", [FakeChecker1, FakeChecker2, FakeChecker3]): - result = analyze(config, pathlib.Path("test-buildpath")) - - r1, r2, r3 = result - assert r1.check_type == "attribute" - assert r1.name == "name1" - assert r1.url == "url1" - assert r1.text == "text1" - assert r1.result == "result1" - assert r2.check_type == "lint" - assert r2.name == "name2" - assert r2.url == "url2" - assert r2.text == "text2" - assert r2.result == "result2" - assert r3.name == "returns_none" - assert r3.url == "url3" - assert r3.text == "n/a" - assert r3.result == "result3" - - -def test_analyze_ignore_attribute(config): - """Run all checkers except the ignored attribute.""" - FakeChecker1 = create_fake_checker( - check_type=CheckType.ATTRIBUTE, - name="name1", - result="res1", - text="text1", - url="url1", - ) - FakeChecker2 = create_fake_checker( - check_type=CheckType.LINT, - name="name2", - result="res2", - text="text2", - url="url2", - ) - - config.analysis.ignore.attributes.append("name1") - with patch("charmcraft.linters.CHECKERS", [FakeChecker1, FakeChecker2]): - result = analyze(config, pathlib.Path("somepath")) - - res1, res2 = result - assert res1.check_type == CheckType.ATTRIBUTE - assert res1.name == "name1" - assert res1.result == LintResult.IGNORED - assert res1.text == "" - assert res1.url == "url1" - assert res2.check_type == CheckType.LINT - assert res2.name == "name2" - assert res2.result == "res2" - assert res2.text == "text2" - assert res2.url == "url2" - - -def test_analyze_ignore_linter(config): - """Run all checkers except the ignored linter.""" - FakeChecker1 = create_fake_checker( - check_type=CheckType.ATTRIBUTE, - name="name1", - result="res1", - text="text1", - url="url1", - ) - FakeChecker2 = create_fake_checker( - check_type=CheckType.LINT, - name="name2", - result="res2", - text="text2", - url="url2", - ) - - config.analysis.ignore.linters.append("name2") - with patch("charmcraft.linters.CHECKERS", [FakeChecker1, FakeChecker2]): - result = analyze(config, pathlib.Path("somepath")) - - res1, res2 = result - assert res1.check_type == CheckType.ATTRIBUTE - assert res1.name == "name1" - assert res1.result == "res1" - assert res1.text == "text1" - assert res1.url == "url1" - assert res2.check_type == CheckType.LINT - assert res2.name == "name2" - assert res2.result == LintResult.IGNORED - assert res2.text == "" - assert res2.url == "url2" - - -def test_analyze_override_ignore(config): - """Run all checkers even the ignored ones, if requested.""" - FakeChecker1 = create_fake_checker(check_type=CheckType.ATTRIBUTE, name="name1", result="res1") - FakeChecker2 = create_fake_checker(check_type=CheckType.LINT, name="name2", result="res2") - - config.analysis.ignore.attributes.append("name1") - config.analysis.ignore.linters.append("name2") - with patch("charmcraft.linters.CHECKERS", [FakeChecker1, FakeChecker2]): - result = analyze(config, pathlib.Path("somepath"), override_ignore_config=True) - - res1, res2 = result - assert res1.check_type == CheckType.ATTRIBUTE - assert res1.name == "name1" - assert res1.result == "res1" - assert res2.check_type == CheckType.LINT - assert res2.name == "name2" - assert res2.result == "res2" - - -def test_analyze_crash_attribute(config): - """The attribute checker crashes.""" - FakeChecker = create_fake_checker( - check_type=CheckType.ATTRIBUTE, name="name", text="text", url="url" - ) - - def raises(*a): - raise ValueError - - FakeChecker.run = raises - - with patch("charmcraft.linters.CHECKERS", [FakeChecker]): - result = analyze(config, pathlib.Path("somepath")) - - (res,) = result - assert res.check_type == CheckType.ATTRIBUTE - assert res.name == "name" - assert res.result == LintResult.UNKNOWN - assert res.text == "text" - assert res.url == "url" - - -def test_analyze_crash_lint(config): - """The lint checker crashes.""" - FakeChecker = create_fake_checker( - check_type=CheckType.LINT, name="name", text="text", url="url" - ) - - def raises(*a): - raise ValueError - - FakeChecker.run = raises - - with patch("charmcraft.linters.CHECKERS", [FakeChecker]): - result = analyze(config, pathlib.Path("somepath")) - - (res,) = result - assert res.check_type == CheckType.LINT - assert res.name == "name" - assert res.result == LintResult.FATAL - assert res.text == "text" - assert res.url == "url" - - -def test_analyze_all_can_be_ignored(config): - """Control that all real life checkers can be ignored.""" - config.analysis.ignore.attributes.extend( - c.name for c in CHECKERS if c.check_type == CheckType.ATTRIBUTE - ) - config.analysis.ignore.linters.extend( - c.name for c in CHECKERS if c.check_type == CheckType.LINT - ) - result = analyze(config, pathlib.Path("somepath")) - assert all(r.result == LintResult.IGNORED for r in result) - - # --- tests for JujuActions checker From 8b759b1b8681e9bc9464c2e7891938d1ddd8adef Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 16:27:46 -0400 Subject: [PATCH 23/59] chore: remove unused config.load function --- charmcraft/config.py | 120 - charmcraft/models/charmcraft.py | 15 +- tests/extensions/test_extensions.py | 88 - tests/test_config.py | 3377 --------------------------- tests/test_models.py | 1270 ---------- 5 files changed, 9 insertions(+), 4861 deletions(-) delete mode 100644 charmcraft/config.py delete mode 100644 tests/test_config.py delete mode 100644 tests/test_models.py diff --git a/charmcraft/config.py b/charmcraft/config.py deleted file mode 100644 index d21ccfaf2..000000000 --- a/charmcraft/config.py +++ /dev/null @@ -1,120 +0,0 @@ -# Copyright 2020-2021 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 - -"""Central configuration management. - -Using pydantic's BaseModel, this module supports the translation of the -charmcraft.yaml to a python object. - -Configuration Schema -==================== - -type: [string] one of "charm" or "bundle" - -charmhub: - api-url: [HttpUrl] optional, defaults to "https://api.charmhub.io" - storage-url: [HttpUrl] optional, defaults to "https://storage.snapcraftcontent.com" - registry-url = [HttpUrl] optional, defaults to "https://registry.jujucharms.com" - -parts: - charm: - charm-entrypoint: [string] optional, defaults to "src/charm.py" - charm-requirements: [list of strings] optional, defaults to ["requirements.txt"] if present - prime: [list of strings] - - bundle: - prime: [list of strings] - -bases: [list of bases and/or long-form base configurations] - -analysis: - ignore: - attributes: [list of attribute names to ignore] - linting: [list of linter names to ignore] - -actions: - my-action: - description: Action as defined at https://juju.is/docs/sdk/actions - - -Object Definitions -================== - -Base -**** - -Object with the following properties: -- name: [string] name of base -- channel: [string] name of channel -- architectures: [list of strings], defaults to [] - -BaseConfiguration -***************** - -Object with the following properties: -- build-on: [list of bases] to build on -- run-on: [list of bases] that build-on entries may run on - -""" - -import datetime -import pathlib - -from charmcraft import const -from charmcraft.env import ( - get_managed_environment_project_path, - is_charmcraft_running_in_managed_mode, -) -from charmcraft.models.charmcraft import CharmcraftConfig, Project -from charmcraft.utils import load_yaml - - -def load(dirpath: str | None) -> CharmcraftConfig: - """Load the config from charmcraft.yaml in the indicated directory.""" - if dirpath is None: - if is_charmcraft_running_in_managed_mode(): - path = get_managed_environment_project_path() - else: - path = pathlib.Path.cwd() - else: - path = pathlib.Path(dirpath).expanduser().resolve() - - now = datetime.datetime.utcnow() - - content = load_yaml(path / const.CHARMCRAFT_FILENAME) - if content is None: - # configuration is mandatory only for some commands; when not provided, it will - # be initialized all with defaults (but marked as not provided for later verification) - return CharmcraftConfig( # pyright: ignore[reportCallIssue] - type="charm", - project=Project( - dirpath=path, - config_provided=False, - started_at=now, - ), - name="missing-charm-name", - summary="missing-charm-summary", - description="missing-charm-description", - ) - - return CharmcraftConfig.unmarshal( - content, - project=Project( - dirpath=path, - config_provided=True, - started_at=now, - ), - ) diff --git a/charmcraft/models/charmcraft.py b/charmcraft/models/charmcraft.py index 881abc41d..59bb4f373 100644 --- a/charmcraft/models/charmcraft.py +++ b/charmcraft/models/charmcraft.py @@ -28,12 +28,7 @@ from charmcraft import const, parts from charmcraft.extensions import apply_extensions -from charmcraft.metafiles.actions import parse_actions_yaml -from charmcraft.metafiles.config import parse_config_yaml -from charmcraft.metafiles.metadata import ( - parse_bundle_metadata_yaml, - parse_charm_metadata_yaml, -) + from charmcraft.models.actions import JujuActions from charmcraft.models.basic import AttributeName, LinterName, ModelConfigDefaults from charmcraft.models.config import JujuConfig @@ -256,6 +251,7 @@ def validate_actions(cls, actions, values): And individual "actions.yaml" should not exists when actions is defined in charmcraft.yaml. """ + from charmcraft.metafiles.actions import parse_actions_yaml actions_yaml = parse_actions_yaml(values["project"].dirpath, allow_broken=True) if actions is None: return actions_yaml @@ -276,6 +272,7 @@ def validate_config(cls, config, values): And individual "actions.yaml" should not exists when actions is defined in charmcraft.yaml. """ + from charmcraft.metafiles.config import parse_config_yaml config_yaml = parse_config_yaml(values["project"].dirpath, allow_broken=True) if config is None: return config_yaml @@ -328,6 +325,12 @@ def unmarshal( # pyright: ignore[reportIncompatibleMethodOverride] :raises CraftError: On failure to unmarshal object. """ + from charmcraft.metafiles.actions import parse_actions_yaml + from charmcraft.metafiles.config import parse_config_yaml + from charmcraft.metafiles.metadata import ( + parse_bundle_metadata_yaml, + parse_charm_metadata_yaml, + ) try: # Expand short-form bases if only the bases is a valid list. If it # is not a valid list, parse_obj() will properly handle the error. diff --git a/tests/extensions/test_extensions.py b/tests/extensions/test_extensions.py index 906e3af69..26aa35004 100644 --- a/tests/extensions/test_extensions.py +++ b/tests/extensions/test_extensions.py @@ -21,7 +21,6 @@ from overrides import override from charmcraft import const, errors, extensions -from charmcraft.config import load from charmcraft.extensions.extension import Extension @@ -204,90 +203,3 @@ def test_apply_extensions(fake_extensions, tmp_path): # New part assert parts[f"{FullExtension.name}/new-part"] == {"plugin": "nil", "source": None} - - -@pytest.mark.parametrize( - ("charmcraft_yaml"), - [ - dedent( - f"""\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - name: ubuntu - channel: "22.04" - extensions: - - {FullExtension.name} - parts: - foo: - plugin: nil - stage-packages: - - old-package - """ - ), - ], -) -def test_load_charmcraft_yaml_with_extensions( - tmp_path, - prepare_charmcraft_yaml, - charmcraft_yaml, - fake_extensions, -): - """Load the config using charmcraft.yaml with extensions.""" - prepare_charmcraft_yaml(charmcraft_yaml) - - config = load(tmp_path) - assert config.type == "charm" - assert config.project.dirpath == tmp_path - assert config.parts["foo"]["stage-packages"] == [ - "new-package-1", - "old-package", - ] - - # New part - assert config.parts[f"{FullExtension.name}/new-part"] == {"plugin": "nil", "source": None} - assert config.terms == ["https://example.com/terms", "https://example.com/terms2"] - - -@pytest.mark.parametrize( - ("charmcraft_yaml"), - [ - dedent( - f"""\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - name: ubuntu - channel: "20.04" - - name: ubuntu - channel: "22.04" - extensions: - - {FullExtension.name} - parts: - foo: - plugin: nil - stage-packages: - - old-package - """ - ), - ], -) -def test_load_charmcraft_yaml_with_extensions_unsupported_base( - tmp_path, - prepare_charmcraft_yaml, - charmcraft_yaml, - fake_extensions, -): - """Load the config using charmcraft.yaml with extensions.""" - prepare_charmcraft_yaml(charmcraft_yaml) - - with pytest.raises(errors.ExtensionError) as exc: - load(tmp_path) - - assert str(exc.value) == ( - "Extension 'full-extension' does not support base: ('ubuntu', '20.04')" - ) diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index b4cd4c140..000000000 --- a/tests/test_config.py +++ /dev/null @@ -1,3377 +0,0 @@ -# Copyright 2020-2023 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 - -import datetime -import os -import pathlib -import sys -from textwrap import dedent -from unittest.mock import patch - -import pytest -from craft_application import util -from craft_cli import CraftError - -from charmcraft import const, linters -from charmcraft.config import load -from charmcraft.models.charmcraft import Base, BasesConfiguration, CharmhubConfig - -# -- tests for the config loading - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - """ - ), - None, - ], - ], -) -def test_load_current_directory( - tmp_path, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml, - metadata_yaml, - monkeypatch, -): - """Init the config using charmcraft.yaml in current directory.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - monkeypatch.chdir(tmp_path) - fake_utcnow = datetime.datetime(1970, 1, 1, 0, 0, 2, tzinfo=datetime.timezone.utc) - with patch("datetime.datetime") as mock: - mock.utcnow.return_value = fake_utcnow - config = load(None) - assert config.type == "charm" - assert config.project.dirpath == tmp_path - assert config.project.config_provided - assert config.project.started_at == fake_utcnow - - -def test_load_managed_mode_directory(monkeypatch, tmp_path): - """Validate managed-mode default directory is /root/project.""" - monkeypatch.chdir(tmp_path) - monkeypatch.setenv(const.MANAGED_MODE_ENV_VAR, "1") - - # Patch out Config (and Project) to prevent directory validation checks. - with patch("charmcraft.config.CharmcraftConfig"): - with patch("charmcraft.config.Project") as mock_project: - with patch("charmcraft.config.load_yaml"): - load(None) - - assert mock_project.call_args.kwargs["dirpath"] == pathlib.Path("/root/project") - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - """ - ), - None, - ], - ], -) -def test_load_specific_directory_ok( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Init the config using charmcraft.yaml in a specific directory.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.type == "charm" - assert config.project.dirpath == tmp_path - - -def test_load_optional_charmcraft_missing(tmp_path): - """Specify a directory where the file is missing.""" - config = load(tmp_path) - assert config.project.dirpath == tmp_path - assert not config.project.config_provided - - -def test_load_optional_charmcraft_bad_directory(tmp_path): - """Specify a missing directory.""" - missing_directory = tmp_path / "missing" - config = load(missing_directory) - assert config.project.dirpath == missing_directory - assert not config.project.config_provided - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - """ - ), - None, - ], - ], -) -def test_load_specific_directory_resolved( - tmp_path, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml, - metadata_yaml, - monkeypatch, -): - """Ensure that the given directory is resolved to always show the whole path.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - # change to some dir, and reference the config dir relatively - subdir = tmp_path / "subdir" - subdir.mkdir() - monkeypatch.chdir(subdir) - config = load("../") - - assert config.type == "charm" - assert config.project.dirpath == tmp_path - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - """ - ), - None, - ], - ], -) -@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported") -def test_load_specific_directory_expanded( - tmp_path, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml, - metadata_yaml, - monkeypatch, -): - """Ensure that the given directory is user-expanded.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - # fake HOME so the '~' indication is verified to work - monkeypatch.setitem(os.environ, "HOME", str(tmp_path)) - config = load("~") - - assert config.type == "charm" - assert config.project.dirpath == tmp_path - - -def test_load_metadata_keys_exists_both(tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml): - """Cannot define metadata keys in both charmcraft.yaml and metadata.yaml.""" - prepare_charmcraft_yaml( - dedent( - """\ - name: test-charm-name - type: charm - """ - ) - ) - prepare_metadata_yaml( - dedent( - """\ - name: test-charm-name - summary: test-summary - description: test-description - """ - ) - ) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - - assert str(cm.value) == dedent( - """Cannot specify 'name' in charmcraft.yaml when 'metadata.yaml' exists""" - ) - - -# -- tests for schema restrictions - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - whatever: new-stuff - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - whatever: new-stuff - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - """ - ), - None, - ], - ], -) -def test_schema_top_level_no_extra_properties( - tmp_path, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml, - metadata_yaml, -): - """Schema validation, cannot add undefined properties at the top level.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - extra field 'whatever' not permitted in top-level configuration""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - charmhub: - api-url: https://www.canonical.com - bases: - - build-on: - - name: test-build-name - channel: test-build-channel - run-on: - - name: test-build-name - channel: test-build-channel - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - charmhub: - api-url: https://www.canonical.com - bases: - - build-on: - - name: test-build-name - channel: test-build-channel - run-on: - - name: test-build-name - channel: test-build-channel - """ - ), - None, - ], - ], -) -def test_schema_type_missing( - tmp_path, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml, - metadata_yaml, -): - """Schema validation, type is mandatory.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - field 'type' required in top-level configuration""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: 33 - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: 33 - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - """ - ), - None, - ], - ], -) -def test_schema_type_bad_type( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, type is a string.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - unexpected value; permitted: 'bundle', 'charm' (in field 'type')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: whatever - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: whatever - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - """ - ), - None, - ], - ], -) -def test_schema_type_limited_values( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, type must be a subset of values.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - unexpected value; permitted: 'bundle', 'charm' (in field 'type')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - charmhub: - api-url: 33 - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - charmhub: - api-url: 33 - """ - ), - None, - ], - ], -) -def test_schema_charmhub_api_url_bad_type( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, charmhub.api-url must be a string.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - invalid or missing URL scheme (in field 'charmhub.api-url')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - charmhub: - api-url: stuff.com - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - charmhub: - api-url: stuff.com - """ - ), - None, - ], - ], -) -def test_schema_charmhub_api_url_bad_format( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, charmhub.api-url must be a full URL.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - invalid or missing URL scheme (in field 'charmhub.api-url')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - charmhub: - storage-url: 33 - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - charmhub: - storage-url: 33 - """ - ), - None, - ], - ], -) -def test_schema_charmhub_storage_url_bad_type( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, charmhub.storage-url must be a string.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - invalid or missing URL scheme (in field 'charmhub.storage-url')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - charmhub: - storage-url: stuff.com - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - charmhub: - storage-url: stuff.com - """ - ), - None, - ], - ], -) -def test_schema_charmhub_storage_url_bad_format( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, charmhub.storage-url must be a full URL.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - invalid or missing URL scheme (in field 'charmhub.storage-url')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - charmhub: - registry-url: 33 - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - charmhub: - registry-url: 33 - """ - ), - None, - ], - ], -) -def test_schema_charmhub_registry_url_bad_type( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, charmhub.registry-url must be a string.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - invalid or missing URL scheme (in field 'charmhub.registry-url')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - charmhub: - registry-url: stuff.com - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - charmhub: - registry-url: stuff.com - """ - ), - None, - ], - ], -) -def test_schema_charmhub_registry_url_bad_format( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, charmhub.registry-url must be a full URL.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - invalid or missing URL scheme (in field 'charmhub.registry-url')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - charmhub: - storage-url: https://some.server.com - crazy: false - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - charmhub: - storage-url: https://some.server.com - crazy: false - """ - ), - None, - ], - ], -) -def test_schema_charmhub_no_extra_properties( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, cannot add undefined properties in charmhub key.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - extra field 'crazy' not permitted in 'charmhub' configuration""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - parts: ['foo', 'bar'] - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - parts: ['foo', 'bar'] - """ - ), - None, - ], - ], -) -def test_schema_basicprime_bad_init_structure( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, basic prime with bad parts.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - value must be a dictionary (in field 'parts')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - parts: - charm: ['foo', 'bar'] - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - parts: - charm: ['foo', 'bar'] - """ - ), - None, - ], - ], -) -def test_schema_basicprime_bad_bundle_structure( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, basic prime with bad bundle.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - part 'charm' must be a dictionary (in field 'parts')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - parts: - charm: - prime: foo - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - parts: - charm: - prime: foo - """ - ), - None, - ], - ], -) -def test_schema_basicprime_bad_prime_structure( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, basic prime with bad prime.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - value is not a valid list (in field 'parts.charm.prime')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - parts: - charm: - prime: [{}, 'foo'] - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - parts: - charm: - prime: [{}, 'foo'] - """ - ), - None, - ], - ], -) -def test_schema_basicprime_bad_prime_type( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, basic prime with a prime holding not strings.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - string type expected (in field 'parts.charm.prime[0]')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - parts: - charm: - prime: ['', 'foo'] - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - - parts: - charm: - prime: ['', 'foo'] - """ - ), - None, - ], - ], -) -def test_schema_basicprime_bad_prime_type_empty( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, basic prime with a prime holding not strings.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - path cannot be empty (in field 'parts.charm.prime[0]')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - parts: - charm: - prime: ['/bar/foo', 'foo'] - """ - ), - None, - ], - ], -) -def test_schema_basicprime_bad_content_format( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, basic prime with a prime holding not strings.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - '/bar/foo' must be a relative path (cannot start with '/') (in field 'parts.charm.prime[0]')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - parts: - other-part: 1 - charm: - prime: ['/bar/foo', 'foo'] - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - parts: - other-part: 1 - charm: - prime: ['/bar/foo', 'foo'] - """ - ), - None, - ], - ], -) -def test_schema_additional_part( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, basic prime with bad part.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - part 'other-part' must be a dictionary (in field 'parts')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: # mandatory - - build-on: - - name: test-build-name - channel: test-build-channel - run-on: - - name: test-build-name - channel: test-build-channel - parts: - other-part: - plugin: charm - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: # mandatory - - build-on: - - name: test-build-name - channel: test-build-channel - run-on: - - name: test-build-name - channel: test-build-channel - parts: - other-part: - plugin: charm - """ - ), - None, - ], - ], -) -def test_schema_other_charm_part_no_source( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, basic prime with bad part.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - field 'source' required in 'parts.other-part' configuration - - cannot validate 'charm-requirements' because invalid 'source' configuration (in field 'parts.other-part.charm-requirements')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - parts: - other-part: - plugin: bundle - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - parts: - other-part: - plugin: bundle - """ - ), - None, - ], - ], -) -def test_schema_other_bundle_part_no_source( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Schema validation, basic prime with bad part.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - field 'source' required in 'parts.other-part' configuration""" - ) - - -# -- tests to check the double layer schema loading; using the 'charm' plugin -# because it is the default (and has good default properties to be overridden and ) -# the 'dump' one because it's a special case of no having a model - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: somebase - channel: "30.04" - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - name: somebase - channel: "30.04" - """ - ), - None, - ], - ], -) -def test_schema_doublelayer_no_parts_type_charm( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """No 'parts' specified at all, full default to charm plugin.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.parts == { - "charm": { - "plugin": "charm", - "source": str(tmp_path), - "charm-binary-python-packages": [], - "charm-entrypoint": "src/charm.py", - "charm-python-packages": [], - "charm-requirements": [], - "charm-strict-dependencies": False, - } - } - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - """ - ), - None, - ], - ], -) -def test_schema_doublelayer_no_parts_type_bundle( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """No 'parts' specified at all, full default to bundle plugin.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.parts == { - "bundle": { - "plugin": "bundle", - "source": str(tmp_path), - } - } - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: somebase - channel: "30.04" - parts: - mycharm: - plugin: dump - source: https://the.net/whatever.tar.gz - source-type: tar - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - name: somebase - channel: "30.04" - parts: - mycharm: - plugin: dump - source: https://the.net/whatever.tar.gz - source-type: tar - """ - ), - None, - ], - ], -) -def test_schema_doublelayer_parts_no_charm( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """The 'parts' key is specified, but no 'charm' entry.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.parts == { - "mycharm": { - "plugin": "dump", - "source": "https://the.net/whatever.tar.gz", - "source-type": "tar", - } - } - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: somebase - channel: "30.04" - parts: - charm: - prime: [to_be_included.*] # random key to have a valid yaml - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - name: somebase - channel: "30.04" - parts: - charm: - prime: [to_be_included.*] # random key to have a valid yaml - """ - ), - None, - ], - ], -) -def test_schema_doublelayer_parts_with_charm_plugin_missing( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """A charm part is specified but no plugin is indicated.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.parts == { - "charm": { - "plugin": "charm", - "source": str(tmp_path), - "charm-binary-python-packages": [], - "charm-entrypoint": "src/charm.py", - "charm-python-packages": [], - "charm-requirements": [], - "prime": ["to_be_included.*"], - "charm-strict-dependencies": False, - } - } - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: somebase - channel: "30.04" - parts: - charm: - plugin: charm - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - name: somebase - channel: "30.04" - parts: - charm: - plugin: charm - """ - ), - None, - ], - ], -) -def test_schema_doublelayer_parts_with_charm_plugin_charm( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """A charm part is fully specified.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.parts == { - "charm": { - "plugin": "charm", - "source": str(tmp_path), - "charm-binary-python-packages": [], - "charm-entrypoint": "src/charm.py", - "charm-python-packages": [], - "charm-requirements": [], - "charm-strict-dependencies": False, - } - } - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: somebase - channel: "30.04" - parts: - charm: - plugin: dump - source: https://the.net/whatever.tar.gz - source-type: tar - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - name: somebase - channel: "30.04" - parts: - charm: - plugin: dump - source: https://the.net/whatever.tar.gz - source-type: tar - """ - ), - None, - ], - ], -) -def test_schema_doublelayer_parts_with_charm_plugin_different( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """There is a 'charm' part but using a different plugin.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.parts == { - "charm": { - "plugin": "dump", - "source": "https://the.net/whatever.tar.gz", - "source-type": "tar", - } - } - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: somebase - channel: "30.04" - parts: - charm: - charm-entrypoint: different.py - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - name: somebase - channel: "30.04" - parts: - charm: - charm-entrypoint: different.py - """ - ), - None, - ], - ], -) -def test_schema_doublelayer_parts_with_charm_overriding_properties( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """A charm plugin is used and its default properties are overridden.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.parts == { - "charm": { - "plugin": "charm", - "source": str(tmp_path), - "charm-binary-python-packages": [], - "charm-entrypoint": "different.py", - "charm-python-packages": [], - "charm-requirements": [], - "charm-strict-dependencies": False, - } - } - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: somebase - channel: "30.04" - parts: - charm: - charm-point: different.py # misspelled! - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - name: somebase - channel: "30.04" - parts: - charm: - charm-point: different.py # misspelled! - """ - ), - None, - ], - ], -) -def test_schema_doublelayer_parts_with_charm_validating_props( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """A charm plugin is used and its validation schema is triggered ok.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - extra field 'charm-point' not permitted in 'parts.charm' configuration""" - ) - - -# -- tests for Charmhub config - - -def test_charmhub_frozen(): - """Cannot change values from the charmhub config.""" - config = CharmhubConfig() - with pytest.raises(TypeError): - config.api_url = "broken" - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - charmhub: - storage_url: https://server1.com - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - charmhub: - storage_url: https://server1.com - """ - ), - None, - ], - ], -) -def test_charmhub_underscore_in_names( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Do not support underscore in attributes, only dash.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - extra field 'storage_url' not permitted in 'charmhub' configuration""" - ) - - -# -- tests for bases - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - """ - ), - None, - ], - ], -) -def test_no_bases_is_ok_for_bundles( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Do not send a deprecation message if it is a bundle.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.type == "bundle" - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - bases: - - build-on: - - name: test-build-name - channel: test-build-channel - """ - ), - None, - ], - ], -) -def test_bases_forbidden_for_bundles( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Do not allow a bases configuration for bundles.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - field not allowed when type=bundle (in field 'bases')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - build-on: - - name: test-build-name - channel: test-build-channel - run-on: - - name: test-run-name - channel: test-run-channel - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - build-on: - - name: test-build-name - channel: test-build-channel - run-on: - - name: test-run-name - channel: test-run-channel - """ - ), - None, - ], - ], -) -def test_bases_minimal_long_form( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Minimal bases configuration, long form.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.bases == [ - BasesConfiguration( - **{ - "build-on": [ - Base( - name="test-build-name", - channel="test-build-channel", - architectures=[util.get_host_architecture()], - ), - ], - "run-on": [ - Base( - name="test-run-name", - channel="test-run-channel", - architectures=[util.get_host_architecture()], - ), - ], - } - ) - ] - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - build-on: - - name: test-name - channel: test-build-channel - extra-extra: read all about it - run-on: - - name: test-name - channel: test-run-channel - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - build-on: - - name: test-name - channel: test-build-channel - extra-extra: read all about it - run-on: - - name: test-name - channel: test-run-channel - """ - ), - None, - ], - ], -) -def test_bases_extra_field_error( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Extra field in bases configuration.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - extra field 'extra-extra' not permitted in 'bases[0].build-on[0]' configuration""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - bases: - - build_on: - - name: test-name - channel: test-build-channel - run_on: - - name: test-name - channel: test-run-channel - """ - ), - None, - ], - ], -) -def test_bases_underscores_error( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - field 'build-on' required in 'bases[0]' configuration - - field 'run-on' required in 'bases[0]' configuration - - extra field 'build_on' not permitted in 'bases[0]' configuration - - extra field 'run_on' not permitted in 'bases[0]' configuration""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - build-on: - - name: test-build-name - channel: 20.10 - run-on: - - name: test-run-name - channel: test-run-channel - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - build-on: - - name: test-build-name - channel: 20.10 - run-on: - - name: test-run-name - channel: test-run-channel - """ - ), - None, - ], - ], -) -def test_channel_is_yaml_number( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - string type expected (in field 'bases[0].build-on[0].channel')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - build-on: - - name: test-build-name - channel: test-build-channel - run-on: - - name: test-run-name - channel: test-run-channel - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - build-on: - - name: test-build-name - channel: test-build-channel - run-on: - - name: test-run-name - channel: test-run-channel - """ - ), - None, - ], - ], -) -def test_minimal_long_form_bases( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.bases == [ - BasesConfiguration( - **{ - "build-on": [ - Base( - name="test-build-name", - channel="test-build-channel", - architectures=[util.get_host_architecture()], - ), - ], - "run-on": [ - Base( - name="test-run-name", - channel="test-run-channel", - architectures=[util.get_host_architecture()], - ), - ], - } - ) - ] - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - build-on: - - name: test-build-name-1 - channel: test-build-channel-1 - - name: test-build-name-2 - channel: test-build-channel-2 - - name: test-build-name-3 - channel: test-build-channel-3 - architectures: [riscVI] - run-on: - - name: test-run-name-1 - channel: test-run-channel-1 - architectures: [amd64] - - name: test-run-name-2 - channel: test-run-channel-2 - architectures: [amd64, arm64] - - name: test-run-name-3 - channel: test-run-channel-3 - architectures: [amd64, arm64, riscVI] - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - build-on: - - name: test-build-name-1 - channel: test-build-channel-1 - - name: test-build-name-2 - channel: test-build-channel-2 - - name: test-build-name-3 - channel: test-build-channel-3 - architectures: [riscVI] - run-on: - - name: test-run-name-1 - channel: test-run-channel-1 - architectures: [amd64] - - name: test-run-name-2 - channel: test-run-channel-2 - architectures: [amd64, arm64] - - name: test-run-name-3 - channel: test-run-channel-3 - architectures: [amd64, arm64, riscVI] - """ - ), - None, - ], - ], -) -def test_complex_long_form_bases( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.bases == [ - BasesConfiguration( - **{ - "build-on": [ - Base( - name="test-build-name-1", - channel="test-build-channel-1", - architectures=[util.get_host_architecture()], - ), - Base( - name="test-build-name-2", - channel="test-build-channel-2", - architectures=[util.get_host_architecture()], - ), - Base( - name="test-build-name-3", - channel="test-build-channel-3", - architectures=["riscVI"], - ), - ], - "run-on": [ - Base( - name="test-run-name-1", - channel="test-run-channel-1", - architectures=["amd64"], - ), - Base( - name="test-run-name-2", - channel="test-run-channel-2", - architectures=["amd64", "arm64"], - ), - Base( - name="test-run-name-3", - channel="test-run-channel-3", - architectures=["amd64", "arm64", "riscVI"], - ), - ], - } - ) - ] - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - build-on: - - name: test-build-name-1 - channel: test-build-channel-1 - run-on: - - name: test-run-name-1 - channel: test-run-channel-1 - architectures: [amd64, arm64] - - build-on: - - name: test-build-name-2 - channel: test-build-channel-2 - run-on: - - name: test-run-name-2 - channel: test-run-channel-2 - architectures: [amd64, arm64] - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - build-on: - - name: test-build-name-1 - channel: test-build-channel-1 - run-on: - - name: test-run-name-1 - channel: test-run-channel-1 - architectures: [amd64, arm64] - - build-on: - - name: test-build-name-2 - channel: test-build-channel-2 - run-on: - - name: test-run-name-2 - channel: test-run-channel-2 - architectures: [amd64, arm64] - """ - ), - None, - ], - ], -) -def test_multiple_long_form_bases( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.bases == [ - BasesConfiguration( - **{ - "build-on": [ - Base( - name="test-build-name-1", - channel="test-build-channel-1", - architectures=[util.get_host_architecture()], - ), - ], - "run-on": [ - Base( - name="test-run-name-1", - channel="test-run-channel-1", - architectures=["amd64", "arm64"], - ), - ], - } - ), - BasesConfiguration( - **{ - "build-on": [ - Base( - name="test-build-name-2", - channel="test-build-channel-2", - architectures=[util.get_host_architecture()], - ), - ], - "run-on": [ - Base( - name="test-run-name-2", - channel="test-run-channel-2", - architectures=["amd64", "arm64"], - ), - ], - } - ), - ] - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: test-name - channel: test-channel - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - name: test-name - channel: test-channel - """ - ), - None, - ], - ], -) -def test_bases_minimal_short_form( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.bases == [ - BasesConfiguration( - **{ - "build-on": [ - Base( - name="test-name", - channel="test-channel", - architectures=[util.get_host_architecture()], - ), - ], - "run-on": [ - Base( - name="test-name", - channel="test-channel", - architectures=[util.get_host_architecture()], - ), - ], - } - ) - ] - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: test-name - channel: test-channel - extra-extra: read all about it - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - ], -) -def test_bases_short_form_extra_field_error( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - extra field 'extra-extra' not permitted in 'bases[0]' configuration""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: test-name - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - name: test-name - """ - ), - None, - ], - ], -) -def test_bases_short_form_missing_field_error( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - field 'channel' required in 'bases[0]' configuration""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - bases: - - name: test-name - - build-on: - - name: test-build-name - run-on: - - name: test-run-name - """ - ), - None, - ], - ], -) -def test_bases_mixed_form_errors( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Only the short-form errors are exposed as its the first validation pass.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - field 'channel' required in 'bases[0]' configuration""" - ) - - -# -- tests for analysis - - -@pytest.fixture() -def create_checker(monkeypatch): - """Helper to patch and add checkers to the real structure.""" - test_checkers = [] - monkeypatch.setattr(linters, "CHECKERS", test_checkers) - - def add_checker(c_name, c_type): - class FakeChecker: - name = c_name - check_type = c_type - - test_checkers.append(FakeChecker) - - return add_checker - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - """ - ), - None, - ], - ], -) -def test_schema_analysis_missing( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """No analysis configuration leads to some defaults in place.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.analysis.ignore.attributes == [] - assert config.analysis.ignore.linters == [] - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - analysis: - ignore: - attributes: [] - linters: [] - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - analysis: - ignore: - attributes: [] - linters: [] - """ - ), - None, - ], - ], -) -def test_schema_analysis_full_struct_just_empty( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """Complete analysis structure, empty.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.analysis.ignore.attributes == [] - assert config.analysis.ignore.linters == [] - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - analysis: - ignore: - attributes: [check_ok_1, check_ok_2] - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - analysis: - ignore: - attributes: [check_ok_1, check_ok_2] - """ - ), - None, - ], - ], -) -def test_schema_analysis_ignore_attributes( - tmp_path, - create_checker, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml, - metadata_yaml, -): - """Some attributes are correctly ignored.""" - create_checker("check_ok_1", linters.CheckType.ATTRIBUTE) - create_checker("check_ok_2", linters.CheckType.ATTRIBUTE) - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.analysis.ignore.attributes == ["check_ok_1", "check_ok_2"] - assert config.analysis.ignore.linters == [] - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - analysis: - ignore: - linters: [check_ok_1, check_ok_2] - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - analysis: - ignore: - linters: [check_ok_1, check_ok_2] - """ - ), - None, - ], - ], -) -def test_schema_analysis_ignore_linters( - tmp_path, - create_checker, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml, - metadata_yaml, -): - """Some linters are correctly ignored.""" - create_checker("check_ok_1", linters.CheckType.LINT) - create_checker("check_ok_2", linters.CheckType.LINT) - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - assert config.analysis.ignore.attributes == [] - assert config.analysis.ignore.linters == ["check_ok_1", "check_ok_2"] - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - analysis: - ignore: - attributes: [check_ok_1, check_missing] - linters: [check_ok_2] - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - analysis: - ignore: - attributes: [check_ok_1, check_missing] - linters: [check_ok_2] - """ - ), - None, - ], - ], -) -def test_schema_analysis_ignore_attribute_missing( - tmp_path, - create_checker, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml, - metadata_yaml, -): - """An attribute specified to ignore is missing in the system.""" - create_checker("check_ok_1", linters.CheckType.ATTRIBUTE) - create_checker("check_ok_2", linters.CheckType.LINT) - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - bad attribute name 'check_missing' (in field 'analysis.ignore.attributes[1]')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: bundle - analysis: - ignore: - attributes: [check_ok_1] - linters: [check_ok_2, check_missing] - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: bundle - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - analysis: - ignore: - attributes: [check_ok_1] - linters: [check_ok_2, check_missing] - """ - ), - None, - ], - ], -) -def test_schema_analysis_ignore_linter_missing( - tmp_path, - create_checker, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml, - metadata_yaml, -): - """A linter specified to ignore is missing in the system.""" - create_checker("check_ok_1", linters.CheckType.ATTRIBUTE) - create_checker("check_ok_2", linters.CheckType.LINT) - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - bad lint name 'check_missing' (in field 'analysis.ignore.linters[1]')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: test-name - channel: test-channel - actions: - pause: - description: Pause the database. - resume: - description: Resume a paused database. - snapshot: - description: Take a snapshot of the database. - params: - filename: - type: string - description: The name of the snapshot file. - compression: - type: object - description: The type of compression to use. - properties: - kind: - type: string - enum: [gzip, bzip2, xz] - quality: - description: Compression quality - type: integer - minimum: 0 - maximum: 9 - required: [filename] - additionalProperties: false - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - name: test-name - channel: test-channel - actions: - pause: - description: Pause the database. - resume: - description: Resume a paused database. - snapshot: - description: Take a snapshot of the database. - params: - filename: - type: string - description: The name of the snapshot file. - compression: - type: object - description: The type of compression to use. - properties: - kind: - type: string - enum: [gzip, bzip2, xz] - quality: - description: Compression quality - type: integer - minimum: 0 - maximum: 9 - required: [filename] - additionalProperties: false - """ - ), - None, - ], - ], -) -def test_actions_defined_in_charmcraft_yaml( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml, charmcraft_yaml, metadata_yaml -): - """test actions defined in charmcraft.yaml""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - - config = load(tmp_path) - - assert config.actions.actions == { - "pause": {"description": "Pause the database."}, - "resume": {"description": "Resume a paused database."}, - "snapshot": { - "description": "Take a snapshot of the database.", - "params": { - "filename": {"type": "string", "description": "The name of the snapshot file."}, - "compression": { - "type": "object", - "description": "The type of compression to use.", - "properties": { - "kind": {"type": "string", "enum": ["gzip", "bzip2", "xz"]}, - "quality": { - "description": "Compression quality", - "type": "integer", - "minimum": 0, - "maximum": 9, - }, - }, - }, - }, - "required": ["filename"], - "additionalProperties": False, - }, - } - - -@pytest.mark.parametrize( - ("charmcraft_yaml_template", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: test-name - channel: test-channel - actions: - pause: - description: Pause the database. - resume: - description: Resume a paused database. - {bad_name}: - description: Take a snapshot of the database. - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - - bases: - - name: test-name - channel: test-channel - actions: - pause: - description: Pause the database. - resume: - description: Resume a paused database. - {bad_name}: - description: Take a snapshot of the database. - """ - ), - None, - ], - ], -) -@pytest.mark.parametrize( - "bad_name", - [ - "is", - "-snapshot", - "111snapshot", - ], -) -def test_actions_badly_defined_in_charmcraft_yaml( - tmp_path, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - charmcraft_yaml_template, - metadata_yaml, - bad_name, -): - """test actions badly defined in charmcraft.yaml""" - prepare_charmcraft_yaml(charmcraft_yaml_template.format(bad_name=bad_name)) - prepare_metadata_yaml(metadata_yaml) - - with pytest.raises(CraftError): - load(tmp_path) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: test-name - channel: test-channel - actions: - pause: - description: Pause the database. - resume: - description: Resume a paused database. - snapshot: - description: Take a snapshot of the database. - params: - filename: - type: string - description: The name of the snapshot file. - compression: - type: object - description: The type of compression to use. - properties: - kind: - type: string - enum: [gzip, bzip2, xz] - quality: - description: Compression quality - type: integer - minimum: 0 - maximum: 9 - required: [filename] - additionalProperties: false - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - name: test-name - channel: test-channel - actions: - pause: - description: Pause the database. - resume: - description: Resume a paused database. - snapshot: - description: Take a snapshot of the database. - params: - filename: - type: string - description: The name of the snapshot file. - compression: - type: object - description: The type of compression to use. - properties: - kind: - type: string - enum: [gzip, bzip2, xz] - quality: - description: Compression quality - type: integer - minimum: 0 - maximum: 9 - required: [filename] - additionalProperties: false - """ - ), - None, - ], - ], -) -def test_actions_defined_in_charmcraft_yaml_and_actions_yaml( - tmp_path, - create_checker, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - prepare_actions_yaml, - charmcraft_yaml, - metadata_yaml, -): - """actions section cannot be used when actions.yaml file is present.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - prepare_actions_yaml( - dedent( - """\ - pause: - description: Pause the database. - """ - ) - ) - - with pytest.raises(CraftError) as cm: - load(tmp_path) - - assert str(cm.value) == dedent( - """\ - Bad charmcraft.yaml content: - - 'actions.yaml' file not allowed when an 'actions' section is defined in 'charmcraft.yaml' (in field 'actions')""" - ) - - -@pytest.mark.parametrize( - ("charmcraft_yaml", "metadata_yaml"), - [ - [ - dedent( - """\ - type: charm - bases: - - name: test-name - channel: test-channel - """ - ), - dedent( - """\ - name: test-charm-name-from-metadata-yaml - summary: test summary - description: test description - """ - ), - ], - [ - dedent( - """\ - type: charm - name: test-charm-name-from-charmcraft-yaml - summary: test summary - description: test description - bases: - - name: test-name - channel: test-channel - """ - ), - None, - ], - ], -) -def test_actions_bad_unenforced_defined_in_actions_yaml( - tmp_path, - create_checker, - prepare_charmcraft_yaml, - prepare_metadata_yaml, - prepare_actions_yaml, - charmcraft_yaml, - metadata_yaml, -): - """Load a bad actions in actions.yaml. Should not raise an error since check unenforced.""" - prepare_charmcraft_yaml(charmcraft_yaml) - prepare_metadata_yaml(metadata_yaml) - prepare_actions_yaml( - dedent( - """\ - invalid: 111 - """ - ) - ) - - config = load(tmp_path) - assert config.actions is None diff --git a/tests/test_models.py b/tests/test_models.py deleted file mode 100644 index 614d27803..000000000 --- a/tests/test_models.py +++ /dev/null @@ -1,1270 +0,0 @@ -# Copyright 2023 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 - -from textwrap import dedent - -import pytest -from craft_application import util -from craft_cli import CraftError -from pydantic import AnyHttpUrl -from pydantic.tools import parse_obj_as - -from charmcraft.config import load -from charmcraft.metafiles.metadata import parse_charm_metadata_yaml -from charmcraft.models.charmcraft import ( - Base, - BasesConfiguration, - Links, -) - - -def test_load_minimal_metadata_from_charmcraft_yaml(tmp_path, prepare_charmcraft_yaml): - """Load a minimal charmcraft.yaml with full metadata. (Spec ST087)""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - description: test-description - - bases: - - name: test-name - channel: test-channel - """ - ) - ) - - config = load(tmp_path) - - assert config.name == "test-charm-name" - assert config.type == "charm" - assert config.summary == "test-summary" - assert config.description == "test-description" - assert config.bases == [ - BasesConfiguration( - **{ - "build-on": [ - Base( - name="test-name", - channel="test-channel", - architectures=[util.get_host_architecture()], - ) - ], - "run-on": [ - Base( - name="test-name", - channel="test-channel", - architectures=[util.get_host_architecture()], - ) - ], - } - ) - ] - assert not config.metadata_legacy - - -def test_load_minimal_metadata_from_charmcraft_yaml_missing_name( - tmp_path, prepare_charmcraft_yaml -): - """Load a minimal charmcraft.yaml with metadata. But missing name.""" - prepare_charmcraft_yaml( - dedent( - """ - type: charm - summary: test-summary - description: test-description - - bases: - - name: test-name - channel: test-channel - """ - ) - ) - - with pytest.raises(CraftError, match=r"needs value \(in field 'name'\)"): - load(tmp_path) - - -def test_load_minimal_metadata_from_charmcraft_yaml_missing_type( - tmp_path, prepare_charmcraft_yaml -): - """Load a minimal charmcraft.yaml with metadata. But missing type.""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - summary: test-summary - description: test-description - - bases: - - name: test-name - channel: test-channel - """ - ), - ) - - with pytest.raises(CraftError, match="field 'type' required in top-level configuration"): - load(tmp_path) - - -def test_load_minimal_metadata_from_charmcraft_yaml_missing_summary( - tmp_path, prepare_charmcraft_yaml -): - """Load a minimal charmcraft.yaml with metadata. But missing summary.""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - description: test-description - - bases: - - name: test-name - channel: test-channel - """ - ), - ) - - with pytest.raises(CraftError, match=r"needs value \(in field 'summary'\)"): - load(tmp_path) - - -def test_load_minimal_metadata_from_charmcraft_yaml_missing_description( - tmp_path, prepare_charmcraft_yaml -): - """Load a minimal charmcraft.yaml with metadata. But missing description.""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - - bases: - - name: test-name - channel: test-channel - """ - ), - ) - - with pytest.raises(CraftError, match=r"needs value \(in field 'description'\)"): - load(tmp_path) - - -def test_load_minimal_metadata_from_metadata_yaml( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml -): - """Load a minimal charmcraft.yaml with full metadata. (Spec ST087)""" - prepare_charmcraft_yaml( - dedent( - """ - type: charm - - bases: - - name: test-name - channel: test-channel - """ - ), - ) - prepare_metadata_yaml( - dedent( - """ - name: test-charm-name - summary: test-summary - description: test-description - """ - ), - ) - - config = load(tmp_path) - - assert config.name == "test-charm-name" - assert config.type == "charm" - assert config.summary == "test-summary" - assert config.description == "test-description" - assert config.bases == [ - BasesConfiguration( - **{ - "build-on": [ - Base( - name="test-name", - channel="test-channel", - architectures=[util.get_host_architecture()], - ) - ], - "run-on": [ - Base( - name="test-name", - channel="test-channel", - architectures=[util.get_host_architecture()], - ) - ], - } - ) - ] - assert config.metadata_legacy - - -def test_load_minimal_metadata_from_metadata_yaml_missing_name( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml -): - """Load a minimal charmcraft.yaml with metadata.yaml. But missing name.""" - prepare_charmcraft_yaml( - dedent( - """ - type: charm - bases: - - name: test-name - channel: test-channel - """ - ), - ) - prepare_metadata_yaml( - dedent( - """ - summary: test-summary - description: test-description - """ - ), - ) - - with pytest.raises(CraftError, match="field 'name' required in top-level configuration"): - load(tmp_path) - - -def test_load_minimal_metadata_from_metadata_yaml_missing_type( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml -): - """Load a minimal charmcraft.yaml with metadata.yaml. But missing type.""" - prepare_charmcraft_yaml( - dedent( - """ - bases: - - name: test-name - channel: test-channel - """ - ), - ) - prepare_metadata_yaml( - dedent( - """ - name: test-charm-name - summary: test-summary - description: test-description - """ - ), - ) - - with pytest.raises(CraftError, match="field 'type' required in top-level configuration"): - load(tmp_path) - - -def test_load_minimal_metadata_from_metadata_yaml_missing_summary( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml -): - """Load a minimal charmcraft.yaml with metadata.yaml. But missing summary.""" - prepare_charmcraft_yaml( - dedent( - """ - type: charm - - bases: - - name: test-name - channel: test-channel - """ - ), - ) - prepare_metadata_yaml( - dedent( - """ - name: test-charm-name - description: test-description - """ - ), - ) - - with pytest.raises(CraftError, match="field 'summary' required in top-level configuration"): - load(tmp_path) - - -def test_load_minimal_metadata_from_metadata_yaml_bad_others_allowed( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml -): - """Load a minimal charmcraft.yaml with metadata.yaml. Had bad other fields but allowed.""" - prepare_charmcraft_yaml( - dedent( - """ - type: charm - - bases: - - name: test-name - channel: test-channel - """ - ), - ) - prepare_metadata_yaml( - dedent( - """ - name: test-charm-name - summary: test-summary - description: test-description - - peers: - - aaa - - bbb - - ccc - """ - ), - ) - - load(tmp_path) - - -def test_load_minimal_metadata_from_metadata_yaml_missing_description( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml -): - """Load a minimal charmcraft.yaml with metadata.yaml. But missing description.""" - prepare_charmcraft_yaml( - dedent( - """ - type: charm - - bases: - - name: test-name - channel: test-channel - """ - ), - ) - prepare_metadata_yaml( - dedent( - """ - name: test-charm-name - summary: test-summary - """ - ), - ) - - with pytest.raises( - CraftError, match="field 'description' required in top-level configuration" - ): - load(tmp_path) - - -def test_load_full_metadata_from_charmcraft_yaml(tmp_path, prepare_charmcraft_yaml): - """Load a charmcraft.yaml with full metadata. (Spec ST087)""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - description: test-description - - bases: - - name: test-name - channel: test-channel - - assumes: - - test-feature - - any-of: - - extra-feature-1 - - extra-feature-2 - - all-of: - - test-feature-1 - - test-feature-2 - - containers: - container-1: - resource: resource-1 - bases: - - name: ubuntu - channel: 22.04 - architectures: - - x86_64 - mounts: - - storage: storage-1 - location: /var/lib/storage-1 - container-2: - resource: resource-2 - bases: - - name: ubuntu - channel: 22.04 - architectures: - - x86_64 - mounts: - - storage: storage-2 - location: /var/lib/storage-2 - - devices: - test-device-1: - type: gpu - description: gpu - countmin: 1 - countmax: 10 - - title: test-title - - extra-bindings: - test-binding-1: binding-1 - - links: - issues: https://example.com/issues - contact: - - https://example.com/contact - - contact@example.com - - "IRC #example" - documentation: https://example.com/docs - source: - - https://example.com/source - - https://example.com/source2 - - https://example.com/source3 - website: - - https://example.com/ - - peers: - peer-1: - interface: eth0 - limit: 1 - optional: true - scope: global - - provides: - provide-1: - interface: eth1 - limit: 1 - optional: true - scope: global - - requires: - peer-1: - interface: eth0 - limit: 1 - optional: true - scope: global - - resources: - resource-1: - type: file - description: resource-1 - filename: /path/to/resource-1 - - storage: - storage-1: - type: filesystem - description: storage-1 - location: /var/lib/storage-1 - shared: true - read-only: false - multiple: 5G - minimum-size: 5G - properties: - - transient - - subordinate: true - - terms: - - https://example.com/terms - - https://example.com/terms2 - """ - ), - ) - - config = load(tmp_path) - config_dict = config.dict() - - # remove unrelated keys. but they should exist in the config - - del config_dict["actions"] - del config_dict["analysis"] - del config_dict["charmhub"] - del config_dict["config"] - del config_dict["project"] - del config_dict["parts"] - - assert config_dict == { - "name": "test-charm-name", - "type": "charm", - "summary": "test-summary", - "description": "test-description", - "bases": [ - BasesConfiguration( - **{ - "build-on": [ - Base( - name="test-name", - channel="test-channel", - architectures=[util.get_host_architecture()], - ) - ], - "run-on": [ - Base( - name="test-name", - channel="test-channel", - architectures=[util.get_host_architecture()], - ) - ], - } - ) - ], - "assumes": [ - "test-feature", - {"any-of": ["extra-feature-1", "extra-feature-2"]}, - {"all-of": ["test-feature-1", "test-feature-2"]}, - ], - "containers": { - "container-1": { - "resource": "resource-1", - "bases": [{"name": "ubuntu", "channel": 22.04, "architectures": ["x86_64"]}], - "mounts": [{"storage": "storage-1", "location": "/var/lib/storage-1"}], - }, - "container-2": { - "resource": "resource-2", - "bases": [{"name": "ubuntu", "channel": 22.04, "architectures": ["x86_64"]}], - "mounts": [{"storage": "storage-2", "location": "/var/lib/storage-2"}], - }, - }, - "devices": { - "test-device-1": {"type": "gpu", "description": "gpu", "countmin": 1, "countmax": 10} - }, - "title": "test-title", - "peers": { - "peer-1": {"interface": "eth0", "limit": 1, "optional": True, "scope": "global"} - }, - "provides": { - "provide-1": {"interface": "eth1", "limit": 1, "optional": True, "scope": "global"} - }, - "requires": { - "peer-1": {"interface": "eth0", "limit": 1, "optional": True, "scope": "global"} - }, - "resources": { - "resource-1": { - "type": "file", - "description": "resource-1", - "filename": "/path/to/resource-1", - }, - }, - "storage": { - "storage-1": { - "type": "filesystem", - "description": "storage-1", - "location": "/var/lib/storage-1", - "shared": True, - "read-only": False, - "multiple": "5G", - "minimum-size": "5G", - "properties": ["transient"], - } - }, - "subordinate": True, - "terms": ["https://example.com/terms", "https://example.com/terms2"], - "extra_bindings": {"test-binding-1": "binding-1"}, - "links": Links( - contact=["https://example.com/contact", "contact@example.com", "IRC #example"], - documentation=parse_obj_as(AnyHttpUrl, "https://example.com/docs"), - issues=parse_obj_as(AnyHttpUrl, "https://example.com/issues"), - source=[ - parse_obj_as(AnyHttpUrl, "https://example.com/source"), - parse_obj_as(AnyHttpUrl, "https://example.com/source2"), - parse_obj_as(AnyHttpUrl, "https://example.com/source3"), - ], - website=[parse_obj_as(AnyHttpUrl, "https://example.com/")], - ), - "metadata_legacy": False, - } - - -def test_load_full_metadata_from_metadata_yaml( - tmp_path, prepare_charmcraft_yaml, prepare_metadata_yaml -): - """Load a charmcraft.yaml with full metadata.yaml. (Legacy)""" - prepare_charmcraft_yaml( - dedent( - """ - type: charm - bases: - - name: test-name - channel: test-channel - """ - ), - ) - prepare_metadata_yaml( - dedent( - """ - name: test-charm-name - summary: test-summary - description: test-description - assumes: - - test-feature - - any-of: - - extra-feature-1 - - extra-feature-2 - - all-of: - - test-feature-1 - - test-feature-2 - - containers: - container-1: - resource: resource-1 - bases: - - name: ubuntu - channel: 22.04 - architectures: - - x86_64 - mounts: - - storage: storage-1 - location: /var/lib/storage-1 - container-2: - resource: resource-2 - bases: - - name: ubuntu - channel: 22.04 - architectures: - - x86_64 - mounts: - - storage: storage-2 - location: /var/lib/storage-2 - - devices: - test-device-1: - type: gpu - description: gpu - countmin: 1 - countmax: 10 - - display-name: test-title - - docs: https://example.com/docs - - extra-bindings: - test-binding-1: binding-1 - - issues: https://example.com/issues - - maintainers: - - https://example.com/contact - - contact@example.com - - "IRC #example" - - peers: - peer-1: - interface: eth0 - limit: 1 - optional: true - scope: global - - provides: - provide-1: - interface: eth1 - limit: 1 - optional: true - scope: global - - requires: - peer-1: - interface: eth0 - limit: 1 - optional: true - scope: global - - resources: - resource-1: - type: file - description: resource-1 - filename: /path/to/resource-1 - - source: - - https://example.com/source - - https://example.com/source2 - - https://example.com/source3 - - storage: - storage-1: - type: filesystem - description: storage-1 - location: /var/lib/storage-1 - shared: true - read-only: false - multiple: 5G - minimum-size: 5G - properties: - - transient - - subordinate: true - - terms: - - https://example.com/terms - - https://example.com/terms2 - - website: - - https://example.com/ - """ - ), - ) - - config = load(tmp_path) - metadata = parse_charm_metadata_yaml(tmp_path) - - assert config.name == "test-charm-name" - assert config.type == "charm" - assert config.summary == "test-summary" - assert config.description == "test-description" - assert config.bases == [ - BasesConfiguration( - **{ - "build-on": [ - Base( - name="test-name", - channel="test-channel", - architectures=[util.get_host_architecture()], - ) - ], - "run-on": [ - Base( - name="test-name", - channel="test-channel", - architectures=[util.get_host_architecture()], - ) - ], - } - ) - ] - assert config.metadata_legacy - - metadata_dict = metadata.dict() - assert metadata_dict == { - "name": "test-charm-name", - "summary": "test-summary", - "description": "test-description", - "assumes": [ - "test-feature", - {"any-of": ["extra-feature-1", "extra-feature-2"]}, - {"all-of": ["test-feature-1", "test-feature-2"]}, - ], - "containers": { - "container-1": { - "resource": "resource-1", - "bases": [{"name": "ubuntu", "channel": 22.04, "architectures": ["x86_64"]}], - "mounts": [{"storage": "storage-1", "location": "/var/lib/storage-1"}], - }, - "container-2": { - "resource": "resource-2", - "bases": [{"name": "ubuntu", "channel": 22.04, "architectures": ["x86_64"]}], - "mounts": [{"storage": "storage-2", "location": "/var/lib/storage-2"}], - }, - }, - "devices": { - "test-device-1": {"type": "gpu", "description": "gpu", "countmin": 1, "countmax": 10} - }, - "display_name": "test-title", - "peers": { - "peer-1": {"interface": "eth0", "limit": 1, "optional": True, "scope": "global"} - }, - "provides": { - "provide-1": {"interface": "eth1", "limit": 1, "optional": True, "scope": "global"} - }, - "requires": { - "peer-1": {"interface": "eth0", "limit": 1, "optional": True, "scope": "global"} - }, - "resources": { - "resource-1": { - "type": "file", - "description": "resource-1", - "filename": "/path/to/resource-1", - }, - }, - "storage": { - "storage-1": { - "type": "filesystem", - "description": "storage-1", - "location": "/var/lib/storage-1", - "shared": True, - "read-only": False, - "multiple": "5G", - "minimum-size": "5G", - "properties": ["transient"], - } - }, - "subordinate": True, - "terms": ["https://example.com/terms", "https://example.com/terms2"], - "extra_bindings": {"test-binding-1": "binding-1"}, - "docs": parse_obj_as(AnyHttpUrl, "https://example.com/docs"), - "issues": parse_obj_as(AnyHttpUrl, "https://example.com/issues"), - "maintainers": ["https://example.com/contact", "contact@example.com", "IRC #example"], - "source": [ - parse_obj_as(AnyHttpUrl, "https://example.com/source"), - parse_obj_as(AnyHttpUrl, "https://example.com/source2"), - parse_obj_as(AnyHttpUrl, "https://example.com/source3"), - ], - "website": [parse_obj_as(AnyHttpUrl, "https://example.com/")], - } - - -def test_load_full_actions_in_charmcraft_yaml(tmp_path, prepare_charmcraft_yaml): - """Load a charmcraft.yaml with full actions.""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - description: test-description - - bases: - - name: test-name - channel: test-channel - - actions: - pause: - description: Pause the database. - resume: - description: Resume a paused database. - snapshot: - description: Take a snapshot of the database. - params: - filename: - type: string - description: The name of the snapshot file. - compression: - type: object - description: The type of compression to use. - properties: - kind: - type: string - enum: [gzip, bzip2, xz] - quality: - description: Compression quality - type: integer - minimum: 0 - maximum: 9 - required: [filename] - additionalProperties: false - """ - ) - ) - - config = load(tmp_path) - - assert config.actions.dict(include={"actions"}, exclude_none=True, by_alias=True)[ - "actions" - ] == { - "pause": {"description": "Pause the database."}, - "resume": {"description": "Resume a paused database."}, - "snapshot": { - "description": "Take a snapshot of the database.", - "params": { - "filename": { - "type": "string", - "description": "The name of the snapshot file.", - }, - "compression": { - "type": "object", - "description": "The type of compression to use.", - "properties": { - "kind": {"type": "string", "enum": ["gzip", "bzip2", "xz"]}, - "quality": { - "description": "Compression quality", - "type": "integer", - "minimum": 0, - "maximum": 9, - }, - }, - }, - }, - "required": ["filename"], - "additionalProperties": False, - }, - } - - -def test_load_full_actions_in_actions_yaml( - tmp_path, prepare_charmcraft_yaml, prepare_actions_yaml -): - """Load a charmcraft.yaml with full actions.""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - description: test-description - """ - ), - ) - - prepare_actions_yaml( - dedent( - """ - pause: - description: Pause the database. - resume: - description: Resume a paused database. - snapshot: - description: Take a snapshot of the database. - params: - filename: - type: string - description: The name of the snapshot file. - compression: - type: object - description: The type of compression to use. - properties: - kind: - type: string - enum: [gzip, bzip2, xz] - quality: - description: Compression quality - type: integer - minimum: 0 - maximum: 9 - required: [filename] - additionalProperties: false - """ - ), - ) - - config = load(tmp_path) - - assert config.actions.dict(include={"actions"}, exclude_none=True, by_alias=True)[ - "actions" - ] == { - "pause": {"description": "Pause the database."}, - "resume": {"description": "Resume a paused database."}, - "snapshot": { - "description": "Take a snapshot of the database.", - "params": { - "filename": { - "type": "string", - "description": "The name of the snapshot file.", - }, - "compression": { - "type": "object", - "description": "The type of compression to use.", - "properties": { - "kind": {"type": "string", "enum": ["gzip", "bzip2", "xz"]}, - "quality": { - "description": "Compression quality", - "type": "integer", - "minimum": 0, - "maximum": 9, - }, - }, - }, - }, - "required": ["filename"], - "additionalProperties": False, - }, - } - - -@pytest.mark.parametrize( - "bad_name", - [ - "is", - "-snapshot", - "111snapshot", - ], -) -def test_load_bad_actions_in_charmcraft_yaml(tmp_path, prepare_charmcraft_yaml, bad_name): - """Load a bad actions in charmcraft.yaml.""" - prepare_charmcraft_yaml( - dedent( - f""" - name: test-charm-name - type: charm - bases: - - name: test-name - channel: test-channel - actions: - pause: - description: Pause the database. - resume: - description: Resume a paused database. - {bad_name}: - description: Take a snapshot of the database. - """ - ) - ) - - with pytest.raises(CraftError): - load(tmp_path) - - -@pytest.mark.parametrize( - "bad_name", - [ - "is", - "-snapshot", - "111snapshot", - ], -) -def test_load_bad_actions_in_actions_yaml( - tmp_path, prepare_charmcraft_yaml, prepare_actions_yaml, bad_name -): - """Load a bad actions in actions.yaml.""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - bases: - - name: test-name - channel: test-channel - """ - ) - ) - prepare_actions_yaml( - dedent( - f"""\ - actions: - pause: - description: Pause the database. - resume: - description: Resume a paused database. - {bad_name}: - description: Take a snapshot of the database. - """ - ) - ) - - with pytest.raises(CraftError): - load(tmp_path) - - -def test_load_actions_in_charmcraft_yaml_and_actions_yaml( - tmp_path, prepare_charmcraft_yaml, prepare_actions_yaml -): - """Load actions in charmcraft.yaml and actions.yaml at the same time.""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - description: test-description - - bases: - - name: test-name - channel: test-channel - - actions: - pause: - description: Pause the database. - resume: - description: Resume a paused database. - snapshot: - description: Take a snapshot of the database. - params: - filename: - type: string - description: The name of the snapshot file. - compression: - type: object - description: The type of compression to use. - properties: - kind: - type: string - enum: [gzip, bzip2, xz] - quality: - description: Compression quality - type: integer - minimum: 0 - maximum: 9 - required: [filename] - additionalProperties: false - """ - ), - ) - prepare_actions_yaml( - dedent( - """ - pause: - description: Pause the database. - """ - ), - ) - - msg = ( - "'actions.yaml' file not allowed when an 'actions' section " - r"is defined in 'charmcraft.yaml' \(in field 'actions'\)" - ) - - with pytest.raises(CraftError, match=msg): - load(tmp_path) - - -def test_load_config_in_charmcraft_yaml(tmp_path, prepare_charmcraft_yaml): - """Load a config in charmcraft.yaml.""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - description: test-description - - bases: - - name: test-name - channel: test-channel - - config: - options: - test-int: - default: 123 - description: test-1 - type: int - test-string: - description: test-2 - type: string - test-float: - default: 1.23 - type: float - test-bool: - default: true - type: boolean - test-secret: - default: secret:co1s9mnmp25c762drvtg - type: secret - """ - ) - ) - config = load(tmp_path) - - assert config.config.dict(include={"options"}, by_alias=True, exclude_none=True) == { - "options": { - "test-int": {"default": 123, "description": "test-1", "type": "int"}, - "test-string": {"description": "test-2", "type": "string"}, - "test-float": {"default": 1.23, "type": "float"}, - "test-bool": {"default": True, "type": "boolean"}, - "test-secret": {"default": "secret:co1s9mnmp25c762drvtg", "type": "secret"}, - }, - } - - -def test_load_config_in_config_yaml(tmp_path, prepare_charmcraft_yaml, prepare_config_yaml): - """Load a config in config.yaml.""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - description: test-description - """ - ), - ) - prepare_config_yaml( - dedent( - """ - options: - test-int: - default: 123 - description: test-1 - type: int - test-string: - description: test-2 - type: string - test-float: - default: 1.23 - type: float - test-bool: - default: true - type: boolean - test-secret: - default: secret:co1s9mnmp25c762drvtg - type: secret - """ - ), - ) - config = load(tmp_path) - - assert config.config.dict(include={"options"}, by_alias=True, exclude_none=True) == { - "options": { - "test-int": {"default": 123, "description": "test-1", "type": "int"}, - "test-string": {"description": "test-2", "type": "string"}, - "test-float": {"default": 1.23, "type": "float"}, - "test-bool": {"default": True, "type": "boolean"}, - "test-secret": {"default": "secret:co1s9mnmp25c762drvtg", "type": "secret"}, - }, - } - - -@pytest.mark.parametrize( - "config_yaml", - [ - "", - "options:", - "options:\n test-int:", - "options: not a dict", - ], -) -def test_load_bad_config_in_config_yaml( - tmp_path, prepare_charmcraft_yaml, prepare_config_yaml, config_yaml -): - """Load a bad config in config.yaml. Should not raise an error since check unenforced.""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - description: test-description - """ - ), - ) - prepare_config_yaml(config_yaml) - config = load(tmp_path) - - assert config.config is None - - -def test_load_bad_config_in_charmcraft_yaml(tmp_path, prepare_charmcraft_yaml): - """Load a config in charmcraft.yaml.""" - prepare_charmcraft_yaml( - dedent( - """ - name: test-charm-name - type: charm - summary: test-summary - description: test-description - - config: - options: - test-int: - default: 123 - descriptionn: test-1 - type: int - test-string: - description: test-2 - type: string - test-float: - default: 1.23 - type: float - test-bool: - default: true - type: boolean - test-secret: - default: secret:co1s9mnmp25c762drvtg - type: secret - """ - ) - ) - - with pytest.raises( - CraftError, - match=r"extra field 'descriptionn' not permitted in 'config.options.test-int", - ): - load(tmp_path) From b1c33b75276f7b3efd53b69083d82937a79b4887 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 16:41:41 -0400 Subject: [PATCH 24/59] chore: replace bundle_config and config fixtures Instead there's just a charmhub_config fixture that provides access to the charmhub staging. --- tests/commands/test_store_api.py | 224 ++++++++++++++-------------- tests/conftest.py | 79 ++-------- tests/extensions/test_extensions.py | 1 - 3 files changed, 123 insertions(+), 181 deletions(-) diff --git a/tests/commands/test_store_api.py b/tests/commands/test_store_api.py index 806a66321..686144db7 100644 --- a/tests/commands/test_store_api.py +++ b/tests/commands/test_store_api.py @@ -70,33 +70,33 @@ def anonymous_client_mock(monkeypatch): # -- tests for client usage -def test_client_init(config): +def test_client_init(charmhub_config): """Check that the client is initiated ok even without config.""" with patch("charmcraft.store.store.Client") as client_mock: - Store(config.charmhub) + Store(charmhub_config) assert client_mock.mock_calls == [ - call(config.charmhub.api_url, config.charmhub.storage_url, ephemeral=False), + call(charmhub_config.api_url, charmhub_config.storage_url, ephemeral=False), ] -def test_client_init_ephemeral(config): +def test_client_init_ephemeral(charmhub_config): """Check that the client is initiated with no keyring.""" with patch("charmcraft.store.store.Client") as client_mock: - Store(config.charmhub, ephemeral=True) + Store(charmhub_config, ephemeral=True) assert client_mock.mock_calls == [ - call(config.charmhub.api_url, config.charmhub.storage_url, ephemeral=True), + call(charmhub_config.api_url, charmhub_config.storage_url, ephemeral=True), ] # -- tests for anonymous client usage -def test_anonymous_client_init(config): +def test_anonymous_client_init(charmhub_config): """Check that the client is initiated ok even without config.""" with patch("charmcraft.store.store.AnonymousClient") as anonymous_client_mock: - Store(config.charmhub, needs_auth=False) + Store(charmhub_config, needs_auth=False) assert anonymous_client_mock.mock_calls == [ - call(config.charmhub.api_url, config.charmhub.storage_url), + call(charmhub_config.api_url, charmhub_config.storage_url), ] @@ -272,43 +272,43 @@ def test_not_logged_in_alternate_auth_disable_auto_login(monkeypatch): # -- tests for auth -def test_auth_valid_credentials(config, monkeypatch): +def test_auth_valid_credentials(charmhub_config, monkeypatch): """No errors raised when initializing Store with valid credentials.""" monkeypatch.setenv( const.ALTERNATE_AUTH_ENV_VAR, base64.b64encode(b"good_credentials").decode() ) - Store(config.charmhub) + Store(charmhub_config) -def test_auth_bad_credentials(config, monkeypatch): +def test_auth_bad_credentials(charmhub_config, monkeypatch): """CraftError raised when initializing Store with bad credentials.""" monkeypatch.setenv(const.ALTERNATE_AUTH_ENV_VAR, "bad_credentials") with pytest.raises(craft_store.errors.CredentialsNotParseable) as error: - Store(config.charmhub) + Store(charmhub_config) assert ( str(error.value) == "Credentials could not be parsed. Expected base64 encoded credentials." ) -def test_no_keyring(config): +def test_no_keyring(charmhub_config): """Verify CraftStore is raised from Store when no keyring is available.""" with patch( "craft_store.StoreClient.__init__", side_effect=craft_store.errors.NoKeyringError() ): with pytest.raises(CraftError) as error: - Store(config.charmhub) + Store(charmhub_config) assert str(error.value) == "No keyring found to store or retrieve credentials from." -def test_login(client_mock, config): +def test_login(client_mock, charmhub_config): """Simple login case.""" # set up a response from client's login acquired_credentials = "super secret stuff" client_mock.login = MagicMock(return_value=acquired_credentials) - store = Store(config.charmhub) + store = Store(charmhub_config) result = store.login() assert client_mock.mock_calls == [ call.login( @@ -325,13 +325,13 @@ def test_login(client_mock, config): assert result == acquired_credentials -def test_login_having_credentials(client_mock, config): +def test_login_having_credentials(client_mock, charmhub_config): """Login attempt when already having credentials..""" # client raises a specific exception for this case original_exception = CredentialsAlreadyAvailable("app", "host") client_mock.login.side_effect = original_exception - store = Store(config.charmhub) + store = Store(charmhub_config) with pytest.raises(CraftError) as cm: store.login() error = cm.value @@ -343,9 +343,9 @@ def test_login_having_credentials(client_mock, config): assert error.__cause__ is original_exception -def test_login_attenuating_ttl(client_mock, config): +def test_login_attenuating_ttl(client_mock, charmhub_config): """Login with specific TTL restrictions.""" - store = Store(config.charmhub) + store = Store(charmhub_config) store.login(ttl=123) assert client_mock.mock_calls == [ call.login( @@ -356,9 +356,9 @@ def test_login_attenuating_ttl(client_mock, config): ] -def test_login_attenuating_permissions(client_mock, config): +def test_login_attenuating_permissions(client_mock, charmhub_config): """Login with specific permissions restrictions.""" - store = Store(config.charmhub) + store = Store(charmhub_config) permissions_subset = [attenuations.ACCOUNT_VIEW_PACKAGES] store.login(permissions=permissions_subset) assert client_mock.mock_calls == [ @@ -370,9 +370,9 @@ def test_login_attenuating_permissions(client_mock, config): ] -def test_login_attenuating_channels(client_mock, config): +def test_login_attenuating_channels(client_mock, charmhub_config): """Login with specific channels restrictions.""" - store = Store(config.charmhub) + store = Store(charmhub_config) channels = ["edge", "beta"] store.login(channels=channels) assert client_mock.mock_calls == [ @@ -385,9 +385,9 @@ def test_login_attenuating_channels(client_mock, config): ] -def test_login_attenuating_packages(client_mock, config): +def test_login_attenuating_packages(client_mock, charmhub_config): """Login with specific packages restrictions.""" - store = Store(config.charmhub) + store = Store(charmhub_config) store.login(charms=["supercharm"], bundles=["mybundle1", "mybundle2"]) assert client_mock.mock_calls == [ call.login( @@ -403,9 +403,9 @@ def test_login_attenuating_packages(client_mock, config): ] -def test_logout(client_mock, config): +def test_logout(client_mock, charmhub_config): """Simple logout case.""" - store = Store(config.charmhub) + store = Store(charmhub_config) result = store.logout() assert client_mock.mock_calls == [ call.logout(), @@ -413,9 +413,9 @@ def test_logout(client_mock, config): assert result is None -def test_whoami_simple(client_mock, config): +def test_whoami_simple(client_mock, charmhub_config): """Simple whoami case.""" - store = Store(config.charmhub) + store = Store(charmhub_config) auth_response = { "account": { "display-name": "John Doe", @@ -441,9 +441,9 @@ def test_whoami_simple(client_mock, config): assert result.permissions == ["perm1", "perm2"] -def test_whoami_packages(client_mock, config): +def test_whoami_packages(client_mock, charmhub_config): """Whoami case that specify packages with name or id.""" - store = Store(config.charmhub) + store = Store(charmhub_config) auth_response = { "account": { "display-name": "John Doe", @@ -469,9 +469,9 @@ def test_whoami_packages(client_mock, config): assert pkg_2.name == "bundlename" -def test_whoami_channels(client_mock, config): +def test_whoami_channels(client_mock, charmhub_config): """Whoami case with channels indicated.""" - store = Store(config.charmhub) + store = Store(charmhub_config) auth_response = { "account": { "display-name": "John Doe", @@ -491,9 +491,9 @@ def test_whoami_channels(client_mock, config): # -- tests for register and list names -def test_register_name(client_mock, config): +def test_register_name(client_mock, charmhub_config): """Simple register case.""" - store = Store(config.charmhub) + store = Store(charmhub_config) result = store.register_name("testname", "stuff") assert client_mock.mock_calls == [ @@ -502,13 +502,13 @@ def test_register_name(client_mock, config): assert result is None -def test_register_name_unauthorized_logs_in(client_mock, config): +def test_register_name_unauthorized_logs_in(client_mock, charmhub_config): client_mock.request_urlpath_json.side_effect = [ StoreServerError(FakeResponse("auth", 401)), None, ] - store = Store(config.charmhub) + store = Store(charmhub_config) store.register_name("testname", "stuff") assert client_mock.mock_calls == [ @@ -529,9 +529,9 @@ def test_register_name_unauthorized_logs_in(client_mock, config): # region Unit tests for unregister_name -def test_unregister_name_success(client_mock, config): +def test_unregister_name_success(client_mock, charmhub_config): """Simple unregistration.""" - store = Store(config.charmhub) + store = Store(charmhub_config) store.unregister_name("testname") assert client_mock.mock_calls == [call.unregister_name("testname")] @@ -561,11 +561,13 @@ def test_unregister_name_success(client_mock, config): ), ], ) -def test_unregister_name_errors(client_mock, config, http_response: FakeResponse, error_cls): +def test_unregister_name_errors( + client_mock, charmhub_config, http_response: FakeResponse, error_cls +): """Errors on unregistering a name.""" client_mock.unregister_name.side_effect = StoreServerError(http_response) - store = Store(config.charmhub) + store = Store(charmhub_config) with pytest.raises(error_cls) as exc_info: store.unregister_name("testname") @@ -586,20 +588,20 @@ def test_unregister_name_errors(client_mock, config, http_response: FakeResponse ), ], ) -def test_unregister_name_login(client_mock, config, http_response: FakeResponse): +def test_unregister_name_login(client_mock, charmhub_config, http_response: FakeResponse): """Retry login when registering a name.""" client_mock.unregister_name.side_effect = [StoreServerError(http_response), None] - store = Store(config.charmhub) + store = Store(charmhub_config) store.unregister_name("testname") # endregion -def test_list_registered_names_empty(client_mock, config): +def test_list_registered_names_empty(client_mock, charmhub_config): """List registered names getting an empty response.""" - store = Store(config.charmhub) + store = Store(charmhub_config) auth_response = {"results": []} client_mock.request_urlpath_json.return_value = auth_response @@ -610,9 +612,9 @@ def test_list_registered_names_empty(client_mock, config): assert result == [] -def test_list_registered_names_multiple(client_mock, config): +def test_list_registered_names_multiple(client_mock, charmhub_config): """List registered names getting a multiple response.""" - store = Store(config.charmhub) + store = Store(charmhub_config) publisher = {"display-name": "J. Doe", "other-info": "a lot"} auth_response = { @@ -651,9 +653,9 @@ def test_list_registered_names_multiple(client_mock, config): assert item2.publisher_display_name == "J. Doe" -def test_list_registered_names_include_collaborations(client_mock, config): +def test_list_registered_names_include_collaborations(client_mock, charmhub_config): """List registered names including collaborations.""" - store = Store(config.charmhub) + store = Store(charmhub_config) auth_response = { "results": [ @@ -696,9 +698,9 @@ def test_list_registered_names_include_collaborations(client_mock, config): # -- tests for the upload functionality (both for charm/bundles and resources) -def test_upload_straightforward(client_mock, emitter, config): +def test_upload_straightforward(client_mock, emitter, charmhub_config): """The full and successful upload case.""" - store = Store(config.charmhub) + store = Store(charmhub_config) # the first response, for when pushing bytes test_upload_id = "test-upload-id" @@ -751,9 +753,9 @@ def test_upload_straightforward(client_mock, emitter, config): ) -def test_upload_polls_status_ok(client_mock, emitter, config): +def test_upload_polls_status_ok(client_mock, emitter, charmhub_config): """Upload polls status url until the end is indicated.""" - store = Store(config.charmhub) + store = Store(charmhub_config) # first and second response, for pushing bytes and let the store know about it test_upload_id = "test-upload-id" @@ -811,13 +813,13 @@ def test_upload_polls_status_ok(client_mock, emitter, config): ) -def test_upload_polls_status_timeout(client_mock, emitter, config): +def test_upload_polls_status_timeout(client_mock, emitter, charmhub_config): """Upload polls status url until a timeout is achieved. This is simulated patching a POLL_DELAYS structure shorter than the number of "keep going" responses. """ - store = Store(config.charmhub) + store = Store(charmhub_config) # first and second response, for pushing bytes and let the store know about it test_upload_id = "test-upload-id" @@ -845,9 +847,9 @@ def test_upload_polls_status_timeout(client_mock, emitter, config): assert str(cm.value) == "Timeout polling Charmhub for upload status (after 0.2s)." -def test_upload_error(client_mock, config): +def test_upload_error(client_mock, charmhub_config): """The upload ended in error.""" - store = Store(config.charmhub) + store = Store(charmhub_config) # the first response, for when pushing bytes test_upload_id = "test-upload-id" @@ -895,9 +897,9 @@ def test_upload_error(client_mock, config): @pytest.mark.usefixtures("client_mock") -def test_upload_charmbundles_endpoint(config): +def test_upload_charmbundles_endpoint(charmhub_config): """The bundle/charm upload prepares ok the endpoint and calls the generic _upload.""" - store = Store(config.charmhub) + store = Store(charmhub_config) test_results = "test-results" with patch.object(store, "_upload") as mock: @@ -908,9 +910,9 @@ def test_upload_charmbundles_endpoint(config): @pytest.mark.usefixtures("client_mock") -def test_upload_resources_endpoint(config): +def test_upload_resources_endpoint(charmhub_config): """The resource upload prepares ok the endpoint and calls the generic _upload.""" - store = Store(config.charmhub) + store = Store(charmhub_config) test_results = "test-results" with patch.object(store, "_upload") as mock: @@ -925,9 +927,9 @@ def test_upload_resources_endpoint(config): assert result == test_results -def test_upload_including_extra_parameters(client_mock, emitter, config): +def test_upload_including_extra_parameters(client_mock, emitter, charmhub_config): """Verify that the upload includes extra parameters if given.""" - store = Store(config.charmhub) + store = Store(charmhub_config) # the first response, for when pushing bytes test_upload_id = "test-upload-id" @@ -972,9 +974,9 @@ def test_upload_including_extra_parameters(client_mock, emitter, config): # -- tests for list revisions -def test_list_revisions_ok(client_mock, config): +def test_list_revisions_ok(client_mock, charmhub_config): """One revision ok.""" - store = Store(config.charmhub) + store = Store(charmhub_config) client_mock.request_urlpath_json.return_value = { "revisions": [ { @@ -1003,9 +1005,9 @@ def test_list_revisions_ok(client_mock, config): assert item.bases == [Base(architecture="amd64", channel="20.04", name="ubuntu")] -def test_list_revisions_empty(client_mock, config): +def test_list_revisions_empty(client_mock, charmhub_config): """No revisions listed.""" - store = Store(config.charmhub) + store = Store(charmhub_config) client_mock.request_urlpath_json.return_value = {"revisions": []} result = store.list_revisions("some-name") @@ -1016,9 +1018,9 @@ def test_list_revisions_empty(client_mock, config): assert result == [] -def test_list_revisions_errors(client_mock, config): +def test_list_revisions_errors(client_mock, charmhub_config): """One revision with errors.""" - store = Store(config.charmhub) + store = Store(charmhub_config) client_mock.request_urlpath_json.return_value = { "revisions": [ { @@ -1049,7 +1051,7 @@ def test_list_revisions_errors(client_mock, config): assert error2.code == "error-code-2" -def test_list_revisions_several_mixed(client_mock, config): +def test_list_revisions_several_mixed(client_mock, charmhub_config): """All cases mixed.""" client_mock.request_urlpath_json.return_value = { "revisions": [ @@ -1074,7 +1076,7 @@ def test_list_revisions_several_mixed(client_mock, config): ] } - store = Store(config.charmhub) + store = Store(charmhub_config) result = store.list_revisions("some-name") (item1, item2) = result @@ -1094,9 +1096,9 @@ def test_list_revisions_several_mixed(client_mock, config): assert item2.errors == [] -def test_list_revisions_bases_none(client_mock, config): +def test_list_revisions_bases_none(client_mock, charmhub_config): """Bases in None answered by the store (happens with bundles, for example).""" - store = Store(config.charmhub) + store = Store(charmhub_config) client_mock.request_urlpath_json.return_value = { "revisions": [ { @@ -1117,9 +1119,9 @@ def test_list_revisions_bases_none(client_mock, config): # -- tests for release -def test_release_simple(client_mock, config): +def test_release_simple(client_mock, charmhub_config): """Releasing a revision into one channel.""" - store = Store(config.charmhub) + store = Store(charmhub_config) store.release("testname", 123, ["somechannel"], []) expected_body = [{"revision": 123, "channel": "somechannel", "resources": []}] @@ -1128,9 +1130,9 @@ def test_release_simple(client_mock, config): ] -def test_release_multiple_channels(client_mock, config): +def test_release_multiple_channels(client_mock, charmhub_config): """Releasing a revision into multiple channels.""" - store = Store(config.charmhub) + store = Store(charmhub_config) store.release("testname", 123, ["channel1", "channel2", "channel3"], []) expected_body = [ @@ -1143,9 +1145,9 @@ def test_release_multiple_channels(client_mock, config): ] -def test_release_with_resources(client_mock, config): +def test_release_with_resources(client_mock, charmhub_config): """Releasing with resources attached.""" - store = Store(config.charmhub) + store = Store(charmhub_config) r1 = ResourceOption(name="foo", revision=3) r2 = ResourceOption(name="bar", revision=17) store.release("testname", 123, ["channel1", "channel2"], [r1, r2]) @@ -1176,7 +1178,7 @@ def test_release_with_resources(client_mock, config): # -- tests for status -def test_status_ok(client_mock, config): +def test_status_ok(client_mock, charmhub_config): """Get all the release information.""" client_mock.request_urlpath_json.return_value = { "channel-map": [ @@ -1237,7 +1239,7 @@ def test_status_ok(client_mock, config): ], } - store = Store(config.charmhub) + store = Store(charmhub_config) channel_map, channels, revisions = store.list_releases("testname") # check how the client is used @@ -1293,7 +1295,7 @@ def test_status_ok(client_mock, config): assert base.architecture == "amd64" -def test_status_with_resources(client_mock, config): +def test_status_with_resources(client_mock, charmhub_config): """Get all the release information.""" client_mock.request_urlpath_json.return_value = { "channel-map": [ @@ -1363,7 +1365,7 @@ def test_status_with_resources(client_mock, config): ], } - store = Store(config.charmhub) + store = Store(charmhub_config) channel_map, _, _ = store.list_releases("testname") # check response @@ -1389,7 +1391,7 @@ def test_status_with_resources(client_mock, config): assert res2.resource_type == "file" -def test_status_base_in_none(client_mock, config): +def test_status_base_in_none(client_mock, charmhub_config): """Support the case of base being None (may happen with bundles).""" client_mock.request_urlpath_json.return_value = { "channel-map": [ @@ -1426,7 +1428,7 @@ def test_status_base_in_none(client_mock, config): ], } - store = Store(config.charmhub) + store = Store(charmhub_config) channel_map, _, revisions = store.list_releases("testname") # check response @@ -1439,9 +1441,9 @@ def test_status_base_in_none(client_mock, config): # -- tests for library related functions -def test_create_library_id(client_mock, config): +def test_create_library_id(client_mock, charmhub_config): """Create a new library in the store.""" - store = Store(config.charmhub) + store = Store(charmhub_config) client_mock.request_urlpath_json.return_value = {"library-id": "test-lib-id"} result = store.create_library_id("test-charm-name", "test-lib-name") @@ -1456,7 +1458,7 @@ def test_create_library_id(client_mock, config): assert result == "test-lib-id" -def test_create_library_revision(client_mock, config): +def test_create_library_revision(client_mock, charmhub_config): """Create a new library revision in the store.""" test_charm_name = "test-charm-name" test_lib_name = "test-lib-name" @@ -1466,7 +1468,7 @@ def test_create_library_revision(client_mock, config): test_content = "test content with quite a lot of funny Python code :p" test_hash = "1234" - store = Store(config.charmhub) + store = Store(charmhub_config) client_mock.request_urlpath_json.return_value = { "api": test_api, "content": test_content, @@ -1501,7 +1503,7 @@ def test_create_library_revision(client_mock, config): assert result_lib.patch == test_patch -def test_get_library(anonymous_client_mock, config): +def test_get_library(anonymous_client_mock, charmhub_config): """Get all the information (including content) for a library revision.""" test_charm_name = "test-charm-name" test_lib_name = "test-lib-name" @@ -1511,7 +1513,7 @@ def test_get_library(anonymous_client_mock, config): test_content = "test content with quite a lot of funny Python code :p" test_hash = "1234" - store = Store(config.charmhub, needs_auth=False) + store = Store(charmhub_config, needs_auth=False) anonymous_client_mock.request_urlpath_json.return_value = { "api": test_api, "content": test_content, @@ -1538,7 +1540,7 @@ def test_get_library(anonymous_client_mock, config): assert result_lib.patch == test_patch -def test_get_tips_simple(anonymous_client_mock, config): +def test_get_tips_simple(anonymous_client_mock, charmhub_config): """Get info for a lib, simple case with successful result.""" test_charm_name = "test-charm-name" test_lib_name = "test-lib-name" @@ -1548,7 +1550,7 @@ def test_get_tips_simple(anonymous_client_mock, config): test_content = "test content with quite a lot of funny Python code :p" test_hash = "1234" - store = Store(config.charmhub, needs_auth=False) + store = Store(charmhub_config, needs_auth=False) anonymous_client_mock.request_urlpath_json.return_value = { "libraries": [ { @@ -1588,11 +1590,11 @@ def test_get_tips_simple(anonymous_client_mock, config): assert result == expected -def test_get_tips_empty(anonymous_client_mock, config): +def test_get_tips_empty(anonymous_client_mock, charmhub_config): """Get info for a lib, with an empty response.""" test_lib_id = "test-lib-id" - store = Store(config.charmhub, needs_auth=False) + store = Store(charmhub_config, needs_auth=False) anonymous_client_mock.request_urlpath_json.return_value = {"libraries": []} query_info = [ @@ -1609,7 +1611,7 @@ def test_get_tips_empty(anonymous_client_mock, config): assert result == {} -def test_get_tips_several(anonymous_client_mock, config): +def test_get_tips_several(anonymous_client_mock, charmhub_config): """Get info for multiple libs at once.""" test_charm_name_1 = "test-charm-name-1" test_lib_name_1 = "test-lib-name-1" @@ -1627,7 +1629,7 @@ def test_get_tips_several(anonymous_client_mock, config): test_content_2 = "more awesome Python code :)" test_hash_2 = "5678" - store = Store(config.charmhub, needs_auth=False) + store = Store(charmhub_config, needs_auth=False) anonymous_client_mock.request_urlpath_json.return_value = { "libraries": [ { @@ -1687,9 +1689,9 @@ def test_get_tips_several(anonymous_client_mock, config): assert result == expected -def test_get_tips_query_combinations(anonymous_client_mock, config): +def test_get_tips_query_combinations(anonymous_client_mock, charmhub_config): """Use all the combinations to specify what's queried.""" - store = Store(config.charmhub, needs_auth=False) + store = Store(charmhub_config, needs_auth=False) anonymous_client_mock.request_urlpath_json.return_value = {"libraries": []} query_info = [ @@ -1722,9 +1724,9 @@ def test_get_tips_query_combinations(anonymous_client_mock, config): # -- tests for list resources -def test_list_resources_ok(client_mock, config): +def test_list_resources_ok(client_mock, charmhub_config): """One resource ok.""" - store = Store(config.charmhub) + store = Store(charmhub_config) client_mock.request_urlpath_json.return_value = { "resources": [ { @@ -1749,9 +1751,9 @@ def test_list_resources_ok(client_mock, config): assert item.resource_type == "file" -def test_list_resources_empty(client_mock, config): +def test_list_resources_empty(client_mock, charmhub_config): """No resources listed.""" - store = Store(config.charmhub) + store = Store(charmhub_config) client_mock.request_urlpath_json.return_value = {"resources": []} result = store.list_resources("some-name") @@ -1762,7 +1764,7 @@ def test_list_resources_empty(client_mock, config): assert result == [] -def test_list_resources_several(client_mock, config): +def test_list_resources_several(client_mock, charmhub_config): """Several items returned.""" client_mock.request_urlpath_json.return_value = { "resources": [ @@ -1781,7 +1783,7 @@ def test_list_resources_several(client_mock, config): ] } - store = Store(config.charmhub) + store = Store(charmhub_config) result = store.list_resources("some-name") (item1, item2) = result @@ -1800,9 +1802,9 @@ def test_list_resources_several(client_mock, config): # -- tests for OCI related functions -def test_get_oci_registry_credentials(client_mock, config): +def test_get_oci_registry_credentials(client_mock, charmhub_config): """Get the credentials to hit the OCI Registry.""" - store = Store(config.charmhub) + store = Store(charmhub_config) client_mock.request_urlpath_json.return_value = { "image-name": "test-image-name", "username": "jane-doe", @@ -1820,9 +1822,9 @@ def test_get_oci_registry_credentials(client_mock, config): assert result.password == "oh boy this is so secret!" -def test_get_oci_image_blob(client_mock, config): +def test_get_oci_image_blob(client_mock, charmhub_config): """Get the blob generated by Charmhub to refer to the OCI image.""" - store = Store(config.charmhub) + store = Store(charmhub_config) client_mock.request_urlpath_text.return_value = "some opaque stuff" result = store.get_oci_image_blob("charm-name", "resource-name", "a-very-specific-digest") diff --git a/tests/conftest.py b/tests/conftest.py index 1e196f827..a01f74345 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,13 +15,13 @@ # For further info, check https://github.com/canonical/charmcraft import contextlib -import datetime import importlib import json import os import pathlib import tempfile import types +from typing import Iterator from unittest import mock import craft_parts @@ -35,10 +35,8 @@ import charmcraft.parts from charmcraft import const, instrum, parts, services, store from charmcraft.application.main import APP_METADATA -from charmcraft.bases import get_host_as_base from charmcraft.models import charmcraft as config_module from charmcraft.models import project -from charmcraft.models.charmcraft import BasesConfiguration @pytest.fixture() @@ -128,7 +126,7 @@ def fake_prime_dir(fs) -> pathlib.Path: @pytest.fixture() -def fake_path(fs) -> pathlib.Path: +def fake_path(fs) -> Iterator[pathlib.Path]: """Like tmp_path, but with a fake filesystem.""" with tempfile.TemporaryDirectory() as tmp_dir: yield pathlib.Path(tmp_dir) @@ -156,71 +154,14 @@ def setup_parts(): @pytest.fixture() -def config(tmp_path): - """Provide a config class with an extra set method for the test to change it.""" - - class TestConfig(config_module.CharmcraftConfig, frozen=False): - """The Config, but with a method to set test values.""" - - def set(self, prime=None, **kwargs): - # prime is special, so we don't need to write all this structure in all tests - if prime is not None: - if self.parts is None: - self.parts = {} - self.parts["charm"] = {"plugin": "charm", "prime": prime} - - # the rest is direct - for k, v in kwargs.items(): - object.__setattr__(self, k, v) - - project = config_module.Project( - dirpath=tmp_path, - started_at=datetime.datetime.utcnow(), - config_provided=True, - ) - - base = BasesConfiguration(**{"build-on": [get_host_as_base()], "run-on": [get_host_as_base()]}) - - return TestConfig( - type="charm", - bases=[base], - project=project, - name="test-charm", - summary="test summary", - description="test description", - ) - - -@pytest.fixture() -def bundle_config(tmp_path): - """Provide a config class with an extra set method for the test to change it.""" - - class TestConfig(config_module.CharmcraftConfig, frozen=False): - """The Config, but with a method to set test values.""" - - def set(self, prime=None, **kwargs): - # prime is special, so we don't need to write all this structure in all tests - if prime is not None: - if self.parts is None: - self.parts = {} - self.parts["bundle"] = {"plugin": "bundle", "prime": prime} - - # the rest is direct - for k, v in kwargs.items(): - object.__setattr__(self, k, v) - - project = config_module.Project( - dirpath=tmp_path, - started_at=datetime.datetime.utcnow(), - config_provided=True, - ) - - return TestConfig( - type="bundle", - project=project, - name="test-bundle", - summary="test summary", - description="test description", +def charmhub_config() -> config_module.CharmhubConfig: + """Provide a charmhub config for use in tests""" + return config_module.CharmhubConfig.parse_obj( + { + "api-url": "https://api.staging.charmhub.io", + "storage-url": "https://storage.staging.snapcraftcontent.com", + "registry-url": "https://registry.staging.jujucharms.com", + } ) diff --git a/tests/extensions/test_extensions.py b/tests/extensions/test_extensions.py index 26aa35004..b93504b11 100644 --- a/tests/extensions/test_extensions.py +++ b/tests/extensions/test_extensions.py @@ -14,7 +14,6 @@ # # For further info, check https://github.com/canonical/charmcraft -from textwrap import dedent from typing import Any import pytest From a6f82eb13643c6e3eaab0960007874ebf7194d5d Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 16:43:03 -0400 Subject: [PATCH 25/59] chore: remove the unused CharmcraftConfig model --- charmcraft/models/charmcraft.py | 275 -------------------------------- charmcraft/models/metadata.py | 2 +- 2 files changed, 1 insertion(+), 276 deletions(-) diff --git a/charmcraft/models/charmcraft.py b/charmcraft/models/charmcraft.py index 59bb4f373..200d786a6 100644 --- a/charmcraft/models/charmcraft.py +++ b/charmcraft/models/charmcraft.py @@ -28,7 +28,6 @@ from charmcraft import const, parts from charmcraft.extensions import apply_extensions - from charmcraft.models.actions import JujuActions from charmcraft.models.basic import AttributeName, LinterName, ModelConfigDefaults from charmcraft.models.config import JujuConfig @@ -130,277 +129,3 @@ class Links(ModelConfigDefaults, frozen=True): """Where to find this charm's source code.""" website: pydantic.AnyHttpUrl | list[pydantic.AnyHttpUrl] | None """The website for this charm.""" - - -class CharmcraftConfig( - ModelConfigDefaults, - validate_all=False, - alias_generator=lambda s: s.replace("_", "-"), - frozen=True, -): - """Definition of charmcraft.yaml configuration.""" - - # this needs to go before 'parts', as it used by the validator - project: Project - - metadata_legacy: bool = False - - type: Literal["bundle", "charm"] - name: pydantic.StrictStr | None - summary: pydantic.StrictStr | None - description: pydantic.StrictStr | None - charmhub: CharmhubConfig = CharmhubConfig() - parts: dict[str, Any] | None - bases: list[BasesConfiguration] | None - analysis: AnalysisConfig = AnalysisConfig() - actions: JujuActions | None - assumes: list[str | dict[str, list | dict]] | None - containers: dict[str, Any] | None - devices: dict[str, Any] | None - title: pydantic.StrictStr | None - extra_bindings: dict[str, Any] | None - peers: dict[str, Any] | None - provides: dict[str, Any] | None - requires: dict[str, Any] | None - resources: dict[str, Any] | None - storage: dict[str, Any] | None - subordinate: bool | None - terms: list[str] | None - links: Links | None - config: JujuConfig | None - - @pydantic.validator("name", pre=True, always=True) - def validate_name(cls, name, values): - """Verify charm name is valid with exception when instantiated without YAML.""" - if values.get("type") == "charm" and not name: - raise ValueError("needs value") - - return name - - @pydantic.validator("summary", pre=True, always=True) - def validate_summary(cls, summary, values): - """Verify charm summary is valid with exception when instantiated without YAML.""" - if values.get("type") == "charm" and not summary: - raise ValueError("needs value") - - return summary - - @pydantic.validator("description", pre=True, always=True) - def validate_description(cls, description, values): - """Verify charm name is valid with exception when instantiated without YAML.""" - if values.get("type") == "charm" and not description: - raise ValueError("needs value") - - return description - - @pydantic.validator("parts", pre=True, always=True) - def validate_special_parts(cls, parts, values): - """Verify parts type (craft-parts will re-validate the schemas for the plugins).""" - if "type" not in values: - # we need 'type' to be set in this validator; if not there it's an error in - # the schema anyway, so the whole loading will fail (no need to raise an - # extra error here, it gets confusing to the user) - return None - - if not parts: - # no parts indicated, default to the type of package - parts = {values["type"]: {}} - - if not isinstance(parts, dict): - raise TypeError("value must be a dictionary") - - for name, part in parts.items(): - if not isinstance(part, dict): - raise TypeError(f"part {name!r} must be a dictionary") - # implicit plugin fixup - if "plugin" not in part: - part["plugin"] = name - - # if needed, create 'source' properties for special parts "charm" with plugin "charm". - # and "bundle" with plugin "bundle", pointing to project's directory - for name, part in parts.items(): - if name == "charm" and part["plugin"] == "charm": - part.setdefault("source", str(values["project"].dirpath)) - - if name == "bundle" and part["plugin"] == "bundle": - part.setdefault("source", str(values["project"].dirpath)) - - return parts - - @pydantic.validator("parts", each_item=True) - def validate_each_part(cls, item): - """Verify each part in the parts section. Craft-parts will re-validate them.""" - return parts.process_part_config(item) - - @pydantic.validator("bases", pre=True) - def validate_bases_presence(cls, bases, values): - """Forbid 'bases' in bundles. - - This is to avoid a possible confusion of expecting the bundle - to be built in a specific environment - """ - if values.get("type") == "bundle": - raise ValueError("Field not allowed when type=bundle") - return bases - - @pydantic.validator("actions", pre=True, always=True) - def validate_actions(cls, actions, values): - """Verify 'actions' in charms. - - Currently, actions will be passed through to the charms. - And individual "actions.yaml" should not exists when actions - is defined in charmcraft.yaml. - """ - from charmcraft.metafiles.actions import parse_actions_yaml - actions_yaml = parse_actions_yaml(values["project"].dirpath, allow_broken=True) - if actions is None: - return actions_yaml - else: - if actions_yaml is not None: - raise ValueError( - "'actions.yaml' file not allowed when an 'actions' section is " - "defined in 'charmcraft.yaml'" - ) - - return JujuActions.parse_obj({"actions": actions}) - - @pydantic.validator("config", pre=True, always=True) - def validate_config(cls, config, values): - """Verify 'actions' in charms. - - Currently, actions will be passed through to the charms. - And individual "actions.yaml" should not exists when actions - is defined in charmcraft.yaml. - """ - from charmcraft.metafiles.config import parse_config_yaml - config_yaml = parse_config_yaml(values["project"].dirpath, allow_broken=True) - if config is None: - return config_yaml - else: - if config_yaml is not None: - raise ValueError( - "'config.yaml' file not allowed when an 'config' section is " - "defined in 'charmcraft.yaml'" - ) - - return JujuConfig.parse_obj(config) - - @classmethod - def expand_short_form_bases(cls, bases: list[dict[str, Any]]) -> None: - """Expand short-form base configuration into long-form in-place.""" - for index, base in enumerate(bases): - # Skip if already long-form. Account for common typos in case user - # intends to use long-form, but did so incorrectly (for better - # error message handling). - if "run-on" in base or "run_on" in base or "build-on" in base or "build_on" in base: - continue - - try: - converted_base = Base(**base) - except pydantic.ValidationError as error: - # Rewrite location to assist user. - pydantic_errors = error.errors() - for pydantic_error in pydantic_errors: - pydantic_error["loc"] = ("bases", index, pydantic_error["loc"][0]) - - raise CraftError( - format_pydantic_errors(pydantic_errors, file_name="charmcraft.yaml") - ) - - base.clear() - base["build-on"] = [converted_base.dict()] - base["run-on"] = [converted_base.dict()] - - @classmethod - def unmarshal( # pyright: ignore[reportIncompatibleMethodOverride] - cls, obj: dict[str, Any], project: Project - ): - """Unmarshal object with necessary translations and error handling. - - (1) Perform any necessary translations. - - (2) Standardize error reporting. - - :returns: valid CharmcraftConfig. - - :raises CraftError: On failure to unmarshal object. - """ - from charmcraft.metafiles.actions import parse_actions_yaml - from charmcraft.metafiles.config import parse_config_yaml - from charmcraft.metafiles.metadata import ( - parse_bundle_metadata_yaml, - parse_charm_metadata_yaml, - ) - try: - # Expand short-form bases if only the bases is a valid list. If it - # is not a valid list, parse_obj() will properly handle the error. - if isinstance(obj.get("bases"), list): - cls.expand_short_form_bases(obj["bases"]) - - obj = apply_extensions(project.dirpath, obj) - - # Re-expand it in case extensions added short-form bases. - if isinstance(obj.get("bases"), list): - cls.expand_short_form_bases(obj["bases"]) - - # If metadata.yaml exists, try merge it into config. - if os.path.isfile(project.dirpath / const.METADATA_FILENAME): - # metadata.yaml exists, so we can't specify metadata keys in charmcraft.yaml. - for key in const.CHARM_METADATA_KEYS.union(const.METADATA_YAML_KEYS): - if key in obj: - raise CraftError( - f"Cannot specify '{key}' in charmcraft.yaml when " - f"'{const.METADATA_FILENAME}' exists" - ) - - if obj.get("type") == "charm": - metadata_legacy = parse_charm_metadata_yaml(project.dirpath, allow_basic=True) - - # need to copy 3 fields from metadata_legacy to charmcraft config - return cls.parse_obj( - { - "project": project, - "name": metadata_legacy.name, - "summary": metadata_legacy.summary, - "description": metadata_legacy.description, - "metadata-legacy": True, - **obj, - } - ) - elif obj.get("type") == "bundle": - # bundle may not have metadata.yaml. - # but if it does, it should have name and optional description - # metadata.yaml will be copied without validation if it exists - metadata_legacy = parse_bundle_metadata_yaml(project.dirpath) - return cls.parse_obj( - { - "project": project, - "name": metadata_legacy.name, - "description": metadata_legacy.description, - "metadata-legacy": True, - **obj, - } - ) - else: - # fallthrough for pydantic to handle - pass - - return cls.parse_obj({"project": project, **obj}) - except pydantic.ValidationError as error: - raise CraftError(format_pydantic_errors(error.errors(), file_name="charmcraft.yaml")) - - @classmethod - def schema( # pyright: ignore[reportIncompatibleMethodOverride] - cls, **kwargs - ) -> dict[str, Any]: - """Perform any schema fixups required to hide internal details.""" - schema = super().schema(**kwargs) - - # The internal __root__ detail is leaked, overwrite it. - schema["properties"]["parts"]["default"] = {} - - # Project is an internal detail, purge references. - schema["definitions"].pop("Project", None) - schema["properties"].pop("project", None) - schema["required"].remove("project") - return schema diff --git a/charmcraft/models/metadata.py b/charmcraft/models/metadata.py index 567167c61..f3493792d 100644 --- a/charmcraft/models/metadata.py +++ b/charmcraft/models/metadata.py @@ -92,7 +92,7 @@ class CharmMetadataLegacy(CharmMetadata): """Object representing LEGACY charm metadata.yaml contents. This model only supports the legacy charm metadata.yaml format for compatibility. - New metadata defined in charmcraft.yaml is handled by the CharmcraftConfig model. + New metadata defined in charmcraft.yaml is handled by the CharmcraftProject models. specs: https://juju.is/docs/sdk/metadata-yaml """ From 9df8be1e32a2ff4d9beb4859dc91b10bbbf2ec5b Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 16:45:50 -0400 Subject: [PATCH 26/59] chore: remove unused CharmcraftConfig model --- charmcraft/models/charmcraft.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/charmcraft/models/charmcraft.py b/charmcraft/models/charmcraft.py index 200d786a6..785628623 100644 --- a/charmcraft/models/charmcraft.py +++ b/charmcraft/models/charmcraft.py @@ -90,19 +90,6 @@ class BasesConfiguration( run_on: list[Base] -class Project(ModelConfigDefaults, frozen=True): - """Internal-only project configuration.""" - - # do not verify that `dirpath` is a valid existing directory; it's used externally as a dir - # to load the config itself (so we're really do the validation there), and we want to support - # the case of a missing directory (and still load a default config structure) - dirpath: pathlib.Path - config_provided: bool = False - - # this timestamp will be used in several places, even sent to Charmhub: needs to be UTC - started_at: datetime.datetime - - class Ignore(ModelConfigDefaults, frozen=True): """Definition of `analysis.ignore` configuration.""" From 13560c9505920a7fe88f4d0757f9d3a54da8bfd3 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 16:46:37 -0400 Subject: [PATCH 27/59] chore: remove unused parse_bundle_metadata_yaml fn --- charmcraft/metafiles/metadata.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/charmcraft/metafiles/metadata.py b/charmcraft/metafiles/metadata.py index 2be4cbc57..e6337a958 100644 --- a/charmcraft/metafiles/metadata.py +++ b/charmcraft/metafiles/metadata.py @@ -73,21 +73,3 @@ def parse_charm_metadata_yaml( } return CharmMetadataLegacy.unmarshal(metadata_basic) raise - - -def parse_bundle_metadata_yaml(charm_dir: pathlib.Path) -> BundleMetadata: - """Parse project's legacy metadata.yaml that used for bundles. - - :returns: a BundleMetadataLegacy object. - - :raises: CraftError if metadata.yaml does not exist or is not valid. - """ - try: - metadata = read_metadata_yaml(charm_dir) - except OSError as exc: - raise CraftError(f"Cannot read the metadata.yaml file: {exc!r}") from exc - if not isinstance(metadata, dict): - raise CraftError(f"The {charm_dir / const.METADATA_FILENAME} file is not valid YAML.") - - emit.debug("Validating metadata keys") - return BundleMetadata.unmarshal(metadata) From f130b2fafa2a6fe02488762d01c2240fe8b8a9c4 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 16:48:02 -0400 Subject: [PATCH 28/59] chore: remove unused file parsers --- charmcraft/metafiles/actions.py | 75 --------------------------------- charmcraft/metafiles/config.py | 68 ------------------------------ 2 files changed, 143 deletions(-) delete mode 100644 charmcraft/metafiles/actions.py delete mode 100644 charmcraft/metafiles/config.py diff --git a/charmcraft/metafiles/actions.py b/charmcraft/metafiles/actions.py deleted file mode 100644 index e78a8df41..000000000 --- a/charmcraft/metafiles/actions.py +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright 2023 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 - -"""Charmcraft project handle actions.yaml file.""" - -import logging -import pathlib -import typing -from typing import Literal - -import pydantic -from craft_application import util -from craft_application.util.error_formatting import format_pydantic_errors -from craft_cli import CraftError, emit - -from charmcraft import const -from charmcraft.models.actions import JujuActions - -logger = logging.getLogger(__name__) - - -@typing.overload -def parse_actions_yaml( - charm_dir: pathlib.Path, allow_broken: Literal[False] = False -) -> JujuActions: ... - - -@typing.overload -def parse_actions_yaml( - charm_dir: pathlib.Path, allow_broken: Literal[True] -) -> JujuActions | None: ... - - -def parse_actions_yaml(charm_dir, allow_broken=False): - """Parse project's actions.yaml. - - :param charm_dir: Directory to read actions.yaml from. - - :returns: a JujuActions object or None if actions.yaml does not exist. - - :raises: CraftError if actions.yaml is not valid. - """ - try: - with (charm_dir / const.JUJU_ACTIONS_FILENAME).open() as file: - actions = util.safe_yaml_load(file) - except FileNotFoundError: - return None - except OSError as exc: - raise CraftError(f"Cannot read the {const.JUJU_ACTIONS_FILENAME} file: {exc!r}") from exc - - emit.debug(f"Validating {const.JUJU_ACTIONS_FILENAME}") - try: - return JujuActions.parse_obj({"actions": actions}) - except pydantic.ValidationError as error: - if allow_broken: - emit.progress( - format_pydantic_errors(error.errors(), file_name=const.JUJU_ACTIONS_FILENAME), - permanent=True, - ) - emit.debug(f"Ignoring {const.JUJU_ACTIONS_FILENAME}") - return None - raise diff --git a/charmcraft/metafiles/config.py b/charmcraft/metafiles/config.py deleted file mode 100644 index 8a821e944..000000000 --- a/charmcraft/metafiles/config.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright 2023 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 - -"""Charmcraft project handle config.yaml file.""" - -import logging -import pathlib - -import pydantic -from craft_application.util.error_formatting import format_pydantic_errors -from craft_cli import CraftError, emit - -from charmcraft import const -from charmcraft.metafiles import read_yaml -from charmcraft.models.config import JujuConfig - -logger = logging.getLogger(__name__) - - -def parse_config_yaml(charm_dir: pathlib.Path, allow_broken=False) -> JujuConfig | None: - """Parse project's config.yaml. - - :param charm_dir: Directory to read config.yaml from. - - :returns: a JujuConfig object. - - :raises: CraftError if config.yaml is not valid. - """ - try: - config = read_yaml(charm_dir / const.JUJU_CONFIG_FILENAME) - except FileNotFoundError: - return None - except OSError as exc: - raise CraftError(f"Cannot read the {const.JUJU_CONFIG_FILENAME} file: {exc!r}") from exc - - if allow_broken and (not isinstance(config, dict) or not config.get("options")): - emit.progress( - "'config.yaml' is not a valid config file.", - permanent=True, - ) - emit.debug(f"Ignoring {const.JUJU_CONFIG_FILENAME}") - return None - - emit.debug(f"Validating {const.JUJU_CONFIG_FILENAME}") - try: - return JujuConfig.parse_obj(config) - except pydantic.ValidationError as error: - if allow_broken: - emit.progress( - format_pydantic_errors(error.errors(), file_name=const.JUJU_CONFIG_FILENAME), - permanent=True, - ) - emit.debug(f"Ignoring {const.JUJU_CONFIG_FILENAME}") - return None - raise From d3acb9ab7149864b0690f266e5b09179aef5493d Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 16:52:12 -0400 Subject: [PATCH 29/59] chore: remove unused parse_charm_metadata_yaml --- charmcraft/linters.py | 5 ++- charmcraft/metafiles/metadata.py | 35 +-------------- charmcraft/models/charmcraft.py | 11 +---- tests/conftest.py | 2 +- tests/test_metadata.py | 75 +------------------------------- 5 files changed, 7 insertions(+), 121 deletions(-) diff --git a/charmcraft/linters.py b/charmcraft/linters.py index 1ce0aa42b..44845dc59 100644 --- a/charmcraft/linters.py +++ b/charmcraft/linters.py @@ -27,8 +27,9 @@ import yaml from charmcraft import const, utils -from charmcraft.metafiles.metadata import parse_charm_metadata_yaml, read_metadata_yaml +from charmcraft.metafiles.metadata import read_metadata_yaml from charmcraft.models.lint import CheckResult, CheckType, LintResult +from charmcraft.models.metadata import CharmMetadataLegacy # the documentation page for "Analyzers and linters" BASE_DOCS_URL = "https://juju.is/docs/sdk/charmcraft-analyzers-and-linters" @@ -244,7 +245,7 @@ def _check_operator(self, basedir: pathlib.Path) -> bool: def _check_reactive(self, basedir: pathlib.Path) -> bool: """Detect if the Reactive Framework is used.""" try: - metadata = parse_charm_metadata_yaml(basedir) + metadata = CharmMetadataLegacy.from_yaml_file(basedir / const.METADATA_FILENAME) except Exception: # file not found, corrupted, or mandatory "name" not present return False diff --git a/charmcraft/metafiles/metadata.py b/charmcraft/metafiles/metadata.py index e6337a958..3346c2312 100644 --- a/charmcraft/metafiles/metadata.py +++ b/charmcraft/metafiles/metadata.py @@ -26,7 +26,7 @@ from craft_cli import CraftError, emit from charmcraft import const -from charmcraft.models.metadata import BundleMetadata, CharmMetadataLegacy +from charmcraft.models.metadata import CharmMetadataLegacy logger = logging.getLogger(__name__) @@ -40,36 +40,3 @@ def read_metadata_yaml(charm_dir: pathlib.Path) -> dict[str, Any]: emit.debug(f"Reading {str(metadata_path)!r}") with metadata_path.open("rt", encoding="utf8") as fh: return yaml.safe_load(fh) - - -def parse_charm_metadata_yaml( - charm_dir: pathlib.Path, allow_basic: bool = False -) -> CharmMetadataLegacy: - """Parse project's legacy metadata.yaml that used for charms. - - :returns: a CharmMetadataLegacy object. - - :raises: CraftError if metadata.yaml does not exist or is not valid. - """ - try: - metadata = read_metadata_yaml(charm_dir) - except OSError as exc: - raise CraftError(f"Cannot read the metadata.yaml file: {exc!r}") from exc - if not isinstance(metadata, dict): - raise CraftError(f"The {charm_dir / const.METADATA_FILENAME} file is not valid YAML.") - - emit.debug("Validating metadata keys") - try: - return CharmMetadataLegacy.unmarshal(metadata) - except pydantic.ValidationError as error: - if allow_basic: - emit.progress( - format_pydantic_errors(error.errors(), file_name=const.METADATA_FILENAME), - permanent=True, - ) - emit.debug("Falling back to basic metadata.yaml") - metadata_basic = { - k: v for k, v in metadata.items() if k in ("name", "summary", "description") - } - return CharmMetadataLegacy.unmarshal(metadata_basic) - raise diff --git a/charmcraft/models/charmcraft.py b/charmcraft/models/charmcraft.py index 785628623..cb5dc753c 100644 --- a/charmcraft/models/charmcraft.py +++ b/charmcraft/models/charmcraft.py @@ -15,22 +15,13 @@ # For further info, check https://github.com/canonical/charmcraft """Charmcraft configuration pydantic model.""" -import datetime -import os -import pathlib -from typing import Any, Literal, cast +from typing import cast import pydantic from craft_application import util -from craft_application.util.error_formatting import format_pydantic_errors -from craft_cli import CraftError from typing_extensions import Self -from charmcraft import const, parts -from charmcraft.extensions import apply_extensions -from charmcraft.models.actions import JujuActions from charmcraft.models.basic import AttributeName, LinterName, ModelConfigDefaults -from charmcraft.models.config import JujuConfig class CharmhubConfig( diff --git a/tests/conftest.py b/tests/conftest.py index a01f74345..2de0a5a67 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,7 +21,7 @@ import pathlib import tempfile import types -from typing import Iterator +from collections.abc import Iterator from unittest import mock import craft_parts diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 7abc95c68..173cd2e2a 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -22,80 +22,7 @@ from craft_cli import CraftError from charmcraft import const -from charmcraft.metafiles.metadata import parse_charm_metadata_yaml, read_metadata_yaml - -# tests for parsing metadata - - -def test_parse_metadata_yaml_complete(tmp_path): - """Example of parsing with all the optional attributes.""" - metadata_file = tmp_path / const.METADATA_FILENAME - metadata_file.write_text( - """ - name: test-name - summary: Test summary - description: Lot of text. - """ - ) - - metadata = parse_charm_metadata_yaml(tmp_path) - - assert metadata.name == "test-name" - assert metadata.summary == "Test summary" - assert metadata.description == "Lot of text." - - -@pytest.mark.parametrize( - "metadata_yaml_template", - [ - dedent( - """\ - name: {name} - summary: Test summary - description: Lot of text. - """ - ), - ], -) -@pytest.mark.parametrize("name", ["name1", "my-charm-foo"]) -def test_parse_metadata_yaml_valid_names( - tmp_path, name, prepare_metadata_yaml, metadata_yaml_template -): - prepare_metadata_yaml(metadata_yaml_template.format(name=name)) - - metadata = parse_charm_metadata_yaml(tmp_path) - - assert metadata.name == name - - -@pytest.mark.parametrize( - "metadata_yaml_template", - [ - dedent( - """\ - name: {name} - summary: Test summary - description: Lot of text. - """ - ), - ], -) -@pytest.mark.parametrize("name", [1, "false", "[]"]) -def test_parse_metadata_yaml_error_invalid_names( - tmp_path, prepare_metadata_yaml, metadata_yaml_template, name -): - prepare_metadata_yaml(metadata_yaml_template.format(name=name)) - - with pytest.raises(pydantic.ValidationError): - parse_charm_metadata_yaml(tmp_path) - - -def test_parse_metadata_yaml_error_missing(tmp_path): - msg = re.escape( - "Cannot read the metadata.yaml file: FileNotFoundError(2, 'No such file or directory')" - ) - with pytest.raises(CraftError, match=msg): - parse_charm_metadata_yaml(tmp_path) +from charmcraft.metafiles.metadata import read_metadata_yaml # tests for reading metadata raw content From d760a182a3d25bcbe6032e624e8d182090790739 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 16:56:50 -0400 Subject: [PATCH 30/59] chore: remove unused metafiles module --- charmcraft/linters.py | 4 +-- charmcraft/metafiles/__init__.py | 36 -------------------- charmcraft/metafiles/metadata.py | 42 ----------------------- tests/test_metadata.py | 57 -------------------------------- 4 files changed, 2 insertions(+), 137 deletions(-) delete mode 100644 charmcraft/metafiles/__init__.py delete mode 100644 charmcraft/metafiles/metadata.py delete mode 100644 tests/test_metadata.py diff --git a/charmcraft/linters.py b/charmcraft/linters.py index 44845dc59..d904659ea 100644 --- a/charmcraft/linters.py +++ b/charmcraft/linters.py @@ -27,7 +27,6 @@ import yaml from charmcraft import const, utils -from charmcraft.metafiles.metadata import read_metadata_yaml from charmcraft.models.lint import CheckResult, CheckType, LintResult from charmcraft.models.metadata import CharmMetadataLegacy @@ -292,7 +291,8 @@ def __init__(self): def run(self, basedir: pathlib.Path) -> str: """Run the proper verifications.""" try: - metadata = read_metadata_yaml(basedir) + with (basedir / const.METADATA_FILENAME).open("rt") as md_file: + metadata = yaml.safe_load(md_file) except yaml.YAMLError: self.text = "The metadata.yaml file is not a valid YAML file." return self.Result.ERROR diff --git a/charmcraft/metafiles/__init__.py b/charmcraft/metafiles/__init__.py deleted file mode 100644 index 3edc2d09e..000000000 --- a/charmcraft/metafiles/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright 2023 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 - -"""Submodule for handling the all extra yaml files.""" - -import pathlib -from typing import Any - -import yaml - -from craft_cli import emit - -__all__ = ["actions", "manifest", "metadata", "config", "read_yaml"] - - -def read_yaml(yaml_file_path: pathlib.Path) -> dict[str, Any]: - """Parse yaml file. - - :returns: the YAML decoded yaml content - """ - emit.debug(f"Reading {str(yaml_file_path)!r}") - with yaml_file_path.open("rt", encoding="utf8") as fh: - return yaml.safe_load(fh) diff --git a/charmcraft/metafiles/metadata.py b/charmcraft/metafiles/metadata.py deleted file mode 100644 index 3346c2312..000000000 --- a/charmcraft/metafiles/metadata.py +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright 2023 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 - -"""Handlers for metadata.yaml file.""" - -import logging -import pathlib -from typing import Any - -import pydantic -import yaml -from craft_application.util.error_formatting import format_pydantic_errors -from craft_cli import CraftError, emit - -from charmcraft import const -from charmcraft.models.metadata import CharmMetadataLegacy - -logger = logging.getLogger(__name__) - - -def read_metadata_yaml(charm_dir: pathlib.Path) -> dict[str, Any]: - """Parse project's metadata.yaml. - - :returns: the YAML decoded metadata.yaml content - """ - metadata_path = charm_dir / const.METADATA_FILENAME - emit.debug(f"Reading {str(metadata_path)!r}") - with metadata_path.open("rt", encoding="utf8") as fh: - return yaml.safe_load(fh) diff --git a/tests/test_metadata.py b/tests/test_metadata.py deleted file mode 100644 index 173cd2e2a..000000000 --- a/tests/test_metadata.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright 2020-2022 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 - -import re -from textwrap import dedent - -import pydantic -import pytest -from craft_cli import CraftError - -from charmcraft import const -from charmcraft.metafiles.metadata import read_metadata_yaml - - -# tests for reading metadata raw content - - -def test_read_metadata_yaml_complete(tmp_path): - """Example of parsing with all the optional attributes.""" - metadata_file = tmp_path / const.METADATA_FILENAME - metadata_file.write_text( - """ - name: test-name - summary: Test summary - description: Text. - """ - ) - - metadata = read_metadata_yaml(tmp_path) - assert metadata == {"name": "test-name", "summary": "Test summary", "description": "Text."} - - -def test_read_metadata_yaml_error_invalid(tmp_path): - """Open a metadata.yaml that would fail verification.""" - metadata_file = tmp_path / const.METADATA_FILENAME - metadata_file.write_text("- whatever") - metadata = read_metadata_yaml(tmp_path) - assert metadata == ["whatever"] - - -def test_read_metadata_yaml_error_missing(tmp_path): - """Do not hide the file not being accessible.""" - with pytest.raises(OSError): - read_metadata_yaml(tmp_path) From 7ea79c0a6c409d43991702ef6624726caa758941 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 17:37:05 -0400 Subject: [PATCH 31/59] fix: make charmplugin requirements validator 'after' --- charmcraft/parts/charm.py | 19 +++++++++---------- tests/test_parts.py | 22 ++-------------------- 2 files changed, 11 insertions(+), 30 deletions(-) diff --git a/charmcraft/parts/charm.py b/charmcraft/parts/charm.py index 65bac2030..0f22b65c5 100644 --- a/charmcraft/parts/charm.py +++ b/charmcraft/parts/charm.py @@ -79,33 +79,32 @@ def _validate_entrypoint(cls, charm_entrypoint: str, info: pydantic.ValidationIn rel_entrypoint = (project_dirpath / charm_entrypoint).relative_to(project_dirpath) return rel_entrypoint.as_posix() - @pydantic.model_validator(mode="before") - def _validate_requirements(cls, values: dict[str, Any]) -> dict[str, Any]: + @pydantic.model_validator(mode="after") + def _validate_requirements(self) -> Self: """Validate the specified requirement or dynamically default it. The default is dynamic because it's only requirements.txt if the file is there. """ # the location of the project is needed - if "source" not in values: + if not self.source: raise ValueError( - "cannot validate 'charm-requirements' because invalid 'source' configuration" + "cannot validate 'charm-requirements' because no 'source' was provided" ) - project_dirpath = pathlib.Path(values["source"]) - charm_requirements = values.setdefault("charm-requirements", []) + project_dirpath = pathlib.Path(self.source) # check that all indicated files are present - for reqs_filename in charm_requirements: + for reqs_filename in self.charm_requirements: reqs_path = project_dirpath / reqs_filename if not reqs_path.is_file(): raise ValueError(f"requirements file {str(reqs_path)!r} not found") # if nothing indicated, and default file is there, use it default_reqs_name = "requirements.txt" - if not charm_requirements and (project_dirpath / default_reqs_name).is_file(): - charm_requirements.append(default_reqs_name) + if not self.charm_requirements and (project_dirpath / default_reqs_name).is_file(): + self.charm_requirements.append(default_reqs_name) - return values + return self @pydantic.model_validator(mode="after") def _validate_strict_dependencies(self) -> Self: diff --git a/tests/test_parts.py b/tests/test_parts.py index fedc699f0..d5b27e3ac 100644 --- a/tests/test_parts.py +++ b/tests/test_parts.py @@ -63,25 +63,7 @@ def test_partconfig_bad_property(): err = raised.value.errors() assert len(err) == 1 assert err[0]["loc"] == ("color",) - assert err[0]["msg"] == "extra fields not permitted" - - -def test_partconfig_bad_type(): - data = { - "plugin": "charm", - "source": ["."], - } - with pytest.raises(pydantic.ValidationError) as raised: - parts.process_part_config(data) - err = raised.value.errors() - assert len(err) == 2 - assert err[0]["loc"] == ("source",) - assert err[0]["msg"] == "str type expected" - assert err[1]["loc"] == ("charm-requirements",) - assert ( - err[1]["msg"] - == "cannot validate 'charm-requirements' because invalid 'source' configuration" - ) + assert err[0]["msg"] == "Extra inputs are not permitted" def test_partconfig_bad_plugin_property(): @@ -95,4 +77,4 @@ def test_partconfig_bad_plugin_property(): err = raised.value.errors() assert len(err) == 1 assert err[0]["loc"] == ("charm-timeout",) - assert err[0]["msg"] == "extra fields not permitted" + assert err[0]["msg"] == "Extra inputs are not permitted" From 69ff3232ad2dcac8a81a7d5bba8fc8036375f20d Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 17:42:02 -0400 Subject: [PATCH 32/59] fix: use the correct type for charmhub config --- tests/conftest.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 724a5479b..2504a5699 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,9 +33,8 @@ from craft_providers import bases import charmcraft.parts -from charmcraft import const, instrum, parts, services, store +from charmcraft import const, env, instrum, parts, services, store from charmcraft.application.main import APP_METADATA -from charmcraft.models import charmcraft as config_module from charmcraft.models import project @@ -154,14 +153,12 @@ def setup_parts(): @pytest.fixture() -def charmhub_config() -> config_module.Charmhub: +def charmhub_config() -> env.CharmhubConfig: """Provide a charmhub config for use in tests""" - return config_module.Charmhub.parse_obj( - { - "api-url": "https://api.staging.charmhub.io", - "storage-url": "https://storage.staging.snapcraftcontent.com", - "registry-url": "https://registry.staging.jujucharms.com", - } + return env.CharmhubConfig( + api_url="https://api.staging.charmhub.io", + storage_url="https://storage.staging.snapcraftcontent.com", + registry_url="https://registry.staging.jujucharms.com", ) From f00648f9d7f6362f16d8c8305b7421f2e72a3b53 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 17:42:12 -0400 Subject: [PATCH 33/59] chore: autoformat --- tests/unit/models/test_config.py | 5 ++++- tests/unit/parts/test_bundle.py | 3 --- tests/unit/parts/test_charm.py | 10 ++++++++-- tests/unit/parts/test_reactive.py | 1 - tests/unit/services/test_package.py | 4 +++- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/unit/models/test_config.py b/tests/unit/models/test_config.py index 2e020e38f..e929d848e 100644 --- a/tests/unit/models/test_config.py +++ b/tests/unit/models/test_config.py @@ -74,7 +74,10 @@ def test_correct_option_type(option, type_): [ (None, "Input should be a valid dict"), ({}, "Unable to extract tag using discriminator 'type'"), - ({"type": "stargate"}, "Input tag 'stargate' found using 'type' does not match any of the expected tags:"), + ( + {"type": "stargate"}, + "Input tag 'stargate' found using 'type' does not match any of the expected tags:", + ), ({"type": "int", "default": 3.14}, "Input should be a valid integer"), ({"type": "float", "default": "pi"}, "Input should be a valid number"), ({"type": "boolean", "default": "maybe"}, "Input should be a valid boolean"), diff --git a/tests/unit/parts/test_bundle.py b/tests/unit/parts/test_bundle.py index 30ef5d797..6ea753113 100644 --- a/tests/unit/parts/test_bundle.py +++ b/tests/unit/parts/test_bundle.py @@ -15,11 +15,8 @@ # For further info, check https://github.com/canonical/charmcraft import sys -import pydantic import pytest -import charmcraft.parts - pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="Windows not supported") diff --git a/tests/unit/parts/test_charm.py b/tests/unit/parts/test_charm.py index 8abb5c689..16e62a8ea 100644 --- a/tests/unit/parts/test_charm.py +++ b/tests/unit/parts/test_charm.py @@ -244,7 +244,10 @@ def test_charmpluginproperties_entrypoint_outside_project_absolute(tmp_path): err = raised.value.errors() assert len(err) == 1 assert err[0]["loc"] == ("charm-entrypoint",) - assert err[0]["msg"] == f"Value error, charm entry point must be inside the project: {str(outside_path)!r}" + assert ( + err[0]["msg"] + == f"Value error, charm entry point must be inside the project: {str(outside_path)!r}" + ) def test_charmpluginproperties_entrypoint_outside_project_relative(tmp_path): @@ -256,7 +259,10 @@ def test_charmpluginproperties_entrypoint_outside_project_relative(tmp_path): err = raised.value.errors() assert len(err) == 1 assert err[0]["loc"] == ("charm-entrypoint",) - assert err[0]["msg"] == f"Value error, charm entry point must be inside the project: {str(outside_path)!r}" + assert ( + err[0]["msg"] + == f"Value error, charm entry point must be inside the project: {str(outside_path)!r}" + ) def test_charmpluginproperties_requirements_default(tmp_path): diff --git a/tests/unit/parts/test_reactive.py b/tests/unit/parts/test_reactive.py index e4d152a22..080715cfc 100644 --- a/tests/unit/parts/test_reactive.py +++ b/tests/unit/parts/test_reactive.py @@ -20,7 +20,6 @@ from unittest.mock import call, patch import craft_parts -import pydantic import pytest import pytest_subprocess from craft_parts import plugins diff --git a/tests/unit/services/test_package.py b/tests/unit/services/test_package.py index 5c6cbcb0d..6b853c2bf 100644 --- a/tests/unit/services/test_package.py +++ b/tests/unit/services/test_package.py @@ -171,7 +171,9 @@ def test_get_manifest_bases_from_bases(fake_path, package_service, bases, expect ) package_service._project = charm - assert package_service.get_manifest_bases() == [models.Base.model_validate(b) for b in expected] + assert package_service.get_manifest_bases() == [ + models.Base.model_validate(b) for b in expected + ] @pytest.mark.parametrize("base", ["ubuntu@22.04", "almalinux@9"]) From bed38145050eabd9fbef0d14677900cb57ed865a Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 17:50:49 -0400 Subject: [PATCH 34/59] build(deps): update doc dependencies for pydantic --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4476ed8e4..4b0c42b64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,7 @@ apt = [ docs = [ "canonical-sphinx~=0.1", "pyspelling", - "autodoc-pydantic<=2.0", + "autodoc-pydantic~=2.0", "sphinx-autobuild~=2024.2", "sphinx-pydantic~=0.1", "sphinx-toolbox~=3.5", From 33b9b998b33fd3b42058a36d979c64bdadcae979 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 31 Jul 2024 17:51:55 -0400 Subject: [PATCH 35/59] chore: update documentation requriements --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2f5ed748e..d175a2e11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ apt = [ docs = [ "canonical-sphinx~=0.1", "pyspelling", - "autodoc-pydantic<=2.0", + "autodoc-pydantic~=2.0", "sphinx-autobuild~=2024.2", "sphinx-pydantic~=0.1", "sphinx-toolbox~=3.5", From 99d6d502b3d44a9846a4162c601091abdf130df8 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 1 Aug 2024 09:08:01 -0400 Subject: [PATCH 36/59] chore: update requirements files --- requirements-dev.txt | 66 ++++++++++++++++++++++---------------------- requirements.txt | 50 ++++++++++++++++----------------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 595f70d03..1a6d3dd65 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,64 +1,65 @@ +annotated-types==0.7.0 attrs==23.2.0 certifi==2024.7.4 cffi==1.16.0 charset-normalizer==3.3.2 -coverage==7.5.3 -craft-application==3.2.0 -craft-archives==1.2.0 +coverage==7.6.0 +craft-application @ git+https://github.com/canonical/craft-application@c838b246797a67f3fbe41662e831522f7036fe03 +craft-archives @ git+https://github.com/canonical/craft-archives@00b9b3b69346ae3c6b2d8da2939cc94ba00843bf craft-cli==2.6.0 -craft-grammar==1.2.0 -craft-parts==1.33.0 +craft-grammar @ git+https://github.com/canonical/craft-grammar@5e225fb806eafbbefa7ffc19b0349e3c34910ca5 +craft-parts @ git+https://github.com/canonical/craft-parts@09a998112ab7a065adf8c3c7df5ed52da7845954 craft-platforms==0.1.1 -craft-providers==1.24.1 -craft-store==2.6.2 -cryptography==42.0.8 -Deprecated==1.2.14 +craft-providers @ git+https://github.com/canonical/craft-providers@9f2f487e722c3b9c06f7556228b0b9a19861791e +craft-store @ git+https://github.com/canonical/craft-store@3e6fe7ae93c4243d0a3b8ec02afe8805294c3d1e +cryptography==43.0.0 distro==1.9.0 docker==7.1.0 -flake8==7.0.0 +flake8==7.1.0 freezegun==1.5.1 httplib2==0.22.0 -humanize==4.9.0 -hypothesis==6.100.5 +humanize==4.10.0 +hypothesis==6.108.5 idna==3.7 -importlib_metadata==7.1.0 +importlib_metadata==8.2.0 iniconfig==2.0.0 jaraco.classes==3.4.0 jeepney==0.8.0 Jinja2==3.1.4 -jsonschema==4.22.0 +jsonschema==4.23.0 jsonschema-specifications==2023.12.1 keyring==24.3.1 -launchpadlib==1.11.0 +launchpadlib==2.0.0 lazr.restfulclient==0.14.6 lazr.uri==1.0.6 macaroonbakery==1.3.4 MarkupSafe==2.1.5 mccabe==0.7.0 -more-itertools==10.2.0 +more-itertools==10.3.0 oauthlib==3.2.2 overrides==7.7.0 -packaging==24.0 +packaging==24.1 platformdirs==4.2.2 pluggy==1.5.0 -protobuf==5.26.1 -pycodestyle==2.11.1 +protobuf==5.27.3 +pycodestyle==2.12.0 pycparser==2.22 -pydantic==1.10.15 -pydantic-yaml==0.11.2 +pydantic==2.8.2 +pydantic_core==2.20.1 +pydantic_yaml==1.3.0 pydocstyle==6.3.0 -pyfakefs==5.4.1 +pyfakefs==5.6.0 pyflakes==3.2.0 pygit2==1.14.1 pymacaroons==0.13.0 PyNaCl==1.5.0 pyparsing==3.1.2 pyRFC3339==1.1 -pytest==8.2.0 +pytest==8.3.2 pytest-check==2.3.1 pytest-cov==5.0.0 pytest-mock==3.14.0 -pytest-subprocess==1.5.0 +pytest-subprocess==1.5.2 python-dateutil==2.9.0.post0 pytz==2024.1 pyxdg==0.28 @@ -67,19 +68,18 @@ referencing==0.35.1 requests==2.31.0 requests-toolbelt==1.0.0 requests-unixsocket==0.3.0 -responses==0.25.0 -rpds-py==0.18.1 +responses==0.25.3 +rpds-py==0.19.1 +ruamel.yaml==0.18.6 +ruamel.yaml.clib==0.2.8 SecretStorage==3.3.3 -setuptools==70.0.0 +setuptools==72.1.0 six==1.16.0 snap-helpers==0.4.2 snowballstemmer==2.2.0 sortedcontainers==2.4.0 tabulate==0.9.0 -types-Deprecated==1.2.9.20240311 -types-PyYAML==6.0.12.20240311 -typing_extensions==4.11.0 -urllib3==1.26.18 +typing_extensions==4.12.2 +urllib3==1.26.19 wadllib==1.3.6 -wrapt==1.16.0 -zipp==3.19.1 +zipp==3.19.2 diff --git a/requirements.txt b/requirements.txt index 606ca0af1..7ff8f5d3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,43 +1,44 @@ +annotated-types==0.7.0 attrs==23.2.0 certifi==2024.7.4 cffi==1.16.0 charset-normalizer==3.3.2 -craft-application==3.2.0 -craft-archives==1.2.0 +craft-application @ git+https://github.com/canonical/craft-application@c838b246797a67f3fbe41662e831522f7036fe03 +craft-archives @ git+https://github.com/canonical/craft-archives@00b9b3b69346ae3c6b2d8da2939cc94ba00843bf craft-cli==2.6.0 -craft-grammar==1.2.0 -craft-parts==1.33.0 +craft-grammar @ git+https://github.com/canonical/craft-grammar@5e225fb806eafbbefa7ffc19b0349e3c34910ca5 +craft-parts @ git+https://github.com/canonical/craft-parts@09a998112ab7a065adf8c3c7df5ed52da7845954 craft-platforms==0.1.1 -craft-providers==1.24.1 -craft-store==2.6.2 -cryptography==42.0.8 -Deprecated==1.2.14 +craft-providers @ git+https://github.com/canonical/craft-providers@9f2f487e722c3b9c06f7556228b0b9a19861791e +craft-store @ git+https://github.com/canonical/craft-store@3e6fe7ae93c4243d0a3b8ec02afe8805294c3d1e +cryptography==43.0.0 distro==1.9.0 docker==7.1.0 httplib2==0.22.0 -humanize==4.9.0 +humanize==4.10.0 idna==3.7 -importlib_metadata==7.1.0 +importlib_metadata==8.2.0 jaraco.classes==3.4.0 jeepney==0.8.0 Jinja2==3.1.4 -jsonschema==4.22.0 +jsonschema==4.23.0 jsonschema-specifications==2023.12.1 keyring==24.3.1 -launchpadlib==1.11.0 +launchpadlib==2.0.0 lazr.restfulclient==0.14.6 lazr.uri==1.0.6 macaroonbakery==1.3.4 MarkupSafe==2.1.5 -more-itertools==10.2.0 +more-itertools==10.3.0 oauthlib==3.2.2 overrides==7.7.0 -packaging==24.0 +packaging==24.1 platformdirs==4.2.2 -protobuf==5.26.1 +protobuf==5.27.3 pycparser==2.22 -pydantic==1.10.15 -pydantic-yaml==0.11.2 +pydantic==2.8.2 +pydantic_core==2.20.1 +pydantic_yaml==1.3.0 pygit2==1.14.1 pymacaroons==0.13.0 PyNaCl==1.5.0 @@ -51,16 +52,15 @@ referencing==0.35.1 requests==2.31.0 requests-toolbelt==1.0.0 requests-unixsocket==0.3.0 -rpds-py==0.18.1 +rpds-py==0.19.1 +ruamel.yaml==0.18.6 +ruamel.yaml.clib==0.2.8 SecretStorage==3.3.3 -setuptools==70.0.0 +setuptools==72.1.0 six==1.16.0 snap-helpers==0.4.2 tabulate==0.9.0 -types-Deprecated==1.2.9.20240311 -types-PyYAML==6.0.12.20240311 -typing_extensions==4.11.0 -urllib3==1.26.18 +typing_extensions==4.12.2 +urllib3==1.26.19 wadllib==1.3.6 -wrapt==1.16.0 -zipp==3.19.1 +zipp==3.19.2 From 6038227362b47493b5d8c12b8936165cddb5cc60 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Fri, 2 Aug 2024 12:16:43 -0400 Subject: [PATCH 37/59] feat: update snapcraft.yaml to build with pydantic 2 --- pyproject.toml | 2 +- requirements-dev.txt | 12 ++++++------ requirements.txt | 12 ++++++------ snap/snapcraft.yaml | 8 +++++--- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d175a2e11..3c371d5ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "The main tool to build, upload, and develop in general the Juju c readme = "README.md" dependencies = [ # TODO: Undo these - "craft-application@git+https://github.com/canonical/craft-application@work/345/pydantic-2", + "craft-application@git+https://github.com/canonical/craft-application@feature/pydantic-2", "craft-cli>=2.3.0", "craft-grammar@git+https://github.com/canonical/craft-grammar@feature/pydantic-2", "craft-parts@git+https://github.com/canonical/craft-parts@feature/2.0", diff --git a/requirements-dev.txt b/requirements-dev.txt index 1a6d3dd65..855a69477 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,14 +4,14 @@ certifi==2024.7.4 cffi==1.16.0 charset-normalizer==3.3.2 coverage==7.6.0 -craft-application @ git+https://github.com/canonical/craft-application@c838b246797a67f3fbe41662e831522f7036fe03 -craft-archives @ git+https://github.com/canonical/craft-archives@00b9b3b69346ae3c6b2d8da2939cc94ba00843bf +craft-application @ git+https://github.com/canonical/craft-application@feature/pydantic-2 +craft-archives @ git+https://github.com/canonical/craft-archives@feature/2.0 craft-cli==2.6.0 -craft-grammar @ git+https://github.com/canonical/craft-grammar@5e225fb806eafbbefa7ffc19b0349e3c34910ca5 -craft-parts @ git+https://github.com/canonical/craft-parts@09a998112ab7a065adf8c3c7df5ed52da7845954 +craft-grammar @ git+https://github.com/canonical/craft-grammar@feature/pydantic-2 +craft-parts @ git+https://github.com/canonical/craft-parts@feature/2.0 craft-platforms==0.1.1 -craft-providers @ git+https://github.com/canonical/craft-providers@9f2f487e722c3b9c06f7556228b0b9a19861791e -craft-store @ git+https://github.com/canonical/craft-store@3e6fe7ae93c4243d0a3b8ec02afe8805294c3d1e +craft-providers @ git+https://github.com/canonical/craft-providers@feature/2.0-git-modules +craft-store @ git+https://github.com/canonical/craft-store@feature/3.0 cryptography==43.0.0 distro==1.9.0 docker==7.1.0 diff --git a/requirements.txt b/requirements.txt index 7ff8f5d3b..dabce956a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,14 +3,14 @@ attrs==23.2.0 certifi==2024.7.4 cffi==1.16.0 charset-normalizer==3.3.2 -craft-application @ git+https://github.com/canonical/craft-application@c838b246797a67f3fbe41662e831522f7036fe03 -craft-archives @ git+https://github.com/canonical/craft-archives@00b9b3b69346ae3c6b2d8da2939cc94ba00843bf +craft-application @ git+https://github.com/canonical/craft-application@feature/pydantic-2 +craft-archives @ git+https://github.com/canonical/craft-archives@feature/2.0 craft-cli==2.6.0 -craft-grammar @ git+https://github.com/canonical/craft-grammar@5e225fb806eafbbefa7ffc19b0349e3c34910ca5 -craft-parts @ git+https://github.com/canonical/craft-parts@09a998112ab7a065adf8c3c7df5ed52da7845954 +craft-grammar @ git+https://github.com/canonical/craft-grammar@feature/pydantic-2 +craft-parts @ git+https://github.com/canonical/craft-parts@feature/2.0 craft-platforms==0.1.1 -craft-providers @ git+https://github.com/canonical/craft-providers@9f2f487e722c3b9c06f7556228b0b9a19861791e -craft-store @ git+https://github.com/canonical/craft-store@3e6fe7ae93c4243d0a3b8ec02afe8805294c3d1e +craft-providers @ git+https://github.com/canonical/craft-providers@feature/2.0-git-modules +craft-store @ git+https://github.com/canonical/craft-store@feature/3.0 cryptography==43.0.0 distro==1.9.0 docker==7.1.0 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 2fcc542f7..6edca969a 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,4 +1,4 @@ -# Copyright 2020-2023 Canonical Ltd. +# Copyright 2020-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. @@ -71,8 +71,6 @@ apps: confinement: classic build-packages: - - cargo - - rustc - pkg-config - python3.10-dev - libpython3.10-dev @@ -88,6 +86,8 @@ build-packages: - libxml2-dev - libxslt1-dev - libyaml-dev +build-snaps: + - rustup parts: @@ -145,6 +145,8 @@ parts: craftctl set version="${version}" [ -n "$(echo $version | grep "post")" ] && grade=devel || grade=stable craftctl set grade="${grade}" + # Set up the rust compiler + rustup default 1.79 override-build: | craftctl default cp -v completion.bash ../install From b360206813f61d00f249b3aecb88133fc67dcc92 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Fri, 2 Aug 2024 14:05:20 -0400 Subject: [PATCH 38/59] chore: autoformat --- charmcraft/application/commands/store.py | 4 +- charmcraft/models/actions.py | 2 +- charmcraft/models/basic.py | 7 +-- charmcraft/models/charmcraft.py | 13 ++---- charmcraft/models/config.py | 8 ++-- charmcraft/models/extension.py | 2 - charmcraft/models/project.py | 57 +++++++++++------------- charmcraft/parts/bundle.py | 2 +- charmcraft/parts/charm.py | 4 +- charmcraft/parts/reactive.py | 2 +- charmcraft/services/package.py | 4 +- 11 files changed, 48 insertions(+), 57 deletions(-) diff --git a/charmcraft/application/commands/store.py b/charmcraft/application/commands/store.py index 63e1c151a..ed30c4b32 100644 --- a/charmcraft/application/commands/store.py +++ b/charmcraft/application/commands/store.py @@ -1683,7 +1683,9 @@ def run(self, parsed_args: argparse.Namespace) -> None: declared_libs = {lib.lib: lib for lib in charm_libs} missing_store_libs = declared_libs.keys() - libs_metadata.keys() if missing_store_libs: - missing_libs_source = [declared_libs[lib].model_dump() for lib in sorted(missing_store_libs)] + missing_libs_source = [ + declared_libs[lib].model_dump() for lib in sorted(missing_store_libs) + ] libs_yaml = util.dump_yaml(missing_libs_source) raise errors.CraftError( f"Could not find the following libraries on charmhub:\n{libs_yaml}", diff --git a/charmcraft/models/actions.py b/charmcraft/models/actions.py index 72af07a29..70f4af2a5 100644 --- a/charmcraft/models/actions.py +++ b/charmcraft/models/actions.py @@ -19,8 +19,8 @@ import keyword import re -from craft_application.models import CraftBaseModel import pydantic +from craft_application.models import CraftBaseModel class JujuActions(CraftBaseModel): diff --git a/charmcraft/models/basic.py b/charmcraft/models/basic.py index 6b6975146..bb49e23a1 100644 --- a/charmcraft/models/basic.py +++ b/charmcraft/models/basic.py @@ -16,7 +16,7 @@ """Charmcraft basic pydantic model.""" from typing import Annotated -import craft_application.models + import craft_parts.constraints import pydantic @@ -49,7 +49,6 @@ def _validate_linter_name(value: str) -> str: return value - RelativePath = craft_parts.constraints.RelativePathStr AttributeName = Annotated[ # TODO: Turn this into a StrEnum str, @@ -57,5 +56,7 @@ def _validate_linter_name(value: str) -> str: pydantic.BeforeValidator(_validate_attribute_name), ] LinterName = Annotated[ - str, pydantic.Field(strict=True), pydantic.BeforeValidator(_validate_linter_name), + str, + pydantic.Field(strict=True), + pydantic.BeforeValidator(_validate_linter_name), ] diff --git a/charmcraft/models/charmcraft.py b/charmcraft/models/charmcraft.py index a724445b3..991581fec 100644 --- a/charmcraft/models/charmcraft.py +++ b/charmcraft/models/charmcraft.py @@ -15,22 +15,14 @@ # For further info, check https://github.com/canonical/charmcraft """Charmcraft configuration pydantic model.""" -import datetime -import os -import pathlib -from typing import Any, Literal, TypedDict, cast -from typing import cast +from typing import TypedDict, cast -from craft_application.models import CraftBaseModel import pydantic from craft_application import util +from craft_application.models import CraftBaseModel from typing_extensions import Self -from charmcraft import const, parts -from charmcraft.extensions import apply_extensions -from charmcraft.models.actions import JujuActions from charmcraft.models.basic import AttributeName, LinterName -from charmcraft.models.config import JujuConfig class BaseDict(TypedDict, total=False): @@ -104,6 +96,7 @@ def _expand_base(cls, base: BaseDict | LongFormBasesDict) -> LongFormBasesDict: return cast(LongFormBasesDict, base) return cast(LongFormBasesDict, {"build-on": [base], "run-on": [base]}) + class Ignore(CraftBaseModel): """Definition of `analysis.ignore` configuration.""" diff --git a/charmcraft/models/config.py b/charmcraft/models/config.py index 09445b245..75b3664dc 100644 --- a/charmcraft/models/config.py +++ b/charmcraft/models/config.py @@ -17,10 +17,8 @@ """Charmcraft Juju Config pydantic model.""" from typing import Annotated, Literal -from craft_application.models import CraftBaseModel import pydantic - -from typing_extensions import Annotated +from craft_application.models import CraftBaseModel class _BaseJujuOption(CraftBaseModel): @@ -66,7 +64,9 @@ class JujuSecretOption(_BaseJujuOption): # that anyone would know what the secret ID (specific to # the deployment in a model) is at the time that they are # writing the config, but included for completeness. - default: Annotated[str, pydantic.StringConstraints(pattern=r"^secret:[a-z0-9]{20}$")] | None = None + default: ( + Annotated[str, pydantic.StringConstraints(pattern=r"^secret:[a-z0-9]{20}$")] | None + ) = None JujuOption = Annotated[ diff --git a/charmcraft/models/extension.py b/charmcraft/models/extension.py index f3f32e5b7..c98d6d753 100644 --- a/charmcraft/models/extension.py +++ b/charmcraft/models/extension.py @@ -16,8 +16,6 @@ """Extension models.""" from typing import Any -from charmcraft.models.basic import CharmcraftModel - # Mypy complaining about frozen inheritance. class ExtensionModel(CraftBaseModel): # type: ignore[misc] diff --git a/charmcraft/models/project.py b/charmcraft/models/project.py index 26ab3371d..13746c1b1 100644 --- a/charmcraft/models/project.py +++ b/charmcraft/models/project.py @@ -35,13 +35,12 @@ from craft_cli import CraftError from craft_providers import bases from pydantic import dataclasses -from typing_extensions import Self, TypedDict +from typing_extensions import Self from charmcraft import const, preprocess, utils from charmcraft.const import ( BaseStr, BuildBaseStr, - CharmArch, ) from charmcraft.models import charmcraft from charmcraft.models.charmcraft import ( @@ -52,7 +51,6 @@ ) from charmcraft.parts import process_part_config - CharmcraftSummaryStr = Annotated[ str, models.SummaryStr, @@ -77,10 +75,7 @@ def get_charm_file_platform_str(bases: Iterable[charmcraft.Base]) -> str: return "_".join(base_strings) -CharmPlatform = Annotated[ - str, - pydantic.StringConstraints(min_length=4, strict=True) -] +CharmPlatform = Annotated[str, pydantic.StringConstraints(min_length=4, strict=True)] class CharmLib(models.CraftBaseModel): @@ -427,7 +422,9 @@ class CharmcraftProject(models.Project, metaclass=abc.ABCMeta): # These private attributes are not part of the project model but are attached here # because Charmcraft uses this metadata. - _started_at: datetime.datetime = pydantic.PrivateAttr(default_factory=lambda: datetime.datetime.now(tz=datetime.timezone.utc)) + _started_at: datetime.datetime = pydantic.PrivateAttr( + default_factory=lambda: datetime.datetime.now(tz=datetime.timezone.utc) + ) _valid: bool = pydantic.PrivateAttr(default=False) @property @@ -534,14 +531,12 @@ def _preprocess_parts( @pydantic.field_validator("parts", mode="before") def _validate_parts(cls, parts: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: """Verify each part in the parts section. Craft-parts will re-validate them.""" - return { - name: process_part_config(part) - for name, part in parts.items() - } + return {name: process_part_config(part) for name, part in parts.items()} class CharmProject(CharmcraftProject): """A base class for all charm types.""" + type: Literal["charm"] """The type of project. Must be the string ``charm``.""" name: models.ProjectName = pydantic.Field( @@ -961,20 +956,23 @@ class CharmProject(CharmcraftProject): ], ) + def _check_base_is_legacy(base: charmcraft.BaseDict) -> bool: - """Check that the given base is a legacy base, usable with 'bases'.""" - # This pyright ignore can go away once we're on Python minimum version 3.11. - # At that point we can mark items as required or not required. - # https://docs.python.org/3/library/typing.html#typing.Required - if ( - base["name"] == "ubuntu" # pyright: ignore[reportTypedDictNotRequiredAccess] - and base["channel"] < "24.04" # pyright: ignore[reportTypedDictNotRequiredAccess] - ): - return True - return base in ({"name": "centos", "channel": "7"}, {"name": "almalinux", "channel": "9"}) - - -def _validate_base(base: charmcraft.BaseDict | charmcraft.LongFormBasesDict) -> charmcraft.LongFormBasesDict: + """Check that the given base is a legacy base, usable with 'bases'.""" + # This pyright ignore can go away once we're on Python minimum version 3.11. + # At that point we can mark items as required or not required. + # https://docs.python.org/3/library/typing.html#typing.Required + if ( + base["name"] == "ubuntu" # pyright: ignore[reportTypedDictNotRequiredAccess] + and base["channel"] < "24.04" # pyright: ignore[reportTypedDictNotRequiredAccess] + ): + return True + return base in ({"name": "centos", "channel": "7"}, {"name": "almalinux", "channel": "9"}) + + +def _validate_base( + base: charmcraft.BaseDict | charmcraft.LongFormBasesDict, +) -> charmcraft.LongFormBasesDict: if "name" in base: # Convert short form to long form base = cast(charmcraft.LongFormBasesDict, {"build-on": [base], "run-on": [base]}) else: # Cast to long form since we know it is one. @@ -1006,12 +1004,9 @@ class BasesCharm(CharmProject): # This is defined this way because using conlist makes mypy sad and using # a ConstrainedList child class has pydantic issues. This appears to be # solved with Pydantic 2. - bases: list[ - Annotated[ - BasesConfiguration, - pydantic.BeforeValidator(_validate_base) - ] - ] = pydantic.Field(min_length=1) + bases: list[Annotated[BasesConfiguration, pydantic.BeforeValidator(_validate_base)]] = ( + pydantic.Field(min_length=1) + ) base: None = None diff --git a/charmcraft/parts/bundle.py b/charmcraft/parts/bundle.py index d22f3180e..629b1354c 100644 --- a/charmcraft/parts/bundle.py +++ b/charmcraft/parts/bundle.py @@ -15,7 +15,7 @@ # For further info, check https://github.com/canonical/charmcraft """Bundle plugin for craft-parts.""" import sys -from typing import Any, Literal +from typing import Literal import overrides from craft_parts import plugins diff --git a/charmcraft/parts/charm.py b/charmcraft/parts/charm.py index 0f22b65c5..2f190a5ff 100644 --- a/charmcraft/parts/charm.py +++ b/charmcraft/parts/charm.py @@ -20,8 +20,7 @@ import shlex import sys from contextlib import suppress -from typing import Any, Literal, cast -from typing_extensions import Self +from typing import Literal, cast import overrides import pydantic @@ -29,6 +28,7 @@ from craft_parts.errors import OsReleaseIdError, OsReleaseVersionIdError from craft_parts.packages import platform from craft_parts.utils import os_utils +from typing_extensions import Self from charmcraft import charm_builder, env, instrum from charmcraft.errors import DependencyError diff --git a/charmcraft/parts/reactive.py b/charmcraft/parts/reactive.py index 49ac4a99e..9bf2f2777 100644 --- a/charmcraft/parts/reactive.py +++ b/charmcraft/parts/reactive.py @@ -19,7 +19,7 @@ import subprocess import sys from pathlib import Path -from typing import Any, Literal, cast +from typing import Literal, cast import overrides from craft_parts import plugins diff --git a/charmcraft/services/package.py b/charmcraft/services/package.py index b6388a3e1..3bf17fd38 100644 --- a/charmcraft/services/package.py +++ b/charmcraft/services/package.py @@ -233,7 +233,9 @@ def write_metadata(self, path: pathlib.Path) -> None: # crystal wine glass. (path / "manifest.yaml").write_text( utils.dump_yaml( - manifest.model_dump(mode="json",by_alias=True, exclude_unset=False, exclude_none=True) + manifest.model_dump( + mode="json", by_alias=True, exclude_unset=False, exclude_none=True + ) ) ) From 0d3788eccdee9668d4a319bd004b767c5c405e9c Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Fri, 2 Aug 2024 14:08:34 -0400 Subject: [PATCH 39/59] chore: remove unused extension model --- charmcraft/models/extension.py | 32 -------------------------------- 1 file changed, 32 deletions(-) delete mode 100644 charmcraft/models/extension.py diff --git a/charmcraft/models/extension.py b/charmcraft/models/extension.py deleted file mode 100644 index c98d6d753..000000000 --- a/charmcraft/models/extension.py +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright 2023 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 -"""Extension models.""" -from typing import Any - - -# Mypy complaining about frozen inheritance. -class ExtensionModel(CraftBaseModel): # type: ignore[misc] - """Extension model for presentation.""" - - name: str - bases: list[tuple[str, str]] - - def marshal(self) -> dict[str, str | list[str] | dict[str, Any]]: - """Marshal model into a dictionary for presentation.""" - return { - "Extension name": self.name, - "Supported bases": ", ".join(f"'{d} {v}'" for d, v in self.bases), - } From cd21f035c73119016927a32ceb1219301de90f3f Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Fri, 2 Aug 2024 14:17:06 -0400 Subject: [PATCH 40/59] chore: fix mypy issue --- charmcraft/models/project.py | 5 +++-- charmcraft/services/package.py | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/charmcraft/models/project.py b/charmcraft/models/project.py index 13746c1b1..20ad0d50d 100644 --- a/charmcraft/models/project.py +++ b/charmcraft/models/project.py @@ -362,7 +362,8 @@ def get_build_plan(self) -> list[models.BuildInfo]: ) ) else: - for build_on in platform.build_on: + # TODO: this should go to craft-platforms, so silence mypy for now. + for build_on in platform.build_on: # type: ignore[union-attr] build_infos.extend( [ models.BuildInfo( @@ -371,7 +372,7 @@ def get_build_plan(self) -> list[models.BuildInfo]: build_for=str(build_for), base=base, ) - for build_for in platform.build_for + for build_for in platform.build_for # type: ignore[union-attr] ] ) return build_infos diff --git a/charmcraft/services/package.py b/charmcraft/services/package.py index 3bf17fd38..1017d9d5d 100644 --- a/charmcraft/services/package.py +++ b/charmcraft/services/package.py @@ -197,10 +197,13 @@ def get_manifest_bases(self) -> list[models.Base]: if isinstance(self._project, PlatformCharm): if not self._platform: architectures = [util.get_host_architecture()] - elif platform := self._project.platforms.get(self._platform): - architectures = [str(arch) for arch in platform.build_for] elif self._platform in (*const.SUPPORTED_ARCHITECTURES, "all"): architectures = [self._platform] + elif platform := self._project.platforms.get(self._platform): + if platform.build_for: + architectures = [str(arch) for arch in platform.build_for] + else: + raise ValueError(f"Platform {self._platform} contains unknown build-for.") else: architectures = [util.get_host_architecture()] return [models.Base.from_str_and_arch(self._project.base, architectures)] From 1873ba97696e8d4c71797a0cb806468d9aa3d8d9 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Fri, 2 Aug 2024 14:20:52 -0400 Subject: [PATCH 41/59] style(type): silence mypy --- charmcraft/models/project.py | 6 ++++-- charmcraft/parts/bundle.py | 4 ++-- charmcraft/parts/charm.py | 4 ++-- charmcraft/parts/reactive.py | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/charmcraft/models/project.py b/charmcraft/models/project.py index 20ad0d50d..7bb700c52 100644 --- a/charmcraft/models/project.py +++ b/charmcraft/models/project.py @@ -556,10 +556,12 @@ class CharmProject(CharmcraftProject): "mysql-k8s", ], ) - summary: CharmcraftSummaryStr = pydantic.Field( + summary: CharmcraftSummaryStr = pydantic.Field( # pyright: ignore[reportGeneralTypeIssues] description="A brief (one-line) summary of your charm.", ) - description: str = pydantic.Field(description="A multi-line summary of your charm.") + description: str = pydantic.Field( # pyright: ignore[reportGeneralTypeIssues] + description="A multi-line summary of your charm." + ) parts: dict[str, dict[str, Any]] = pydantic.Field( default={"charm": {"plugin": "charm", "source": "."}}, diff --git a/charmcraft/parts/bundle.py b/charmcraft/parts/bundle.py index 629b1354c..1ad3856a6 100644 --- a/charmcraft/parts/bundle.py +++ b/charmcraft/parts/bundle.py @@ -1,4 +1,4 @@ -# Copyright 2023 Canonical Ltd. +# Copyright 2023-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. @@ -25,7 +25,7 @@ class BundlePluginProperties(plugins.PluginProperties, frozen=True): """Properties used to pack bundles.""" plugin: Literal["bundle"] = "bundle" - source: str + source: str # pyright: ignore[reportGeneralTypeIssues] class BundlePlugin(plugins.Plugin): diff --git a/charmcraft/parts/charm.py b/charmcraft/parts/charm.py index 2f190a5ff..37f73e798 100644 --- a/charmcraft/parts/charm.py +++ b/charmcraft/parts/charm.py @@ -1,4 +1,4 @@ -# Copyright 2023 Canonical Ltd. +# Copyright 2023-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. @@ -44,7 +44,7 @@ class CharmPluginProperties(plugins.PluginProperties, frozen=True): """Properties used in charm building.""" plugin: Literal["charm"] = "charm" - source: str + source: str # pyright: ignore[reportGeneralTypeIssues] charm_entrypoint: str = "src/charm.py" charm_binary_python_packages: list[str] = [] charm_python_packages: list[str] = [] diff --git a/charmcraft/parts/reactive.py b/charmcraft/parts/reactive.py index 9bf2f2777..20fa52e66 100644 --- a/charmcraft/parts/reactive.py +++ b/charmcraft/parts/reactive.py @@ -1,4 +1,4 @@ -# Copyright 2021-2022 Canonical Ltd. +# Copyright 2021-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 @@ -30,7 +30,7 @@ class ReactivePluginProperties(plugins.PluginProperties, frozen=True): """Properties used to pack reactive charms using charm-tools.""" plugin: Literal["reactive"] = "reactive" - source: str + source: str # pyright: ignore[reportGeneralTypeIssues] reactive_charm_build_arguments: list[str] = [] From db181866ad754bd5c7fbd9c9504ebe99a1df958a Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 8 Aug 2024 11:39:02 -0400 Subject: [PATCH 42/59] fix(parts): give plugins a default source location --- charmcraft/parts/bundle.py | 2 +- charmcraft/parts/charm.py | 2 +- charmcraft/parts/reactive.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charmcraft/parts/bundle.py b/charmcraft/parts/bundle.py index 1ad3856a6..057131997 100644 --- a/charmcraft/parts/bundle.py +++ b/charmcraft/parts/bundle.py @@ -25,7 +25,7 @@ class BundlePluginProperties(plugins.PluginProperties, frozen=True): """Properties used to pack bundles.""" plugin: Literal["bundle"] = "bundle" - source: str # pyright: ignore[reportGeneralTypeIssues] + source: str = "." class BundlePlugin(plugins.Plugin): diff --git a/charmcraft/parts/charm.py b/charmcraft/parts/charm.py index 37f73e798..aae48afc9 100644 --- a/charmcraft/parts/charm.py +++ b/charmcraft/parts/charm.py @@ -44,7 +44,7 @@ class CharmPluginProperties(plugins.PluginProperties, frozen=True): """Properties used in charm building.""" plugin: Literal["charm"] = "charm" - source: str # pyright: ignore[reportGeneralTypeIssues] + source: str = "." charm_entrypoint: str = "src/charm.py" charm_binary_python_packages: list[str] = [] charm_python_packages: list[str] = [] diff --git a/charmcraft/parts/reactive.py b/charmcraft/parts/reactive.py index 20fa52e66..275979740 100644 --- a/charmcraft/parts/reactive.py +++ b/charmcraft/parts/reactive.py @@ -30,7 +30,7 @@ class ReactivePluginProperties(plugins.PluginProperties, frozen=True): """Properties used to pack reactive charms using charm-tools.""" plugin: Literal["reactive"] = "reactive" - source: str # pyright: ignore[reportGeneralTypeIssues] + source: str = "." reactive_charm_build_arguments: list[str] = [] From 2a3462fe7d49b36007bf9d5ad4d95ef7228ad1c9 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 8 Aug 2024 11:39:33 -0400 Subject: [PATCH 43/59] tests: fix unit tests for craft-application changes --- tests/unit/models/test_project.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/models/test_project.py b/tests/unit/models/test_project.py index ea571a5bb..6e7966a8c 100644 --- a/tests/unit/models/test_project.py +++ b/tests/unit/models/test_project.py @@ -750,7 +750,7 @@ def test_instantiate_bases_charm_error( project.BasesCharm(**values) -@pytest.mark.parametrize("base", ["ubuntu@18.04", "ubuntu@24.04"]) +@pytest.mark.parametrize("base", ["ubuntu@18.04", "ubuntu@22.04"]) def test_devel_bases(monkeypatch, base): monkeypatch.setattr(const, "DEVEL_BASE_STRINGS", [base]) @@ -815,8 +815,8 @@ def test_read_charm_from_yaml_file_self_contained_success(tmp_path, filename: st dedent( """\ Bad invalid-base.yaml content: - - value error, Base requires 'platforms' definition: {'name': 'ubuntu', 'channel': '24.04'} (in field 'bases[0]') - - value error, Base requires 'platforms' definition: {'name': 'ubuntu', 'channel': 'devel'} (in field 'bases[1]')""" + - base requires 'platforms' definition: {'name': 'ubuntu', 'channel': '24.04'} (in field 'bases[0]') + - base requires 'platforms' definition: {'name': 'ubuntu', 'channel': 'devel'} (in field 'bases[1]')""" ), ), ], From 6b21fe11443e99a14e048a14eb02fc742500ee4a Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 8 Aug 2024 12:03:41 -0400 Subject: [PATCH 44/59] fix(project): preprocess the parts in order --- charmcraft/models/project.py | 6 --- tests/unit/models/test_project.py | 70 +++++++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/charmcraft/models/project.py b/charmcraft/models/project.py index 7bb700c52..52be90c66 100644 --- a/charmcraft/models/project.py +++ b/charmcraft/models/project.py @@ -527,11 +527,6 @@ def _preprocess_parts( if name == "bundle" and part["plugin"] == "bundle": part.setdefault("source", ".") - return parts - - @pydantic.field_validator("parts", mode="before") - def _validate_parts(cls, parts: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: - """Verify each part in the parts section. Craft-parts will re-validate them.""" return {name: process_part_config(part) for name, part in parts.items()} @@ -1021,7 +1016,6 @@ class PlatformCharm(CharmProject): base: BaseStr # pyright: ignore[reportGeneralTypeIssues] build_base: BuildBaseStr | None = None platforms: dict[str, models.Platform | None] # type: ignore[assignment] - parts: dict[str, dict[str, Any]] # pyright: ignore[reportGeneralTypeIssues] @pydantic.model_validator(mode="after") def _validate_dev_base_needs_build_base(self) -> Self: diff --git a/tests/unit/models/test_project.py b/tests/unit/models/test_project.py index 6e7966a8c..9e4de9c45 100644 --- a/tests/unit/models/test_project.py +++ b/tests/unit/models/test_project.py @@ -19,6 +19,7 @@ import json import pathlib from textwrap import dedent +import textwrap from typing import Any import hypothesis @@ -67,6 +68,17 @@ "run-on": [{"channel": "22.04", "name": "ubuntu"}], } BASIC_CHARM_PARTS = {"charm": {"plugin": "charm", "source": "."}} +BASIC_CHARM_PARTS_EXPANDED = { + "charm": { + "plugin": "charm", + "source": ".", + "charm-binary-python-packages": [], + "charm-entrypoint": "src/charm.py", + "charm-python-packages": [], + "charm-requirements": [], + "charm-strict-dependencies": False, + } +} MINIMAL_CHARMCRAFT_YAML = """\ type: charm @@ -555,28 +567,28 @@ def test_unmarshal_invalid_type(type_): None, None, None, - {"parts": BASIC_CHARM_PARTS}, + {"parts": BASIC_CHARM_PARTS_EXPANDED}, ), ( MINIMAL_CHARMCRAFT_YAML, SIMPLE_METADATA_YAML, None, None, - {"parts": BASIC_CHARM_PARTS}, + {"parts": BASIC_CHARM_PARTS_EXPANDED}, ), ( SIMPLE_CHARMCRAFT_YAML, None, SIMPLE_CONFIG_YAML, None, - {"config": SIMPLE_CONFIG_DICT, "parts": BASIC_CHARM_PARTS}, + {"config": SIMPLE_CONFIG_DICT, "parts": BASIC_CHARM_PARTS_EXPANDED}, ), ( SIMPLE_CHARMCRAFT_YAML, None, None, SIMPLE_ACTIONS_YAML, - {"actions": SIMPLE_ACTIONS_DICT, "parts": BASIC_CHARM_PARTS}, + {"actions": SIMPLE_ACTIONS_DICT, "parts": BASIC_CHARM_PARTS_EXPANDED}, ), ( MINIMAL_CHARMCRAFT_YAML, @@ -586,9 +598,50 @@ def test_unmarshal_invalid_type(type_): { "actions": SIMPLE_ACTIONS_DICT, "config": SIMPLE_CONFIG_DICT, - "parts": BASIC_CHARM_PARTS, + "parts": BASIC_CHARM_PARTS_EXPANDED, }, ), + pytest.param( + textwrap.dedent( + """\ + type: charm + bases: [{name: ubuntu, channel: "22.04", architectures: [arm64]}] + name: charmy-mccharmface + summary: Charmy! + description: Very charming! + parts: + charm: {} + reactive: {} + bundle: {} + """ + ), + None, + None, + None, + { + "parts": { + "charm": { + 'charm-binary-python-packages': [], + 'charm-entrypoint': 'src/charm.py', + 'charm-python-packages': [], + 'charm-requirements': [], + 'charm-strict-dependencies': False, + 'plugin': 'charm', + 'source': '.', + }, + "reactive": { + "plugin": "reactive", + "reactive-charm-build-arguments": [], + "source": ".", + }, + "bundle": { + "plugin": "bundle", + "source": ".", + }, + } + }, + id="implicit-parts-plugins", + ) ], ) def test_from_yaml_file_success( @@ -600,9 +653,8 @@ def test_from_yaml_file_success( actions_yaml: str | None, expected_diff: dict[str, Any], ): - expected_dict = simple_charm.marshal() + expected_dict = simple_charm.marshal().copy() expected_dict.update(expected_diff) - expected = project.CharmcraftProject.unmarshal(expected_dict) fs.create_file("/charmcraft.yaml", contents=charmcraft_yaml) if metadata_yaml: @@ -614,7 +666,9 @@ def test_from_yaml_file_success( actual = project.CharmcraftProject.from_yaml_file(pathlib.Path("/charmcraft.yaml")) - assert actual.marshal() == expected.marshal() + # breakpoint() + + assert actual.marshal() == expected_dict @pytest.mark.parametrize( From dc19aae1e6d0525201fff53612616163a0620dfc Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 8 Aug 2024 12:08:30 -0400 Subject: [PATCH 45/59] tests(integration): fix manifest files for tz-aware 'charmcraft-started-at' field is now timezone-aware --- .../services/sample_projects/basic-reactive/prime/manifest.yaml | 2 +- .../services/sample_projects/basic/prime/manifest.yaml | 2 +- .../services/sample_projects/complex-legacy/prime/manifest.yaml | 2 +- .../sample_projects/complex-self-contained/prime/manifest.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration/services/sample_projects/basic-reactive/prime/manifest.yaml b/tests/integration/services/sample_projects/basic-reactive/prime/manifest.yaml index cc074556f..5a5c6f944 100644 --- a/tests/integration/services/sample_projects/basic-reactive/prime/manifest.yaml +++ b/tests/integration/services/sample_projects/basic-reactive/prime/manifest.yaml @@ -1,5 +1,5 @@ charmcraft-version: 3.0-test-version -charmcraft-started-at: '2020-03-14T00:00:00' +charmcraft-started-at: '2020-03-14T00:00:00+00:00' bases: - name: ubuntu channel: '22.04' diff --git a/tests/integration/services/sample_projects/basic/prime/manifest.yaml b/tests/integration/services/sample_projects/basic/prime/manifest.yaml index cc074556f..5a5c6f944 100644 --- a/tests/integration/services/sample_projects/basic/prime/manifest.yaml +++ b/tests/integration/services/sample_projects/basic/prime/manifest.yaml @@ -1,5 +1,5 @@ charmcraft-version: 3.0-test-version -charmcraft-started-at: '2020-03-14T00:00:00' +charmcraft-started-at: '2020-03-14T00:00:00+00:00' bases: - name: ubuntu channel: '22.04' diff --git a/tests/integration/services/sample_projects/complex-legacy/prime/manifest.yaml b/tests/integration/services/sample_projects/complex-legacy/prime/manifest.yaml index 6409b27c4..a504f06cf 100644 --- a/tests/integration/services/sample_projects/complex-legacy/prime/manifest.yaml +++ b/tests/integration/services/sample_projects/complex-legacy/prime/manifest.yaml @@ -1,5 +1,5 @@ charmcraft-version: 3.0-test-version -charmcraft-started-at: '2020-03-14T00:00:00' +charmcraft-started-at: '2020-03-14T00:00:00+00:00' bases: - name: ubuntu channel: '22.04' diff --git a/tests/integration/services/sample_projects/complex-self-contained/prime/manifest.yaml b/tests/integration/services/sample_projects/complex-self-contained/prime/manifest.yaml index 467326074..7c240b26a 100644 --- a/tests/integration/services/sample_projects/complex-self-contained/prime/manifest.yaml +++ b/tests/integration/services/sample_projects/complex-self-contained/prime/manifest.yaml @@ -1,5 +1,5 @@ charmcraft-version: 3.0-test-version -charmcraft-started-at: '2020-03-14T00:00:00' +charmcraft-started-at: '2020-03-14T00:00:00+00:00' bases: - name: ubuntu channel: '22.04' From 9ea71d70598e86d975edd96b85df3fb454df4989 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 8 Aug 2024 12:17:56 -0400 Subject: [PATCH 46/59] tests(data): fix expected yaml files parts properties The plugin is now the first thing mentioned in a part. --- tests/integration/sample-charms/actions-included/expected.yaml | 2 +- tests/integration/sample-charms/actions-separate/expected.yaml | 2 +- tests/integration/sample-charms/basic-bases/expected.yaml | 2 +- tests/integration/sample-charms/basic-platforms/expected.yaml | 2 +- tests/integration/sample-charms/config-included/expected.yaml | 2 +- tests/integration/sample-charms/config-separate/expected.yaml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/integration/sample-charms/actions-included/expected.yaml b/tests/integration/sample-charms/actions-included/expected.yaml index 19807f807..39530bd97 100644 --- a/tests/integration/sample-charms/actions-included/expected.yaml +++ b/tests/integration/sample-charms/actions-included/expected.yaml @@ -11,13 +11,13 @@ platforms: - amd64 parts: charm: + plugin: charm source: . charm-entrypoint: src/charm.py charm-binary-python-packages: [] charm-python-packages: [] charm-requirements: [] charm-strict-dependencies: false - plugin: charm type: charm actions: pause: diff --git a/tests/integration/sample-charms/actions-separate/expected.yaml b/tests/integration/sample-charms/actions-separate/expected.yaml index 19807f807..39530bd97 100644 --- a/tests/integration/sample-charms/actions-separate/expected.yaml +++ b/tests/integration/sample-charms/actions-separate/expected.yaml @@ -11,13 +11,13 @@ platforms: - amd64 parts: charm: + plugin: charm source: . charm-entrypoint: src/charm.py charm-binary-python-packages: [] charm-python-packages: [] charm-requirements: [] charm-strict-dependencies: false - plugin: charm type: charm actions: pause: diff --git a/tests/integration/sample-charms/basic-bases/expected.yaml b/tests/integration/sample-charms/basic-bases/expected.yaml index a7f02d3d6..b1c449ee9 100644 --- a/tests/integration/sample-charms/basic-bases/expected.yaml +++ b/tests/integration/sample-charms/basic-bases/expected.yaml @@ -4,13 +4,13 @@ description: | A description for an example charm with bases. parts: charm: + plugin: charm source: . charm-entrypoint: src/charm.py charm-binary-python-packages: [] charm-python-packages: [] charm-requirements: [] charm-strict-dependencies: false - plugin: charm type: charm bases: - build-on: diff --git a/tests/integration/sample-charms/basic-platforms/expected.yaml b/tests/integration/sample-charms/basic-platforms/expected.yaml index 3d2bdb7b7..81b6692d4 100644 --- a/tests/integration/sample-charms/basic-platforms/expected.yaml +++ b/tests/integration/sample-charms/basic-platforms/expected.yaml @@ -11,11 +11,11 @@ platforms: - amd64 parts: charm: + plugin: charm source: . charm-entrypoint: src/charm.py charm-binary-python-packages: [] charm-python-packages: [] charm-requirements: [] charm-strict-dependencies: false - plugin: charm type: charm diff --git a/tests/integration/sample-charms/config-included/expected.yaml b/tests/integration/sample-charms/config-included/expected.yaml index e662c017a..8f3f51340 100644 --- a/tests/integration/sample-charms/config-included/expected.yaml +++ b/tests/integration/sample-charms/config-included/expected.yaml @@ -11,13 +11,13 @@ platforms: - amd64 parts: charm: + plugin: charm source: . charm-entrypoint: src/charm.py charm-binary-python-packages: [] charm-python-packages: [] charm-requirements: [] charm-strict-dependencies: false - plugin: charm type: charm config: options: diff --git a/tests/integration/sample-charms/config-separate/expected.yaml b/tests/integration/sample-charms/config-separate/expected.yaml index e662c017a..8f3f51340 100644 --- a/tests/integration/sample-charms/config-separate/expected.yaml +++ b/tests/integration/sample-charms/config-separate/expected.yaml @@ -11,13 +11,13 @@ platforms: - amd64 parts: charm: + plugin: charm source: . charm-entrypoint: src/charm.py charm-binary-python-packages: [] charm-python-packages: [] charm-requirements: [] charm-strict-dependencies: false - plugin: charm type: charm config: options: From 77f5c7b332b93cbbd2761735540ff39da4ca4218 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 8 Aug 2024 12:18:14 -0400 Subject: [PATCH 47/59] tests(application): improve equality tests. --- tests/integration/test_application.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_application.py b/tests/integration/test_application.py index 8e074d437..74a8ece63 100644 --- a/tests/integration/test_application.py +++ b/tests/integration/test_application.py @@ -36,7 +36,8 @@ def test_load_charm(app, charm_dir): project = app.get_project() with (charm_dir / "expected.yaml").open() as f: expected_data = util.safe_yaml_load(f) - expected_project = models.CharmcraftProject.unmarshal(expected_data) - assert project == expected_project - assert utils.dump_yaml(project.marshal()) == (charm_dir / "expected.yaml").read_text() + project_dict = project.marshal() + + assert project_dict == expected_data + assert utils.dump_yaml(project_dict) == (charm_dir / "expected.yaml").read_text() From 12fdd3b46a5eb11e99495650e129c600944f556a Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 8 Aug 2024 12:26:27 -0400 Subject: [PATCH 48/59] fix(deps): fix requirements --- pyproject.toml | 2 +- requirements-dev.txt | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3c371d5ad..d33cc1f63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "craft-cli>=2.3.0", "craft-grammar@git+https://github.com/canonical/craft-grammar@feature/pydantic-2", "craft-parts@git+https://github.com/canonical/craft-parts@feature/2.0", - "craft-providers@git+https://github.com/canonical/craft-providers@feature/2.0-git-modules", + "craft-providers@git+https://github.com/canonical/craft-providers@feature/2.0", "craft-platforms~=0.1", "craft-providers>=1.23.0", "craft-store@git+https://github.com/canonical/craft-store@feature/3.0", diff --git a/requirements-dev.txt b/requirements-dev.txt index 855a69477..dc3f3cb7d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,7 +10,7 @@ craft-cli==2.6.0 craft-grammar @ git+https://github.com/canonical/craft-grammar@feature/pydantic-2 craft-parts @ git+https://github.com/canonical/craft-parts@feature/2.0 craft-platforms==0.1.1 -craft-providers @ git+https://github.com/canonical/craft-providers@feature/2.0-git-modules +craft-providers @ git+https://github.com/canonical/craft-providers@feature/2.0 craft-store @ git+https://github.com/canonical/craft-store@feature/3.0 cryptography==43.0.0 distro==1.9.0 diff --git a/requirements.txt b/requirements.txt index dabce956a..3de08f47b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ craft-cli==2.6.0 craft-grammar @ git+https://github.com/canonical/craft-grammar@feature/pydantic-2 craft-parts @ git+https://github.com/canonical/craft-parts@feature/2.0 craft-platforms==0.1.1 -craft-providers @ git+https://github.com/canonical/craft-providers@feature/2.0-git-modules +craft-providers @ git+https://github.com/canonical/craft-providers@feature/2.0 craft-store @ git+https://github.com/canonical/craft-store@feature/3.0 cryptography==43.0.0 distro==1.9.0 From 1b42bc3a3a0996358621a56bb8e38ba1f390f3df Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 8 Aug 2024 12:31:42 -0400 Subject: [PATCH 49/59] chore: autoformat --- tests/integration/test_application.py | 2 +- tests/unit/models/test_project.py | 20 +++++++++----------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/integration/test_application.py b/tests/integration/test_application.py index 74a8ece63..78b9304a8 100644 --- a/tests/integration/test_application.py +++ b/tests/integration/test_application.py @@ -20,7 +20,7 @@ import pytest from craft_application import util -from charmcraft import models, utils +from charmcraft import utils @pytest.mark.parametrize( diff --git a/tests/unit/models/test_project.py b/tests/unit/models/test_project.py index 9e4de9c45..c4aed3189 100644 --- a/tests/unit/models/test_project.py +++ b/tests/unit/models/test_project.py @@ -18,8 +18,8 @@ import itertools import json import pathlib -from textwrap import dedent import textwrap +from textwrap import dedent from typing import Any import hypothesis @@ -621,13 +621,13 @@ def test_unmarshal_invalid_type(type_): { "parts": { "charm": { - 'charm-binary-python-packages': [], - 'charm-entrypoint': 'src/charm.py', - 'charm-python-packages': [], - 'charm-requirements': [], - 'charm-strict-dependencies': False, - 'plugin': 'charm', - 'source': '.', + "charm-binary-python-packages": [], + "charm-entrypoint": "src/charm.py", + "charm-python-packages": [], + "charm-requirements": [], + "charm-strict-dependencies": False, + "plugin": "charm", + "source": ".", }, "reactive": { "plugin": "reactive", @@ -641,7 +641,7 @@ def test_unmarshal_invalid_type(type_): } }, id="implicit-parts-plugins", - ) + ), ], ) def test_from_yaml_file_success( @@ -666,8 +666,6 @@ def test_from_yaml_file_success( actual = project.CharmcraftProject.from_yaml_file(pathlib.Path("/charmcraft.yaml")) - # breakpoint() - assert actual.marshal() == expected_dict From b38f6b8fc4387e94893186a2224e1918c5bcf9a4 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 8 Aug 2024 13:07:35 -0400 Subject: [PATCH 50/59] fix: make bundles pack correctly --- charmcraft/models/project.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/charmcraft/models/project.py b/charmcraft/models/project.py index 52be90c66..a09a27525 100644 --- a/charmcraft/models/project.py +++ b/charmcraft/models/project.py @@ -510,7 +510,9 @@ def _preprocess_parts( if parts is not None and not isinstance(parts, dict): raise TypeError("'parts' in charmcraft.yaml must conform to the charmcraft.yaml spec.") if not parts: - if "type" in info.data: + if info.config and info.config.get("title") == "Bundle": + parts = {"bundle": {"plugin": "bundle"}} + elif "type" in info.data: parts = {info.data["type"]: {"plugin": info.data["type"]}} else: parts = {} From 7103719f072a9cac02f404549de6b364ac53a250 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 8 Aug 2024 15:47:12 -0400 Subject: [PATCH 51/59] chore: use Python 3.12 on Windows Python 3.11 is the first version with `os.EX_OK`, but 3.12 is the version used on noble. --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 9789ab83f..e8ee1c401 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -211,7 +211,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.11' - name: Install dependencies run: | pip install -U pyinstaller -r requirements.txt From 29f22f9e872fb4986529700d8604006631b214b0 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 8 Aug 2024 15:56:05 -0400 Subject: [PATCH 52/59] ci: use built-in python on Linux, else installed On Windows, set the minimum to Python 3.11 as python 3.10 doesn't have os.EX_OK --- .github/workflows/tests.yaml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index e8ee1c401..4e67ce65a 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -46,6 +46,15 @@ jobs: strategy: matrix: os: [ubuntu-22.04, macos-12, macos-13, windows-2019, windows-2022] + include: + - os: [windows-2019, windows-2022] + python-version: | + 3.11 + 3.12 + - os: [macos-12, macos-13] + python_version: | + 3.10 + 3.12 runs-on: ${{ matrix.os }} steps: - name: Checkout code @@ -53,11 +62,10 @@ jobs: with: fetch-depth: 0 - name: Set up Python + if: ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: | - 3.10 - 3.12 + python-version: ${{ matrix.python-version }} cache: "pip" - name: Install Ubuntu-specific dependencies if: ${{ startsWith(matrix.os, 'ubuntu') }} From d2097fc64c643679e0c6ff0fb8c9fc24d4bbf605 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Fri, 9 Aug 2024 18:06:36 -0400 Subject: [PATCH 53/59] build(deps): update dependencies for release --- pyproject.toml | 13 +- requirements-dev.lock | 1361 +++++++++++++++++++++++++++++++++++++++++ requirements-dev.txt | 12 +- requirements.lock | 931 ++++++++++++++++++++++++++++ requirements.txt | 12 +- 5 files changed, 2310 insertions(+), 19 deletions(-) create mode 100644 requirements-dev.lock create mode 100644 requirements.lock diff --git a/pyproject.toml b/pyproject.toml index d33cc1f63..400ac282e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,15 +4,14 @@ dynamic = ["version"] description = "The main tool to build, upload, and develop in general the Juju charms." readme = "README.md" dependencies = [ - # TODO: Undo these - "craft-application@git+https://github.com/canonical/craft-application@feature/pydantic-2", + "craft-application>=4.0.0", "craft-cli>=2.3.0", - "craft-grammar@git+https://github.com/canonical/craft-grammar@feature/pydantic-2", - "craft-parts@git+https://github.com/canonical/craft-parts@feature/2.0", - "craft-providers@git+https://github.com/canonical/craft-providers@feature/2.0", + "craft-grammar>=2.0.0", + "craft-parts>=2.0.0", + "craft-providers>=2.0.0", "craft-platforms~=0.1", - "craft-providers>=1.23.0", - "craft-store@git+https://github.com/canonical/craft-store@feature/3.0", + "craft-providers>=2.0.0", + "craft-store>=3.0.0", "distro>=1.3.0", "docker>=7.0.0", "humanize>=2.6.0", diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 000000000..51663cfd0 --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,1361 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: ["dev","lint","types","apt"] +# all-features: false +# with-sources: true +# generate-hashes: true +# universal: true + +--extra-index-url https://people.canonical.com/~lengau/pypi/ +--index-url https://pypi.org/simple/ + +-e file:. +annotated-types==0.7.0 \ + --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ + --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 + # via craft-platforms + # via pydantic +attrs==24.2.0 \ + --hash=sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346 \ + --hash=sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2 + # via hypothesis + # via jsonschema + # via referencing +black==24.8.0 \ + --hash=sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6 \ + --hash=sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e \ + --hash=sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f \ + --hash=sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018 \ + --hash=sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e \ + --hash=sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd \ + --hash=sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4 \ + --hash=sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed \ + --hash=sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2 \ + --hash=sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42 \ + --hash=sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af \ + --hash=sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb \ + --hash=sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368 \ + --hash=sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb \ + --hash=sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af \ + --hash=sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed \ + --hash=sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47 \ + --hash=sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2 \ + --hash=sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a \ + --hash=sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c \ + --hash=sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920 \ + --hash=sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1 + # via charmcraft +boolean-py==4.0 \ + --hash=sha256:17b9a181630e43dde1851d42bef546d616d5d9b4480357514597e78b203d06e4 \ + --hash=sha256:2876f2051d7d6394a531d82dc6eb407faa0b01a0a0b3083817ccd7323b8d96bd + # via license-expression +certifi==2024.7.4 \ + --hash=sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b \ + --hash=sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90 + # via requests +cffi==1.17.0 \ + --hash=sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f \ + --hash=sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab \ + --hash=sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499 \ + --hash=sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058 \ + --hash=sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693 \ + --hash=sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb \ + --hash=sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377 \ + --hash=sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885 \ + --hash=sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2 \ + --hash=sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401 \ + --hash=sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4 \ + --hash=sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b \ + --hash=sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59 \ + --hash=sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f \ + --hash=sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c \ + --hash=sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555 \ + --hash=sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa \ + --hash=sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424 \ + --hash=sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb \ + --hash=sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2 \ + --hash=sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8 \ + --hash=sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e \ + --hash=sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9 \ + --hash=sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82 \ + --hash=sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828 \ + --hash=sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759 \ + --hash=sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc \ + --hash=sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118 \ + --hash=sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf \ + --hash=sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932 \ + --hash=sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a \ + --hash=sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29 \ + --hash=sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206 \ + --hash=sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2 \ + --hash=sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c \ + --hash=sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c \ + --hash=sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0 \ + --hash=sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a \ + --hash=sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195 \ + --hash=sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6 \ + --hash=sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9 \ + --hash=sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc \ + --hash=sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb \ + --hash=sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0 \ + --hash=sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7 \ + --hash=sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb \ + --hash=sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a \ + --hash=sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492 \ + --hash=sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720 \ + --hash=sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42 \ + --hash=sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7 \ + --hash=sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d \ + --hash=sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d \ + --hash=sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb \ + --hash=sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4 \ + --hash=sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2 \ + --hash=sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b \ + --hash=sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8 \ + --hash=sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e \ + --hash=sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204 \ + --hash=sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3 \ + --hash=sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150 \ + --hash=sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4 \ + --hash=sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76 \ + --hash=sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e \ + --hash=sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb \ + --hash=sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91 + # via cryptography + # via pygit2 + # via pynacl +charset-normalizer==3.3.2 \ + --hash=sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027 \ + --hash=sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087 \ + --hash=sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786 \ + --hash=sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8 \ + --hash=sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09 \ + --hash=sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185 \ + --hash=sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574 \ + --hash=sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e \ + --hash=sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519 \ + --hash=sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898 \ + --hash=sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269 \ + --hash=sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3 \ + --hash=sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f \ + --hash=sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6 \ + --hash=sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8 \ + --hash=sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a \ + --hash=sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73 \ + --hash=sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc \ + --hash=sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714 \ + --hash=sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2 \ + --hash=sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc \ + --hash=sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce \ + --hash=sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d \ + --hash=sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e \ + --hash=sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6 \ + --hash=sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269 \ + --hash=sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96 \ + --hash=sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d \ + --hash=sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a \ + --hash=sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4 \ + --hash=sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77 \ + --hash=sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d \ + --hash=sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0 \ + --hash=sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed \ + --hash=sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068 \ + --hash=sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac \ + --hash=sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25 \ + --hash=sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8 \ + --hash=sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab \ + --hash=sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26 \ + --hash=sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2 \ + --hash=sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db \ + --hash=sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f \ + --hash=sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5 \ + --hash=sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99 \ + --hash=sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c \ + --hash=sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d \ + --hash=sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811 \ + --hash=sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa \ + --hash=sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a \ + --hash=sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03 \ + --hash=sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b \ + --hash=sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04 \ + --hash=sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c \ + --hash=sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001 \ + --hash=sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458 \ + --hash=sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389 \ + --hash=sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99 \ + --hash=sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985 \ + --hash=sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537 \ + --hash=sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238 \ + --hash=sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f \ + --hash=sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d \ + --hash=sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796 \ + --hash=sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a \ + --hash=sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143 \ + --hash=sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8 \ + --hash=sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c \ + --hash=sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5 \ + --hash=sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5 \ + --hash=sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711 \ + --hash=sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4 \ + --hash=sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6 \ + --hash=sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c \ + --hash=sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7 \ + --hash=sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4 \ + --hash=sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b \ + --hash=sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae \ + --hash=sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12 \ + --hash=sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c \ + --hash=sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae \ + --hash=sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8 \ + --hash=sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887 \ + --hash=sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b \ + --hash=sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4 \ + --hash=sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f \ + --hash=sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5 \ + --hash=sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33 \ + --hash=sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519 \ + --hash=sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561 + # via requests +click==8.1.7 \ + --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 \ + --hash=sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de + # via black +codespell==2.3.0 \ + --hash=sha256:360c7d10f75e65f67bad720af7007e1060a5d395670ec11a7ed1fed9dd17471f \ + --hash=sha256:a9c7cef2501c9cfede2110fd6d4e5e62296920efe9abfb84648df866e47f58d1 + # via charmcraft +colorama==0.4.6 ; platform_system == 'Windows' or sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via click + # via pytest +coverage==7.6.1 \ + --hash=sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca \ + --hash=sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d \ + --hash=sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6 \ + --hash=sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989 \ + --hash=sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c \ + --hash=sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b \ + --hash=sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223 \ + --hash=sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f \ + --hash=sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56 \ + --hash=sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3 \ + --hash=sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8 \ + --hash=sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb \ + --hash=sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388 \ + --hash=sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0 \ + --hash=sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a \ + --hash=sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8 \ + --hash=sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f \ + --hash=sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a \ + --hash=sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962 \ + --hash=sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8 \ + --hash=sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391 \ + --hash=sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc \ + --hash=sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2 \ + --hash=sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155 \ + --hash=sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb \ + --hash=sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0 \ + --hash=sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c \ + --hash=sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a \ + --hash=sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004 \ + --hash=sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060 \ + --hash=sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232 \ + --hash=sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93 \ + --hash=sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129 \ + --hash=sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163 \ + --hash=sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de \ + --hash=sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6 \ + --hash=sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23 \ + --hash=sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569 \ + --hash=sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d \ + --hash=sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778 \ + --hash=sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d \ + --hash=sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36 \ + --hash=sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a \ + --hash=sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6 \ + --hash=sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34 \ + --hash=sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704 \ + --hash=sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106 \ + --hash=sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9 \ + --hash=sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862 \ + --hash=sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b \ + --hash=sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255 \ + --hash=sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16 \ + --hash=sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3 \ + --hash=sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133 \ + --hash=sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb \ + --hash=sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657 \ + --hash=sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d \ + --hash=sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca \ + --hash=sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36 \ + --hash=sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c \ + --hash=sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e \ + --hash=sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff \ + --hash=sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7 \ + --hash=sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5 \ + --hash=sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02 \ + --hash=sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c \ + --hash=sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df \ + --hash=sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3 \ + --hash=sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a \ + --hash=sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959 \ + --hash=sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234 \ + --hash=sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc + # via charmcraft + # via pytest-cov +craft-application==4.0.0 \ + --hash=sha256:0ea7ae7c467f15a4bde3cc2840a96a5df9576dd2e86b5ca5b869ddf3bea440a5 \ + --hash=sha256:ea8e38e3393c05e20868bac80e817478f87d8f809aa6e5bbae1a8f98a74d67a8 + # via charmcraft +craft-archives==2.0.0 \ + --hash=sha256:3e0c8593ea0f77d32b115599fa29836fbd7a1df17e3c861739a39c80ab6363e2 \ + --hash=sha256:f0f69d4ee67dbeae3213036879510c5767205a67f0df33af8bc4b3dabc4e6e09 + # via craft-application +craft-cli==2.6.0 \ + --hash=sha256:138453abad5207ca872369496ffc7731a4124d6d36aed523c6f2f7f73924e814 \ + --hash=sha256:48208b40cad0800a633519a5c45e70c1b4801d296450d8f7cc01d1f5d548d972 + # via charmcraft + # via craft-application +craft-grammar==2.0.0 \ + --hash=sha256:3dd8c44aad6d36f4e06a56e6466903cc6996085d77901e7f895dd8da36f33cac \ + --hash=sha256:7b086496bd020e439f196cc688cdc7d847310cbfbdde06251bc5af34c9628865 + # via charmcraft + # via craft-application +craft-parts==2.0.0 \ + --hash=sha256:2c547f2b2fc9a0df8d9be7cca36506434f15df0856f9ea121b5a40061173338a \ + --hash=sha256:aa09b904c26baa0b2db2be97463a48eae091cf84500f04ff5574b55c6292d58f + # via charmcraft + # via craft-application +craft-platforms==0.1.1 \ + --hash=sha256:183d2df56bbe10225d97c6fac3e6644752233dcf5b2d010b00a5b126b8153361 \ + --hash=sha256:462da03aac2fadb656dbdeb4207de44a2208dcfbb9977f1433d59e59d6b74f42 + # via charmcraft +craft-providers==2.0.0 \ + --hash=sha256:55e9a97dbaabbad01111bbbc57c4e4cbc19a1e79127fcb559118ae7dd129e342 \ + --hash=sha256:6b49e22e5b6ee80de7d39cd4666077fab47cbed06d8fc6f3fe379d6e388f0105 + # via charmcraft + # via craft-application +craft-store==3.0.0 \ + --hash=sha256:0a0d63e5e1b36f65383aa6191b10f43ba094d3dce757c830ce162d64e6b5fe9c \ + --hash=sha256:f3644b1df09dccb26d5250fdbd91b3618abeede3958fa185c54fa6b5462863b3 + # via charmcraft +cryptography==43.0.0 ; sys_platform == 'linux' \ + --hash=sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709 \ + --hash=sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069 \ + --hash=sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2 \ + --hash=sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b \ + --hash=sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e \ + --hash=sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70 \ + --hash=sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778 \ + --hash=sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22 \ + --hash=sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895 \ + --hash=sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf \ + --hash=sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431 \ + --hash=sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f \ + --hash=sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947 \ + --hash=sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74 \ + --hash=sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc \ + --hash=sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66 \ + --hash=sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66 \ + --hash=sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf \ + --hash=sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f \ + --hash=sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5 \ + --hash=sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e \ + --hash=sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f \ + --hash=sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55 \ + --hash=sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1 \ + --hash=sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47 \ + --hash=sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5 \ + --hash=sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0 + # via secretstorage +distro==1.9.0 \ + --hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \ + --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2 + # via charmcraft + # via craft-archives + # via craft-platforms + # via lazr-restfulclient +docker==7.1.0 \ + --hash=sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c \ + --hash=sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0 + # via charmcraft +exceptiongroup==1.2.2 ; python_version < '3.11' \ + --hash=sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b \ + --hash=sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc + # via hypothesis + # via pytest +flake8==7.1.1 \ + --hash=sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38 \ + --hash=sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213 + # via charmcraft +freezegun==1.5.1 \ + --hash=sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9 \ + --hash=sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1 + # via charmcraft +httplib2==0.22.0 \ + --hash=sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc \ + --hash=sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81 + # via launchpadlib + # via lazr-restfulclient +humanize==4.10.0 \ + --hash=sha256:06b6eb0293e4b85e8d385397c5868926820db32b9b654b932f57fa41c23c9978 \ + --hash=sha256:39e7ccb96923e732b5c2e27aeaa3b10a8dfeeba3eb965ba7b74a3eb0e30040a6 + # via charmcraft +hypothesis==6.110.1 \ + --hash=sha256:4b54a4ed3385c53b247b99e7b3c9630e7b665ef3cfdb2c557dd1c0b34d090481 \ + --hash=sha256:a138bfaea11aba6daadf8effd9065251a6fec1549f25b7d72ac9881a413f76b0 + # via charmcraft +idna==3.7 \ + --hash=sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc \ + --hash=sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0 + # via requests +importlib-metadata==8.2.0 \ + --hash=sha256:11901fa0c2f97919b288679932bb64febaeacf289d18ac84dd68cb2e74213369 \ + --hash=sha256:72e8d4399996132204f9a16dcc751af254a48f8d1b20b9ff0f98d4a8f901e73d + # via keyring + # via pydantic-yaml +iniconfig==2.0.0 \ + --hash=sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3 \ + --hash=sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 + # via pytest +jaraco-classes==3.4.0 \ + --hash=sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd \ + --hash=sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790 + # via keyring +jeepney==0.8.0 ; sys_platform == 'linux' \ + --hash=sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806 \ + --hash=sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755 + # via keyring + # via secretstorage +jinja2==3.1.4 \ + --hash=sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369 \ + --hash=sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d + # via charmcraft +jsonschema==4.23.0 \ + --hash=sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4 \ + --hash=sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566 + # via charmcraft +jsonschema-specifications==2023.12.1 \ + --hash=sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc \ + --hash=sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c + # via jsonschema +keyring==24.3.1 \ + --hash=sha256:c3327b6ffafc0e8befbdb597cacdb4928ffe5c1212f7645f186e6d9957a898db \ + --hash=sha256:df38a4d7419a6a60fea5cef1e45a948a3e8430dd12ad88b0f423c5c143906218 + # via craft-store +launchpadlib==2.0.0 \ + --hash=sha256:5d4a9095e91773a7565d4c159594ae30eca792fd5f9b89ded459d711484a96cb \ + --hash=sha256:bd158ec67e6a3e37d16aeb06b4dca4ef0da7ff1b684c51c896b03feef9aab875 + # via craft-archives +lazr-restfulclient==0.14.6 \ + --hash=sha256:43f12a1d3948463b1462038c47b429dcb5e42e0ba7f2e16511b02ba5d2adffdb \ + --hash=sha256:97e95b1d8f0ec7fed998b48aea773baf8dcab06cf78a4deb9a046af5cca0cea2 + # via craft-archives + # via launchpadlib +lazr-uri==1.0.6 \ + --hash=sha256:5026853fcbf6f91d5a6b11ea7860a641fe27b36d4172c731f4aa16b900cf8464 + # via craft-archives + # via launchpadlib + # via wadllib +license-expression==30.3.0 \ + --hash=sha256:1295406f736b4f395ff069aec1cebfad53c0fcb3cf57df0f5ec58fc7b905aea5 \ + --hash=sha256:ae0ba9a829d6909c785dc2f0131f13d10d68318e4a5f28af5ef152d6b52f9b41 + # via craft-application +lxml==5.2.2 \ + --hash=sha256:02437fb7308386867c8b7b0e5bc4cd4b04548b1c5d089ffb8e7b31009b961dc3 \ + --hash=sha256:02f6a8eb6512fdc2fd4ca10a49c341c4e109aa6e9448cc4859af5b949622715a \ + --hash=sha256:05f8757b03208c3f50097761be2dea0aba02e94f0dc7023ed73a7bb14ff11eb0 \ + --hash=sha256:06668e39e1f3c065349c51ac27ae430719d7806c026fec462e5693b08b95696b \ + --hash=sha256:07542787f86112d46d07d4f3c4e7c760282011b354d012dc4141cc12a68cef5f \ + --hash=sha256:08ea0f606808354eb8f2dfaac095963cb25d9d28e27edcc375d7b30ab01abbf6 \ + --hash=sha256:0969e92af09c5687d769731e3f39ed62427cc72176cebb54b7a9d52cc4fa3b73 \ + --hash=sha256:0a028b61a2e357ace98b1615fc03f76eb517cc028993964fe08ad514b1e8892d \ + --hash=sha256:0b3f5016e00ae7630a4b83d0868fca1e3d494c78a75b1c7252606a3a1c5fc2ad \ + --hash=sha256:13e69be35391ce72712184f69000cda04fc89689429179bc4c0ae5f0b7a8c21b \ + --hash=sha256:16a8326e51fcdffc886294c1e70b11ddccec836516a343f9ed0f82aac043c24a \ + --hash=sha256:19b4e485cd07b7d83e3fe3b72132e7df70bfac22b14fe4bf7a23822c3a35bff5 \ + --hash=sha256:1a2569a1f15ae6c8c64108a2cd2b4a858fc1e13d25846be0666fc144715e32ab \ + --hash=sha256:1a7aca7964ac4bb07680d5c9d63b9d7028cace3e2d43175cb50bba8c5ad33316 \ + --hash=sha256:1b590b39ef90c6b22ec0be925b211298e810b4856909c8ca60d27ffbca6c12e6 \ + --hash=sha256:1d8a701774dfc42a2f0b8ccdfe7dbc140500d1049e0632a611985d943fcf12df \ + --hash=sha256:1e275ea572389e41e8b039ac076a46cb87ee6b8542df3fff26f5baab43713bca \ + --hash=sha256:2304d3c93f2258ccf2cf7a6ba8c761d76ef84948d87bf9664e14d203da2cd264 \ + --hash=sha256:23441e2b5339bc54dc949e9e675fa35efe858108404ef9aa92f0456929ef6fe8 \ + --hash=sha256:23cfafd56887eaed93d07bc4547abd5e09d837a002b791e9767765492a75883f \ + --hash=sha256:28bf95177400066596cdbcfc933312493799382879da504633d16cf60bba735b \ + --hash=sha256:2eb2227ce1ff998faf0cd7fe85bbf086aa41dfc5af3b1d80867ecfe75fb68df3 \ + --hash=sha256:2fb0ba3e8566548d6c8e7dd82a8229ff47bd8fb8c2da237607ac8e5a1b8312e5 \ + --hash=sha256:303f540ad2dddd35b92415b74b900c749ec2010e703ab3bfd6660979d01fd4ed \ + --hash=sha256:339ee4a4704bc724757cd5dd9dc8cf4d00980f5d3e6e06d5847c1b594ace68ab \ + --hash=sha256:33ce9e786753743159799fdf8e92a5da351158c4bfb6f2db0bf31e7892a1feb5 \ + --hash=sha256:343ab62e9ca78094f2306aefed67dcfad61c4683f87eee48ff2fd74902447726 \ + --hash=sha256:34e17913c431f5ae01d8658dbf792fdc457073dcdfbb31dc0cc6ab256e664a8d \ + --hash=sha256:364d03207f3e603922d0d3932ef363d55bbf48e3647395765f9bfcbdf6d23632 \ + --hash=sha256:38b67afb0a06b8575948641c1d6d68e41b83a3abeae2ca9eed2ac59892b36706 \ + --hash=sha256:3a745cc98d504d5bd2c19b10c79c61c7c3df9222629f1b6210c0368177589fb8 \ + --hash=sha256:3b019d4ee84b683342af793b56bb35034bd749e4cbdd3d33f7d1107790f8c472 \ + --hash=sha256:3b6a30a9ab040b3f545b697cb3adbf3696c05a3a68aad172e3fd7ca73ab3c835 \ + --hash=sha256:3d1e35572a56941b32c239774d7e9ad724074d37f90c7a7d499ab98761bd80cf \ + --hash=sha256:3d98de734abee23e61f6b8c2e08a88453ada7d6486dc7cdc82922a03968928db \ + --hash=sha256:453d037e09a5176d92ec0fd282e934ed26d806331a8b70ab431a81e2fbabf56d \ + --hash=sha256:45f9494613160d0405682f9eee781c7e6d1bf45f819654eb249f8f46a2c22545 \ + --hash=sha256:4820c02195d6dfb7b8508ff276752f6b2ff8b64ae5d13ebe02e7667e035000b9 \ + --hash=sha256:49095a38eb333aaf44c06052fd2ec3b8f23e19747ca7ec6f6c954ffea6dbf7be \ + --hash=sha256:4aefd911793b5d2d7a921233a54c90329bf3d4a6817dc465f12ffdfe4fc7b8fe \ + --hash=sha256:4bc6cb140a7a0ad1f7bc37e018d0ed690b7b6520ade518285dc3171f7a117905 \ + --hash=sha256:4c30a2f83677876465f44c018830f608fa3c6a8a466eb223535035fbc16f3438 \ + --hash=sha256:50127c186f191b8917ea2fb8b206fbebe87fd414a6084d15568c27d0a21d60db \ + --hash=sha256:50ccb5d355961c0f12f6cf24b7187dbabd5433f29e15147a67995474f27d1776 \ + --hash=sha256:519895c99c815a1a24a926d5b60627ce5ea48e9f639a5cd328bda0515ea0f10c \ + --hash=sha256:54401c77a63cc7d6dc4b4e173bb484f28a5607f3df71484709fe037c92d4f0ed \ + --hash=sha256:546cf886f6242dff9ec206331209db9c8e1643ae642dea5fdbecae2453cb50fd \ + --hash=sha256:55ce6b6d803890bd3cc89975fca9de1dff39729b43b73cb15ddd933b8bc20484 \ + --hash=sha256:56793b7a1a091a7c286b5f4aa1fe4ae5d1446fe742d00cdf2ffb1077865db10d \ + --hash=sha256:57f0a0bbc9868e10ebe874e9f129d2917750adf008fe7b9c1598c0fbbfdde6a6 \ + --hash=sha256:5b8c041b6265e08eac8a724b74b655404070b636a8dd6d7a13c3adc07882ef30 \ + --hash=sha256:5e097646944b66207023bc3c634827de858aebc226d5d4d6d16f0b77566ea182 \ + --hash=sha256:60499fe961b21264e17a471ec296dcbf4365fbea611bf9e303ab69db7159ce61 \ + --hash=sha256:610b5c77428a50269f38a534057444c249976433f40f53e3b47e68349cca1425 \ + --hash=sha256:625e3ef310e7fa3a761d48ca7ea1f9d8718a32b1542e727d584d82f4453d5eeb \ + --hash=sha256:657a972f46bbefdbba2d4f14413c0d079f9ae243bd68193cb5061b9732fa54c1 \ + --hash=sha256:69ab77a1373f1e7563e0fb5a29a8440367dec051da6c7405333699d07444f511 \ + --hash=sha256:6a520b4f9974b0a0a6ed73c2154de57cdfd0c8800f4f15ab2b73238ffed0b36e \ + --hash=sha256:6d68ce8e7b2075390e8ac1e1d3a99e8b6372c694bbe612632606d1d546794207 \ + --hash=sha256:6dcc3d17eac1df7859ae01202e9bb11ffa8c98949dcbeb1069c8b9a75917e01b \ + --hash=sha256:6dfdc2bfe69e9adf0df4915949c22a25b39d175d599bf98e7ddf620a13678585 \ + --hash=sha256:739e36ef7412b2bd940f75b278749106e6d025e40027c0b94a17ef7968d55d56 \ + --hash=sha256:7429e7faa1a60cad26ae4227f4dd0459efde239e494c7312624ce228e04f6391 \ + --hash=sha256:74da9f97daec6928567b48c90ea2c82a106b2d500f397eeb8941e47d30b1ca85 \ + --hash=sha256:74e4f025ef3db1c6da4460dd27c118d8cd136d0391da4e387a15e48e5c975147 \ + --hash=sha256:75a9632f1d4f698b2e6e2e1ada40e71f369b15d69baddb8968dcc8e683839b18 \ + --hash=sha256:76acba4c66c47d27c8365e7c10b3d8016a7da83d3191d053a58382311a8bf4e1 \ + --hash=sha256:79d1fb9252e7e2cfe4de6e9a6610c7cbb99b9708e2c3e29057f487de5a9eaefa \ + --hash=sha256:7ce7ad8abebe737ad6143d9d3bf94b88b93365ea30a5b81f6877ec9c0dee0a48 \ + --hash=sha256:7ed07b3062b055d7a7f9d6557a251cc655eed0b3152b76de619516621c56f5d3 \ + --hash=sha256:7ff762670cada8e05b32bf1e4dc50b140790909caa8303cfddc4d702b71ea184 \ + --hash=sha256:8268cbcd48c5375f46e000adb1390572c98879eb4f77910c6053d25cc3ac2c67 \ + --hash=sha256:875a3f90d7eb5c5d77e529080d95140eacb3c6d13ad5b616ee8095447b1d22e7 \ + --hash=sha256:89feb82ca055af0fe797a2323ec9043b26bc371365847dbe83c7fd2e2f181c34 \ + --hash=sha256:8a7e24cb69ee5f32e003f50e016d5fde438010c1022c96738b04fc2423e61706 \ + --hash=sha256:8ab6a358d1286498d80fe67bd3d69fcbc7d1359b45b41e74c4a26964ca99c3f8 \ + --hash=sha256:8b8df03a9e995b6211dafa63b32f9d405881518ff1ddd775db4e7b98fb545e1c \ + --hash=sha256:8cf85a6e40ff1f37fe0f25719aadf443686b1ac7652593dc53c7ef9b8492b115 \ + --hash=sha256:8e8d351ff44c1638cb6e980623d517abd9f580d2e53bfcd18d8941c052a5a009 \ + --hash=sha256:9164361769b6ca7769079f4d426a41df6164879f7f3568be9086e15baca61466 \ + --hash=sha256:96e85aa09274955bb6bd483eaf5b12abadade01010478154b0ec70284c1b1526 \ + --hash=sha256:981a06a3076997adf7c743dcd0d7a0415582661e2517c7d961493572e909aa1d \ + --hash=sha256:9cd5323344d8ebb9fb5e96da5de5ad4ebab993bbf51674259dbe9d7a18049525 \ + --hash=sha256:9d6c6ea6a11ca0ff9cd0390b885984ed31157c168565702959c25e2191674a14 \ + --hash=sha256:a02d3c48f9bb1e10c7788d92c0c7db6f2002d024ab6e74d6f45ae33e3d0288a3 \ + --hash=sha256:a233bb68625a85126ac9f1fc66d24337d6e8a0f9207b688eec2e7c880f012ec0 \ + --hash=sha256:a2f6a1bc2460e643785a2cde17293bd7a8f990884b822f7bca47bee0a82fc66b \ + --hash=sha256:a6d17e0370d2516d5bb9062c7b4cb731cff921fc875644c3d751ad857ba9c5b1 \ + --hash=sha256:a6d2092797b388342c1bc932077ad232f914351932353e2e8706851c870bca1f \ + --hash=sha256:ab67ed772c584b7ef2379797bf14b82df9aa5f7438c5b9a09624dd834c1c1aaf \ + --hash=sha256:ac6540c9fff6e3813d29d0403ee7a81897f1d8ecc09a8ff84d2eea70ede1cdbf \ + --hash=sha256:ae4073a60ab98529ab8a72ebf429f2a8cc612619a8c04e08bed27450d52103c0 \ + --hash=sha256:ae791f6bd43305aade8c0e22f816b34f3b72b6c820477aab4d18473a37e8090b \ + --hash=sha256:aef5474d913d3b05e613906ba4090433c515e13ea49c837aca18bde190853dff \ + --hash=sha256:b0b3f2df149efb242cee2ffdeb6674b7f30d23c9a7af26595099afaf46ef4e88 \ + --hash=sha256:b128092c927eaf485928cec0c28f6b8bead277e28acf56800e972aa2c2abd7a2 \ + --hash=sha256:b16db2770517b8799c79aa80f4053cd6f8b716f21f8aca962725a9565ce3ee40 \ + --hash=sha256:b336b0416828022bfd5a2e3083e7f5ba54b96242159f83c7e3eebaec752f1716 \ + --hash=sha256:b47633251727c8fe279f34025844b3b3a3e40cd1b198356d003aa146258d13a2 \ + --hash=sha256:b537bd04d7ccd7c6350cdaaaad911f6312cbd61e6e6045542f781c7f8b2e99d2 \ + --hash=sha256:b5e4ef22ff25bfd4ede5f8fb30f7b24446345f3e79d9b7455aef2836437bc38a \ + --hash=sha256:b74b9ea10063efb77a965a8d5f4182806fbf59ed068b3c3fd6f30d2ac7bee734 \ + --hash=sha256:bb2dc4898180bea79863d5487e5f9c7c34297414bad54bcd0f0852aee9cfdb87 \ + --hash=sha256:bbc4b80af581e18568ff07f6395c02114d05f4865c2812a1f02f2eaecf0bfd48 \ + --hash=sha256:bcc98f911f10278d1daf14b87d65325851a1d29153caaf146877ec37031d5f36 \ + --hash=sha256:be49ad33819d7dcc28a309b86d4ed98e1a65f3075c6acd3cd4fe32103235222b \ + --hash=sha256:bec4bd9133420c5c52d562469c754f27c5c9e36ee06abc169612c959bd7dbb07 \ + --hash=sha256:c2faf60c583af0d135e853c86ac2735ce178f0e338a3c7f9ae8f622fd2eb788c \ + --hash=sha256:c689d0d5381f56de7bd6966a4541bff6e08bf8d3871bbd89a0c6ab18aa699573 \ + --hash=sha256:c7079d5eb1c1315a858bbf180000757db8ad904a89476653232db835c3114001 \ + --hash=sha256:cb3942960f0beb9f46e2a71a3aca220d1ca32feb5a398656be934320804c0df9 \ + --hash=sha256:cd9e78285da6c9ba2d5c769628f43ef66d96ac3085e59b10ad4f3707980710d3 \ + --hash=sha256:cf2a978c795b54c539f47964ec05e35c05bd045db5ca1e8366988c7f2fe6b3ce \ + --hash=sha256:d14a0d029a4e176795cef99c056d58067c06195e0c7e2dbb293bf95c08f772a3 \ + --hash=sha256:d237ba6664b8e60fd90b8549a149a74fcc675272e0e95539a00522e4ca688b04 \ + --hash=sha256:d26a618ae1766279f2660aca0081b2220aca6bd1aa06b2cf73f07383faf48927 \ + --hash=sha256:d28cb356f119a437cc58a13f8135ab8a4c8ece18159eb9194b0d269ec4e28083 \ + --hash=sha256:d4ed0c7cbecde7194cd3228c044e86bf73e30a23505af852857c09c24e77ec5d \ + --hash=sha256:d83e2d94b69bf31ead2fa45f0acdef0757fa0458a129734f59f67f3d2eb7ef32 \ + --hash=sha256:d8bbcd21769594dbba9c37d3c819e2d5847656ca99c747ddb31ac1701d0c0ed9 \ + --hash=sha256:d9b342c76003c6b9336a80efcc766748a333573abf9350f4094ee46b006ec18f \ + --hash=sha256:dc911208b18842a3a57266d8e51fc3cfaccee90a5351b92079beed912a7914c2 \ + --hash=sha256:dfa7c241073d8f2b8e8dbc7803c434f57dbb83ae2a3d7892dd068d99e96efe2c \ + --hash=sha256:e282aedd63c639c07c3857097fc0e236f984ceb4089a8b284da1c526491e3f3d \ + --hash=sha256:e290d79a4107d7d794634ce3e985b9ae4f920380a813717adf61804904dc4393 \ + --hash=sha256:e3d9d13603410b72787579769469af730c38f2f25505573a5888a94b62b920f8 \ + --hash=sha256:e481bba1e11ba585fb06db666bfc23dbe181dbafc7b25776156120bf12e0d5a6 \ + --hash=sha256:e49b052b768bb74f58c7dda4e0bdf7b79d43a9204ca584ffe1fb48a6f3c84c66 \ + --hash=sha256:eb00b549b13bd6d884c863554566095bf6fa9c3cecb2e7b399c4bc7904cb33b5 \ + --hash=sha256:ec87c44f619380878bd49ca109669c9f221d9ae6883a5bcb3616785fa8f94c97 \ + --hash=sha256:edcfa83e03370032a489430215c1e7783128808fd3e2e0a3225deee278585196 \ + --hash=sha256:f11ae142f3a322d44513de1018b50f474f8f736bc3cd91d969f464b5bfef8836 \ + --hash=sha256:f2a09f6184f17a80897172863a655467da2b11151ec98ba8d7af89f17bf63dae \ + --hash=sha256:f5b65529bb2f21ac7861a0e94fdbf5dc0daab41497d18223b46ee8515e5ad297 \ + --hash=sha256:f60fdd125d85bf9c279ffb8e94c78c51b3b6a37711464e1f5f31078b45002421 \ + --hash=sha256:f61efaf4bed1cc0860e567d2ecb2363974d414f7f1f124b1df368bbf183453a6 \ + --hash=sha256:f90e552ecbad426eab352e7b2933091f2be77115bb16f09f78404861c8322981 \ + --hash=sha256:f956196ef61369f1685d14dad80611488d8dc1ef00be57c0c5a03064005b0f30 \ + --hash=sha256:fb91819461b1b56d06fa4bcf86617fac795f6a99d12239fb0c68dbeba41a0a30 \ + --hash=sha256:fbc9d316552f9ef7bba39f4edfad4a734d3d6f93341232a9dddadec4f15d425f \ + --hash=sha256:ff69a9a0b4b17d78170c73abe2ab12084bdf1691550c5629ad1fe7849433f324 \ + --hash=sha256:ffb2be176fed4457e445fe540617f0252a72a8bc56208fd65a690fdb1f57660b + # via mypy +macaroonbakery==1.3.4 \ + --hash=sha256:1e952a189f5c1e96ef82b081b2852c770d7daa20987e2088e762dd5689fb253b \ + --hash=sha256:41ca993a23e4f8ef2fe7723b5cd4a30c759735f1d5021e990770c8a0e0f33970 + # via craft-store +markupsafe==2.1.5 \ + --hash=sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf \ + --hash=sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff \ + --hash=sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f \ + --hash=sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3 \ + --hash=sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532 \ + --hash=sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f \ + --hash=sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617 \ + --hash=sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df \ + --hash=sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4 \ + --hash=sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906 \ + --hash=sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f \ + --hash=sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4 \ + --hash=sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8 \ + --hash=sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371 \ + --hash=sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2 \ + --hash=sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465 \ + --hash=sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52 \ + --hash=sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6 \ + --hash=sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169 \ + --hash=sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad \ + --hash=sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2 \ + --hash=sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0 \ + --hash=sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029 \ + --hash=sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f \ + --hash=sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a \ + --hash=sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced \ + --hash=sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5 \ + --hash=sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c \ + --hash=sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf \ + --hash=sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9 \ + --hash=sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb \ + --hash=sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad \ + --hash=sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3 \ + --hash=sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1 \ + --hash=sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46 \ + --hash=sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc \ + --hash=sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a \ + --hash=sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee \ + --hash=sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900 \ + --hash=sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5 \ + --hash=sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea \ + --hash=sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f \ + --hash=sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5 \ + --hash=sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e \ + --hash=sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a \ + --hash=sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f \ + --hash=sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50 \ + --hash=sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a \ + --hash=sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b \ + --hash=sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4 \ + --hash=sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff \ + --hash=sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2 \ + --hash=sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46 \ + --hash=sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b \ + --hash=sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf \ + --hash=sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5 \ + --hash=sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5 \ + --hash=sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab \ + --hash=sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd \ + --hash=sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68 + # via jinja2 +mccabe==0.7.0 \ + --hash=sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325 \ + --hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e + # via flake8 +more-itertools==10.4.0 \ + --hash=sha256:0f7d9f83a0a8dcfa8a2694a770590d98a67ea943e3d9f5298309a484758c4e27 \ + --hash=sha256:fe0e63c4ab068eac62410ab05cccca2dc71ec44ba8ef29916a0090df061cf923 + # via jaraco-classes +mypy==1.11.1 \ + --hash=sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54 \ + --hash=sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a \ + --hash=sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72 \ + --hash=sha256:11965c2f571ded6239977b14deebd3f4c3abd9a92398712d6da3a772974fad69 \ + --hash=sha256:1a81cf05975fd61aec5ae16501a091cfb9f605dc3e3c878c0da32f250b74760b \ + --hash=sha256:2684d3f693073ab89d76da8e3921883019ea8a3ec20fa5d8ecca6a2db4c54bbe \ + --hash=sha256:2c63350af88f43a66d3dfeeeb8d77af34a4f07d760b9eb3a8697f0386c7590b4 \ + --hash=sha256:45df906e8b6804ef4b666af29a87ad9f5921aad091c79cc38e12198e220beabd \ + --hash=sha256:4c956b49c5d865394d62941b109728c5c596a415e9c5b2be663dd26a1ff07bc0 \ + --hash=sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525 \ + --hash=sha256:749fd3213916f1751fff995fccf20c6195cae941dc968f3aaadf9bb4e430e5a2 \ + --hash=sha256:79c07eb282cb457473add5052b63925e5cc97dfab9812ee65a7c7ab5e3cb551c \ + --hash=sha256:7b6343d338390bb946d449677726edf60102a1c96079b4f002dedff375953fc5 \ + --hash=sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de \ + --hash=sha256:a2b43895a0f8154df6519706d9bca8280cda52d3d9d1514b2d9c3e26792a0b74 \ + --hash=sha256:a32fc80b63de4b5b3e65f4be82b4cfa362a46702672aa6a0f443b4689af7008c \ + --hash=sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e \ + --hash=sha256:a831671bad47186603872a3abc19634f3011d7f83b083762c942442d51c58d58 \ + --hash=sha256:b639dce63a0b19085213ec5fdd8cffd1d81988f47a2dec7100e93564f3e8fb3b \ + --hash=sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417 \ + --hash=sha256:c1952f5ea8a5a959b05ed5f16452fddadbaae48b5d39235ab4c3fc444d5fd411 \ + --hash=sha256:d44be7551689d9d47b7abc27c71257adfdb53f03880841a5db15ddb22dc63edb \ + --hash=sha256:e1e30dc3bfa4e157e53c1d17a0dad20f89dc433393e7702b813c10e200843b03 \ + --hash=sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca \ + --hash=sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8 \ + --hash=sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08 \ + --hash=sha256:fca4a60e1dd9fd0193ae0067eaeeb962f2d79e0d9f0f66223a0682f26ffcc809 + # via charmcraft +mypy-extensions==1.0.0 \ + --hash=sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d \ + --hash=sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782 + # via black + # via mypy +nodeenv==1.9.1 \ + --hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \ + --hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9 + # via pyright +oauthlib==3.2.2 \ + --hash=sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca \ + --hash=sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918 + # via lazr-restfulclient +overrides==7.7.0 \ + --hash=sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a \ + --hash=sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49 + # via craft-archives + # via craft-grammar + # via craft-parts + # via craft-store +packaging==24.1 \ + --hash=sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002 \ + --hash=sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124 + # via black + # via craft-providers + # via pytest +pathspec==0.12.1 \ + --hash=sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 \ + --hash=sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712 + # via black + # via yamllint +platformdirs==4.2.2 \ + --hash=sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee \ + --hash=sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3 + # via black + # via craft-application + # via craft-cli +pluggy==1.5.0 \ + --hash=sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1 \ + --hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 + # via pytest +protobuf==5.27.3 \ + --hash=sha256:043853dcb55cc262bf2e116215ad43fa0859caab79bb0b2d31b708f128ece035 \ + --hash=sha256:16ddf3f8c6c41e1e803da7abea17b1793a97ef079a912e42351eabb19b2cffe7 \ + --hash=sha256:68248c60d53f6168f565a8c76dc58ba4fa2ade31c2d1ebdae6d80f969cdc2d4f \ + --hash=sha256:82460903e640f2b7e34ee81a947fdaad89de796d324bcbc38ff5430bcdead82c \ + --hash=sha256:8572c6533e544ebf6899c360e91d6bcbbee2549251643d32c52cf8a5de295ba5 \ + --hash=sha256:a55c48f2a2092d8e213bd143474df33a6ae751b781dd1d1f4d953c128a415b25 \ + --hash=sha256:af7c0b7cfbbb649ad26132e53faa348580f844d9ca46fd3ec7ca48a1ea5db8a1 \ + --hash=sha256:b8a994fb3d1c11156e7d1e427186662b64694a62b55936b2b9348f0a7c6625ce \ + --hash=sha256:c2a105c24f08b1e53d6c7ffe69cb09d0031512f0b72f812dd4005b8112dbe91e \ + --hash=sha256:c84eee2c71ed83704f1afbf1a85c3171eab0fd1ade3b399b3fad0884cbcca8bf \ + --hash=sha256:dcb307cd4ef8fec0cf52cb9105a03d06fbb5275ce6d84a6ae33bc6cf84e0a07b + # via macaroonbakery +pycodestyle==2.12.1 \ + --hash=sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3 \ + --hash=sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521 + # via flake8 +pycparser==2.22 \ + --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ + --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc + # via cffi +pydantic==2.8.2 \ + --hash=sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a \ + --hash=sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8 + # via charmcraft + # via craft-application + # via craft-archives + # via craft-grammar + # via craft-parts + # via craft-providers + # via craft-store + # via pydantic-yaml +pydantic-core==2.20.1 \ + --hash=sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d \ + --hash=sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f \ + --hash=sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686 \ + --hash=sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482 \ + --hash=sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006 \ + --hash=sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83 \ + --hash=sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6 \ + --hash=sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88 \ + --hash=sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86 \ + --hash=sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a \ + --hash=sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6 \ + --hash=sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a \ + --hash=sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6 \ + --hash=sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6 \ + --hash=sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43 \ + --hash=sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c \ + --hash=sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4 \ + --hash=sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e \ + --hash=sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203 \ + --hash=sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd \ + --hash=sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1 \ + --hash=sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24 \ + --hash=sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc \ + --hash=sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc \ + --hash=sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3 \ + --hash=sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598 \ + --hash=sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98 \ + --hash=sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331 \ + --hash=sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2 \ + --hash=sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a \ + --hash=sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6 \ + --hash=sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688 \ + --hash=sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91 \ + --hash=sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa \ + --hash=sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b \ + --hash=sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0 \ + --hash=sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840 \ + --hash=sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c \ + --hash=sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd \ + --hash=sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3 \ + --hash=sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231 \ + --hash=sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1 \ + --hash=sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953 \ + --hash=sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250 \ + --hash=sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a \ + --hash=sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2 \ + --hash=sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20 \ + --hash=sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434 \ + --hash=sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab \ + --hash=sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703 \ + --hash=sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a \ + --hash=sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2 \ + --hash=sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac \ + --hash=sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611 \ + --hash=sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121 \ + --hash=sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e \ + --hash=sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b \ + --hash=sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09 \ + --hash=sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906 \ + --hash=sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9 \ + --hash=sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7 \ + --hash=sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b \ + --hash=sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987 \ + --hash=sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c \ + --hash=sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b \ + --hash=sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e \ + --hash=sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237 \ + --hash=sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1 \ + --hash=sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19 \ + --hash=sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b \ + --hash=sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad \ + --hash=sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0 \ + --hash=sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94 \ + --hash=sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312 \ + --hash=sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f \ + --hash=sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669 \ + --hash=sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1 \ + --hash=sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe \ + --hash=sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99 \ + --hash=sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a \ + --hash=sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a \ + --hash=sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52 \ + --hash=sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c \ + --hash=sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad \ + --hash=sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1 \ + --hash=sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a \ + --hash=sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f \ + --hash=sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a \ + --hash=sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27 + # via pydantic +pydantic-yaml==1.3.0 \ + --hash=sha256:0684255a860522c9226d4eff5c0e8ba44339683b5c5fa79fac4470c0e3821911 \ + --hash=sha256:5671c9ef1731570aa2644432ae1e2dd34c406bd4a0a393df622f6b897a88df83 + # via craft-parts +pydocstyle==6.3.0 \ + --hash=sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019 \ + --hash=sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1 + # via charmcraft +pyfakefs==5.6.0 \ + --hash=sha256:1a45bba8615323ec29d65929d32dc66d7b59a1e60a02109950440edb0486c539 \ + --hash=sha256:7a549b32865aa97d8ba6538285a93816941d9b7359be2954ac60ec36b277e879 + # via charmcraft +pyflakes==3.2.0 \ + --hash=sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f \ + --hash=sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a + # via flake8 +pygit2==1.14.1 \ + --hash=sha256:0fff3d1aaf1d7372757888c4620347d6ad8b1b3a637b30a3abd156da7cf9476b \ + --hash=sha256:11058be23a5d6c1308303fd450d690eada117c564154634d81676e66530056be \ + --hash=sha256:141a1b37fc431d98b3de2f4651eab8b1b1b038cd50de42bfd1c8de057ec2284e \ + --hash=sha256:15db91695259f672f8be3080eb943889f7c8bdc5fbd8b89555e0c53ba2481f15 \ + --hash=sha256:230493d43945e10365070d349da206d39cc885ae8c52fdeca93942f36661dd93 \ + --hash=sha256:404d3d9bac22ff022157de3fbfd8997c108d86814ba88cbc8709c1c2daef833a \ + --hash=sha256:46ae2149851d5da2934e27c9ac45c375d04af1e549f8c4cbb4e9e4de5f43dc42 \ + --hash=sha256:67b6e5911101dc5ecb679bf241c0b9ee2099f4d76aa0ad66b326400cb4590afa \ + --hash=sha256:760614370fcce4e9606ff675d6fc11165badb59aaedc2ea6cb2e7ec1855616c2 \ + --hash=sha256:793f49ce66640d41d977e1337ddb5dec9b3b4ff818040d78d3ded052e1ea52e6 \ + --hash=sha256:7b6d1202d6a0c21281d2697321292aff9e2e2e195d6ce553efcdf86c2de2af1a \ + --hash=sha256:8589c8c0005b5ba373b3b101f903d4451338f3dfc09f8a38c76da6584fef84d0 \ + --hash=sha256:9d96e46b94dc706e6316e6cc293c0a0692e5b0811a6f8f2738728a4a68d7a827 \ + --hash=sha256:a03de11ba5205628996d867280e5181605009c966c801dbb94781bed55b740d7 \ + --hash=sha256:acb849cea89438192e78eea91a27fb9c54c7286a82aac65a3f746ea8c498fedb \ + --hash=sha256:acc7be8a439274fc6227e33b63b9ec83cd51fa210ab898eaadffb7bf930c0087 \ + --hash=sha256:bc3326a5ce891ef26429ae6d4290acb80ea0064947b4184a4c4940b4bd6ab4a3 \ + --hash=sha256:c22027f748d125698964ed696406075dac85f114e01d50547e67053c1bb03308 \ + --hash=sha256:e4f371c4b7ee86c0a751209fac7c941d1f6a3aca6af89ac09481469dbe0ea1cc \ + --hash=sha256:ea505739af41496b1d36c99bc15e2bd5631880059514458977c8931e27063a8d \ + --hash=sha256:ec5958571b82a6351785ca645e5394c31ae45eec5384b2fa9c4e05dde3597ad6 \ + --hash=sha256:ed16f2bc8ca9c42af8adb967c73227b1de973e9c4d717bd738fb2f177890ca2c \ + --hash=sha256:f2378f9a70cea27809a2c78b823e22659691a91db9d81b1f3a58d537067815ac \ + --hash=sha256:f35152b96a31ab705cdd63aef08fb199d6c1e87fc6fd45b1945f8cd040a43b7b \ + --hash=sha256:f5a87744e6c36f03fe488b975c73d3eaef22eadce433152516a2b8dbc4015233 + # via craft-application +pymacaroons==0.13.0 \ + --hash=sha256:1e6bba42a5f66c245adf38a5a4006a99dcc06a0703786ea636098667d42903b8 \ + --hash=sha256:3e14dff6a262fdbf1a15e769ce635a8aea72e6f8f91e408f9a97166c53b91907 + # via macaroonbakery +pynacl==1.5.0 \ + --hash=sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858 \ + --hash=sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d \ + --hash=sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93 \ + --hash=sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1 \ + --hash=sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92 \ + --hash=sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff \ + --hash=sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba \ + --hash=sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394 \ + --hash=sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b \ + --hash=sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543 + # via macaroonbakery + # via pymacaroons +pyparsing==3.1.2 \ + --hash=sha256:a1bac0ce561155ecc3ed78ca94d3c9378656ad4c94c1270de543f621420f94ad \ + --hash=sha256:f9db75911801ed778fe61bb643079ff86601aca99fcae6345aa67292038fb742 + # via httplib2 +pyrfc3339==1.1 \ + --hash=sha256:67196cb83b470709c580bb4738b83165e67c6cc60e1f2e4f286cfcb402a926f4 \ + --hash=sha256:81b8cbe1519cdb79bed04910dd6fa4e181faf8c88dff1e1b987b5f7ab23a5b1a + # via macaroonbakery +pyright==1.1.366 \ + --hash=sha256:10e4d60be411f6d960cd39b0b58bf2ff76f2c83b9aeb102ffa9d9fda2e1303cb \ + --hash=sha256:c09e73ccc894976bcd6d6a5784aa84d724dbd9ceb7b873b39d475ca61c2de071 + # via charmcraft +pytest==8.3.2 \ + --hash=sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5 \ + --hash=sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce + # via charmcraft + # via pytest-check + # via pytest-cov + # via pytest-mock + # via pytest-subprocess +pytest-check==2.3.1 \ + --hash=sha256:51b8f18a8ccaa426c5d913c4e0e46f014aaa7579481ea03d22d7e1f498f689b2 \ + --hash=sha256:c54c18f0b890cac1c610c78ef2bb3d8ecb29cf33d1cf09fc1166802d6ab88e28 + # via charmcraft +pytest-cov==5.0.0 \ + --hash=sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652 \ + --hash=sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857 + # via charmcraft +pytest-mock==3.14.0 \ + --hash=sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f \ + --hash=sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0 + # via charmcraft +pytest-subprocess==1.5.2 \ + --hash=sha256:23ac7732aa8bd45f1757265b1316eb72a7f55b41fb21e2ca22e149ba3629fa46 \ + --hash=sha256:ad3ca8a35e798bf9c82d9f16d88700b30d98c5a28236117b86c5d6e581a8ed97 + # via charmcraft +python-apt==2.7.7+ubuntu1 ; sys_platform == 'linux' + # via charmcraft +python-dateutil==2.9.0.post0 \ + --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 + # via charmcraft + # via freezegun +pytz==2024.1 \ + --hash=sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812 \ + --hash=sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319 + # via pyrfc3339 +pywin32==306 ; sys_platform == 'win32' \ + --hash=sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d \ + --hash=sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65 \ + --hash=sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e \ + --hash=sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b \ + --hash=sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4 \ + --hash=sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040 \ + --hash=sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a \ + --hash=sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36 \ + --hash=sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8 \ + --hash=sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e \ + --hash=sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802 \ + --hash=sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a \ + --hash=sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407 \ + --hash=sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0 + # via craft-cli + # via docker +pywin32-ctypes==0.2.2 ; sys_platform == 'win32' \ + --hash=sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60 \ + --hash=sha256:bf490a1a709baf35d688fe0ecf980ed4de11d2b3e37b51e5442587a75d9957e7 + # via keyring +pyxdg==0.28 \ + --hash=sha256:3267bb3074e934df202af2ee0868575484108581e6f3cb006af1da35395e88b4 \ + --hash=sha256:bdaf595999a0178ecea4052b7f4195569c1ff4d344567bccdc12dfdf02d545ab + # via craft-parts + # via craft-store +pyyaml==6.0.2 \ + --hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ + --hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \ + --hash=sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086 \ + --hash=sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e \ + --hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 \ + --hash=sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5 \ + --hash=sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484 \ + --hash=sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee \ + --hash=sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5 \ + --hash=sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68 \ + --hash=sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a \ + --hash=sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf \ + --hash=sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99 \ + --hash=sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8 \ + --hash=sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85 \ + --hash=sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19 \ + --hash=sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc \ + --hash=sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a \ + --hash=sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1 \ + --hash=sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317 \ + --hash=sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c \ + --hash=sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631 \ + --hash=sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d \ + --hash=sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652 \ + --hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \ + --hash=sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e \ + --hash=sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b \ + --hash=sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8 \ + --hash=sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476 \ + --hash=sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706 \ + --hash=sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563 \ + --hash=sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237 \ + --hash=sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b \ + --hash=sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083 \ + --hash=sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180 \ + --hash=sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425 \ + --hash=sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e \ + --hash=sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f \ + --hash=sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725 \ + --hash=sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183 \ + --hash=sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab \ + --hash=sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774 \ + --hash=sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725 \ + --hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e \ + --hash=sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5 \ + --hash=sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d \ + --hash=sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290 \ + --hash=sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44 \ + --hash=sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed \ + --hash=sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4 \ + --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \ + --hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \ + --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 + # via charmcraft + # via craft-application + # via craft-cli + # via craft-parts + # via craft-providers + # via responses + # via snap-helpers + # via yamllint +referencing==0.35.1 \ + --hash=sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c \ + --hash=sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de + # via jsonschema + # via jsonschema-specifications +requests==2.31.0 \ + --hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f \ + --hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1 + # via charmcraft + # via craft-application + # via craft-parts + # via craft-providers + # via craft-store + # via docker + # via macaroonbakery + # via requests-toolbelt + # via requests-unixsocket + # via responses +requests-toolbelt==1.0.0 \ + --hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \ + --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06 + # via charmcraft + # via craft-store +requests-unixsocket==0.3.0 \ + --hash=sha256:28304283ea9357d45fff58ad5b11e47708cfbf5806817aa59b2a363228ee971e \ + --hash=sha256:c685c680f0809e1b2955339b1e5afc3c0022b3066f4f7eb343f43a6065fc0e5d + # via charmcraft + # via craft-parts + # via craft-providers +responses==0.25.3 \ + --hash=sha256:521efcbc82081ab8daa588e08f7e8a64ce79b91c39f6e62199b19159bea7dbcb \ + --hash=sha256:617b9247abd9ae28313d57a75880422d55ec63c29d33d629697590a034358dba + # via charmcraft +rpds-py==0.20.0 \ + --hash=sha256:06db23d43f26478303e954c34c75182356ca9aa7797d22c5345b16871ab9c45c \ + --hash=sha256:0e13e6952ef264c40587d510ad676a988df19adea20444c2b295e536457bc585 \ + --hash=sha256:11ef6ce74616342888b69878d45e9f779b95d4bd48b382a229fe624a409b72c5 \ + --hash=sha256:1259c7b3705ac0a0bd38197565a5d603218591d3f6cee6e614e380b6ba61c6f6 \ + --hash=sha256:18d7585c463087bddcfa74c2ba267339f14f2515158ac4db30b1f9cbdb62c8ef \ + --hash=sha256:1e0f80b739e5a8f54837be5d5c924483996b603d5502bfff79bf33da06164ee2 \ + --hash=sha256:1e5f3cd7397c8f86c8cc72d5a791071431c108edd79872cdd96e00abd8497d29 \ + --hash=sha256:220002c1b846db9afd83371d08d239fdc865e8f8c5795bbaec20916a76db3318 \ + --hash=sha256:22e6c9976e38f4d8c4a63bd8a8edac5307dffd3ee7e6026d97f3cc3a2dc02a0b \ + --hash=sha256:238a2d5b1cad28cdc6ed15faf93a998336eb041c4e440dd7f902528b8891b399 \ + --hash=sha256:2580b0c34583b85efec8c5c5ec9edf2dfe817330cc882ee972ae650e7b5ef739 \ + --hash=sha256:28527c685f237c05445efec62426d285e47a58fb05ba0090a4340b73ecda6dee \ + --hash=sha256:2cf126d33a91ee6eedc7f3197b53e87a2acdac63602c0f03a02dd69e4b138174 \ + --hash=sha256:338ca4539aad4ce70a656e5187a3a31c5204f261aef9f6ab50e50bcdffaf050a \ + --hash=sha256:39ed0d010457a78f54090fafb5d108501b5aa5604cc22408fc1c0c77eac14344 \ + --hash=sha256:3ad0fda1635f8439cde85c700f964b23ed5fc2d28016b32b9ee5fe30da5c84e2 \ + --hash=sha256:3d2b1ad682a3dfda2a4e8ad8572f3100f95fad98cb99faf37ff0ddfe9cbf9d03 \ + --hash=sha256:3d61339e9f84a3f0767b1995adfb171a0d00a1185192718a17af6e124728e0f5 \ + --hash=sha256:3fde368e9140312b6e8b6c09fb9f8c8c2f00999d1823403ae90cc00480221b22 \ + --hash=sha256:40ce74fc86ee4645d0a225498d091d8bc61f39b709ebef8204cb8b5a464d3c0e \ + --hash=sha256:49a8063ea4296b3a7e81a5dfb8f7b2d73f0b1c20c2af401fb0cdf22e14711a96 \ + --hash=sha256:4a1f1d51eccb7e6c32ae89243cb352389228ea62f89cd80823ea7dd1b98e0b91 \ + --hash=sha256:4b16aa0107ecb512b568244ef461f27697164d9a68d8b35090e9b0c1c8b27752 \ + --hash=sha256:4f1ed4749a08379555cebf4650453f14452eaa9c43d0a95c49db50c18b7da075 \ + --hash=sha256:4fe84294c7019456e56d93e8ababdad5a329cd25975be749c3f5f558abb48253 \ + --hash=sha256:50eccbf054e62a7b2209b28dc7a22d6254860209d6753e6b78cfaeb0075d7bee \ + --hash=sha256:514b3293b64187172bc77c8fb0cdae26981618021053b30d8371c3a902d4d5ad \ + --hash=sha256:54b43a2b07db18314669092bb2de584524d1ef414588780261e31e85846c26a5 \ + --hash=sha256:55fea87029cded5df854ca7e192ec7bdb7ecd1d9a3f63d5c4eb09148acf4a7ce \ + --hash=sha256:569b3ea770c2717b730b61998b6c54996adee3cef69fc28d444f3e7920313cf7 \ + --hash=sha256:56e27147a5a4c2c21633ff8475d185734c0e4befd1c989b5b95a5d0db699b21b \ + --hash=sha256:57eb94a8c16ab08fef6404301c38318e2c5a32216bf5de453e2714c964c125c8 \ + --hash=sha256:5a35df9f5548fd79cb2f52d27182108c3e6641a4feb0f39067911bf2adaa3e57 \ + --hash=sha256:5a8c94dad2e45324fc74dce25e1645d4d14df9a4e54a30fa0ae8bad9a63928e3 \ + --hash=sha256:5b4f105deeffa28bbcdff6c49b34e74903139afa690e35d2d9e3c2c2fba18cec \ + --hash=sha256:5c1dc0f53856b9cc9a0ccca0a7cc61d3d20a7088201c0937f3f4048c1718a209 \ + --hash=sha256:614fdafe9f5f19c63ea02817fa4861c606a59a604a77c8cdef5aa01d28b97921 \ + --hash=sha256:617c7357272c67696fd052811e352ac54ed1d9b49ab370261a80d3b6ce385045 \ + --hash=sha256:65794e4048ee837494aea3c21a28ad5fc080994dfba5b036cf84de37f7ad5074 \ + --hash=sha256:6632f2d04f15d1bd6fe0eedd3b86d9061b836ddca4c03d5cf5c7e9e6b7c14580 \ + --hash=sha256:6c8ef2ebf76df43f5750b46851ed1cdf8f109d7787ca40035fe19fbdc1acc5a7 \ + --hash=sha256:758406267907b3781beee0f0edfe4a179fbd97c0be2e9b1154d7f0a1279cf8e5 \ + --hash=sha256:7e60cb630f674a31f0368ed32b2a6b4331b8350d67de53c0359992444b116dd3 \ + --hash=sha256:89c19a494bf3ad08c1da49445cc5d13d8fefc265f48ee7e7556839acdacf69d0 \ + --hash=sha256:8a86a9b96070674fc88b6f9f71a97d2c1d3e5165574615d1f9168ecba4cecb24 \ + --hash=sha256:8bc7690f7caee50b04a79bf017a8d020c1f48c2a1077ffe172abec59870f1139 \ + --hash=sha256:8d7919548df3f25374a1f5d01fbcd38dacab338ef5f33e044744b5c36729c8db \ + --hash=sha256:9426133526f69fcaba6e42146b4e12d6bc6c839b8b555097020e2b78ce908dcc \ + --hash=sha256:9824fb430c9cf9af743cf7aaf6707bf14323fb51ee74425c380f4c846ea70789 \ + --hash=sha256:9bb4a0d90fdb03437c109a17eade42dfbf6190408f29b2744114d11586611d6f \ + --hash=sha256:9bc2d153989e3216b0559251b0c260cfd168ec78b1fac33dd485750a228db5a2 \ + --hash=sha256:9d35cef91e59ebbeaa45214861874bc6f19eb35de96db73e467a8358d701a96c \ + --hash=sha256:a1862d2d7ce1674cffa6d186d53ca95c6e17ed2b06b3f4c476173565c862d232 \ + --hash=sha256:a84ab91cbe7aab97f7446652d0ed37d35b68a465aeef8fc41932a9d7eee2c1a6 \ + --hash=sha256:aa7f429242aae2947246587d2964fad750b79e8c233a2367f71b554e9447949c \ + --hash=sha256:aa9a0521aeca7d4941499a73ad7d4f8ffa3d1affc50b9ea11d992cd7eff18a29 \ + --hash=sha256:ac2f4f7a98934c2ed6505aead07b979e6f999389f16b714448fb39bbaa86a489 \ + --hash=sha256:ae94bd0b2f02c28e199e9bc51485d0c5601f58780636185660f86bf80c89af94 \ + --hash=sha256:af0fc424a5842a11e28956e69395fbbeab2c97c42253169d87e90aac2886d751 \ + --hash=sha256:b2a5db5397d82fa847e4c624b0c98fe59d2d9b7cf0ce6de09e4d2e80f8f5b3f2 \ + --hash=sha256:b4c29cbbba378759ac5786730d1c3cb4ec6f8ababf5c42a9ce303dc4b3d08cda \ + --hash=sha256:b74b25f024b421d5859d156750ea9a65651793d51b76a2e9238c05c9d5f203a9 \ + --hash=sha256:b7f19250ceef892adf27f0399b9e5afad019288e9be756d6919cb58892129f51 \ + --hash=sha256:b80d4a7900cf6b66bb9cee5c352b2d708e29e5a37fe9bf784fa97fc11504bf6c \ + --hash=sha256:b8c00a3b1e70c1d3891f0db1b05292747f0dbcfb49c43f9244d04c70fbc40eb8 \ + --hash=sha256:bb273176be34a746bdac0b0d7e4e2c467323d13640b736c4c477881a3220a989 \ + --hash=sha256:c3c20f0ddeb6e29126d45f89206b8291352b8c5b44384e78a6499d68b52ae511 \ + --hash=sha256:c3e130fd0ec56cb76eb49ef52faead8ff09d13f4527e9b0c400307ff72b408e1 \ + --hash=sha256:c52d3f2f82b763a24ef52f5d24358553e8403ce05f893b5347098014f2d9eff2 \ + --hash=sha256:c6377e647bbfd0a0b159fe557f2c6c602c159fc752fa316572f012fc0bf67150 \ + --hash=sha256:c638144ce971df84650d3ed0096e2ae7af8e62ecbbb7b201c8935c370df00a2c \ + --hash=sha256:ce9845054c13696f7af7f2b353e6b4f676dab1b4b215d7fe5e05c6f8bb06f965 \ + --hash=sha256:cf258ede5bc22a45c8e726b29835b9303c285ab46fc7c3a4cc770736b5304c9f \ + --hash=sha256:d0a26ffe9d4dd35e4dfdd1e71f46401cff0181c75ac174711ccff0459135fa58 \ + --hash=sha256:d0b67d87bb45ed1cd020e8fbf2307d449b68abc45402fe1a4ac9e46c3c8b192b \ + --hash=sha256:d20277fd62e1b992a50c43f13fbe13277a31f8c9f70d59759c88f644d66c619f \ + --hash=sha256:d454b8749b4bd70dd0a79f428731ee263fa6995f83ccb8bada706e8d1d3ff89d \ + --hash=sha256:d4c7d1a051eeb39f5c9547e82ea27cbcc28338482242e3e0b7768033cb083821 \ + --hash=sha256:d72278a30111e5b5525c1dd96120d9e958464316f55adb030433ea905866f4de \ + --hash=sha256:d72a210824facfdaf8768cf2d7ca25a042c30320b3020de2fa04640920d4e121 \ + --hash=sha256:d807dc2051abe041b6649681dce568f8e10668e3c1c6543ebae58f2d7e617855 \ + --hash=sha256:dbe982f38565bb50cb7fb061ebf762c2f254ca3d8c20d4006878766e84266272 \ + --hash=sha256:dcedf0b42bcb4cfff4101d7771a10532415a6106062f005ab97d1d0ab5681c60 \ + --hash=sha256:deb62214c42a261cb3eb04d474f7155279c1a8a8c30ac89b7dcb1721d92c3c02 \ + --hash=sha256:def7400461c3a3f26e49078302e1c1b38f6752342c77e3cf72ce91ca69fb1bc1 \ + --hash=sha256:df3de6b7726b52966edf29663e57306b23ef775faf0ac01a3e9f4012a24a4140 \ + --hash=sha256:e1940dae14e715e2e02dfd5b0f64a52e8374a517a1e531ad9412319dc3ac7879 \ + --hash=sha256:e4df1e3b3bec320790f699890d41c59d250f6beda159ea3c44c3f5bac1976940 \ + --hash=sha256:e6900ecdd50ce0facf703f7a00df12374b74bbc8ad9fe0f6559947fb20f82364 \ + --hash=sha256:ea438162a9fcbee3ecf36c23e6c68237479f89f962f82dae83dc15feeceb37e4 \ + --hash=sha256:eb851b7df9dda52dc1415ebee12362047ce771fc36914586b2e9fcbd7d293b3e \ + --hash=sha256:ec31a99ca63bf3cd7f1a5ac9fe95c5e2d060d3c768a09bc1d16e235840861420 \ + --hash=sha256:f0475242f447cc6cb8a9dd486d68b2ef7fbee84427124c232bff5f63b1fe11e5 \ + --hash=sha256:f2fbf7db2012d4876fb0d66b5b9ba6591197b0f165db8d99371d976546472a24 \ + --hash=sha256:f60012a73aa396be721558caa3a6fd49b3dd0033d1675c6d59c4502e870fcf0c \ + --hash=sha256:f8e604fe73ba048c06085beaf51147eaec7df856824bfe7b98657cf436623daf \ + --hash=sha256:f90a4cd061914a60bd51c68bcb4357086991bd0bb93d8aa66a6da7701370708f \ + --hash=sha256:f918a1a130a6dfe1d7fe0f105064141342e7dd1611f2e6a21cd2f5c8cb1cfb3e \ + --hash=sha256:fa518bcd7600c584bf42e6617ee8132869e877db2f76bcdc281ec6a4113a53ab \ + --hash=sha256:faefcc78f53a88f3076b7f8be0a8f8d35133a3ecf7f3770895c25f8813460f08 \ + --hash=sha256:fcaeb7b57f1a1e071ebd748984359fef83ecb026325b9d4ca847c95bc7311c92 \ + --hash=sha256:fd2d84f40633bc475ef2d5490b9c19543fbf18596dcb1b291e3a12ea5d722f7a \ + --hash=sha256:fdfc3a892927458d98f3d55428ae46b921d1f7543b89382fdb483f5640daaec8 + # via jsonschema + # via referencing +ruamel-yaml==0.18.6 \ + --hash=sha256:57b53ba33def16c4f3d807c0ccbc00f8a6081827e81ba2491691b76882d0c636 \ + --hash=sha256:8b27e6a217e786c6fbe5634d8f3f11bc63e0f80f6a5890f28863d9c45aac311b + # via pydantic-yaml +ruamel-yaml-clib==0.2.8 ; python_version < '3.13' and platform_python_implementation == 'CPython' \ + --hash=sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d \ + --hash=sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001 \ + --hash=sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462 \ + --hash=sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9 \ + --hash=sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe \ + --hash=sha256:1758ce7d8e1a29d23de54a16ae867abd370f01b5a69e1a3ba75223eaa3ca1a1b \ + --hash=sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b \ + --hash=sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615 \ + --hash=sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62 \ + --hash=sha256:25ac8c08322002b06fa1d49d1646181f0b2c72f5cbc15a85e80b4c30a544bb15 \ + --hash=sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b \ + --hash=sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1 \ + --hash=sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9 \ + --hash=sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675 \ + --hash=sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899 \ + --hash=sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7 \ + --hash=sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7 \ + --hash=sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312 \ + --hash=sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa \ + --hash=sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91 \ + --hash=sha256:75e1ed13e1f9de23c5607fe6bd1aeaae21e523b32d83bb33918245361e9cc51b \ + --hash=sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6 \ + --hash=sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3 \ + --hash=sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334 \ + --hash=sha256:84b554931e932c46f94ab306913ad7e11bba988104c5cff26d90d03f68258cd5 \ + --hash=sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3 \ + --hash=sha256:955eae71ac26c1ab35924203fda6220f84dce57d6d7884f189743e2abe3a9fbe \ + --hash=sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c \ + --hash=sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed \ + --hash=sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337 \ + --hash=sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880 \ + --hash=sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f \ + --hash=sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d \ + --hash=sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248 \ + --hash=sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d \ + --hash=sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf \ + --hash=sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512 \ + --hash=sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069 \ + --hash=sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb \ + --hash=sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942 \ + --hash=sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d \ + --hash=sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31 \ + --hash=sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92 \ + --hash=sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5 \ + --hash=sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28 \ + --hash=sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d \ + --hash=sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1 \ + --hash=sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2 \ + --hash=sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875 \ + --hash=sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412 + # via ruamel-yaml +secretstorage==3.3.3 ; sys_platform == 'linux' \ + --hash=sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77 \ + --hash=sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99 + # via keyring +setuptools==72.1.0 \ + --hash=sha256:5a03e1860cf56bb6ef48ce186b0e557fdba433237481a9a625176c2831be15d1 \ + --hash=sha256:8d243eff56d095e5817f796ede6ae32941278f542e0f941867cc05ae52b162ec + # via lazr-restfulclient + # via lazr-uri + # via pygit2 + # via wadllib +six==1.16.0 \ + --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \ + --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 + # via lazr-restfulclient + # via macaroonbakery + # via pymacaroons + # via python-dateutil +snap-helpers==0.4.2 \ + --hash=sha256:04d0ebd167c943849c99ec68b87829fef4a915cbe9b02d8afc3891d889327327 \ + --hash=sha256:ef3b8621e331bb71afe27e54ef742a7dd2edd9e8026afac285beb42109c8b9a9 + # via charmcraft + # via craft-application +snowballstemmer==2.2.0 \ + --hash=sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1 \ + --hash=sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a + # via pydocstyle +sortedcontainers==2.4.0 \ + --hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \ + --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 + # via hypothesis +tabulate==0.9.0 \ + --hash=sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c \ + --hash=sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f + # via charmcraft +tomli==2.0.1 ; python_full_version <= '3.11.0a6' or python_version < '3.11' \ + --hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc \ + --hash=sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f + # via black + # via coverage + # via mypy + # via pytest +types-python-dateutil==2.9.0.20240316 \ + --hash=sha256:5d2f2e240b86905e40944dd787db6da9263f0deabef1076ddaed797351ec0202 \ + --hash=sha256:6b8cb66d960771ce5ff974e9dd45e38facb81718cc1e208b10b1baccbfdbee3b + # via charmcraft +types-requests==2.31.0.6 \ + --hash=sha256:a2db9cb228a81da8348b49ad6db3f5519452dd20a9c1e1a868c83c5fe88fd1a9 \ + --hash=sha256:cd74ce3b53c461f1228a9b783929ac73a666658f223e28ed29753771477b3bd0 + # via charmcraft +types-setuptools==71.1.0.20240806 \ + --hash=sha256:3bd8dd02039be0bb79ad880d8893b8eefcb022fabbeeb61245c61b20c9ab1ed0 \ + --hash=sha256:ae5e7b4d643ab9e99fc00ac00041804118cabe72a56183c30d524fb064897ad6 + # via charmcraft +types-tabulate==0.9.0.20240106 \ + --hash=sha256:0378b7b6fe0ccb4986299496d027a6d4c218298ecad67199bbd0e2d7e9d335a1 \ + --hash=sha256:c9b6db10dd7fcf55bd1712dd3537f86ddce72a08fd62bb1af4338c7096ce947e + # via charmcraft +types-urllib3==1.26.25.14 \ + --hash=sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f \ + --hash=sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e + # via charmcraft + # via types-requests +typing-extensions==4.12.2 \ + --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ + --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 + # via black + # via craft-application + # via craft-platforms + # via mypy + # via pydantic + # via pydantic-core + # via pydantic-yaml +urllib3==1.26.19 \ + --hash=sha256:37a0344459b199fce0e80b0d3569837ec6b6937435c5244e7fd73fa6006830f3 \ + --hash=sha256:3e3d753a8618b86d7de333b4223005f68720bcd6a7d2bcb9fbd2229ec7c1e429 + # via charmcraft + # via craft-parts + # via craft-providers + # via docker + # via requests + # via responses +wadllib==1.3.6 \ + --hash=sha256:acd9ad6a2c1007d34ca208e1da6341bbca1804c0e6850f954db04bdd7666c5fc + # via lazr-restfulclient +yamllint==1.35.1 \ + --hash=sha256:2e16e504bb129ff515b37823b472750b36b6de07963bd74b307341ef5ad8bdc3 \ + --hash=sha256:7a003809f88324fd2c877734f2d575ee7881dd9043360657cc8049c809eba6cd + # via charmcraft +zipp==3.19.2 \ + --hash=sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19 \ + --hash=sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c + # via importlib-metadata diff --git a/requirements-dev.txt b/requirements-dev.txt index dc3f3cb7d..4cf50da13 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,14 +4,14 @@ certifi==2024.7.4 cffi==1.16.0 charset-normalizer==3.3.2 coverage==7.6.0 -craft-application @ git+https://github.com/canonical/craft-application@feature/pydantic-2 -craft-archives @ git+https://github.com/canonical/craft-archives@feature/2.0 +craft-application==4.0.0 +craft-archives==2.0.0 craft-cli==2.6.0 -craft-grammar @ git+https://github.com/canonical/craft-grammar@feature/pydantic-2 -craft-parts @ git+https://github.com/canonical/craft-parts@feature/2.0 +craft-grammar==2.0.0 +craft-parts==2.0.0 craft-platforms==0.1.1 -craft-providers @ git+https://github.com/canonical/craft-providers@feature/2.0 -craft-store @ git+https://github.com/canonical/craft-store@feature/3.0 +craft-providers==2.0.0 +craft-store==3.0.0 cryptography==43.0.0 distro==1.9.0 docker==7.1.0 diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 000000000..a8912be4b --- /dev/null +++ b/requirements.lock @@ -0,0 +1,931 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: ["apt"] +# all-features: false +# with-sources: false +# generate-hashes: true +# universal: true + +-e file:. +annotated-types==0.7.0 \ + --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ + --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 + # via craft-platforms + # via pydantic +attrs==24.2.0 \ + --hash=sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346 \ + --hash=sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2 + # via jsonschema + # via referencing +boolean-py==4.0 \ + --hash=sha256:17b9a181630e43dde1851d42bef546d616d5d9b4480357514597e78b203d06e4 \ + --hash=sha256:2876f2051d7d6394a531d82dc6eb407faa0b01a0a0b3083817ccd7323b8d96bd + # via license-expression +certifi==2024.7.4 \ + --hash=sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b \ + --hash=sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90 + # via requests +cffi==1.17.0 \ + --hash=sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f \ + --hash=sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab \ + --hash=sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499 \ + --hash=sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058 \ + --hash=sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693 \ + --hash=sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb \ + --hash=sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377 \ + --hash=sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885 \ + --hash=sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2 \ + --hash=sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401 \ + --hash=sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4 \ + --hash=sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b \ + --hash=sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59 \ + --hash=sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f \ + --hash=sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c \ + --hash=sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555 \ + --hash=sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa \ + --hash=sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424 \ + --hash=sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb \ + --hash=sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2 \ + --hash=sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8 \ + --hash=sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e \ + --hash=sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9 \ + --hash=sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82 \ + --hash=sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828 \ + --hash=sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759 \ + --hash=sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc \ + --hash=sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118 \ + --hash=sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf \ + --hash=sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932 \ + --hash=sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a \ + --hash=sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29 \ + --hash=sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206 \ + --hash=sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2 \ + --hash=sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c \ + --hash=sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c \ + --hash=sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0 \ + --hash=sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a \ + --hash=sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195 \ + --hash=sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6 \ + --hash=sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9 \ + --hash=sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc \ + --hash=sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb \ + --hash=sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0 \ + --hash=sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7 \ + --hash=sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb \ + --hash=sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a \ + --hash=sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492 \ + --hash=sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720 \ + --hash=sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42 \ + --hash=sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7 \ + --hash=sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d \ + --hash=sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d \ + --hash=sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb \ + --hash=sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4 \ + --hash=sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2 \ + --hash=sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b \ + --hash=sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8 \ + --hash=sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e \ + --hash=sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204 \ + --hash=sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3 \ + --hash=sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150 \ + --hash=sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4 \ + --hash=sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76 \ + --hash=sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e \ + --hash=sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb \ + --hash=sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91 + # via cryptography + # via pygit2 + # via pynacl +charset-normalizer==3.3.2 \ + --hash=sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027 \ + --hash=sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087 \ + --hash=sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786 \ + --hash=sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8 \ + --hash=sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09 \ + --hash=sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185 \ + --hash=sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574 \ + --hash=sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e \ + --hash=sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519 \ + --hash=sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898 \ + --hash=sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269 \ + --hash=sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3 \ + --hash=sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f \ + --hash=sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6 \ + --hash=sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8 \ + --hash=sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a \ + --hash=sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73 \ + --hash=sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc \ + --hash=sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714 \ + --hash=sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2 \ + --hash=sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc \ + --hash=sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce \ + --hash=sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d \ + --hash=sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e \ + --hash=sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6 \ + --hash=sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269 \ + --hash=sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96 \ + --hash=sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d \ + --hash=sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a \ + --hash=sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4 \ + --hash=sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77 \ + --hash=sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d \ + --hash=sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0 \ + --hash=sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed \ + --hash=sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068 \ + --hash=sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac \ + --hash=sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25 \ + --hash=sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8 \ + --hash=sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab \ + --hash=sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26 \ + --hash=sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2 \ + --hash=sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db \ + --hash=sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f \ + --hash=sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5 \ + --hash=sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99 \ + --hash=sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c \ + --hash=sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d \ + --hash=sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811 \ + --hash=sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa \ + --hash=sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a \ + --hash=sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03 \ + --hash=sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b \ + --hash=sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04 \ + --hash=sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c \ + --hash=sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001 \ + --hash=sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458 \ + --hash=sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389 \ + --hash=sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99 \ + --hash=sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985 \ + --hash=sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537 \ + --hash=sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238 \ + --hash=sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f \ + --hash=sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d \ + --hash=sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796 \ + --hash=sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a \ + --hash=sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143 \ + --hash=sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8 \ + --hash=sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c \ + --hash=sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5 \ + --hash=sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5 \ + --hash=sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711 \ + --hash=sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4 \ + --hash=sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6 \ + --hash=sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c \ + --hash=sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7 \ + --hash=sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4 \ + --hash=sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b \ + --hash=sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae \ + --hash=sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12 \ + --hash=sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c \ + --hash=sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae \ + --hash=sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8 \ + --hash=sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887 \ + --hash=sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b \ + --hash=sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4 \ + --hash=sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f \ + --hash=sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5 \ + --hash=sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33 \ + --hash=sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519 \ + --hash=sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561 + # via requests +craft-application==4.0.0 \ + --hash=sha256:0ea7ae7c467f15a4bde3cc2840a96a5df9576dd2e86b5ca5b869ddf3bea440a5 \ + --hash=sha256:ea8e38e3393c05e20868bac80e817478f87d8f809aa6e5bbae1a8f98a74d67a8 + # via charmcraft +craft-archives==2.0.0 \ + --hash=sha256:3e0c8593ea0f77d32b115599fa29836fbd7a1df17e3c861739a39c80ab6363e2 \ + --hash=sha256:f0f69d4ee67dbeae3213036879510c5767205a67f0df33af8bc4b3dabc4e6e09 + # via craft-application +craft-cli==2.6.0 \ + --hash=sha256:138453abad5207ca872369496ffc7731a4124d6d36aed523c6f2f7f73924e814 \ + --hash=sha256:48208b40cad0800a633519a5c45e70c1b4801d296450d8f7cc01d1f5d548d972 + # via charmcraft + # via craft-application +craft-grammar==2.0.0 \ + --hash=sha256:3dd8c44aad6d36f4e06a56e6466903cc6996085d77901e7f895dd8da36f33cac \ + --hash=sha256:7b086496bd020e439f196cc688cdc7d847310cbfbdde06251bc5af34c9628865 + # via charmcraft + # via craft-application +craft-parts==2.0.0 \ + --hash=sha256:2c547f2b2fc9a0df8d9be7cca36506434f15df0856f9ea121b5a40061173338a \ + --hash=sha256:aa09b904c26baa0b2db2be97463a48eae091cf84500f04ff5574b55c6292d58f + # via charmcraft + # via craft-application +craft-platforms==0.1.1 \ + --hash=sha256:183d2df56bbe10225d97c6fac3e6644752233dcf5b2d010b00a5b126b8153361 \ + --hash=sha256:462da03aac2fadb656dbdeb4207de44a2208dcfbb9977f1433d59e59d6b74f42 + # via charmcraft +craft-providers==2.0.0 \ + --hash=sha256:55e9a97dbaabbad01111bbbc57c4e4cbc19a1e79127fcb559118ae7dd129e342 \ + --hash=sha256:6b49e22e5b6ee80de7d39cd4666077fab47cbed06d8fc6f3fe379d6e388f0105 + # via charmcraft + # via craft-application +craft-store==3.0.0 \ + --hash=sha256:0a0d63e5e1b36f65383aa6191b10f43ba094d3dce757c830ce162d64e6b5fe9c \ + --hash=sha256:f3644b1df09dccb26d5250fdbd91b3618abeede3958fa185c54fa6b5462863b3 + # via charmcraft +cryptography==43.0.0 ; sys_platform == 'linux' \ + --hash=sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709 \ + --hash=sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069 \ + --hash=sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2 \ + --hash=sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b \ + --hash=sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e \ + --hash=sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70 \ + --hash=sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778 \ + --hash=sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22 \ + --hash=sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895 \ + --hash=sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf \ + --hash=sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431 \ + --hash=sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f \ + --hash=sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947 \ + --hash=sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74 \ + --hash=sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc \ + --hash=sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66 \ + --hash=sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66 \ + --hash=sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf \ + --hash=sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f \ + --hash=sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5 \ + --hash=sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e \ + --hash=sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f \ + --hash=sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55 \ + --hash=sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1 \ + --hash=sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47 \ + --hash=sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5 \ + --hash=sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0 + # via secretstorage +distro==1.9.0 \ + --hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \ + --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2 + # via charmcraft + # via craft-archives + # via craft-platforms + # via lazr-restfulclient +docker==7.1.0 \ + --hash=sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c \ + --hash=sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0 + # via charmcraft +httplib2==0.22.0 \ + --hash=sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc \ + --hash=sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81 + # via launchpadlib + # via lazr-restfulclient +humanize==4.10.0 \ + --hash=sha256:06b6eb0293e4b85e8d385397c5868926820db32b9b654b932f57fa41c23c9978 \ + --hash=sha256:39e7ccb96923e732b5c2e27aeaa3b10a8dfeeba3eb965ba7b74a3eb0e30040a6 + # via charmcraft +idna==3.7 \ + --hash=sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc \ + --hash=sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0 + # via requests +importlib-metadata==8.2.0 \ + --hash=sha256:11901fa0c2f97919b288679932bb64febaeacf289d18ac84dd68cb2e74213369 \ + --hash=sha256:72e8d4399996132204f9a16dcc751af254a48f8d1b20b9ff0f98d4a8f901e73d + # via keyring + # via pydantic-yaml +jaraco-classes==3.4.0 \ + --hash=sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd \ + --hash=sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790 + # via keyring +jeepney==0.8.0 ; sys_platform == 'linux' \ + --hash=sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806 \ + --hash=sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755 + # via keyring + # via secretstorage +jinja2==3.1.4 \ + --hash=sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369 \ + --hash=sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d + # via charmcraft +jsonschema==4.23.0 \ + --hash=sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4 \ + --hash=sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566 + # via charmcraft +jsonschema-specifications==2023.12.1 \ + --hash=sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc \ + --hash=sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c + # via jsonschema +keyring==24.3.1 \ + --hash=sha256:c3327b6ffafc0e8befbdb597cacdb4928ffe5c1212f7645f186e6d9957a898db \ + --hash=sha256:df38a4d7419a6a60fea5cef1e45a948a3e8430dd12ad88b0f423c5c143906218 + # via craft-store +launchpadlib==2.0.0 \ + --hash=sha256:5d4a9095e91773a7565d4c159594ae30eca792fd5f9b89ded459d711484a96cb \ + --hash=sha256:bd158ec67e6a3e37d16aeb06b4dca4ef0da7ff1b684c51c896b03feef9aab875 + # via craft-archives +lazr-restfulclient==0.14.6 \ + --hash=sha256:43f12a1d3948463b1462038c47b429dcb5e42e0ba7f2e16511b02ba5d2adffdb \ + --hash=sha256:97e95b1d8f0ec7fed998b48aea773baf8dcab06cf78a4deb9a046af5cca0cea2 + # via craft-archives + # via launchpadlib +lazr-uri==1.0.6 \ + --hash=sha256:5026853fcbf6f91d5a6b11ea7860a641fe27b36d4172c731f4aa16b900cf8464 + # via craft-archives + # via launchpadlib + # via wadllib +license-expression==30.3.0 \ + --hash=sha256:1295406f736b4f395ff069aec1cebfad53c0fcb3cf57df0f5ec58fc7b905aea5 \ + --hash=sha256:ae0ba9a829d6909c785dc2f0131f13d10d68318e4a5f28af5ef152d6b52f9b41 + # via craft-application +macaroonbakery==1.3.4 \ + --hash=sha256:1e952a189f5c1e96ef82b081b2852c770d7daa20987e2088e762dd5689fb253b \ + --hash=sha256:41ca993a23e4f8ef2fe7723b5cd4a30c759735f1d5021e990770c8a0e0f33970 + # via craft-store +markupsafe==2.1.5 \ + --hash=sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf \ + --hash=sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff \ + --hash=sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f \ + --hash=sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3 \ + --hash=sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532 \ + --hash=sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f \ + --hash=sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617 \ + --hash=sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df \ + --hash=sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4 \ + --hash=sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906 \ + --hash=sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f \ + --hash=sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4 \ + --hash=sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8 \ + --hash=sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371 \ + --hash=sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2 \ + --hash=sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465 \ + --hash=sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52 \ + --hash=sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6 \ + --hash=sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169 \ + --hash=sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad \ + --hash=sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2 \ + --hash=sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0 \ + --hash=sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029 \ + --hash=sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f \ + --hash=sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a \ + --hash=sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced \ + --hash=sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5 \ + --hash=sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c \ + --hash=sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf \ + --hash=sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9 \ + --hash=sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb \ + --hash=sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad \ + --hash=sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3 \ + --hash=sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1 \ + --hash=sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46 \ + --hash=sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc \ + --hash=sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a \ + --hash=sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee \ + --hash=sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900 \ + --hash=sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5 \ + --hash=sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea \ + --hash=sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f \ + --hash=sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5 \ + --hash=sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e \ + --hash=sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a \ + --hash=sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f \ + --hash=sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50 \ + --hash=sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a \ + --hash=sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b \ + --hash=sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4 \ + --hash=sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff \ + --hash=sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2 \ + --hash=sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46 \ + --hash=sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b \ + --hash=sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf \ + --hash=sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5 \ + --hash=sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5 \ + --hash=sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab \ + --hash=sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd \ + --hash=sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68 + # via jinja2 +more-itertools==10.4.0 \ + --hash=sha256:0f7d9f83a0a8dcfa8a2694a770590d98a67ea943e3d9f5298309a484758c4e27 \ + --hash=sha256:fe0e63c4ab068eac62410ab05cccca2dc71ec44ba8ef29916a0090df061cf923 + # via jaraco-classes +oauthlib==3.2.2 \ + --hash=sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca \ + --hash=sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918 + # via lazr-restfulclient +overrides==7.7.0 \ + --hash=sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a \ + --hash=sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49 + # via craft-archives + # via craft-grammar + # via craft-parts + # via craft-store +packaging==24.1 \ + --hash=sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002 \ + --hash=sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124 + # via craft-providers +platformdirs==4.2.2 \ + --hash=sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee \ + --hash=sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3 + # via craft-application + # via craft-cli +protobuf==5.27.3 \ + --hash=sha256:043853dcb55cc262bf2e116215ad43fa0859caab79bb0b2d31b708f128ece035 \ + --hash=sha256:16ddf3f8c6c41e1e803da7abea17b1793a97ef079a912e42351eabb19b2cffe7 \ + --hash=sha256:68248c60d53f6168f565a8c76dc58ba4fa2ade31c2d1ebdae6d80f969cdc2d4f \ + --hash=sha256:82460903e640f2b7e34ee81a947fdaad89de796d324bcbc38ff5430bcdead82c \ + --hash=sha256:8572c6533e544ebf6899c360e91d6bcbbee2549251643d32c52cf8a5de295ba5 \ + --hash=sha256:a55c48f2a2092d8e213bd143474df33a6ae751b781dd1d1f4d953c128a415b25 \ + --hash=sha256:af7c0b7cfbbb649ad26132e53faa348580f844d9ca46fd3ec7ca48a1ea5db8a1 \ + --hash=sha256:b8a994fb3d1c11156e7d1e427186662b64694a62b55936b2b9348f0a7c6625ce \ + --hash=sha256:c2a105c24f08b1e53d6c7ffe69cb09d0031512f0b72f812dd4005b8112dbe91e \ + --hash=sha256:c84eee2c71ed83704f1afbf1a85c3171eab0fd1ade3b399b3fad0884cbcca8bf \ + --hash=sha256:dcb307cd4ef8fec0cf52cb9105a03d06fbb5275ce6d84a6ae33bc6cf84e0a07b + # via macaroonbakery +pycparser==2.22 \ + --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ + --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc + # via cffi +pydantic==2.8.2 \ + --hash=sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a \ + --hash=sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8 + # via charmcraft + # via craft-application + # via craft-archives + # via craft-grammar + # via craft-parts + # via craft-providers + # via craft-store + # via pydantic-yaml +pydantic-core==2.20.1 \ + --hash=sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d \ + --hash=sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f \ + --hash=sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686 \ + --hash=sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482 \ + --hash=sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006 \ + --hash=sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83 \ + --hash=sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6 \ + --hash=sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88 \ + --hash=sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86 \ + --hash=sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a \ + --hash=sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6 \ + --hash=sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a \ + --hash=sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6 \ + --hash=sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6 \ + --hash=sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43 \ + --hash=sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c \ + --hash=sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4 \ + --hash=sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e \ + --hash=sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203 \ + --hash=sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd \ + --hash=sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1 \ + --hash=sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24 \ + --hash=sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc \ + --hash=sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc \ + --hash=sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3 \ + --hash=sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598 \ + --hash=sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98 \ + --hash=sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331 \ + --hash=sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2 \ + --hash=sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a \ + --hash=sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6 \ + --hash=sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688 \ + --hash=sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91 \ + --hash=sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa \ + --hash=sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b \ + --hash=sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0 \ + --hash=sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840 \ + --hash=sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c \ + --hash=sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd \ + --hash=sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3 \ + --hash=sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231 \ + --hash=sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1 \ + --hash=sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953 \ + --hash=sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250 \ + --hash=sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a \ + --hash=sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2 \ + --hash=sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20 \ + --hash=sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434 \ + --hash=sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab \ + --hash=sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703 \ + --hash=sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a \ + --hash=sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2 \ + --hash=sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac \ + --hash=sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611 \ + --hash=sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121 \ + --hash=sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e \ + --hash=sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b \ + --hash=sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09 \ + --hash=sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906 \ + --hash=sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9 \ + --hash=sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7 \ + --hash=sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b \ + --hash=sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987 \ + --hash=sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c \ + --hash=sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b \ + --hash=sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e \ + --hash=sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237 \ + --hash=sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1 \ + --hash=sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19 \ + --hash=sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b \ + --hash=sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad \ + --hash=sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0 \ + --hash=sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94 \ + --hash=sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312 \ + --hash=sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f \ + --hash=sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669 \ + --hash=sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1 \ + --hash=sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe \ + --hash=sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99 \ + --hash=sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a \ + --hash=sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a \ + --hash=sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52 \ + --hash=sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c \ + --hash=sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad \ + --hash=sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1 \ + --hash=sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a \ + --hash=sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f \ + --hash=sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a \ + --hash=sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27 + # via pydantic +pydantic-yaml==1.3.0 \ + --hash=sha256:0684255a860522c9226d4eff5c0e8ba44339683b5c5fa79fac4470c0e3821911 \ + --hash=sha256:5671c9ef1731570aa2644432ae1e2dd34c406bd4a0a393df622f6b897a88df83 + # via craft-parts +pygit2==1.14.1 \ + --hash=sha256:0fff3d1aaf1d7372757888c4620347d6ad8b1b3a637b30a3abd156da7cf9476b \ + --hash=sha256:11058be23a5d6c1308303fd450d690eada117c564154634d81676e66530056be \ + --hash=sha256:141a1b37fc431d98b3de2f4651eab8b1b1b038cd50de42bfd1c8de057ec2284e \ + --hash=sha256:15db91695259f672f8be3080eb943889f7c8bdc5fbd8b89555e0c53ba2481f15 \ + --hash=sha256:230493d43945e10365070d349da206d39cc885ae8c52fdeca93942f36661dd93 \ + --hash=sha256:404d3d9bac22ff022157de3fbfd8997c108d86814ba88cbc8709c1c2daef833a \ + --hash=sha256:46ae2149851d5da2934e27c9ac45c375d04af1e549f8c4cbb4e9e4de5f43dc42 \ + --hash=sha256:67b6e5911101dc5ecb679bf241c0b9ee2099f4d76aa0ad66b326400cb4590afa \ + --hash=sha256:760614370fcce4e9606ff675d6fc11165badb59aaedc2ea6cb2e7ec1855616c2 \ + --hash=sha256:793f49ce66640d41d977e1337ddb5dec9b3b4ff818040d78d3ded052e1ea52e6 \ + --hash=sha256:7b6d1202d6a0c21281d2697321292aff9e2e2e195d6ce553efcdf86c2de2af1a \ + --hash=sha256:8589c8c0005b5ba373b3b101f903d4451338f3dfc09f8a38c76da6584fef84d0 \ + --hash=sha256:9d96e46b94dc706e6316e6cc293c0a0692e5b0811a6f8f2738728a4a68d7a827 \ + --hash=sha256:a03de11ba5205628996d867280e5181605009c966c801dbb94781bed55b740d7 \ + --hash=sha256:acb849cea89438192e78eea91a27fb9c54c7286a82aac65a3f746ea8c498fedb \ + --hash=sha256:acc7be8a439274fc6227e33b63b9ec83cd51fa210ab898eaadffb7bf930c0087 \ + --hash=sha256:bc3326a5ce891ef26429ae6d4290acb80ea0064947b4184a4c4940b4bd6ab4a3 \ + --hash=sha256:c22027f748d125698964ed696406075dac85f114e01d50547e67053c1bb03308 \ + --hash=sha256:e4f371c4b7ee86c0a751209fac7c941d1f6a3aca6af89ac09481469dbe0ea1cc \ + --hash=sha256:ea505739af41496b1d36c99bc15e2bd5631880059514458977c8931e27063a8d \ + --hash=sha256:ec5958571b82a6351785ca645e5394c31ae45eec5384b2fa9c4e05dde3597ad6 \ + --hash=sha256:ed16f2bc8ca9c42af8adb967c73227b1de973e9c4d717bd738fb2f177890ca2c \ + --hash=sha256:f2378f9a70cea27809a2c78b823e22659691a91db9d81b1f3a58d537067815ac \ + --hash=sha256:f35152b96a31ab705cdd63aef08fb199d6c1e87fc6fd45b1945f8cd040a43b7b \ + --hash=sha256:f5a87744e6c36f03fe488b975c73d3eaef22eadce433152516a2b8dbc4015233 + # via craft-application +pymacaroons==0.13.0 \ + --hash=sha256:1e6bba42a5f66c245adf38a5a4006a99dcc06a0703786ea636098667d42903b8 \ + --hash=sha256:3e14dff6a262fdbf1a15e769ce635a8aea72e6f8f91e408f9a97166c53b91907 + # via macaroonbakery +pynacl==1.5.0 \ + --hash=sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858 \ + --hash=sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d \ + --hash=sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93 \ + --hash=sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1 \ + --hash=sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92 \ + --hash=sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff \ + --hash=sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba \ + --hash=sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394 \ + --hash=sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b \ + --hash=sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543 + # via macaroonbakery + # via pymacaroons +pyparsing==3.1.2 \ + --hash=sha256:a1bac0ce561155ecc3ed78ca94d3c9378656ad4c94c1270de543f621420f94ad \ + --hash=sha256:f9db75911801ed778fe61bb643079ff86601aca99fcae6345aa67292038fb742 + # via httplib2 +pyrfc3339==1.1 \ + --hash=sha256:67196cb83b470709c580bb4738b83165e67c6cc60e1f2e4f286cfcb402a926f4 \ + --hash=sha256:81b8cbe1519cdb79bed04910dd6fa4e181faf8c88dff1e1b987b5f7ab23a5b1a + # via macaroonbakery +python-apt==2.7.7+ubuntu1 ; sys_platform == 'linux' + # via charmcraft +python-dateutil==2.9.0.post0 \ + --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 + # via charmcraft +pytz==2024.1 \ + --hash=sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812 \ + --hash=sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319 + # via pyrfc3339 +pywin32==306 ; sys_platform == 'win32' \ + --hash=sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d \ + --hash=sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65 \ + --hash=sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e \ + --hash=sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b \ + --hash=sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4 \ + --hash=sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040 \ + --hash=sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a \ + --hash=sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36 \ + --hash=sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8 \ + --hash=sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e \ + --hash=sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802 \ + --hash=sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a \ + --hash=sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407 \ + --hash=sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0 + # via craft-cli + # via docker +pywin32-ctypes==0.2.2 ; sys_platform == 'win32' \ + --hash=sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60 \ + --hash=sha256:bf490a1a709baf35d688fe0ecf980ed4de11d2b3e37b51e5442587a75d9957e7 + # via keyring +pyxdg==0.28 \ + --hash=sha256:3267bb3074e934df202af2ee0868575484108581e6f3cb006af1da35395e88b4 \ + --hash=sha256:bdaf595999a0178ecea4052b7f4195569c1ff4d344567bccdc12dfdf02d545ab + # via craft-parts + # via craft-store +pyyaml==6.0.2 \ + --hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ + --hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \ + --hash=sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086 \ + --hash=sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e \ + --hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 \ + --hash=sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5 \ + --hash=sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484 \ + --hash=sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee \ + --hash=sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5 \ + --hash=sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68 \ + --hash=sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a \ + --hash=sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf \ + --hash=sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99 \ + --hash=sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8 \ + --hash=sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85 \ + --hash=sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19 \ + --hash=sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc \ + --hash=sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a \ + --hash=sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1 \ + --hash=sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317 \ + --hash=sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c \ + --hash=sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631 \ + --hash=sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d \ + --hash=sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652 \ + --hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \ + --hash=sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e \ + --hash=sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b \ + --hash=sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8 \ + --hash=sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476 \ + --hash=sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706 \ + --hash=sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563 \ + --hash=sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237 \ + --hash=sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b \ + --hash=sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083 \ + --hash=sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180 \ + --hash=sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425 \ + --hash=sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e \ + --hash=sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f \ + --hash=sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725 \ + --hash=sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183 \ + --hash=sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab \ + --hash=sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774 \ + --hash=sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725 \ + --hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e \ + --hash=sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5 \ + --hash=sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d \ + --hash=sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290 \ + --hash=sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44 \ + --hash=sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed \ + --hash=sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4 \ + --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \ + --hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \ + --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 + # via charmcraft + # via craft-application + # via craft-cli + # via craft-parts + # via craft-providers + # via snap-helpers +referencing==0.35.1 \ + --hash=sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c \ + --hash=sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de + # via jsonschema + # via jsonschema-specifications +requests==2.31.0 \ + --hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f \ + --hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1 + # via charmcraft + # via craft-application + # via craft-parts + # via craft-providers + # via craft-store + # via docker + # via macaroonbakery + # via requests-toolbelt + # via requests-unixsocket +requests-toolbelt==1.0.0 \ + --hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \ + --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06 + # via charmcraft + # via craft-store +requests-unixsocket==0.3.0 \ + --hash=sha256:28304283ea9357d45fff58ad5b11e47708cfbf5806817aa59b2a363228ee971e \ + --hash=sha256:c685c680f0809e1b2955339b1e5afc3c0022b3066f4f7eb343f43a6065fc0e5d + # via charmcraft + # via craft-parts + # via craft-providers +rpds-py==0.20.0 \ + --hash=sha256:06db23d43f26478303e954c34c75182356ca9aa7797d22c5345b16871ab9c45c \ + --hash=sha256:0e13e6952ef264c40587d510ad676a988df19adea20444c2b295e536457bc585 \ + --hash=sha256:11ef6ce74616342888b69878d45e9f779b95d4bd48b382a229fe624a409b72c5 \ + --hash=sha256:1259c7b3705ac0a0bd38197565a5d603218591d3f6cee6e614e380b6ba61c6f6 \ + --hash=sha256:18d7585c463087bddcfa74c2ba267339f14f2515158ac4db30b1f9cbdb62c8ef \ + --hash=sha256:1e0f80b739e5a8f54837be5d5c924483996b603d5502bfff79bf33da06164ee2 \ + --hash=sha256:1e5f3cd7397c8f86c8cc72d5a791071431c108edd79872cdd96e00abd8497d29 \ + --hash=sha256:220002c1b846db9afd83371d08d239fdc865e8f8c5795bbaec20916a76db3318 \ + --hash=sha256:22e6c9976e38f4d8c4a63bd8a8edac5307dffd3ee7e6026d97f3cc3a2dc02a0b \ + --hash=sha256:238a2d5b1cad28cdc6ed15faf93a998336eb041c4e440dd7f902528b8891b399 \ + --hash=sha256:2580b0c34583b85efec8c5c5ec9edf2dfe817330cc882ee972ae650e7b5ef739 \ + --hash=sha256:28527c685f237c05445efec62426d285e47a58fb05ba0090a4340b73ecda6dee \ + --hash=sha256:2cf126d33a91ee6eedc7f3197b53e87a2acdac63602c0f03a02dd69e4b138174 \ + --hash=sha256:338ca4539aad4ce70a656e5187a3a31c5204f261aef9f6ab50e50bcdffaf050a \ + --hash=sha256:39ed0d010457a78f54090fafb5d108501b5aa5604cc22408fc1c0c77eac14344 \ + --hash=sha256:3ad0fda1635f8439cde85c700f964b23ed5fc2d28016b32b9ee5fe30da5c84e2 \ + --hash=sha256:3d2b1ad682a3dfda2a4e8ad8572f3100f95fad98cb99faf37ff0ddfe9cbf9d03 \ + --hash=sha256:3d61339e9f84a3f0767b1995adfb171a0d00a1185192718a17af6e124728e0f5 \ + --hash=sha256:3fde368e9140312b6e8b6c09fb9f8c8c2f00999d1823403ae90cc00480221b22 \ + --hash=sha256:40ce74fc86ee4645d0a225498d091d8bc61f39b709ebef8204cb8b5a464d3c0e \ + --hash=sha256:49a8063ea4296b3a7e81a5dfb8f7b2d73f0b1c20c2af401fb0cdf22e14711a96 \ + --hash=sha256:4a1f1d51eccb7e6c32ae89243cb352389228ea62f89cd80823ea7dd1b98e0b91 \ + --hash=sha256:4b16aa0107ecb512b568244ef461f27697164d9a68d8b35090e9b0c1c8b27752 \ + --hash=sha256:4f1ed4749a08379555cebf4650453f14452eaa9c43d0a95c49db50c18b7da075 \ + --hash=sha256:4fe84294c7019456e56d93e8ababdad5a329cd25975be749c3f5f558abb48253 \ + --hash=sha256:50eccbf054e62a7b2209b28dc7a22d6254860209d6753e6b78cfaeb0075d7bee \ + --hash=sha256:514b3293b64187172bc77c8fb0cdae26981618021053b30d8371c3a902d4d5ad \ + --hash=sha256:54b43a2b07db18314669092bb2de584524d1ef414588780261e31e85846c26a5 \ + --hash=sha256:55fea87029cded5df854ca7e192ec7bdb7ecd1d9a3f63d5c4eb09148acf4a7ce \ + --hash=sha256:569b3ea770c2717b730b61998b6c54996adee3cef69fc28d444f3e7920313cf7 \ + --hash=sha256:56e27147a5a4c2c21633ff8475d185734c0e4befd1c989b5b95a5d0db699b21b \ + --hash=sha256:57eb94a8c16ab08fef6404301c38318e2c5a32216bf5de453e2714c964c125c8 \ + --hash=sha256:5a35df9f5548fd79cb2f52d27182108c3e6641a4feb0f39067911bf2adaa3e57 \ + --hash=sha256:5a8c94dad2e45324fc74dce25e1645d4d14df9a4e54a30fa0ae8bad9a63928e3 \ + --hash=sha256:5b4f105deeffa28bbcdff6c49b34e74903139afa690e35d2d9e3c2c2fba18cec \ + --hash=sha256:5c1dc0f53856b9cc9a0ccca0a7cc61d3d20a7088201c0937f3f4048c1718a209 \ + --hash=sha256:614fdafe9f5f19c63ea02817fa4861c606a59a604a77c8cdef5aa01d28b97921 \ + --hash=sha256:617c7357272c67696fd052811e352ac54ed1d9b49ab370261a80d3b6ce385045 \ + --hash=sha256:65794e4048ee837494aea3c21a28ad5fc080994dfba5b036cf84de37f7ad5074 \ + --hash=sha256:6632f2d04f15d1bd6fe0eedd3b86d9061b836ddca4c03d5cf5c7e9e6b7c14580 \ + --hash=sha256:6c8ef2ebf76df43f5750b46851ed1cdf8f109d7787ca40035fe19fbdc1acc5a7 \ + --hash=sha256:758406267907b3781beee0f0edfe4a179fbd97c0be2e9b1154d7f0a1279cf8e5 \ + --hash=sha256:7e60cb630f674a31f0368ed32b2a6b4331b8350d67de53c0359992444b116dd3 \ + --hash=sha256:89c19a494bf3ad08c1da49445cc5d13d8fefc265f48ee7e7556839acdacf69d0 \ + --hash=sha256:8a86a9b96070674fc88b6f9f71a97d2c1d3e5165574615d1f9168ecba4cecb24 \ + --hash=sha256:8bc7690f7caee50b04a79bf017a8d020c1f48c2a1077ffe172abec59870f1139 \ + --hash=sha256:8d7919548df3f25374a1f5d01fbcd38dacab338ef5f33e044744b5c36729c8db \ + --hash=sha256:9426133526f69fcaba6e42146b4e12d6bc6c839b8b555097020e2b78ce908dcc \ + --hash=sha256:9824fb430c9cf9af743cf7aaf6707bf14323fb51ee74425c380f4c846ea70789 \ + --hash=sha256:9bb4a0d90fdb03437c109a17eade42dfbf6190408f29b2744114d11586611d6f \ + --hash=sha256:9bc2d153989e3216b0559251b0c260cfd168ec78b1fac33dd485750a228db5a2 \ + --hash=sha256:9d35cef91e59ebbeaa45214861874bc6f19eb35de96db73e467a8358d701a96c \ + --hash=sha256:a1862d2d7ce1674cffa6d186d53ca95c6e17ed2b06b3f4c476173565c862d232 \ + --hash=sha256:a84ab91cbe7aab97f7446652d0ed37d35b68a465aeef8fc41932a9d7eee2c1a6 \ + --hash=sha256:aa7f429242aae2947246587d2964fad750b79e8c233a2367f71b554e9447949c \ + --hash=sha256:aa9a0521aeca7d4941499a73ad7d4f8ffa3d1affc50b9ea11d992cd7eff18a29 \ + --hash=sha256:ac2f4f7a98934c2ed6505aead07b979e6f999389f16b714448fb39bbaa86a489 \ + --hash=sha256:ae94bd0b2f02c28e199e9bc51485d0c5601f58780636185660f86bf80c89af94 \ + --hash=sha256:af0fc424a5842a11e28956e69395fbbeab2c97c42253169d87e90aac2886d751 \ + --hash=sha256:b2a5db5397d82fa847e4c624b0c98fe59d2d9b7cf0ce6de09e4d2e80f8f5b3f2 \ + --hash=sha256:b4c29cbbba378759ac5786730d1c3cb4ec6f8ababf5c42a9ce303dc4b3d08cda \ + --hash=sha256:b74b25f024b421d5859d156750ea9a65651793d51b76a2e9238c05c9d5f203a9 \ + --hash=sha256:b7f19250ceef892adf27f0399b9e5afad019288e9be756d6919cb58892129f51 \ + --hash=sha256:b80d4a7900cf6b66bb9cee5c352b2d708e29e5a37fe9bf784fa97fc11504bf6c \ + --hash=sha256:b8c00a3b1e70c1d3891f0db1b05292747f0dbcfb49c43f9244d04c70fbc40eb8 \ + --hash=sha256:bb273176be34a746bdac0b0d7e4e2c467323d13640b736c4c477881a3220a989 \ + --hash=sha256:c3c20f0ddeb6e29126d45f89206b8291352b8c5b44384e78a6499d68b52ae511 \ + --hash=sha256:c3e130fd0ec56cb76eb49ef52faead8ff09d13f4527e9b0c400307ff72b408e1 \ + --hash=sha256:c52d3f2f82b763a24ef52f5d24358553e8403ce05f893b5347098014f2d9eff2 \ + --hash=sha256:c6377e647bbfd0a0b159fe557f2c6c602c159fc752fa316572f012fc0bf67150 \ + --hash=sha256:c638144ce971df84650d3ed0096e2ae7af8e62ecbbb7b201c8935c370df00a2c \ + --hash=sha256:ce9845054c13696f7af7f2b353e6b4f676dab1b4b215d7fe5e05c6f8bb06f965 \ + --hash=sha256:cf258ede5bc22a45c8e726b29835b9303c285ab46fc7c3a4cc770736b5304c9f \ + --hash=sha256:d0a26ffe9d4dd35e4dfdd1e71f46401cff0181c75ac174711ccff0459135fa58 \ + --hash=sha256:d0b67d87bb45ed1cd020e8fbf2307d449b68abc45402fe1a4ac9e46c3c8b192b \ + --hash=sha256:d20277fd62e1b992a50c43f13fbe13277a31f8c9f70d59759c88f644d66c619f \ + --hash=sha256:d454b8749b4bd70dd0a79f428731ee263fa6995f83ccb8bada706e8d1d3ff89d \ + --hash=sha256:d4c7d1a051eeb39f5c9547e82ea27cbcc28338482242e3e0b7768033cb083821 \ + --hash=sha256:d72278a30111e5b5525c1dd96120d9e958464316f55adb030433ea905866f4de \ + --hash=sha256:d72a210824facfdaf8768cf2d7ca25a042c30320b3020de2fa04640920d4e121 \ + --hash=sha256:d807dc2051abe041b6649681dce568f8e10668e3c1c6543ebae58f2d7e617855 \ + --hash=sha256:dbe982f38565bb50cb7fb061ebf762c2f254ca3d8c20d4006878766e84266272 \ + --hash=sha256:dcedf0b42bcb4cfff4101d7771a10532415a6106062f005ab97d1d0ab5681c60 \ + --hash=sha256:deb62214c42a261cb3eb04d474f7155279c1a8a8c30ac89b7dcb1721d92c3c02 \ + --hash=sha256:def7400461c3a3f26e49078302e1c1b38f6752342c77e3cf72ce91ca69fb1bc1 \ + --hash=sha256:df3de6b7726b52966edf29663e57306b23ef775faf0ac01a3e9f4012a24a4140 \ + --hash=sha256:e1940dae14e715e2e02dfd5b0f64a52e8374a517a1e531ad9412319dc3ac7879 \ + --hash=sha256:e4df1e3b3bec320790f699890d41c59d250f6beda159ea3c44c3f5bac1976940 \ + --hash=sha256:e6900ecdd50ce0facf703f7a00df12374b74bbc8ad9fe0f6559947fb20f82364 \ + --hash=sha256:ea438162a9fcbee3ecf36c23e6c68237479f89f962f82dae83dc15feeceb37e4 \ + --hash=sha256:eb851b7df9dda52dc1415ebee12362047ce771fc36914586b2e9fcbd7d293b3e \ + --hash=sha256:ec31a99ca63bf3cd7f1a5ac9fe95c5e2d060d3c768a09bc1d16e235840861420 \ + --hash=sha256:f0475242f447cc6cb8a9dd486d68b2ef7fbee84427124c232bff5f63b1fe11e5 \ + --hash=sha256:f2fbf7db2012d4876fb0d66b5b9ba6591197b0f165db8d99371d976546472a24 \ + --hash=sha256:f60012a73aa396be721558caa3a6fd49b3dd0033d1675c6d59c4502e870fcf0c \ + --hash=sha256:f8e604fe73ba048c06085beaf51147eaec7df856824bfe7b98657cf436623daf \ + --hash=sha256:f90a4cd061914a60bd51c68bcb4357086991bd0bb93d8aa66a6da7701370708f \ + --hash=sha256:f918a1a130a6dfe1d7fe0f105064141342e7dd1611f2e6a21cd2f5c8cb1cfb3e \ + --hash=sha256:fa518bcd7600c584bf42e6617ee8132869e877db2f76bcdc281ec6a4113a53ab \ + --hash=sha256:faefcc78f53a88f3076b7f8be0a8f8d35133a3ecf7f3770895c25f8813460f08 \ + --hash=sha256:fcaeb7b57f1a1e071ebd748984359fef83ecb026325b9d4ca847c95bc7311c92 \ + --hash=sha256:fd2d84f40633bc475ef2d5490b9c19543fbf18596dcb1b291e3a12ea5d722f7a \ + --hash=sha256:fdfc3a892927458d98f3d55428ae46b921d1f7543b89382fdb483f5640daaec8 + # via jsonschema + # via referencing +ruamel-yaml==0.18.6 \ + --hash=sha256:57b53ba33def16c4f3d807c0ccbc00f8a6081827e81ba2491691b76882d0c636 \ + --hash=sha256:8b27e6a217e786c6fbe5634d8f3f11bc63e0f80f6a5890f28863d9c45aac311b + # via pydantic-yaml +ruamel-yaml-clib==0.2.8 ; python_version < '3.13' and platform_python_implementation == 'CPython' \ + --hash=sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d \ + --hash=sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001 \ + --hash=sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462 \ + --hash=sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9 \ + --hash=sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe \ + --hash=sha256:1758ce7d8e1a29d23de54a16ae867abd370f01b5a69e1a3ba75223eaa3ca1a1b \ + --hash=sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b \ + --hash=sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615 \ + --hash=sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62 \ + --hash=sha256:25ac8c08322002b06fa1d49d1646181f0b2c72f5cbc15a85e80b4c30a544bb15 \ + --hash=sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b \ + --hash=sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1 \ + --hash=sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9 \ + --hash=sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675 \ + --hash=sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899 \ + --hash=sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7 \ + --hash=sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7 \ + --hash=sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312 \ + --hash=sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa \ + --hash=sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91 \ + --hash=sha256:75e1ed13e1f9de23c5607fe6bd1aeaae21e523b32d83bb33918245361e9cc51b \ + --hash=sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6 \ + --hash=sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3 \ + --hash=sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334 \ + --hash=sha256:84b554931e932c46f94ab306913ad7e11bba988104c5cff26d90d03f68258cd5 \ + --hash=sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3 \ + --hash=sha256:955eae71ac26c1ab35924203fda6220f84dce57d6d7884f189743e2abe3a9fbe \ + --hash=sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c \ + --hash=sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed \ + --hash=sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337 \ + --hash=sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880 \ + --hash=sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f \ + --hash=sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d \ + --hash=sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248 \ + --hash=sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d \ + --hash=sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf \ + --hash=sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512 \ + --hash=sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069 \ + --hash=sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb \ + --hash=sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942 \ + --hash=sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d \ + --hash=sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31 \ + --hash=sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92 \ + --hash=sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5 \ + --hash=sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28 \ + --hash=sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d \ + --hash=sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1 \ + --hash=sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2 \ + --hash=sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875 \ + --hash=sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412 + # via ruamel-yaml +secretstorage==3.3.3 ; sys_platform == 'linux' \ + --hash=sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77 \ + --hash=sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99 + # via keyring +setuptools==72.1.0 \ + --hash=sha256:5a03e1860cf56bb6ef48ce186b0e557fdba433237481a9a625176c2831be15d1 \ + --hash=sha256:8d243eff56d095e5817f796ede6ae32941278f542e0f941867cc05ae52b162ec + # via lazr-restfulclient + # via lazr-uri + # via pygit2 + # via wadllib +six==1.16.0 \ + --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \ + --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 + # via lazr-restfulclient + # via macaroonbakery + # via pymacaroons + # via python-dateutil +snap-helpers==0.4.2 \ + --hash=sha256:04d0ebd167c943849c99ec68b87829fef4a915cbe9b02d8afc3891d889327327 \ + --hash=sha256:ef3b8621e331bb71afe27e54ef742a7dd2edd9e8026afac285beb42109c8b9a9 + # via charmcraft + # via craft-application +tabulate==0.9.0 \ + --hash=sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c \ + --hash=sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f + # via charmcraft +typing-extensions==4.12.2 \ + --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ + --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 + # via craft-application + # via craft-platforms + # via pydantic + # via pydantic-core + # via pydantic-yaml +urllib3==1.26.19 \ + --hash=sha256:37a0344459b199fce0e80b0d3569837ec6b6937435c5244e7fd73fa6006830f3 \ + --hash=sha256:3e3d753a8618b86d7de333b4223005f68720bcd6a7d2bcb9fbd2229ec7c1e429 + # via charmcraft + # via craft-parts + # via craft-providers + # via docker + # via requests +wadllib==1.3.6 \ + --hash=sha256:acd9ad6a2c1007d34ca208e1da6341bbca1804c0e6850f954db04bdd7666c5fc + # via lazr-restfulclient +zipp==3.19.2 \ + --hash=sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19 \ + --hash=sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c + # via importlib-metadata diff --git a/requirements.txt b/requirements.txt index 3de08f47b..cd0f697ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,14 +3,14 @@ attrs==23.2.0 certifi==2024.7.4 cffi==1.16.0 charset-normalizer==3.3.2 -craft-application @ git+https://github.com/canonical/craft-application@feature/pydantic-2 -craft-archives @ git+https://github.com/canonical/craft-archives@feature/2.0 +craft-application==4.0.0 +craft-archives==2.0.0 craft-cli==2.6.0 -craft-grammar @ git+https://github.com/canonical/craft-grammar@feature/pydantic-2 -craft-parts @ git+https://github.com/canonical/craft-parts@feature/2.0 +craft-grammar==2.0.0 +craft-parts==2.0.0 craft-platforms==0.1.1 -craft-providers @ git+https://github.com/canonical/craft-providers@feature/2.0 -craft-store @ git+https://github.com/canonical/craft-store@feature/3.0 +craft-providers==2.0.0 +craft-store==3.0.0 cryptography==43.0.0 distro==1.9.0 docker==7.1.0 From f0d27868174e633202f1055a28c5c4c942c2047c Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Fri, 9 Aug 2024 18:12:02 -0400 Subject: [PATCH 54/59] style(lint): fix linting issues --- charmcraft/services/provider.py | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/charmcraft/services/provider.py b/charmcraft/services/provider.py index 910c4b4e6..01db7069b 100644 --- a/charmcraft/services/provider.py +++ b/charmcraft/services/provider.py @@ -42,7 +42,7 @@ def setup(self) -> None: def get_base( self, - base_name: bases.BaseName | tuple[str, str], + base_name: bases.BaseName, *, instance_name: str, **kwargs: bool | str | None | pathlib.Path, diff --git a/pyproject.toml b/pyproject.toml index 400ac282e..ae7f55aec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ types = [ "mypy[reports]~=1.5", "pyright==1.1.366", "types-python-dateutil", + "types-PyYAML", "types-requests<2.31.0.20240312", # Frozen until we can get urllib3 v2 "types-setuptools", "types-tabulate", From b74fcd009499a3bc2cee089fac6568250b647e95 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Fri, 9 Aug 2024 18:17:03 -0400 Subject: [PATCH 55/59] ci: adjust tests for windows --- .github/workflows/tests.yaml | 2 +- requirements-jammy.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4e67ce65a..28feb13b3 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -85,7 +85,7 @@ jobs: brew install skopeo - name: Configure environment run: | - python -m pip install tox + pipx install tox tox run --colored yes -m tests --notest - name: Run tests shell: bash diff --git a/requirements-jammy.txt b/requirements-jammy.txt index de56fc318..6f5010229 100644 --- a/requirements-jammy.txt +++ b/requirements-jammy.txt @@ -1 +1 @@ -python-apt@https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/python-apt/2.4.0ubuntu3/python-apt_2.4.0ubuntu3.tar.xz; sys_platform == "linux" +python-apt @ https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/python-apt/2.4.0ubuntu3/python-apt_2.4.0ubuntu3.tar.xz ; sys_platform == "linux" From 4249c32f95df774d7bea223733d24c49c68fbfa9 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Fri, 9 Aug 2024 18:20:45 -0400 Subject: [PATCH 56/59] ci: more fixes --- .github/workflows/tests.yaml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 28feb13b3..1544ff2a8 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -47,11 +47,19 @@ jobs: matrix: os: [ubuntu-22.04, macos-12, macos-13, windows-2019, windows-2022] include: - - os: [windows-2019, windows-2022] + - os: windows-2019 python-version: | 3.11 3.12 - - os: [macos-12, macos-13] + - os: windows-2022 + python-version: | + 3.11 + 3.12 + - os: macos-12 + python_version: | + 3.10 + 3.12 + - os: macos-13 python_version: | 3.10 3.12 @@ -72,8 +80,6 @@ jobs: run: | sudo apt update sudo apt install -y python3-pip python3-setuptools python3-wheel python3-venv libapt-pkg-dev - export $(cat /etc/os-release | grep VERSION_CODENAME) - pip install -U -r "requirements-${VERSION_CODENAME}.txt" - name: Install external dependencies with homebrew # This is only necessary for Linux until skopeo >= 1.11 is in repos. # Once we're running on Noble, we can get skopeo from apt. From a9a3958aa1ba1a126b6e835c58bc9a948a9d1ece Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Fri, 9 Aug 2024 18:21:34 -0400 Subject: [PATCH 57/59] ci: enable noble --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 1544ff2a8..5532bf154 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -45,7 +45,7 @@ jobs: run-tests: strategy: matrix: - os: [ubuntu-22.04, macos-12, macos-13, windows-2019, windows-2022] + os: [ubuntu-22.04, ubuntu-24.04, macos-12, macos-13, windows-2019, windows-2022] include: - os: windows-2019 python-version: | From 52cd8cbd3895c0aad0b90e264175c2b6323e2bd0 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Fri, 9 Aug 2024 18:29:15 -0400 Subject: [PATCH 58/59] tests: expect failures on Windows with Python < 3.11 --- tests/integration/commands/test_expand_extensions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/integration/commands/test_expand_extensions.py b/tests/integration/commands/test_expand_extensions.py index fe40bd380..4fbf54d04 100644 --- a/tests/integration/commands/test_expand_extensions.py +++ b/tests/integration/commands/test_expand_extensions.py @@ -13,6 +13,7 @@ # limitations under the License. # # For further info, check https://github.com/canonical/charmcraft +import platform import sys from textwrap import dedent from typing import Any @@ -41,6 +42,10 @@ def fake_extensions(stub_extensions): extensions.register(TestExtension.name, TestExtension) +@pytest.mark.xfail( + platform.system() == "Windows" and sys.version_info < (3, 11), + reason="'os' module doesn't have EX_OK on Windows until 3.11", +) @pytest.mark.parametrize( ("charmcraft_yaml", "expected"), [ From 2a0eb973dc7d4aeb04454da32d37d6160f010b78 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Thu, 15 Aug 2024 15:50:10 -0400 Subject: [PATCH 59/59] fix(yaml): correctly add default parts (#1815) The pydantic 2 change caused some weirdness in adding default parts, so this fixes it. After this, pydantic 2 should be ready to merge to main. --- .github/workflows/tests.yaml | 29 ++++++++------- charmcraft/parts/__init__.py | 2 +- charmcraft/preprocess.py | 2 +- .../actions-included/expected.yaml | 5 --- .../actions-separate/expected.yaml | 5 --- .../sample-charms/basic-bases/expected.yaml | 5 --- .../basic-platforms/expected.yaml | 5 --- .../config-included/expected.yaml | 5 --- .../config-separate/expected.yaml | 5 --- tests/test_parts.py | 36 +++++++++++++------ tests/unit/models/test_project.py | 17 +++------ .../models/valid_charms_yaml/basic-bases.yaml | 5 ++- tests/unit/test_parts.py | 13 ++----- tests/unit/test_preprocess.py | 8 +++-- 14 files changed, 61 insertions(+), 81 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 5532bf154..caaca6885 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -80,15 +80,28 @@ jobs: run: | sudo apt update sudo apt install -y python3-pip python3-setuptools python3-wheel python3-venv libapt-pkg-dev - - name: Install external dependencies with homebrew + - name: Install skopeo (mac) # This is only necessary for Linux until skopeo >= 1.11 is in repos. # Once we're running on Noble, we can get skopeo from apt. - if: ${{ runner.os == 'Linux' || runner.os == 'macOS' }} + if: ${{ runner.os == 'macOS' }} run: | - if [[ $(uname --kernel-name) == "Linux" ]]; then + brew install skopeo + - name: Install skopeo (Linux) + if: ${{ runner.os == 'Linux' }} + run: | + if [[ $(cat /etc/os-release | grep VERSION_CODENAME) == 'VERSION_CODENAME=jammy' ]]; then eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" + brew install skopeo + sudo rm -f /bin/skopeo + sudo ln -s $(which skopeo) /bin/skopeo + else + sudo apt install skopeo fi - brew install skopeo + # Allow skopeo to access the contents of /run/containers + sudo chmod 777 /run/containers + # Add an xdg runtime dir for skopeo to look into for an auth.json file + sudo mkdir -p /run/user/$(id -u) + sudo chown $USER /run/user/$(id -u) - name: Configure environment run: | pipx install tox @@ -97,14 +110,6 @@ jobs: shell: bash run: | if [[ $(uname --kernel-name) == "Linux" ]]; then - # Ensure the version of skopeo comes from homebrew - # This is only necessary until we move to noble. - eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" - # Allow skopeo to access the contents of /run/containers - sudo chmod 777 /run/containers - # Add an xdg runtime dir for skopeo to look into for an auth.json file - sudo mkdir -p /run/user/$(id -u) - sudo chown $USER /run/user/$(id -u) export XDG_RUNTIME_DIR=/run/user/$(id -u) fi tox run --skip-pkg-install --no-list-dependencies --result-json results/tox-${{ matrix.platform }}.json --colored yes -m tests diff --git a/charmcraft/parts/__init__.py b/charmcraft/parts/__init__.py index d7883ab26..eec52a538 100644 --- a/charmcraft/parts/__init__.py +++ b/charmcraft/parts/__init__.py @@ -71,7 +71,7 @@ def process_part_config(data: dict[str, Any]) -> dict[str, Any]: # get plugin properties data if it's model based (otherwise it's empty), and # update with the received config if isinstance(plugin_properties, plugins.PluginProperties): - full_config = plugin_properties.model_dump(by_alias=True) + full_config = plugin_properties.model_dump(by_alias=True, exclude_unset=True) else: full_config = {} full_config.update(data) diff --git a/charmcraft/preprocess.py b/charmcraft/preprocess.py index 0eae25022..f114a87e3 100644 --- a/charmcraft/preprocess.py +++ b/charmcraft/preprocess.py @@ -32,7 +32,7 @@ def add_default_parts(yaml_data: dict[str, Any]) -> None: :param yaml_data: The raw YAML dictionary of the project. :returns: The same dictionary passed in, with necessary mutations. """ - if "parts" in yaml_data: # Only operate if there isn't a parts key + if yaml_data.get("parts"): # Only operate if there aren't any parts. return if yaml_data.get("type") == "bundle": diff --git a/tests/integration/sample-charms/actions-included/expected.yaml b/tests/integration/sample-charms/actions-included/expected.yaml index 39530bd97..227757695 100644 --- a/tests/integration/sample-charms/actions-included/expected.yaml +++ b/tests/integration/sample-charms/actions-included/expected.yaml @@ -13,11 +13,6 @@ parts: charm: plugin: charm source: . - charm-entrypoint: src/charm.py - charm-binary-python-packages: [] - charm-python-packages: [] - charm-requirements: [] - charm-strict-dependencies: false type: charm actions: pause: diff --git a/tests/integration/sample-charms/actions-separate/expected.yaml b/tests/integration/sample-charms/actions-separate/expected.yaml index 39530bd97..227757695 100644 --- a/tests/integration/sample-charms/actions-separate/expected.yaml +++ b/tests/integration/sample-charms/actions-separate/expected.yaml @@ -13,11 +13,6 @@ parts: charm: plugin: charm source: . - charm-entrypoint: src/charm.py - charm-binary-python-packages: [] - charm-python-packages: [] - charm-requirements: [] - charm-strict-dependencies: false type: charm actions: pause: diff --git a/tests/integration/sample-charms/basic-bases/expected.yaml b/tests/integration/sample-charms/basic-bases/expected.yaml index b1c449ee9..99a5276d2 100644 --- a/tests/integration/sample-charms/basic-bases/expected.yaml +++ b/tests/integration/sample-charms/basic-bases/expected.yaml @@ -6,11 +6,6 @@ parts: charm: plugin: charm source: . - charm-entrypoint: src/charm.py - charm-binary-python-packages: [] - charm-python-packages: [] - charm-requirements: [] - charm-strict-dependencies: false type: charm bases: - build-on: diff --git a/tests/integration/sample-charms/basic-platforms/expected.yaml b/tests/integration/sample-charms/basic-platforms/expected.yaml index 81b6692d4..e2a2c6414 100644 --- a/tests/integration/sample-charms/basic-platforms/expected.yaml +++ b/tests/integration/sample-charms/basic-platforms/expected.yaml @@ -13,9 +13,4 @@ parts: charm: plugin: charm source: . - charm-entrypoint: src/charm.py - charm-binary-python-packages: [] - charm-python-packages: [] - charm-requirements: [] - charm-strict-dependencies: false type: charm diff --git a/tests/integration/sample-charms/config-included/expected.yaml b/tests/integration/sample-charms/config-included/expected.yaml index 8f3f51340..b6b4f991f 100644 --- a/tests/integration/sample-charms/config-included/expected.yaml +++ b/tests/integration/sample-charms/config-included/expected.yaml @@ -13,11 +13,6 @@ parts: charm: plugin: charm source: . - charm-entrypoint: src/charm.py - charm-binary-python-packages: [] - charm-python-packages: [] - charm-requirements: [] - charm-strict-dependencies: false type: charm config: options: diff --git a/tests/integration/sample-charms/config-separate/expected.yaml b/tests/integration/sample-charms/config-separate/expected.yaml index 8f3f51340..b6b4f991f 100644 --- a/tests/integration/sample-charms/config-separate/expected.yaml +++ b/tests/integration/sample-charms/config-separate/expected.yaml @@ -13,11 +13,6 @@ parts: charm: plugin: charm source: . - charm-entrypoint: src/charm.py - charm-binary-python-packages: [] - charm-python-packages: [] - charm-requirements: [] - charm-strict-dependencies: false type: charm config: options: diff --git a/tests/test_parts.py b/tests/test_parts.py index d5b27e3ac..55fe754fe 100644 --- a/tests/test_parts.py +++ b/tests/test_parts.py @@ -26,21 +26,35 @@ @pytest.mark.usefixtures("new_path") -def test_partconfig_happy_validation_and_completion(): - data = { +@pytest.mark.parametrize( + "binary_packages", + [ + {}, + {"charm-binary-python-packages": ["pydantic-core"]}, + ], +) +@pytest.mark.parametrize("packages", [{}, {"charm-python-packages": ["pytest"]}]) +@pytest.mark.parametrize("reqs", [{}, {"charm-requirements": ["requirements.lock"]}]) +@pytest.mark.parametrize("strict_deps", [{}, {"charm-strict-dependencies": False}]) +@pytest.mark.parametrize("entrypoint", [{}, {"charm-entrypoint": "my_charm.py"}]) +def test_partconfig_happy_validation_and_completion( + binary_packages: dict[str, str], + packages: dict[str, str], + reqs: dict[str, str], + strict_deps: dict[str, bool], + entrypoint: dict[str, str], +): + data: dict[str, str | bool] = { "plugin": "charm", "source": ".", } + data.update(binary_packages) + data.update(packages) + data.update(strict_deps) + data.update(entrypoint) + completed = parts.process_part_config(data) - assert completed == { - "plugin": "charm", - "source": ".", - "charm-binary-python-packages": [], - "charm-entrypoint": "src/charm.py", - "charm-python-packages": [], - "charm-requirements": [], - "charm-strict-dependencies": False, - } + assert completed == data def test_partconfig_no_plugin(): diff --git a/tests/unit/models/test_project.py b/tests/unit/models/test_project.py index c4aed3189..400116033 100644 --- a/tests/unit/models/test_project.py +++ b/tests/unit/models/test_project.py @@ -567,28 +567,28 @@ def test_unmarshal_invalid_type(type_): None, None, None, - {"parts": BASIC_CHARM_PARTS_EXPANDED}, + {"parts": BASIC_CHARM_PARTS}, ), ( MINIMAL_CHARMCRAFT_YAML, SIMPLE_METADATA_YAML, None, None, - {"parts": BASIC_CHARM_PARTS_EXPANDED}, + {"parts": BASIC_CHARM_PARTS}, ), ( SIMPLE_CHARMCRAFT_YAML, None, SIMPLE_CONFIG_YAML, None, - {"config": SIMPLE_CONFIG_DICT, "parts": BASIC_CHARM_PARTS_EXPANDED}, + {"config": SIMPLE_CONFIG_DICT, "parts": BASIC_CHARM_PARTS}, ), ( SIMPLE_CHARMCRAFT_YAML, None, None, SIMPLE_ACTIONS_YAML, - {"actions": SIMPLE_ACTIONS_DICT, "parts": BASIC_CHARM_PARTS_EXPANDED}, + {"actions": SIMPLE_ACTIONS_DICT, "parts": BASIC_CHARM_PARTS}, ), ( MINIMAL_CHARMCRAFT_YAML, @@ -598,7 +598,7 @@ def test_unmarshal_invalid_type(type_): { "actions": SIMPLE_ACTIONS_DICT, "config": SIMPLE_CONFIG_DICT, - "parts": BASIC_CHARM_PARTS_EXPANDED, + "parts": BASIC_CHARM_PARTS, }, ), pytest.param( @@ -621,18 +621,11 @@ def test_unmarshal_invalid_type(type_): { "parts": { "charm": { - "charm-binary-python-packages": [], - "charm-entrypoint": "src/charm.py", - "charm-python-packages": [], - "charm-requirements": [], - "charm-strict-dependencies": False, "plugin": "charm", "source": ".", }, "reactive": { "plugin": "reactive", - "reactive-charm-build-arguments": [], - "source": ".", }, "bundle": { "plugin": "bundle", diff --git a/tests/unit/models/valid_charms_yaml/basic-bases.yaml b/tests/unit/models/valid_charms_yaml/basic-bases.yaml index 225fef360..bfe494e58 100644 --- a/tests/unit/models/valid_charms_yaml/basic-bases.yaml +++ b/tests/unit/models/valid_charms_yaml/basic-bases.yaml @@ -12,4 +12,7 @@ bases: - name: ubuntu channel: '22.04' -parts: {} +parts: + charm: + source: . + plugin: charm diff --git a/tests/unit/test_parts.py b/tests/unit/test_parts.py index d4e70dc0f..d086bd8c9 100644 --- a/tests/unit/test_parts.py +++ b/tests/unit/test_parts.py @@ -19,15 +19,6 @@ from charmcraft import parts -FULLY_DEFINED_STRICT_CHARM = { - "source": ".", - "plugin": "charm", - "charm-strict-dependencies": True, - "charm-binary-python-packages": [], - "charm-python-packages": [], - "charm-requirements": [], - "charm-entrypoint": "src/charm.py", -} MINIMAL_STRICT_CHARM = { "source": ".", "plugin": "charm", @@ -38,7 +29,7 @@ @pytest.mark.parametrize( ("part_config", "expected"), [ - ({}, {"charm-requirements": ["requirements.txt"]}), + ({}, {}), ( {"charm-requirements": ["requirements.txt"]}, {"charm-requirements": ["requirements.txt"]}, @@ -55,7 +46,7 @@ def test_partconfig_strict_dependencies_success(fs: FakeFilesystem, part_config, fs.create_file(file, contents="ops~=2.5") part_config.update(MINIMAL_STRICT_CHARM) - real_expected = FULLY_DEFINED_STRICT_CHARM.copy() + real_expected = MINIMAL_STRICT_CHARM.copy() real_expected.update(expected) actual = parts.process_part_config(part_config) diff --git a/tests/unit/test_preprocess.py b/tests/unit/test_preprocess.py index bd7c49f42..137d1f82a 100644 --- a/tests/unit/test_preprocess.py +++ b/tests/unit/test_preprocess.py @@ -32,8 +32,12 @@ pytest.param({}, {}, id="no-type"), pytest.param({"type": "bundle"}, BASIC_BUNDLE, id="empty-bundle"), pytest.param(BASIC_BUNDLE.copy(), BASIC_BUNDLE, id="prefilled-bundle"), - pytest.param({"type": "charm"}, {"type": "charm"}, id="empty-charm"), - pytest.param(BASIC_CHARM.copy(), BASIC_CHARM, id="empty-charm"), + pytest.param( + {"type": "charm", "bases": []}, + {"type": "charm", "bases": [], "parts": {"charm": {"plugin": "charm", "source": "."}}}, + id="empty-charm", + ), + pytest.param(BASIC_CHARM.copy(), BASIC_CHARM, id="basic-charm"), ], ) def test_add_default_parts_correct(yaml_data, expected):