From 4082e4d06fb840dc5b1a9a8c762cee9b413160f2 Mon Sep 17 00:00:00 2001 From: bertiethorpe Date: Wed, 2 Jul 2025 16:18:04 +0100 Subject: [PATCH] Update tox for linting and formatting --- kube_custom_resource/__init__.py | 4 +- kube_custom_resource/custom_resource.py | 168 ++++++++++----------- kube_custom_resource/generate.py | 10 +- kube_custom_resource/registry.py | 48 +++--- kube_custom_resource/schema.py | 149 +++++++++---------- pyproject.toml | 46 ++++++ setup.cfg | 9 +- tests/models/v1alpha1/__init__.py | 4 +- tests/models/v1alpha1/car.py | 88 +++++------ tests/models/v1alpha1/manufacturer.py | 27 ++-- tests/test_schema_generation.py | 187 +++++++++--------------- tests/test_validation.py | 13 +- tox.ini | 22 ++- 13 files changed, 385 insertions(+), 390 deletions(-) diff --git a/kube_custom_resource/__init__.py b/kube_custom_resource/__init__.py index ba593d7..c1fa7f3 100644 --- a/kube_custom_resource/__init__.py +++ b/kube_custom_resource/__init__.py @@ -1,2 +1,2 @@ -from .custom_resource import CustomResource, Scope -from .registry import CustomResourceRegistry +from .custom_resource import CustomResource, Scope # noqa: F401 +from .registry import CustomResourceRegistry # noqa: F401 diff --git a/kube_custom_resource/custom_resource.py b/kube_custom_resource/custom_resource.py index 7d73605..1aed693 100644 --- a/kube_custom_resource/custom_resource.py +++ b/kube_custom_resource/custom_resource.py @@ -3,14 +3,12 @@ import re import typing -from pydantic_core import CoreSchema - from pydantic import Field, GetJsonSchemaHandler from pydantic.json_schema import JsonSchemaValue +from pydantic_core import CoreSchema from .schema import BaseModel, Enum - #: Regex used for extracting versions from module names VERSION_REGEX = re.compile(r"^v\d+((alpha|beta)\d+)?$") @@ -19,7 +17,8 @@ class Scope(str, Enum): """ Enum representing the possible scopes for a custom resource. """ - CLUSTER = "Cluster" + + CLUSTER = "Cluster" NAMESPACED = "Namespaced" @@ -28,17 +27,18 @@ class CustomResourceMeta: """ Dataclass to hold metadata for the custom resource. """ + # The API subgroup for the custom resource api_subgroup: typing.Optional[str] # The version of the custom resource version: str # The scope of the custom resource scope: Scope - # The kind of the resource + # The kind of the resource kind: str - # The singular name of the resource + # The singular name of the resource singular_name: str - # The plural name of the resource + # The plural name of the resource plural_name: str # A list of short names for the resource short_names: typing.List[str] @@ -54,6 +54,7 @@ class CustomResourceMetaclass(type(BaseModel)): """ Metaclass for the custom resource class. """ + def __new__( cls, name, @@ -63,39 +64,42 @@ def __new__( # Indicates that the resource is abstract and is not actually a CRD abstract: bool = False, # The API subgroup for the custom resource - # If not given, the resource is placed in the root api group + # If not given, the resource is placed in the root api group api_subgroup: typing.Optional[str] = None, # The version of the custom resource - # If not given, it is inferred from the module if possible + # If not given, it is inferred from the module if possible version: typing.Optional[str] = None, # The scope of the resource, either 'Namespaced' or 'Cluster' - # If not given, Namespaced is used by default + # If not given, Namespaced is used by default scope: Scope = Scope.NAMESPACED, - # The kind of the resource - # If not given, the model class name is used + # The kind of the resource + # If not given, the model class name is used kind: typing.Optional[str] = None, - # The singular name of the resource - # If not given, the lower-cased kind is used + # The singular name of the resource + # If not given, the lower-cased kind is used singular_name: typing.Optional[str] = None, - # The plural name of the resource - # If not given, the singular name with 's' appended is used + # The plural name of the resource + # If not given, the singular name with 's' appended is used plural_name: typing.Optional[str] = None, # A list of short names for the resource - # If not given, no short names are used + # If not given, no short names are used short_names: typing.Optional[typing.List[str]] = None, # The subresources for the CRD - # If not given, no subresources are defined + # If not given, no subresources are defined subresources: typing.Optional[typing.Dict[str, typing.Any]] = None, # A list of printer column definitions for the version # An "age" column is always appended to the given list - printer_columns: typing.Optional[typing.List[typing.Dict[str, typing.Any]]] = None, + printer_columns: typing.Optional[ + typing.List[typing.Dict[str, typing.Any]] + ] = None, # Indicates if this is the storage version storage_version: bool = True, # Other kwargs, passed to Pydantic - **kwargs + **kwargs, ): if not abstract: - # If no version is given, find the first module component that looks like a version + # If no version is given, find the first module component that looks like a + # version if not version: version = next( part @@ -107,8 +111,10 @@ def __new__( singular_name = singular_name or kind.lower() plural_name = plural_name or f"{singular_name}s" # Add the metadata to the attrs - # We also add an annotation so that Pydantic leaves it alone - attrs.setdefault("__annotations__", {})["_meta"] = typing.ClassVar[CustomResourceMeta] + # We also add an annotation so that Pydantic leaves it alone + attrs.setdefault("__annotations__", {})["_meta"] = typing.ClassVar[ + CustomResourceMeta + ] attrs["_meta"] = CustomResourceMeta( api_subgroup, version, @@ -118,14 +124,15 @@ def __new__( plural_name, short_names or [], subresources or {}, - (printer_columns or []) + [ + (printer_columns or []) + + [ { "name": "Age", "type": "date", "jsonPath": ".metadata.creationTimestamp", }, ], - storage_version + storage_version, ) return super().__new__(cls, name, bases, attrs, **kwargs) @@ -134,29 +141,20 @@ class OwnerReference(BaseModel): """ Model for an owner reference. """ - api_version: str = Field( - ..., - description = "The API version of the owner." - ) - kind: str = Field( - ..., - description = "The kind of the owner." - ) - name: str = Field( - ..., - description = "The name of the owner." - ) - uid: str = Field( - ..., - description = "The UID of the owner." - ) + + api_version: str = Field(..., description="The API version of the owner.") + kind: str = Field(..., description="The kind of the owner.") + name: str = Field(..., description="The name of the owner.") + uid: str = Field(..., description="The UID of the owner.") block_owner_deletion: bool = Field( False, - description = "If true, the owner cannot be removed until this resource is removed." + description=( + "If true, the owner cannot be removed until this resource is removed." + ), ) controller: bool = Field( False, - description = "Indicates if this resource points to the managing controller." + description="Indicates if this resource points to the managing controller.", ) @@ -164,51 +162,47 @@ class Metadata(BaseModel): """ Model for the metadata associated with a resource instance. """ - name: str = Field( - ..., - description = "The name of the resource." - ) + + name: str = Field(..., description="The name of the resource.") namespace: typing.Optional[str] = Field( - None, - description = "The namespace for the resource." + None, description="The namespace for the resource." ) labels: typing.Dict[str, str] = Field( - default_factory = dict, - description = "The labels for the resource. Can be used to match selectors." + default_factory=dict, + description="The labels for the resource. Can be used to match selectors.", ) annotations: typing.Dict[str, str] = Field( - default_factory = dict, - description = "Annotations for the resource. Can be used to store arbitrary metadata." + default_factory=dict, + description=( + "Annotations for the resource. Can be used to store arbitrary metadata." + ), ) owner_references: typing.List[OwnerReference] = Field( - default_factory = list, - description = "List of resources that this resource depends on." + default_factory=list, + description="List of resources that this resource depends on.", ) finalizers: typing.List[str] = Field( - default_factory = list, - description = "List of identifiers blocking removal of the resource." - ) - uid: typing.Optional[str] = Field( - None, - description = "The UID for the resource." + default_factory=list, + description="List of identifiers blocking removal of the resource.", ) + uid: typing.Optional[str] = Field(None, description="The UID for the resource.") creation_timestamp: typing.Optional[datetime.datetime] = Field( - None, - description = "The timestamp at which the resource was created." + None, description="The timestamp at which the resource was created." ) deletion_timestamp: typing.Optional[datetime.datetime] = Field( - None, - description = "The timestamp at which the resource was deleted." + None, description="The timestamp at which the resource was deleted." ) resource_version: typing.Optional[str] = Field( None, - description = ( + description=( "The internal version of the resource. " "Used to implement optimistic concurrency and resumable watches." - ) + ), ) - def add_owner_reference(self, owner, /, block_owner_deletion = False, controller = False): + def add_owner_reference( + self, owner, /, block_owner_deletion=False, controller=False + ): """ Adds the given owner as a reference if it doesn't already exist. @@ -220,43 +214,29 @@ def add_owner_reference(self, owner, /, block_owner_deletion = False, controller else: self.owner_references.append( OwnerReference( - api_version = owner["apiVersion"], - kind = owner["kind"], - name = owner["metadata"]["name"], - uid = owner["metadata"]["uid"], - block_owner_deletion = block_owner_deletion, - controller = controller + api_version=owner["apiVersion"], + kind=owner["kind"], + name=owner["metadata"]["name"], + uid=owner["metadata"]["uid"], + block_owner_deletion=block_owner_deletion, + controller=controller, ) ) return True -class CustomResource( - BaseModel, - metaclass = CustomResourceMetaclass, - abstract = True -): +class CustomResource(BaseModel, metaclass=CustomResourceMetaclass, abstract=True): """ Base class for defining custom resources. """ - api_version: str = Field( - ..., - description = "The API version of the resource." - ) - kind: str = Field( - ..., - description = "The kind of the resource." - ) - metadata: Metadata = Field( - ..., - description = "The metadata for the resource." - ) + + api_version: str = Field(..., description="The API version of the resource.") + kind: str = Field(..., description="The kind of the resource.") + metadata: Metadata = Field(..., description="The metadata for the resource.") @classmethod def __get_pydantic_json_schema__( - cls, - core_schema: CoreSchema, - handler: GetJsonSchemaHandler + cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler ) -> JsonSchemaValue: json_schema = handler(core_schema) json_schema = handler.resolve_ref_schema(json_schema) diff --git a/kube_custom_resource/generate.py b/kube_custom_resource/generate.py index dc4cfa7..408301c 100644 --- a/kube_custom_resource/generate.py +++ b/kube_custom_resource/generate.py @@ -12,13 +12,13 @@ @click.option( "--category", "categories", - multiple = True, - help = "Category to place CRDs in. Can be specified multiple times." + multiple=True, + help="Category to place CRDs in. Can be specified multiple times.", ) @click.argument("models_mod") @click.argument("api_group") -@click.argument("output_dir", type = click.Path()) -def main(models_mod, api_group, output_dir, categories = None): +@click.argument("output_dir", type=click.Path()) +def main(models_mod, api_group, output_dir, categories=None): """ Generate CRDs from the models in the given module with the given API group. @@ -31,7 +31,7 @@ def main(models_mod, api_group, output_dir, categories = None): registry.discover_models(models_module) # Produce the CRDs in the output directory output_dir = pathlib.Path(output_dir) - output_dir.mkdir(parents = True, exist_ok = True) + output_dir.mkdir(parents=True, exist_ok=True) for crd in registry: output_path = output_dir / f"{crd.plural_name}.{crd.api_group}.yaml" with output_path.open("w") as fh: diff --git a/kube_custom_resource/registry.py b/kube_custom_resource/registry.py index a0b4cec..ae98b8e 100644 --- a/kube_custom_resource/registry.py +++ b/kube_custom_resource/registry.py @@ -13,7 +13,8 @@ class CustomResourceDefinitionVersion: """ Storage object for CRD version information. """ - # The name of the version + + # The name of the version name: str # The model that defines the schema for the version model: CustomResource @@ -30,13 +31,14 @@ class CustomResourceDefinition: """ Storage object for CRD information. """ + # The API group for the resource api_group: str - # The kind of the resource + # The kind of the resource kind: str - # The singular name of the resource + # The singular name of the resource singular_name: str - # The plural name of the resource + # The plural name of the resource plural_name: str # The scope of the resource scope: Scope @@ -48,9 +50,7 @@ class CustomResourceDefinition: versions: typing.Dict[str, CustomResourceDefinitionVersion] def kubernetes_resource( - self, - /, - include_defaults: bool = False + self, /, include_defaults: bool = False ) -> typing.Dict[str, typing.Any]: return { "apiVersion": "apiextensions.k8s.io/v1", @@ -75,15 +75,15 @@ def kubernetes_resource( "storage": version.storage, "schema": { "openAPIV3Schema": version.model.model_json_schema( - include_defaults = include_defaults + include_defaults=include_defaults ), }, "subresources": version.subresources, "additionalPrinterColumns": version.printer_columns, } for version in self.versions.values() - ] - } + ], + }, } @@ -92,8 +92,9 @@ def iscustomresourcemodel(obj): Utility function to check if a given object is a custom resource model. """ return ( - inspect.isclass(obj) and - issubclass(obj, CustomResource) and + inspect.isclass(obj) + and issubclass(obj, CustomResource) + and # Only consider concrete custom resource models hasattr(obj, "_meta") ) @@ -103,10 +104,9 @@ class CustomResourceRegistry: """ Class for indexing and querying available custom resources. """ + def __init__( - self, - api_group: str, - categories: typing.Optional[typing.List[str]] = None + self, api_group: str, categories: typing.Optional[typing.List[str]] = None ): self._api_group = api_group self._categories = categories or [] @@ -134,7 +134,9 @@ def register_model(self, model: CustomResource): model._meta.plural_name, model._meta.scope, # Merge but dedupe the short names given by each version - list(set(getattr(existing_crd, "short_names", []) + model._meta.short_names)), + list( + set(getattr(existing_crd, "short_names", []) + model._meta.short_names) + ), self._categories, # Add the new version dict( @@ -145,10 +147,10 @@ def register_model(self, model: CustomResource): model, model._meta.subresources, model._meta.printer_columns, - model._meta.storage_version + model._meta.storage_version, ) - } - ) + }, + ), ) return model @@ -157,13 +159,17 @@ def discover_models(self, module: types.ModuleType): Discovers and registers all the custom resource models in the given module. """ # Register all the models in this module - for _, model in inspect.getmembers(module, lambda obj: iscustomresourcemodel(obj)): + for _, model in inspect.getmembers( + module, lambda obj: iscustomresourcemodel(obj) + ): self.register_model(model) # Register all the models in the submodules of the given module # Only modules with submodules have a __path__, it seems if hasattr(module, "__path__"): for _, name, _ in pkgutil.iter_modules(module.__path__): - self.discover_models(importlib.import_module(f".{name}", module.__name__)) + self.discover_models( + importlib.import_module(f".{name}", module.__name__) + ) def get_crd(self, api_group, kind) -> typing.Type[CustomResourceDefinition]: """ diff --git a/kube_custom_resource/schema.py b/kube_custom_resource/schema.py index 46a592b..3188902 100644 --- a/kube_custom_resource/schema.py +++ b/kube_custom_resource/schema.py @@ -2,25 +2,28 @@ import typing as t import annotated_types - -from pydantic_core import CoreSchema, core_schema - from pydantic import ( - BaseModel as PydanticModel, - TypeAdapter, - AnyUrl as PydanticAnyUrl, - AnyHttpUrl as PydanticAnyHttpUrl, - TypeAdapter, - StringConstraints, AllowInfNan, - Strict, GetCoreSchemaHandler, - GetJsonSchemaHandler + GetJsonSchemaHandler, + Strict, + StringConstraints, + TypeAdapter, +) +from pydantic import ( + AnyHttpUrl as PydanticAnyHttpUrl, +) +from pydantic import ( + AnyUrl as PydanticAnyUrl, +) +from pydantic import ( + BaseModel as PydanticModel, ) from pydantic.json_schema import JsonSchemaValue +from pydantic_core import CoreSchema, core_schema -def schema_apply(schema, func, pre = False): +def schema_apply(schema, func, pre=False): """ Return a new schema that is the result of recursively applying the given function to the given schema and all subschemas. @@ -43,7 +46,9 @@ def schema_apply(schema, func, pre = False): # If it is a dict, then it is the schema for the additional properties additional_properties = schema_new.get("additionalProperties") if isinstance(additional_properties, dict): - schema_new["additionalProperties"] = schema_apply(additional_properties, func, pre) + schema_new["additionalProperties"] = schema_apply( + additional_properties, func, pre + ) # For array schemas, we need to apply the function to the schema for items elif schema_new.get("type") == "array": if "items" in schema_new: @@ -83,14 +88,16 @@ def func(schema): # Apply the function to each schema _before_ recursing # This ensures that refs are recursed into correctly - return schema_apply(schema_new, func, pre = True) + return schema_apply(schema_new, func, pre=True) def remove_fields(schema, *fields): """ Recursively remove the specified fields from all the types in the schema. """ - return schema_apply(schema, lambda s: { k: v for k, v in s.items() if k not in fields }) + return schema_apply( + schema, lambda s: {k: v for k, v in s.items() if k not in fields} + ) def snake_to_pascal(name): @@ -105,14 +112,13 @@ class Enum(enum.Enum): """ Enum that does not include a title in the JSON-Schema. """ + def __str__(self): return str(self.value) @classmethod def __get_pydantic_json_schema__( - cls, - core_schema: CoreSchema, - handler: GetJsonSchemaHandler + cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler ) -> JsonSchemaValue: json_schema = handler(core_schema) json_schema = handler.resolve_ref_schema(json_schema) @@ -125,11 +131,10 @@ class XKubernetesPreserveUnknownFields: Type annotation that adds the x-kubernetes-preserve-unknown-fields property to the generated schema. """ + @classmethod def __get_pydantic_json_schema__( - cls, - core_schema: CoreSchema, - handler: GetJsonSchemaHandler + cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler ) -> JsonSchemaValue: json_schema = handler(core_schema) json_schema = handler.resolve_ref_schema(json_schema) @@ -147,25 +152,22 @@ def __get_pydantic_json_schema__( class XKubernetesIntOrString: """ - Type annotation that adds the x-kubernetes-int-or-string property to the generated schema. + Type annotation that adds the x-kubernetes-int-or-string property to the generated + schema. """ + @classmethod def __get_pydantic_core_schema__( - cls, - source_type: t.Any, - handler: GetCoreSchemaHandler + cls, source_type: t.Any, handler: GetCoreSchemaHandler ) -> CoreSchema: # Validate the value as a union of int and str, but convert to a string after return core_schema.no_info_after_validator_function( - str, - handler.generate_schema(t.Union[str, int]) + str, handler.generate_schema(t.Union[str, int]) ) @classmethod def __get_pydantic_json_schema__( - cls, - core_schema: CoreSchema, - handler: GetJsonSchemaHandler + cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler ) -> JsonSchemaValue: json_schema = handler(core_schema) json_schema = handler.resolve_ref_schema(json_schema) @@ -186,25 +188,24 @@ class ValidateStrAs: """ Type annotation that allows a str to be annotated with validation from another type. """ + def __init__(self, validation_type): self.validation_type = validation_type self.type_adapter = TypeAdapter(self.validation_type) def __get_pydantic_core_schema__( - self, - source_type: t.Any, - handler: GetCoreSchemaHandler + self, source_type: t.Any, handler: GetCoreSchemaHandler ) -> CoreSchema: - # Validate the value as the given validation type, but convert back to a string after + # Validate the value as the given validation type, but convert back to a string + # after json_schema = core_schema.no_info_after_validator_function( - str, - handler.generate_schema(self.validation_type) + str, handler.generate_schema(self.validation_type) ) return core_schema.json_or_python_schema( - json_schema = json_schema, - python_schema = json_schema, + json_schema=json_schema, + python_schema=json_schema, # When serializing, just use the string - serialization = core_schema.plain_serializer_function_ser_schema(lambda x: x) + serialization=core_schema.plain_serializer_function_ser_schema(lambda x: x), ) @@ -215,24 +216,26 @@ def __get_pydantic_core_schema__( class _ConvertExclusiveMinMax: @classmethod def __get_pydantic_json_schema__( - cls, - core_schema: CoreSchema, - handler: GetJsonSchemaHandler + cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler ) -> JsonSchemaValue: json_schema = handler(core_schema) json_schema = handler.resolve_ref_schema(json_schema) exclusive_min = json_schema.pop("exclusiveMinimum", None) if exclusive_min is not None: - json_schema.update({ - "minimum": exclusive_min, - "exclusiveMinimum": True, - }) + json_schema.update( + { + "minimum": exclusive_min, + "exclusiveMinimum": True, + } + ) exclusive_max = json_schema.pop("exclusiveMaximum", None) if exclusive_max is not None: - json_schema.update({ - "maximum": exclusive_max, - "exclusiveMaximum": True, - }) + json_schema.update( + { + "maximum": exclusive_max, + "exclusiveMaximum": True, + } + ) return json_schema @@ -243,12 +246,12 @@ def conint( ge: t.Optional[int] = None, lt: t.Optional[int] = None, le: t.Optional[int] = None, - multiple_of: t.Optional[int] = None + multiple_of: t.Optional[int] = None, ) -> t.Type[int]: return t.Annotated[ int, Strict(strict) if strict is not None else None, - annotated_types.Interval(gt = gt, ge = ge, lt = lt, le = le), + annotated_types.Interval(gt=gt, ge=ge, lt=lt, le=le), annotated_types.MultipleOf(multiple_of) if multiple_of is not None else None, _ConvertExclusiveMinMax, ] @@ -267,7 +270,7 @@ def confloat( return t.Annotated[ float, Strict(strict) if strict is not None else None, - annotated_types.Interval(gt = gt, ge = ge, lt = lt, le = le), + annotated_types.Interval(gt=gt, ge=ge, lt=lt, le=le), annotated_types.MultipleOf(multiple_of) if multiple_of is not None else None, AllowInfNan(allow_inf_nan) if allow_inf_nan is not None else None, _ConvertExclusiveMinMax, @@ -277,12 +280,10 @@ def confloat( class Nullable: @classmethod def __get_pydantic_json_schema__( - cls, - core_schema: CoreSchema, - handler: GetJsonSchemaHandler + cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler ) -> JsonSchemaValue: - # We don't want to produce the anyOf schema that Optional would normally produce, - # as it is not valid OpenAPI + # We don't want to produce the anyOf schema that Optional would normally + # produce, as it is not valid OpenAPI # Instead, we want to produce the schema for the wrapped type with nullable set json_schema = handler(core_schema) json_schema = handler.resolve_ref_schema(json_schema) @@ -303,11 +304,10 @@ class StructuralUnion: See https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#specifying-a-structural-schema. """ + @classmethod def __get_pydantic_json_schema__( - cls, - core_schema: CoreSchema, - handler: GetJsonSchemaHandler + cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler ) -> JsonSchemaValue: # We only support structural union schemas for unions (obvs!) assert core_schema["type"] == "union" @@ -316,7 +316,8 @@ def __get_pydantic_json_schema__( properties = {} for choice_schema in core_schema.get("choices", []): if choice_schema["type"] == "model": - # For models, we need the fully resolved schema to do the rest of the ops + # For models, we need the fully resolved schema to do the rest of the + # ops # The handler only gives us a ref schema adapter = TypeAdapter(choice_schema["cls"]) choice_json_schema = resolve_refs(adapter.json_schema()) @@ -346,15 +347,16 @@ def __get_pydantic_json_schema__( class BaseModel( PydanticModel, - alias_generator = snake_to_pascal, - populate_by_name = True, + alias_generator=snake_to_pascal, + populate_by_name=True, # Validate any mutations to the model - frozen = False, - validate_assignment = True + frozen=False, + validate_assignment=True, ): """ Base model for use within CRD definitions. """ + def model_dump(self, **kwargs): # Unless otherwise specified, we want by_alias = True kwargs.setdefault("by_alias", True) @@ -367,9 +369,7 @@ def model_dump_json(self, **kwargs): @classmethod def __get_pydantic_json_schema__( - cls, - core_schema: CoreSchema, - handler: GetJsonSchemaHandler + cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler ) -> JsonSchemaValue: json_schema = handler(core_schema) json_schema = handler.resolve_ref_schema(json_schema) @@ -383,7 +383,7 @@ def __get_pydantic_json_schema__( return json_schema @classmethod - def model_json_schema(cls, *args, include_defaults = False, **kwargs): + def model_json_schema(cls, *args, include_defaults=False, **kwargs): json_schema = super().model_json_schema(*args, **kwargs) # Resolve all the refs json_schema = resolve_refs(json_schema) @@ -391,10 +391,11 @@ def model_json_schema(cls, *args, include_defaults = False, **kwargs): fields_to_remove = ["title"] # Unless explicitly included, we remove defaults from the schema as they cause # Kubernetes to rewrite objects - # In most cases, it is better that defaults are applied at model instantiation time - # as rewriting the Kubernetes objects themselves can have unintended side-effects - # However in some cases it is more appropriate for the defaults to be "locked in" at - # creation time + # In most cases, it is better that defaults are applied at model instantiation + # time as rewriting the Kubernetes objects themselves can have unintended + # side-effects + # However in some cases it is more appropriate for the defaults to be + # "locked in" at creation time if not include_defaults: fields_to_remove.append("default") return remove_fields(json_schema, *fields_to_remove) diff --git a/pyproject.toml b/pyproject.toml index 1cd983f..181f15b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,3 +3,49 @@ requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=3.4"] build-backend = "setuptools.build_meta" [tool.setuptools_scm] + +[tool.ruff] +line-length = 88 +target-version = "py39" + +[tool.ruff.format] +line-ending = "lf" + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # flake8-builtins + "A", + # Ruff rules + "RUF", + # flake8-async + "ASYNC", + # pyupgrade + "UP", + # tidy imports + "TID", + # sorted imports + "I", + # check complexity + "C90", + # pep8 naming + "N", +] +ignore = [ + "UP038", # deprecated + "UP007", # unsafe (fix on move to 3.10) + "UP006", # unsafe (fix on move to 3.10) +] + +[tool.mypy] +follow_imports = "silent" +warn_redundant_casts = true +warn_unused_ignores = true +check_untyped_defs = true + +[tool.ruff.lint.mccabe] +# Flag errors (`C901`) whenever the complexity level exceeds 5. +max-complexity = 15 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index e0b272d..d5fc1c4 100755 --- a/setup.cfg +++ b/setup.cfg @@ -21,6 +21,13 @@ test = pytest pytest-cov pytest-sugar + ruff + codespell + types-PyYAML + types-setuptools + mypy + black + [options.entry_points] console_scripts = @@ -28,4 +35,4 @@ console_scripts = [options.package_data] kube_custom_resource = - py.typed + py.typed \ No newline at end of file diff --git a/tests/models/v1alpha1/__init__.py b/tests/models/v1alpha1/__init__.py index 1f925f2..0b9073a 100644 --- a/tests/models/v1alpha1/__init__.py +++ b/tests/models/v1alpha1/__init__.py @@ -1,2 +1,2 @@ -from .car import * -from .manufacturer import * +from .car import * # noqa: F403 +from .manufacturer import * # noqa: F403 diff --git a/tests/models/v1alpha1/car.py b/tests/models/v1alpha1/car.py index 33b77ff..3388917 100644 --- a/tests/models/v1alpha1/car.py +++ b/tests/models/v1alpha1/car.py @@ -2,111 +2,98 @@ import pydantic as p -from kube_custom_resource import custom_resource as crd, schema as s +from kube_custom_resource import custom_resource as crd +from kube_custom_resource import schema as s class CombustionEngineSpec(s.BaseModel): """Spec for a combustion engine.""" - capacity: s.conint(gt = 0) = p.Field( - ..., - description = "The capacity of the engine in litres." + + capacity: s.conint(gt=0) = p.Field( + ..., description="The capacity of the engine in litres." ) class ElectricEngineSpec(s.BaseModel): """Spec for an electric engine.""" - max_power_output: s.conint(gt = 0) = p.Field( - ..., - description = "The maximum power output of the engine in kW." - ) + max_power_output: s.conint(gt=0) = p.Field( + ..., description="The maximum power output of the engine in kW." + ) class PetrolEngine(s.BaseModel): """Union member for a petrol engine.""" + petrol: CombustionEngineSpec class DieselEngine(s.BaseModel): """Union member for a diesel engine.""" + diesel: CombustionEngineSpec class ElectricEngine(s.BaseModel): """Union member for an electric engine.""" + electric: ElectricEngineSpec Engine = t.Annotated[ - t.Union[PetrolEngine, DieselEngine, ElectricEngine], - s.StructuralUnion + t.Union[PetrolEngine, DieselEngine, ElectricEngine], s.StructuralUnion ] class Colour(str, s.Enum): """Enum of possible colours for a car.""" - WHITE = "White" + + WHITE = "White" SILVER = "Silver" - RED = "Red" - BLACK = "Black" + RED = "Red" + BLACK = "Black" class CarSpec(s.BaseModel): """Spec for a car.""" - manufacturer: s.constr(pattern = r"^[a-zA-Z0-9-]+$") = p.Field( - ..., - description = "The manufacturer of the car." - ) - model: s.constr(pattern = r"^[a-zA-Z0-9-]+$") = p.Field( - ..., - description = "The model of the car." - ) - engine: Engine = p.Field( - ..., - description = "The engine for the car." + + manufacturer: s.constr(pattern=r"^[a-zA-Z0-9-]+$") = p.Field( + ..., description="The manufacturer of the car." ) - colour: Colour = p.Field( - ..., - description = "The colour of the car." + model: s.constr(pattern=r"^[a-zA-Z0-9-]+$") = p.Field( + ..., description="The model of the car." ) + engine: Engine = p.Field(..., description="The engine for the car.") + colour: Colour = p.Field(..., description="The colour of the car.") owner: s.Optional[ - s.constr( - pattern = r"^[a-zA-Z0-9 ]*$", - strip_whitespace = True, - min_length = 1 - ) - ] = p.Field( - None, - description = "The owner of the car." - ) + s.constr(pattern=r"^[a-zA-Z0-9 ]*$", strip_whitespace=True, min_length=1) + ] = p.Field(None, description="The owner of the car.") extras: s.Dict[str, s.Any] = p.Field( - default_factory = dict, - description = "Any extras for the car." + default_factory=dict, description="Any extras for the car." ) class Phase(str, s.Enum): """Enum of possible phases for the car.""" - UNKNOWN = "Unknown" + + UNKNOWN = "Unknown" ON_ORDER = "OnOrder" BUILDING = "Building" - READY = "Ready" - DELAYED = "Delayed" + READY = "Ready" + DELAYED = "Delayed" -class CarStatus(s.BaseModel, extra = "allow"): +class CarStatus(s.BaseModel, extra="allow"): """Status for a car.""" - phase: Phase = p.Field( - Phase.UNKNOWN, - description = "The phase of the car." - ) + + phase: Phase = p.Field(Phase.UNKNOWN, description="The phase of the car.") class Car( crd.CustomResource, - scope = crd.Scope.NAMESPACED, - subresources = {"status": {}}, - printer_columns = [ + scope=crd.Scope.NAMESPACED, + subresources={"status": {}}, + printer_columns=[ { "name": "Manufacturer", "type": "string", @@ -125,5 +112,6 @@ class Car( ], ): """Represents a car.""" + spec: CarSpec - status: CarStatus = p.Field(default_factory = CarStatus) + status: CarStatus = p.Field(default_factory=CarStatus) diff --git a/tests/models/v1alpha1/manufacturer.py b/tests/models/v1alpha1/manufacturer.py index f8bd079..f4fa054 100644 --- a/tests/models/v1alpha1/manufacturer.py +++ b/tests/models/v1alpha1/manufacturer.py @@ -1,35 +1,31 @@ -import annotated_types as a import typing as t +import annotated_types as a import pydantic as p -from kube_custom_resource import custom_resource as crd, schema as s - +from kube_custom_resource import custom_resource as crd +from kube_custom_resource import schema as s -ModelsList = t.Annotated[t.List[s.constr(pattern = r"^[a-z0-9]+$")], a.Len(min_length = 1)] +ModelsList = t.Annotated[t.List[s.constr(pattern=r"^[a-z0-9]+$")], a.Len(min_length=1)] class ManufacturerSpec(s.BaseModel): """Spec for a car manufacturer.""" + founded: s.IntOrString = p.Field( - ..., - description = "The year the company was founded." - ) - website: s.AnyHttpUrl = p.Field( - ..., - description = "The company website." + ..., description="The year the company was founded." ) + website: s.AnyHttpUrl = p.Field(..., description="The company website.") models: ModelsList = p.Field( - ..., - description = "The models supported by this manufacturer." + ..., description="The models supported by this manufacturer." ) class Manufacturer( crd.CustomResource, - scope = crd.Scope.CLUSTER, - subresources = {"status": {}}, - printer_columns = [ + scope=crd.Scope.CLUSTER, + subresources={"status": {}}, + printer_columns=[ { "name": "Founded", "type": "string", @@ -38,4 +34,5 @@ class Manufacturer( ], ): """Represents a car manufacturer.""" + spec: ManufacturerSpec diff --git a/tests/test_schema_generation.py b/tests/test_schema_generation.py index 238ff2c..cc59b07 100644 --- a/tests/test_schema_generation.py +++ b/tests/test_schema_generation.py @@ -1,7 +1,7 @@ -import kube_custom_resource as kcr - +# ruff: noqa: E501 import models +import kube_custom_resource as kcr API_GROUP = "test.kcr.azimuth-cloud.io" @@ -17,9 +17,7 @@ def test_model_discovery_found_two_models(): MANUFACTURER_CRD_EXPECTED = { "apiVersion": "apiextensions.k8s.io/v1", "kind": "CustomResourceDefinition", - "metadata": { - "name": "manufacturers.test.kcr.azimuth-cloud.io" - }, + "metadata": {"name": "manufacturers.test.kcr.azimuth-cloud.io"}, "spec": { "group": "test.kcr.azimuth-cloud.io", "scope": "Cluster", @@ -28,7 +26,7 @@ def test_model_discovery_found_two_models(): "singular": "manufacturer", "plural": "manufacturers", "shortNames": [], - "categories": [] + "categories": [], }, "versions": [ { @@ -44,58 +42,47 @@ def test_model_discovery_found_two_models(): "properties": { "founded": { "description": "The year the company was founded.", - "x-kubernetes-int-or-string": True + "x-kubernetes-int-or-string": True, }, "website": { "description": "The company website.", "format": "uri", "minLength": 1, - "type": "string" + "type": "string", }, "models": { "description": "The models supported by this manufacturer.", "items": { "pattern": "^[a-z0-9]+$", - "type": "string" + "type": "string", }, "minItems": 1, - "type": "array" - } + "type": "array", + }, }, - "required": [ - "founded", - "website", - "models" - ], - "type": "object" + "required": ["founded", "website", "models"], + "type": "object", } }, - "required": [ - "spec" - ], - "type": "object" + "required": ["spec"], + "type": "object", } }, - "subresources": { - "status": {} - }, + "subresources": {"status": {}}, "additionalPrinterColumns": [ - { - "name": "Founded", - "type": "string", - "jsonPath": ".spec.founded" - }, + {"name": "Founded", "type": "string", "jsonPath": ".spec.founded"}, { "name": "Age", "type": "date", - "jsonPath": ".metadata.creationTimestamp" - } - ] + "jsonPath": ".metadata.creationTimestamp", + }, + ], } - ] - } + ], + }, } + def test_manufacturer_crd(): """ Verifies that the manufacturer CRD resource is as expected. @@ -107,9 +94,7 @@ def test_manufacturer_crd(): CAR_CRD_EXPECTED = { "apiVersion": "apiextensions.k8s.io/v1", "kind": "CustomResourceDefinition", - "metadata": { - "name": "cars.test.kcr.azimuth-cloud.io" - }, + "metadata": {"name": "cars.test.kcr.azimuth-cloud.io"}, "spec": { "group": "test.kcr.azimuth-cloud.io", "scope": "Namespaced", @@ -118,7 +103,7 @@ def test_manufacturer_crd(): "singular": "car", "plural": "cars", "shortNames": [], - "categories": [] + "categories": [], }, "versions": [ { @@ -135,12 +120,12 @@ def test_manufacturer_crd(): "manufacturer": { "description": "The manufacturer of the car.", "pattern": "^[a-zA-Z0-9-]+$", - "type": "string" + "type": "string", }, "model": { "description": "The model of the car.", "pattern": "^[a-zA-Z0-9-]+$", - "type": "string" + "type": "string", }, "engine": { "anyOf": [ @@ -150,17 +135,13 @@ def test_manufacturer_crd(): "properties": { "capacity": { "exclusiveMinimum": True, - "minimum": 0 + "minimum": 0, } }, - "required": [ - "capacity" - ] + "required": ["capacity"], } }, - "required": [ - "petrol" - ] + "required": ["petrol"], }, { "properties": { @@ -168,17 +149,13 @@ def test_manufacturer_crd(): "properties": { "capacity": { "exclusiveMinimum": True, - "minimum": 0 + "minimum": 0, } }, - "required": [ - "capacity" - ] + "required": ["capacity"], } }, - "required": [ - "diesel" - ] + "required": ["diesel"], }, { "properties": { @@ -186,18 +163,14 @@ def test_manufacturer_crd(): "properties": { "maxPowerOutput": { "exclusiveMinimum": True, - "minimum": 0 + "minimum": 0, } }, - "required": [ - "maxPowerOutput" - ] + "required": ["maxPowerOutput"], } }, - "required": [ - "electric" - ] - } + "required": ["electric"], + }, ], "description": "The engine for the car.", "properties": { @@ -208,13 +181,11 @@ def test_manufacturer_crd(): "description": "The capacity of the engine in litres.", "exclusiveMinimum": True, "minimum": 0, - "type": "integer" + "type": "integer", } }, - "required": [ - "capacity" - ], - "type": "object" + "required": ["capacity"], + "type": "object", }, "diesel": { "description": "Spec for a combustion engine.", @@ -223,13 +194,11 @@ def test_manufacturer_crd(): "description": "The capacity of the engine in litres.", "exclusiveMinimum": True, "minimum": 0, - "type": "integer" + "type": "integer", } }, - "required": [ - "capacity" - ], - "type": "object" + "required": ["capacity"], + "type": "object", }, "electric": { "description": "Spec for an electric engine.", @@ -238,33 +207,26 @@ def test_manufacturer_crd(): "description": "The maximum power output of the engine in kW.", "exclusiveMinimum": True, "minimum": 0, - "type": "integer" + "type": "integer", } }, - "required": [ - "maxPowerOutput" - ], - "type": "object" - } + "required": ["maxPowerOutput"], + "type": "object", + }, }, - "type": "object" + "type": "object", }, "colour": { "description": "Enum of possible colours for a car.", - "enum": [ - "White", - "Silver", - "Red", - "Black" - ], - "type": "string" + "enum": ["White", "Silver", "Red", "Black"], + "type": "string", }, "owner": { "description": "The owner of the car.", "minLength": 1, "nullable": True, "pattern": "^[a-zA-Z0-9 ]*$", - "type": "string" + "type": "string", }, "extras": { "additionalProperties": { @@ -272,16 +234,16 @@ def test_manufacturer_crd(): }, "description": "Any extras for the car.", "type": "object", - "x-kubernetes-preserve-unknown-fields": True - } + "x-kubernetes-preserve-unknown-fields": True, + }, }, "required": [ "manufacturer", "model", "engine", - "colour" + "colour", ], - "type": "object" + "type": "object", }, "status": { "description": "Status for a car.", @@ -293,51 +255,40 @@ def test_manufacturer_crd(): "OnOrder", "Building", "Ready", - "Delayed" + "Delayed", ], - "type": "string" + "type": "string", } }, "type": "object", - "x-kubernetes-preserve-unknown-fields": True - } + "x-kubernetes-preserve-unknown-fields": True, + }, }, - "required": [ - "spec" - ], - "type": "object" + "required": ["spec"], + "type": "object", } }, - "subresources": { - "status": {} - }, + "subresources": {"status": {}}, "additionalPrinterColumns": [ { "name": "Manufacturer", "type": "string", - "jsonPath": ".spec.manufacturer" - }, - { - "name": "Model", - "type": "string", - "jsonPath": ".spec.model" - }, - { - "name": "Phase", - "type": "string", - "jsonPath": ".status.phase" + "jsonPath": ".spec.manufacturer", }, + {"name": "Model", "type": "string", "jsonPath": ".spec.model"}, + {"name": "Phase", "type": "string", "jsonPath": ".status.phase"}, { "name": "Age", "type": "date", - "jsonPath": ".metadata.creationTimestamp" - } - ] + "jsonPath": ".metadata.creationTimestamp", + }, + ], } - ] - } + ], + }, } + def test_car_crd(): """ Verifies that the car CRD resource is as expected. diff --git a/tests/test_validation.py b/tests/test_validation.py index 3d44800..6456d67 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,14 +1,11 @@ import copy -import pytest - -import pydantic as p - -import kube_custom_resource as kcr - import models +import pydantic as p +import pytest from models import v1alpha1 as api +import kube_custom_resource as kcr API_GROUP = "test.kcr.azimuth-cloud.io" @@ -36,6 +33,7 @@ }, } + def test_validate_valid_manufacturer(): """ Tests that a valid manufacturer resource can be loaded. @@ -73,6 +71,7 @@ def test_validate_valid_manufacturer(): }, } + def test_validate_valid_car(): """ Tests that a valid car resource can be loaded. @@ -83,7 +82,7 @@ def test_validate_valid_car(): assert car.metadata.name == "john-doe-escort" assert car.spec.manufacturer == "ford" assert car.spec.model == "escort" - assert type(car.spec.engine) == api.PetrolEngine + assert type(car.spec.engine) == api.PetrolEngine # noqa: E721 assert car.spec.engine.petrol.capacity == 2 assert car.spec.colour == api.Colour.BLACK assert car.spec.owner is None diff --git a/tox.ini b/tox.ini index 6926d5a..03dfab0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -env_list = clean,report,py39 +env_list = clean,report,py39,ruff,codespell,autofix [testenv] description = run unit tests @@ -23,6 +23,26 @@ deps = coverage skip_install = true commands = coverage erase +[testenv:autofix] +commands = + ruff format {tox_root} + codespell --skip "./cover/*," -w + ruff check {tox_root} --fix + +[testenv:black] +# TODO: understand why ruff doesn't fix +# line lengths as well as black does +commands = black {tox_root} {posargs} + +[testenv:codespell] +commands = codespell --skip "./cover/*" {posargs} + +[testenv:ruff] +description = Run Ruff checks +commands = + ruff check {tox_root} + ruff format {tox_root} --check + [testenv:mypy] deps = mypy types-PyYAML