Skip to content

Commit e5d737b

Browse files
Support Pydantic Models for Validation (#165)
--------- Co-authored-by: Christian Adell <chadell@gmail.com>
1 parent 93dbda5 commit e5d737b

35 files changed

Lines changed: 946 additions & 32 deletions

File tree

docs/custom_validators.md

Lines changed: 103 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
With custom validators, you can implement business logic in Python. Schema-enforcer will automatically
44
load your plugins from the `validator_directory` and run them against your host data.
55

6-
The validator plugin provides two base classes: ModelValidation and JmesPathModelValidation. The former can be used
7-
when you want to implement all logic and the latter can be used as a shortcut for jmespath validation.
6+
The validator plugin provides a few base classes: BaseValidation, JmesPathModelValidation, and PydanticValidation. BaseValidation can be used when you want to implement all logic, JmesPathModelValidation can be used as a shortcut for jmespath validation, and PydanticValidation will validate data against a specific Pydantic model.
87

98
## BaseValidation
109

@@ -26,7 +25,7 @@ by providing a class-level `id` variable.
2625

2726
Helper functions are provided to add pass/fail results:
2827

29-
```
28+
```python
3029
def add_validation_error(self, message: str, **kwargs):
3130
"""Add validator error to results.
3231
Args:
@@ -40,6 +39,7 @@ def add_validation_pass(self, **kwargs):
4039
kwargs (optional): additional arguments to add to ValidationResult when required
4140
"""
4241
```
42+
4343
In most cases, you will not need to provide kwargs. However, if you find a use case that requires updating other fields
4444
in the ValidationResult, you can send the key/value pairs to update the result directly. This is for advanced users only.
4545

@@ -59,7 +59,7 @@ the following criteria:
5959
* `operator`: Operator to use for comparison between left and right hand side of expression
6060
* `error`: Message to report when validation fails
6161

62-
### Supported operators:
62+
### Supported operators
6363

6464
The class provides the following operators for basic use cases:
6565

@@ -74,10 +74,11 @@ The class provides the following operators for basic use cases:
7474

7575
If you require additional logic or need to compare other types, use the BaseValidation class and create your own validate method.
7676

77-
### Examples:
77+
### Examples
7878

7979
#### Basic
80-
```
80+
81+
```python
8182
from schema_enforcer.schemas.validator import JmesPathModelValidation
8283

8384
class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods
@@ -90,7 +91,8 @@ class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public
9091
```
9192

9293
#### With compiled jmespath expression
93-
```
94+
95+
```python
9496
import jmespath
9597
from schema_enforcer.schemas.validator import JmesPathModelValidation
9698

@@ -104,6 +106,100 @@ class CheckInterfaceIPv4(JmesPathModelValidation): # pylint: disable=too-few-pu
104106
error = "All core interfaces do not have IPv4 addresses"
105107
```
106108

109+
## PydanticValidation
110+
111+
Schema Enforcer supports utilizing Pydantic models for validation. Pydantic models can be loaded two different ways.
112+
113+
1. Store your models in your `validator_directory`.
114+
2. Load from a separate library using the `schema_enforcer.schemas.PydanticManager`. These must be defined within the `schema_enforcer` configuration file.
115+
1. `pydantic_validators` requires a list of library paths to a `PydanticManager` instance.
116+
117+
Both methods will replace the Pydantic `BaseModel` with the `PydanticValidation` class that provides the required `validate` method that uses the `model_validate` method to validate data. The model is set to the original Pydantic model to validate data against.
118+
119+
### Pydantic Models in External Libraries
120+
121+
```python
122+
class PydanticValidation(BaseValidation):
123+
"""Basic wrapper for Pydantic models to be used as validators."""
124+
125+
model: BaseModel
126+
127+
def validate(self, data: dict, strict: bool = False):
128+
"""Validate data against Pydantic model.
129+
130+
Args:
131+
data (dict): variables to be validated by validator
132+
strict (bool): true when --strict cli option is used to request strict validation (if provided)
133+
134+
Returns:
135+
None
136+
137+
Use add_validation_error and add_validation_pass to report results.
138+
"""
139+
try:
140+
self.model.model_validate(data, strict=strict)
141+
self.add_validation_pass()
142+
except ValidationError as err:
143+
self.add_validation_error(str(err))
144+
```
145+
146+
### Pydantic Models in Validators Directory
147+
148+
The Pydantic models can be located in any Python file within this directory (new or existing). The only requirement is these are valid Pydantic `BaseModel` subclasses.
149+
150+
These will be loaded and can be referenced by their class name. For example, `CheckHostname` will show up as `CheckHostname`.
151+
152+
```python
153+
"""Validate hostname is valid."""
154+
from pydantic import BaseModel
155+
156+
157+
class CheckHostname(BaseModel):
158+
"""Validate hostname is valid."""
159+
160+
hostname: str
161+
```
162+
163+
```yaml
164+
# jsonschema: Hostname
165+
---
166+
hostname: "az-phx-rtr01"
167+
```
168+
169+
### Load from External Library
170+
171+
As an example, we will look at models that are within our `my_custom_pydantic_models.manager`. If a **prefix** is defined, you can reference the validators like `f"{prefix}/{model.__name__}`.
172+
173+
```python
174+
"""Load our models to be used for Schema Enforcer."""
175+
from pydantic import BaseModel
176+
from schema_enforcer.schemas.manager import PydanticManager
177+
178+
179+
class Hostname(BaseModel):
180+
hostname: str = Field(pattern="^[a-z]{2}-[a-z]{3}-[a-z]{1,2}[0-9]{2}$")
181+
182+
183+
# Prefix is optional and will default to a blank string, aka no prefix.
184+
# Models is required to pass in custom Pydantic models.
185+
manager = PydanticManager(prefix="custom", models=[Hostname])
186+
```
187+
188+
```toml
189+
[tool.schema_enforcer]
190+
pydantic_validators = [
191+
"my_custom_pydantic_models.manager"
192+
]
193+
```
194+
195+
An example YAML file schema correlation would look like:
196+
197+
```yaml
198+
# jsonschema: custom/Hostname
199+
---
200+
hostname: "az-phx-pe01"
201+
```
202+
107203
## Running validators
108204

109205
Custom validators are run with `schema-enforcer validate` and `schema-enforcer ansible` commands.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ exclude = '''
6363
| dist
6464
)/
6565
'''
66+
6667
[tool.pylint.master]
6768
ignore=".venv"
6869

schema_enforcer/cli.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@ def main():
2323
"""
2424

2525

26-
@click.option("--show-pass", default=False, help="Shows validation checks that passed", is_flag=True, show_default=True)
26+
@click.option(
27+
"--show-pass",
28+
default=False,
29+
help="Shows validation checks that passed",
30+
is_flag=True,
31+
show_default=True,
32+
)
2733
@click.option(
2834
"--strict",
2935
default=False,
@@ -135,7 +141,11 @@ def validate(show_pass, show_checks, strict): # noqa D205
135141
is_flag=True,
136142
)
137143
@click.option(
138-
"--schema-id", default=None, cls=MutuallyExclusiveOption, mutually_exclusive=["list"], help="The name of a schema."
144+
"--schema-id",
145+
default=None,
146+
cls=MutuallyExclusiveOption,
147+
mutually_exclusive=["list"],
148+
help="The name of a schema.",
139149
)
140150
@main.command()
141151
def schema(check, generate_invalid, list_schemas, schema_id, dump_schemas): # noqa: D417,D301,D205
@@ -192,8 +202,20 @@ def schema(check, generate_invalid, list_schemas, schema_id, dump_schemas): # n
192202

193203
@main.command()
194204
@click.option("--inventory", "-i", help="Ansible inventory file.", required=False)
195-
@click.option("--host", "-h", "limit", help="Limit the execution to a single host.", required=False)
196-
@click.option("--show-pass", default=False, help="Shows validation checks that passed", is_flag=True, show_default=True)
205+
@click.option(
206+
"--host",
207+
"-h",
208+
"limit",
209+
help="Limit the execution to a single host.",
210+
required=False,
211+
)
212+
@click.option(
213+
"--show-pass",
214+
default=False,
215+
help="Shows validation checks that passed",
216+
is_flag=True,
217+
show_default=True,
218+
)
197219
@click.option(
198220
"--show-checks",
199221
default=False,

schema_enforcer/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import sys
55
from pathlib import Path
66
from typing import Dict, List, Optional
7+
from typing_extensions import Annotated
78

89
import toml
910
from pydantic import Field, ValidationError
@@ -31,6 +32,7 @@ class Settings(BaseSettings): # pylint: disable=too-few-public-methods
3132
definition_directory: str = "definitions"
3233
schema_directory: str = "schemas"
3334
validator_directory: str = "validators"
35+
pydantic_validators: Optional[List[Annotated[str, Field(pattern="^.*:.*$")]]] = Field(default_factory=list)
3436
test_directory: str = "tests"
3537

3638
# Settings specific to the schema files

schema_enforcer/instances/file.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import re
44
import itertools
55
from pathlib import Path
6+
from ruamel.yaml.comments import CommentedMap
67
from schema_enforcer.utils import find_files, load_file
78

89
SCHEMA_TAG = "jsonschema"
@@ -23,7 +24,6 @@ def __init__(self, config):
2324
self.config = config
2425

2526
# Find all instance files
26-
# TODO need to load file extensions from the config
2727
instance_files = find_files(
2828
file_extensions=config.data_file_extensions,
2929
search_directories=config.data_file_search_directories,
@@ -97,7 +97,24 @@ def top_level_properties(self):
9797
"""
9898
if not self._top_level_properties:
9999
content = self._get_content()
100-
self._top_level_properties = set(content.keys())
100+
# TODO: Investigate and see if we should be checking this on initialization if the file doesn't exists or is empty.
101+
if not content:
102+
return self._top_level_properties
103+
104+
if isinstance(content, CommentedMap) or hasattr(content, "keys"):
105+
self._top_level_properties = set(content.keys())
106+
elif isinstance(content, str):
107+
self._top_level_properties = set([content])
108+
elif isinstance(content, list):
109+
properties = set()
110+
for m in content:
111+
if isinstance(m, dict) or hasattr(m, "keys"):
112+
properties.update(m.keys())
113+
else:
114+
properties.add(m)
115+
self._top_level_properties = properties
116+
else:
117+
self._top_level_properties = set(content)
101118

102119
return self._top_level_properties
103120

schema_enforcer/schemas/manager.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
from termcolor import colored
88
from rich.console import Console
99
from rich.table import Table
10+
from typing import List, Optional, Type
11+
12+
from pydantic import BaseModel
1013

1114
from schema_enforcer.utils import load_file, find_file, find_files, dump_data_to_yaml
1215
from schema_enforcer.validation import ValidationResult, RESULT_PASS, RESULT_FAIL
@@ -45,7 +48,7 @@ def __init__(self, config):
4548
self.schemas[schema.get_id()] = schema
4649

4750
# Load validators
48-
validators = load_validators(config.validator_directory)
51+
validators = load_validators(config.validator_directory, config.pydantic_validators)
4952
self.schemas.update(validators)
5053

5154
def create_schema_from_file(self, root, filename):
@@ -94,7 +97,12 @@ def print_schemas_list(self):
9497
table.add_column("Location")
9598
table.add_column("Filename")
9699
for schema_id, schema in self.iter_schemas():
97-
table.add_row(schema_id, schema.schematype, schema.root.replace(current_dir, "."), schema.filename)
100+
table.add_row(
101+
schema_id,
102+
schema.schematype,
103+
schema.root.replace(current_dir, "."),
104+
schema.filename,
105+
)
98106
console.print(table)
99107

100108
def dump_schema(self, schema_id=None):
@@ -336,3 +344,10 @@ def _ensure_results_invalid(results, data_file):
336344
if "PASS" in results_pass_or_fail:
337345
error(f"{data_file} is schema valid, but should be schema invalid as it defines an invalid test")
338346
sys.exit(1)
347+
348+
349+
class PydanticManager(BaseModel):
350+
"""Class for managing Pydantic models and adding them to the SchemaManager."""
351+
352+
prefix: Optional[str] = ""
353+
models: List[Type[BaseModel]]

0 commit comments

Comments
 (0)