Skip to content

Commit

Permalink
chore!: merge pydantic 2 feature branch to main
Browse files Browse the repository at this point in the history
  • Loading branch information
lengau authored Aug 9, 2024
2 parents 5b09ab3 + 25ea79c commit c3aae96
Show file tree
Hide file tree
Showing 24 changed files with 762 additions and 381 deletions.
4 changes: 2 additions & 2 deletions craft_application/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@ def _expand_environment(self, yaml_data: dict[str, Any], build_for: str) -> None
info = craft_parts.ProjectInfo(
application_name=self.app.name, # not used in environment expansion
cache_dir=pathlib.Path(), # not used in environment expansion
arch=util.convert_architecture_deb_to_platform(build_for_arch),
arch=build_for_arch,
project_name=yaml_data.get("name", ""),
project_dirs=project_dirs,
project_vars=environment_vars,
Expand All @@ -682,7 +682,7 @@ def _get_project_vars(self, yaml_data: dict[str, Any]) -> dict[str, str]:
"""Return a dict with project variables to be expanded."""
pvars: dict[str, str] = {}
for var in self.app.project_variables:
pvars[var] = yaml_data.get(var, "")
pvars[var] = str(yaml_data.get(var, ""))
return pvars

def _set_global_environment(self, info: craft_parts.ProjectInfo) -> None:
Expand Down
5 changes: 3 additions & 2 deletions craft_application/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@
# with this program. If not, see <http://www.gnu.org/licenses/>.
"""General-purpose models for *craft applications."""

from craft_application.models.base import CraftBaseConfig, CraftBaseModel
from craft_application.models.base import CraftBaseModel
from craft_application.models.constraints import (
ProjectName,
ProjectTitle,
SummaryStr,
UniqueStrList,
VersionStr,
get_validator_by_regex,
)
from craft_application.models.grammar import (
GrammarAwareProject,
Expand All @@ -43,7 +44,6 @@
"BuildInfo",
"DEVEL_BASE_INFOS",
"DEVEL_BASE_WARNING",
"CraftBaseConfig",
"CraftBaseModel",
"get_grammar_aware_part_keywords",
"GrammarAwareProject",
Expand All @@ -55,4 +55,5 @@
"SummaryStr",
"UniqueStrList",
"VersionStr",
"get_validator_by_regex",
]
25 changes: 12 additions & 13 deletions craft_application/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,24 +30,19 @@ def alias_generator(s: str) -> str:
return s.replace("_", "-")


class CraftBaseConfig(pydantic.BaseConfig): # pylint: disable=too-few-public-methods
"""Pydantic model configuration."""

validate_assignment = True
extra = pydantic.Extra.forbid
allow_mutation = True
allow_population_by_field_name = True
alias_generator = alias_generator


class CraftBaseModel(pydantic.BaseModel):
"""Base model for craft-application classes."""

Config = CraftBaseConfig
model_config = pydantic.ConfigDict(
validate_assignment=True,
extra="forbid",
populate_by_name=True,
alias_generator=alias_generator,
)

def marshal(self) -> dict[str, str | list[str] | dict[str, Any]]:
"""Convert to a dictionary."""
return self.dict(by_alias=True, exclude_unset=True)
return self.model_dump(mode="json", by_alias=True, exclude_unset=True)

@classmethod
def unmarshal(cls, data: dict[str, Any]) -> Self:
Expand All @@ -62,7 +57,7 @@ def unmarshal(cls, data: dict[str, Any]) -> Self:
if not isinstance(data, dict): # pyright: ignore[reportUnnecessaryIsInstance]
raise TypeError("Project data is not a dictionary")

return cls(**data)
return cls.model_validate(data)

@classmethod
def from_yaml_file(cls, path: pathlib.Path) -> Self:
Expand Down Expand Up @@ -94,6 +89,10 @@ def to_yaml_file(self, path: pathlib.Path) -> None:
with path.open("wt") as file:
util.dump_yaml(self.marshal(), stream=file)

def to_yaml_string(self) -> str:
"""Return this model as a YAML string."""
return util.dump_yaml(self.marshal())

@classmethod
def transform_pydantic_error(cls, error: pydantic.ValidationError) -> None:
"""Modify, in-place, validation errors generated by Pydantic.
Expand Down
262 changes: 207 additions & 55 deletions craft_application/models/constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,78 +14,230 @@
# You should have received a copy of the GNU Lesser General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
"""Constrained pydantic types for *craft applications."""

import collections
import re
from collections.abc import Callable
from typing import Annotated, Literal, TypeVar

from pydantic import ConstrainedList, ConstrainedStr, StrictStr
import license_expression # type: ignore[import]
import pydantic
from pydantic_core import PydanticCustomError

T = TypeVar("T")
Tv = TypeVar("Tv")

class ProjectName(ConstrainedStr):
"""A constrained string for describing a project name.

Project name rules:
* Valid characters are lower-case ASCII letters, numerals and hyphens.
* Must contain at least one letter
* May not start or end with a hyphen
* May not have two hyphens in a row
"""
def _validate_list_is_unique(value: list[T]) -> list[T]:
value_set = set(value)
if len(value_set) == len(value):
return value
dupes = [item for item, count in collections.Counter(value).items() if count > 1]
raise ValueError(f"duplicate values in list: {dupes}")


min_length = 1
max_length = 40
strict = True
strip_whitespace = True
regex = re.compile(r"^([a-z0-9][a-z0-9-]?)*[a-z]+([a-z0-9-]?[a-z0-9])*$")
def get_validator_by_regex(
regex: re.Pattern[str], error_msg: str
) -> Callable[[str], str]:
"""Get a string validator by regular expression with a known error message.
This allows providing better error messages for regex-based validation than the
standard message provided by pydantic. Simply place the result of this function in
a BeforeValidator attached to your annotated type.
:param regex: a compiled regular expression on a string.
:param error_msg: The error message to raise if the value is invalid.
:returns: A validator function ready to be used by pydantic.BeforeValidator
"""

def validate(value: str) -> str:
"""Validate the given string with the outer regex, raising the error message.
:param value: a string to be validated
:returns: that same string if it's valid.
:raises: ValueError if the string is invalid.
"""
value = str(value)
if not regex.match(value):
raise ValueError(error_msg)
return value

return validate


UniqueList = Annotated[
list[T],
pydantic.AfterValidator(_validate_list_is_unique),
pydantic.Field(json_schema_extra={"uniqueItems": True}),
]

SingleEntryList = Annotated[
list[T],
pydantic.Field(min_length=1, max_length=1),
]

SingleEntryDict = Annotated[
dict[T, Tv],
pydantic.Field(min_length=1, max_length=1),
]

_PROJECT_NAME_DESCRIPTION = """\
The name of this project. This is used when uploading, publishing, or installing.
Project name rules:
* Valid characters are lower-case ASCII letters, numerals and hyphens.
* Must contain at least one letter
* May not start or end with a hyphen
* May not have two hyphens in a row
"""

_PROJECT_NAME_REGEX = r"^([a-z0-9][a-z0-9-]?)*[a-z]+([a-z0-9-]?[a-z0-9])*$"
_PROJECT_NAME_COMPILED_REGEX = re.compile(_PROJECT_NAME_REGEX)
MESSAGE_INVALID_NAME = (
"invalid name: Names can only use ASCII lowercase letters, numbers, and hyphens. "
"They must have at least one letter, may not start or end with a hyphen, "
"and may not have two hyphens in a row."
)


class ProjectTitle(StrictStr):
"""A constrained string for describing a project title."""

min_length = 2
max_length = 40
strip_whitespace = True


class SummaryStr(ConstrainedStr):
"""A constrained string for a short summary of a project."""

strip_whitespace = True
max_length = 78


class UniqueStrList(ConstrainedList):
"""A list of strings, each of which must be unique.
This is roughly equivalent to an ordered set of strings, but implemented with a list.
"""

__args__ = (str,)
item_type = str
unique_items = True


class VersionStr(ConstrainedStr):
"""A valid version string.
Should match snapd valid versions:
https://github.com/snapcore/snapd/blame/a39482ead58bf06cddbc0d3ffad3c17dfcf39913/snap/validate.go#L96
Applications may use a different set of constraints if necessary, but
ideally they will retain this same constraint.
"""

max_length = 32
strip_whitespace = True
regex = re.compile(r"^[a-zA-Z0-9](?:[a-zA-Z0-9:.+~-]*[a-zA-Z0-9+~])?$")


ProjectName = Annotated[
str,
pydantic.BeforeValidator(
get_validator_by_regex(_PROJECT_NAME_COMPILED_REGEX, MESSAGE_INVALID_NAME)
),
pydantic.Field(
min_length=1,
max_length=40,
strict=True,
pattern=_PROJECT_NAME_REGEX,
description=_PROJECT_NAME_DESCRIPTION,
title="Project Name",
examples=[
"ubuntu",
"jupyterlab-desktop",
"lxd",
"digikam",
"kafka",
"mysql-router-k8s",
],
),
]


ProjectTitle = Annotated[
str,
pydantic.Field(
min_length=2,
max_length=40,
title="Title",
description="A human-readable title.",
examples=[
"Ubuntu Linux",
"Jupyter Lab Desktop",
"LXD",
"DigiKam",
"Apache Kafka",
"MySQL Router K8s charm",
],
),
]

SummaryStr = Annotated[
str,
pydantic.Field(
max_length=78,
title="Summary",
description="A short description of your project.",
examples=[
"Linux for Human Beings",
"The cross-platform desktop application for JupyterLab",
"Container and VM manager",
"Photo Management Program",
"Charm for routing MySQL databases in Kubernetes",
"An open-source event streaming platform for high-performance data pipelines",
],
),
]

UniqueStrList = UniqueList[str]

_VERSION_STR_REGEX = r"^[a-zA-Z0-9](?:[a-zA-Z0-9:.+~-]*[a-zA-Z0-9+~])?$"
_VERSION_STR_COMPILED_REGEX = re.compile(_VERSION_STR_REGEX)
MESSAGE_INVALID_VERSION = (
"invalid version: Valid versions consist of upper- and lower-case "
"alphanumeric characters, as well as periods, colons, plus signs, tildes, "
"and hyphens. They cannot begin with a period, colon, plus sign, tilde, or "
"hyphen. They cannot end with a period, colon, or hyphen."
)

VersionStr = Annotated[
str,
pydantic.BeforeValidator(
get_validator_by_regex(_VERSION_STR_COMPILED_REGEX, MESSAGE_INVALID_VERSION)
),
pydantic.Field(
max_length=32,
pattern=_VERSION_STR_REGEX,
strict=False,
coerce_numbers_to_str=True,
title="version string",
description="A string containing the version of the project",
examples=[
"0.1",
"1.0.0",
"v1.0.0",
"24.04",
],
),
]
"""A valid version string.
Should match snapd valid versions:
https://github.com/snapcore/snapd/blame/a39482ead58bf06cddbc0d3ffad3c17dfcf39913/snap/validate.go#L96
Applications may use a different set of constraints if necessary, but
ideally they will retain this same constraint.
"""


def _parse_spdx_license(value: str) -> license_expression.LicenseExpression:
licensing = license_expression.get_spdx_licensing()
if (
lic := licensing.parse( # pyright: ignore[reportUnknownMemberType]
value, validate=True
)
) is not None:
return lic
raise ValueError


def _validate_spdx_license(value: str) -> str:
"""Ensure the provided licence is a valid SPDX licence."""
try:
_ = _parse_spdx_license(value)
except (license_expression.ExpressionError, ValueError):
raise PydanticCustomError(
"not_spdx_license",
"License '{wrong_license}' not valid. It must be in SPDX format.",
{"wrong_license": value},
) from None
else:
return value


SpdxLicenseStr = Annotated[
str,
pydantic.AfterValidator(_validate_spdx_license),
pydantic.Field(
title="License",
description="SPDX license string.",
examples=[
"GPL-3.0",
"MIT",
"LGPL-3.0-or-later",
"GPL-3.0+ and MIT",
],
),
]

ProprietaryLicenseStr = Literal["proprietary"]

LicenseStr = SpdxLicenseStr | ProprietaryLicenseStr
Loading

0 comments on commit c3aae96

Please sign in to comment.