Skip to content

Commit 32e6f5c

Browse files
authored
add AttributeParameters and link to AttributeSchema (#6274)
* add AttributeParameters and link to AttributeSchema * few small updates * support content validation for regex, min/max_length * update constraint validators for parameters * fix some unit tests * integrate parameters into schema diff, deprecate regex/min/max * regenerate schemas * update tests * add changelog * add unit tests * undo deprecation b/c it raises 10000 warnings * update docs * remove unnecessary typing ignores * handle double falsy values * add new ConstraintIdentifier enum * update comment * maybe fix flaky test * allow parameters fields to specify validator behavior
1 parent 5c5785c commit 32e6f5c

29 files changed

+715
-82
lines changed

backend/infrahub/core/attribute.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -232,30 +232,28 @@ def validate_content(cls, value: Any, name: str, schema: AttributeSchema) -> Non
232232
Raises:
233233
ValidationError: Content of the attribute value is not valid
234234
"""
235-
if schema.regex:
235+
if regex := schema.get_regex():
236236
if schema.kind == "List":
237237
validation_values = [str(entry) for entry in value]
238238
else:
239239
validation_values = [str(value)]
240240

241241
for validation_value in validation_values:
242242
try:
243-
is_valid = re.match(pattern=schema.regex, string=str(validation_value))
243+
is_valid = re.match(pattern=regex, string=str(validation_value))
244244
except re.error as exc:
245-
raise ValidationError(
246-
{name: f"The regex defined in the schema is not valid ({schema.regex!r})"}
247-
) from exc
245+
raise ValidationError({name: f"The regex defined in the schema is not valid ({regex!r})"}) from exc
248246

249247
if not is_valid:
250-
raise ValidationError({name: f"{validation_value} must conform with the regex: {schema.regex!r}"})
248+
raise ValidationError({name: f"{validation_value} must conform with the regex: {regex!r}"})
251249

252-
if schema.min_length:
253-
if len(value) < schema.min_length:
254-
raise ValidationError({name: f"{value} must have a minimum length of {schema.min_length!r}"})
250+
if min_length := schema.get_min_length():
251+
if len(value) < min_length:
252+
raise ValidationError({name: f"{value} must have a minimum length of {min_length!r}"})
255253

256-
if schema.max_length:
257-
if len(value) > schema.max_length:
258-
raise ValidationError({name: f"{value} must have a maximum length of {schema.max_length!r}"})
254+
if max_length := schema.get_max_length():
255+
if len(value) > max_length:
256+
raise ValidationError({name: f"{value} must have a maximum length of {max_length!r}"})
259257

260258
if schema.enum:
261259
try:

backend/infrahub/core/models.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from infrahub.core.schema.schema_branch import SchemaBranch
2121

2222
GENERIC_ATTRIBUTES_TO_IGNORE = ["namespace", "name", "branch"]
23+
PROPERTY_NAMES_TO_IGNORE = ["regex", "min_length", "max_length"]
2324

2425

2526
class NodeKind(BaseModel):
@@ -252,11 +253,37 @@ def _process_attrs_rels(
252253
if not sub_field_diff:
253254
raise ValueError("sub_field_diff must be defined, unexpected situation")
254255

255-
for prop_name in sub_field_diff.changed:
256+
for prop_name, prop_diff in sub_field_diff.changed.items():
257+
if prop_name in PROPERTY_NAMES_TO_IGNORE:
258+
continue
259+
256260
field_info = field.model_fields[prop_name]
257261
field_update = str(field_info.json_schema_extra.get("update")) # type: ignore[union-attr]
258262

259-
schema_path = SchemaPath( # type: ignore[call-arg]
263+
if isinstance(prop_diff, HashableModelDiff):
264+
for param_field_name in prop_diff.changed:
265+
# override field_update if this field has its own json_schema_extra.update
266+
try:
267+
prop_field = getattr(field, prop_name)
268+
param_field_info = prop_field.model_fields[param_field_name]
269+
param_field_update = str(param_field_info.json_schema_extra.get("update"))
270+
except (AttributeError, KeyError):
271+
param_field_update = None
272+
273+
schema_path = SchemaPath(
274+
schema_kind=schema.kind,
275+
path_type=path_type,
276+
field_name=field_name,
277+
property_name=f"{prop_name}.{param_field_name}",
278+
)
279+
280+
self._process_field(
281+
schema_path=schema_path,
282+
field_update=param_field_update or field_update,
283+
)
284+
continue
285+
286+
schema_path = SchemaPath(
260287
schema_kind=schema.kind,
261288
path_type=path_type,
262289
field_name=field_name,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from __future__ import annotations
2+
3+
from pydantic import Field
4+
5+
from infrahub.core.constants.schema import UpdateSupport
6+
from infrahub.core.models import HashableModel
7+
8+
9+
def get_attribute_parameters_class_for_kind(kind: str) -> type[AttributeParameters]:
10+
return {
11+
"Text": TextAttributeParameters,
12+
"TextArea": TextAttributeParameters,
13+
}.get(kind, AttributeParameters)
14+
15+
16+
class AttributeParameters(HashableModel):
17+
class Config:
18+
extra = "forbid"
19+
20+
21+
class TextAttributeParameters(AttributeParameters):
22+
regex: str | None = Field(
23+
default=None,
24+
description="Regular expression that attribute value must match if defined",
25+
json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
26+
)
27+
min_length: int | None = Field(
28+
default=None,
29+
description="Set a minimum number of characters allowed.",
30+
json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
31+
)
32+
max_length: int | None = Field(
33+
default=None,
34+
description="Set a maximum number of characters allowed.",
35+
json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
36+
)

backend/infrahub/core/schema/attribute_schema.py

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@
22

33
import enum
44
from enum import Enum
5-
from typing import TYPE_CHECKING, Any
5+
from typing import TYPE_CHECKING, Any, Self
66

7-
from pydantic import field_validator, model_validator
7+
from pydantic import Field, ValidationInfo, field_validator, model_validator
88

99
from infrahub import config
10+
from infrahub.core.constants.schema import UpdateSupport
1011
from infrahub.core.enums import generate_python_enum
1112
from infrahub.core.query.attribute import default_attribute_query_filter
1213
from infrahub.types import ATTRIBUTE_KIND_LABELS, ATTRIBUTE_TYPES
1314

15+
from .attribute_parameters import AttributeParameters, TextAttributeParameters, get_attribute_parameters_class_for_kind
1416
from .generated.attribute_schema import GeneratedAttributeSchema
1517

1618
if TYPE_CHECKING:
@@ -21,6 +23,14 @@
2123
from infrahub.database import InfrahubDatabase
2224

2325

26+
def get_attribute_schema_class_for_kind(kind: str) -> type[AttributeSchema]:
27+
attribute_schema_class_by_kind: dict[str, type[AttributeSchema]] = {
28+
"Text": TextAttributeSchema,
29+
"TextArea": TextAttributeSchema,
30+
}
31+
return attribute_schema_class_by_kind.get(kind, AttributeSchema)
32+
33+
2434
class AttributeSchema(GeneratedAttributeSchema):
2535
_sort_by: list[str] = ["name"]
2636
_enum_class: type[enum.Enum] | None = None
@@ -53,16 +63,36 @@ def kind_options(cls, v: str) -> str:
5363

5464
@model_validator(mode="before")
5565
@classmethod
56-
def validate_dropdown_choices(cls, values: dict[str, Any]) -> dict[str, Any]:
66+
def validate_dropdown_choices(cls, values: Any) -> Any:
5767
"""Validate that choices are defined for a dropdown but not for other kinds."""
58-
if values.get("kind") != "Dropdown" and values.get("choices"):
59-
raise ValueError(f"Can only specify 'choices' for kind=Dropdown: {values['kind']}")
60-
61-
if values.get("kind") == "Dropdown" and not values.get("choices"):
68+
if isinstance(values, dict):
69+
kind = values.get("kind")
70+
choices = values.get("choices")
71+
elif isinstance(values, AttributeSchema):
72+
kind = values.kind
73+
choices = values.choices
74+
else:
75+
return values
76+
if kind != "Dropdown" and choices:
77+
raise ValueError(f"Can only specify 'choices' for kind=Dropdown: {kind}")
78+
79+
if kind == "Dropdown" and not choices:
6280
raise ValueError("The property 'choices' is required for kind=Dropdown")
6381

6482
return values
6583

84+
@field_validator("parameters", mode="before")
85+
@classmethod
86+
def set_parameters_type(cls, value: Any, info: ValidationInfo) -> Any:
87+
"""Override parameters class if using base AttributeParameters class and should be using a subclass"""
88+
kind = info.data["kind"]
89+
expected_parameters_class = get_attribute_parameters_class_for_kind(kind=kind)
90+
if value is None:
91+
return expected_parameters_class()
92+
if not isinstance(value, expected_parameters_class) and isinstance(value, AttributeParameters):
93+
return expected_parameters_class(**value.model_dump())
94+
return value
95+
6696
def get_class(self) -> type[BaseAttribute]:
6797
return ATTRIBUTE_TYPES[self.kind].get_infrahub_class()
6898

@@ -106,7 +136,7 @@ def update_from_generic(self, other: AttributeSchema) -> None:
106136

107137
def to_node(self) -> dict[str, Any]:
108138
fields_to_exclude = {"id", "state", "filters"}
109-
fields_to_json = {"computed_attribute"}
139+
fields_to_json = {"computed_attribute", "parameters"}
110140
data = self.model_dump(exclude=fields_to_exclude | fields_to_json)
111141

112142
for field_name in fields_to_json:
@@ -117,6 +147,15 @@ def to_node(self) -> dict[str, Any]:
117147

118148
return data
119149

150+
def get_regex(self) -> str | None:
151+
return self.regex
152+
153+
def get_min_length(self) -> int | None:
154+
return self.min_length
155+
156+
def get_max_length(self) -> int | None:
157+
return self.max_length
158+
120159
async def get_query_filter(
121160
self,
122161
name: str,
@@ -144,3 +183,39 @@ async def get_query_filter(
144183
partial_match=partial_match,
145184
support_profiles=support_profiles,
146185
)
186+
187+
188+
class TextAttributeSchema(AttributeSchema):
189+
parameters: TextAttributeParameters = Field(
190+
default_factory=TextAttributeParameters,
191+
description="Extra parameters specific to text attributes",
192+
json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
193+
)
194+
195+
@model_validator(mode="after")
196+
def reconcile_parameters(self) -> Self:
197+
if self.regex != self.parameters.regex:
198+
final_regex = self.parameters.regex or self.regex
199+
if not final_regex: # falsy parameters.regex override falsy regex
200+
final_regex = self.parameters.regex
201+
self.regex = self.parameters.regex = final_regex
202+
if self.min_length != self.parameters.min_length:
203+
final_min_length = self.parameters.min_length or self.min_length
204+
if not final_min_length: # falsy parameters.min_length override falsy min_length
205+
final_min_length = self.parameters.min_length
206+
self.min_length = self.parameters.min_length = final_min_length
207+
if self.max_length != self.parameters.max_length:
208+
final_max_length = self.parameters.max_length or self.max_length
209+
if not final_max_length: # falsy parameters.max_length override falsy max_length
210+
final_max_length = self.parameters.max_length
211+
self.max_length = self.parameters.max_length = final_max_length
212+
return self
213+
214+
def get_regex(self) -> str | None:
215+
return self.parameters.regex
216+
217+
def get_min_length(self) -> int | None:
218+
return self.parameters.min_length
219+
220+
def get_max_length(self) -> int | None:
221+
return self.parameters.max_length

backend/infrahub/core/schema/basenode_schema.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from infrahub.core.constants import RelationshipCardinality, RelationshipKind
1414
from infrahub.core.models import HashableModel, HashableModelDiff
1515

16-
from .attribute_schema import AttributeSchema
16+
from .attribute_schema import AttributeSchema, get_attribute_schema_class_for_kind
1717
from .generated.base_node_schema import GeneratedBaseNodeSchema
1818
from .relationship_schema import RelationshipSchema
1919

@@ -74,6 +74,30 @@ def __hash__(self) -> int:
7474
Be careful hash generated from hash() have a salt by default and they will not be the same across run"""
7575
return hash(self.get_hash())
7676

77+
@field_validator("attributes", mode="before")
78+
@classmethod
79+
def set_attribute_type(cls, raw_attributes: Any) -> Any:
80+
if not isinstance(raw_attributes, list):
81+
return raw_attributes
82+
attribute_schemas_with_types: list[Any] = []
83+
for raw_attr in raw_attributes:
84+
if not isinstance(raw_attr, (dict, AttributeSchema)):
85+
attribute_schemas_with_types.append(raw_attr)
86+
continue
87+
if isinstance(raw_attr, dict):
88+
kind = raw_attr.get("kind")
89+
attribute_type_class = get_attribute_schema_class_for_kind(kind=kind)
90+
attribute_schemas_with_types.append(attribute_type_class(**raw_attr))
91+
continue
92+
93+
expected_attr_schema_class = get_attribute_schema_class_for_kind(kind=raw_attr.kind)
94+
if not isinstance(raw_attr, expected_attr_schema_class):
95+
final_attr = expected_attr_schema_class(**raw_attr.model_dump())
96+
else:
97+
final_attr = raw_attr
98+
attribute_schemas_with_types.append(final_attr)
99+
return attribute_schemas_with_types
100+
77101
def to_dict(self) -> dict:
78102
data = self.model_dump(
79103
exclude_unset=True, exclude_none=True, exclude_defaults=True, exclude={"attributes", "relationships"}

backend/infrahub/core/schema/definitions/internal.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
RelationshipKind,
3232
UpdateSupport,
3333
)
34+
from infrahub.core.schema.attribute_parameters import AttributeParameters
3435
from infrahub.core.schema.attribute_schema import AttributeSchema
3536
from infrahub.core.schema.computed_attribute import ComputedAttribute
3637
from infrahub.core.schema.dropdown import DropdownChoice
@@ -506,21 +507,21 @@ def to_dict(self) -> dict[str, Any]:
506507
SchemaAttribute(
507508
name="regex",
508509
kind="Text",
509-
description="Regex uses to limit the characters allowed in for the attributes.",
510+
description="Regex uses to limit the characters allowed in for the attributes. (deprecated: please use parameters.regex instead)",
510511
optional=True,
511512
extra={"update": UpdateSupport.VALIDATE_CONSTRAINT},
512513
),
513514
SchemaAttribute(
514515
name="max_length",
515516
kind="Number",
516-
description="Set a maximum number of characters allowed for a given attribute.",
517+
description="Set a maximum number of characters allowed for a given attribute. (deprecated: please use parameters.max_length instead)",
517518
optional=True,
518519
extra={"update": UpdateSupport.VALIDATE_CONSTRAINT},
519520
),
520521
SchemaAttribute(
521522
name="min_length",
522523
kind="Number",
523-
description="Set a minimum number of characters allowed for a given attribute.",
524+
description="Set a minimum number of characters allowed for a given attribute. (deprecated: please use parameters.min_length instead)",
524525
optional=True,
525526
extra={"update": UpdateSupport.VALIDATE_CONSTRAINT},
526527
),
@@ -617,6 +618,15 @@ def to_dict(self) -> dict[str, Any]:
617618
optional=True,
618619
extra={"update": UpdateSupport.ALLOWED},
619620
),
621+
SchemaAttribute(
622+
name="parameters",
623+
kind="JSON",
624+
internal_kind=AttributeParameters,
625+
optional=True,
626+
description="Extra parameters specific to this kind of attribute",
627+
extra={"update": UpdateSupport.VALIDATE_CONSTRAINT},
628+
default_factory="AttributeParameters",
629+
),
620630
SchemaAttribute(
621631
name="deprecation",
622632
kind="Text",

backend/infrahub/core/schema/generated/attribute_schema.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from infrahub.core.constants import AllowOverrideType, BranchSupportType, HashableModelState
1010
from infrahub.core.models import HashableModel
11+
from infrahub.core.schema.attribute_parameters import AttributeParameters # noqa: TC001
1112
from infrahub.core.schema.computed_attribute import ComputedAttribute # noqa: TC001
1213
from infrahub.core.schema.dropdown import DropdownChoice # noqa: TC001
1314

@@ -44,17 +45,17 @@ class GeneratedAttributeSchema(HashableModel):
4445
)
4546
regex: str | None = Field(
4647
default=None,
47-
description="Regex uses to limit the characters allowed in for the attributes.",
48+
description="Regex uses to limit the characters allowed in for the attributes. (deprecated: please use parameters.regex instead)",
4849
json_schema_extra={"update": "validate_constraint"},
4950
)
5051
max_length: int | None = Field(
5152
default=None,
52-
description="Set a maximum number of characters allowed for a given attribute.",
53+
description="Set a maximum number of characters allowed for a given attribute. (deprecated: please use parameters.max_length instead)",
5354
json_schema_extra={"update": "validate_constraint"},
5455
)
5556
min_length: int | None = Field(
5657
default=None,
57-
description="Set a minimum number of characters allowed for a given attribute.",
58+
description="Set a minimum number of characters allowed for a given attribute. (deprecated: please use parameters.min_length instead)",
5859
json_schema_extra={"update": "validate_constraint"},
5960
)
6061
label: str | None = Field(
@@ -112,6 +113,11 @@ class GeneratedAttributeSchema(HashableModel):
112113
description="Type of allowed override for the attribute.",
113114
json_schema_extra={"update": "allowed"},
114115
)
116+
parameters: AttributeParameters = Field(
117+
default_factory=AttributeParameters,
118+
description="Extra parameters specific to this kind of attribute",
119+
json_schema_extra={"update": "validate_constraint"},
120+
)
115121
deprecation: str | None = Field(
116122
default=None,
117123
description="Mark attribute as deprecated and provide a user-friendly message to display",

0 commit comments

Comments
 (0)