Skip to content

Commit

Permalink
chore: merge pydantic 2 support to main (#1817)
Browse files Browse the repository at this point in the history
  • Loading branch information
lengau authored Aug 16, 2024
2 parents 61e34e2 + f9d16ef commit 394e6e5
Show file tree
Hide file tree
Showing 52 changed files with 2,860 additions and 714 deletions.
2 changes: 0 additions & 2 deletions .github/renovate.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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.
],
Expand Down
59 changes: 39 additions & 20 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,52 +45,71 @@ 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: |
3.11
3.12
- 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
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
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') }}
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
- 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: |
python -m pip install tox
pipx install tox
tox run --colored yes -m tests --notest
- name: Run tests
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
Expand Down Expand Up @@ -211,7 +230,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
Expand Down
6 changes: 4 additions & 2 deletions charmcraft/application/commands/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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].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}",
Expand Down Expand Up @@ -2205,7 +2207,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
]
Expand Down
11 changes: 5 additions & 6 deletions charmcraft/models/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,10 @@
import re

import pydantic
from craft_application.models import CraftBaseModel

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
Expand All @@ -33,7 +32,7 @@ class JujuActions(ModelConfigDefaults, frozen=True):
_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):
Expand All @@ -48,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")
Expand Down
116 changes: 42 additions & 74 deletions charmcraft/models/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,80 +15,48 @@
# For further info, check https://github.com/canonical/charmcraft

"""Charmcraft basic pydantic model."""
import craft_application.models
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
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.
from typing import Annotated

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."""
import craft_parts.constraints
import pydantic

@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),
]
58 changes: 37 additions & 21 deletions charmcraft/models/charmcraft.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,41 @@
# For further info, check https://github.com/canonical/charmcraft

"""Charmcraft configuration pydantic model."""
from typing import cast
from typing import TypedDict, cast

import pydantic
from craft_application import util
from craft_application.models import CraftBaseModel
from typing_extensions import Self

from charmcraft.models.basic import AttributeName, LinterName, ModelConfigDefaults
from charmcraft.models.basic import AttributeName, LinterName


class CharmhubConfig(
ModelConfigDefaults,
alias_generator=lambda s: s.replace("_", "-"),
frozen=True,
):
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."""

api_url: pydantic.HttpUrl = cast(pydantic.HttpUrl, "https://api.charmhub.io")
storage_url: pydantic.HttpUrl = cast(pydantic.HttpUrl, "https://storage.snapcraftcontent.com")
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
Expand All @@ -54,11 +67,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::
Expand All @@ -80,30 +89,37 @@ class BasesConfiguration(
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 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."""
Loading

0 comments on commit 394e6e5

Please sign in to comment.