Skip to content

Commit

Permalink
Use importlib.import_module instead of __import__. Add error logic wh…
Browse files Browse the repository at this point in the history
…en encountering invalid package/module when loading Pydantic models from an external package. Minor changes to address feedback.
  • Loading branch information
FragmentedPacket committed Mar 6, 2024
1 parent d2776ec commit e650f44
Show file tree
Hide file tree
Showing 9 changed files with 83 additions and 49 deletions.
32 changes: 24 additions & 8 deletions docs/custom_validators.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ by providing a class-level `id` variable.

Helper functions are provided to add pass/fail results:

```
```python
def add_validation_error(self, message: str, **kwargs):
"""Add validator error to results.
Args:
Expand All @@ -39,6 +39,7 @@ def add_validation_pass(self, **kwargs):
kwargs (optional): additional arguments to add to ValidationResult when required
"""
```

In most cases, you will not need to provide kwargs. However, if you find a use case that requires updating other fields
in the ValidationResult, you can send the key/value pairs to update the result directly. This is for advanced users only.

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

### Supported operators:
### Supported operators

The class provides the following operators for basic use cases:

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

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

### Examples:
### Examples

#### Basic
```

```python
from schema_enforcer.schemas.validator import JmesPathModelValidation

class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public-methods
Expand All @@ -89,7 +91,8 @@ class CheckInterface(JmesPathModelValidation): # pylint: disable=too-few-public
```

#### With compiled jmespath expression
```

```python
import jmespath
from schema_enforcer.schemas.validator import JmesPathModelValidation

Expand All @@ -113,6 +116,8 @@ Schema Enforcer supports utilizing Pydantic models for validation. Pydantic mode

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.

### Pydantic Models in External Libraries

```python
class PydanticValidation(BaseValidation):
"""Basic wrapper for Pydantic models to be used as validators."""
Expand All @@ -138,11 +143,22 @@ class PydanticValidation(BaseValidation):
self.add_validation_error(str(err))
```

### Storing in Validators Directory
### Pydantic Models in Validators Directory

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.

These will be loaded and can be referenced by their class name. For example, `CheckHostname` will show up as `CheckHostname`.

```python
"""Validate hostname is valid."""
from pydantic import BaseModel

The Pydantic models can be located in any Python file within this directory. The only requirement is these are valid Pydantic `BaseModel` subclasses.

These will be loaded and can be referenced by their class name. For example, `Hostname` will show up as `Hostname`.
class CheckHostname(BaseModel):
"""Validate hostname is valid."""

hostname: str
```

```yaml
# jsonschema: Hostname
Expand Down
42 changes: 18 additions & 24 deletions schema_enforcer/schemas/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from __future__ import annotations
from typing import List, Union
import pkgutil
import importlib
import inspect
import jmespath
from pydantic import BaseModel, ValidationError
Expand All @@ -25,21 +26,15 @@ def add_validation_error(self, message: str, **kwargs):
message (str): error message
kwargs (optional): additional arguments to add to ValidationResult when required
"""
self._results.append(
ValidationResult(
result="FAIL", schema_id=self.id, message=message, **kwargs
)
)
self._results.append(ValidationResult(result="FAIL", schema_id=self.id, message=message, **kwargs))

def add_validation_pass(self, **kwargs):
"""Add validator pass to results.
Args:
kwargs (optional): additional arguments to add to ValidationResult when required
"""
self._results.append(
ValidationResult(result="PASS", schema_id=self.id, **kwargs)
)
self._results.append(ValidationResult(result="PASS", schema_id=self.id, **kwargs))

def get_results(self) -> list[ValidationResult]:
"""Return all validation results for this validator."""
Expand Down Expand Up @@ -120,9 +115,7 @@ def validate(self, data: dict, strict: bool = False):
def is_validator(obj) -> bool:
"""Returns True if the object is a BaseValidation or JmesPathModelValidation subclass."""
try:
return (
issubclass(obj, BaseValidation) or issubclass(obj, BaseModel)
) and obj not in (
return (issubclass(obj, BaseValidation) or issubclass(obj, BaseModel)) and obj not in (
BaseModel,
BaseValidation,
JmesPathModelValidation,
Expand All @@ -138,9 +131,7 @@ def pydantic_validation_factory(orig_model) -> PydanticValidation:
(PydanticValidation,),
{
"id": f"{orig_model.id}",
"top_level_properties": set(
[property for property in orig_model.model_fields]
),
"top_level_properties": set([property for property in orig_model.model_fields]),
"model": orig_model,
},
)
Expand All @@ -153,20 +144,23 @@ def load_pydantic_validators(
validators = {}
for package in model_packages:
module_name, attr = package.split(":")
module = __import__(module_name, fromlist=[attr])
manager = getattr(module, attr)
try:
module = importlib.import_module(module_name)
except ModuleNotFoundError:
print(f"Unable to load the validator {package}, the module ({module_name}) does not exist.")
continue

manager = getattr(module, attr, None)
if not manager:
print(f"Unable to load the validator {package}, the module or attribute ({attr}) does not exist.")
continue

for model in manager.models:
model.id = (
f"{manager.prefix}/{model.__name__}"
if manager.prefix
else model.__name__
)
model.id = f"{manager.prefix}/{model.__name__}" if manager.prefix else model.__name__
cls = pydantic_validation_factory(model)

if cls.id in validators:
print(
f"Unable to load the validator {cls.id}, there is already a validator with the same name."
)
print(f"Unable to load the validator {cls.id}, there is already a validator with the same name.")
continue

validators[cls.id] = cls()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
---
# jsonschema: pydantic/Dns
dns_servers:
- "8.8.8.8"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
---
# jsonschema: pydantic/Dns
dns_servers:
- "8.8.8.8"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
---
# jsonschema: pydantic/Dns
dns_servers:
- "8.8.8.8"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
---
# jsonschema: pydantic/Dns
dns_servers:
- "8.8.8.8"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Validate hostname is valid."""
"""Validate interfaces are valid."""

from enum import Enum
from typing import Dict, Optional
Expand All @@ -22,6 +22,6 @@ class Interface(BaseModel):


class Interfaces(BaseModel):
"""Validate hostname is valid."""
"""Validate interfaces are valid."""

interfaces: Dict[str, Interface]
12 changes: 9 additions & 3 deletions tests/test_ansible_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@


@pytest.fixture(scope="module")
def ansible_inv(): # pylint: disable=unused-argument
def ansible_inv():
"""Ansible inventory fixture."""
return AnsibleInventory(INVENTORY_DIR)

Expand Down Expand Up @@ -55,7 +55,10 @@ def test_get_hosts_containing_var(ansible_inv):

def test_get_host_vars(ansible_inv):
expected = {
"dns_servers": [{"address": "10.7.7.7", "vrf": "mgmt"}, {"address": "10.8.8.8"}],
"dns_servers": [
{"address": "10.7.7.7", "vrf": "mgmt"},
{"address": "10.8.8.8"},
],
"group_names": ["ios", "na", "nyc"],
"inventory_hostname": "host3",
"ntp_servers": [{"address": "10.3.3.3"}],
Expand All @@ -80,7 +83,10 @@ def test_get_host_vars(ansible_inv):

def test_get_clean_host_vars(ansible_inv):
expected = {
"dns_servers": [{"address": "10.7.7.7", "vrf": "mgmt"}, {"address": "10.8.8.8"}],
"dns_servers": [
{"address": "10.7.7.7", "vrf": "mgmt"},
{"address": "10.8.8.8"},
],
"os_dns": [{"address": "10.7.7.7", "vrf": "mgmt"}, {"address": "10.8.8.8"}],
"region_dns": [{"address": "10.1.1.1", "vrf": "mgmt"}, {"address": "10.2.2.2"}],
"ntp_servers": [{"address": "10.3.3.3"}],
Expand Down
38 changes: 26 additions & 12 deletions tests/test_schemas_pydantic_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@
"pydantic_validators.models:manager1",
"pydantic_validators.models:manager2",
],
"data_file_search_directories": [
f"{FIXTURE_DIR}/test_validators_pydantic/inventory"
],
"data_file_search_directories": [f"{FIXTURE_DIR}/test_validators_pydantic/inventory"],
"ansible_inventory": f"{FIXTURE_DIR}/test_validators_pydantic/inventory/inventory.yml",
}

Expand Down Expand Up @@ -60,9 +58,7 @@ def instance_file_manager():
],
)
def test_pydantic_manager_validate_correct_schemas(schema, schema_manager_pydantic):
assert (
schema in schema_manager_pydantic.schemas
), f"Schema {schema} not found in {schema_manager_pydantic.schemas}"
assert schema in schema_manager_pydantic.schemas, f"Schema {schema} not found in {schema_manager_pydantic.schemas}"
assert len(schema_manager_pydantic.schemas) == 5, "There should be 5 schemas."


Expand All @@ -83,9 +79,7 @@ def test_pydantic_manager_validate_correct_files(file, instance_file_manager):
paths = [f"{f.full_path}/{f.filename}" for f in instance_file_manager.instances]
assert file in paths, f"File {file} not found in {paths}"
# 6 includes the `inventory.yml` for now.
assert (
len(instance_file_manager.instances) == 6
), "There should be 6 variable files found."
assert len(instance_file_manager.instances) == 6, "There should be 6 variable files found."


@mock.patch("schema_enforcer.config.load")
Expand Down Expand Up @@ -244,9 +238,7 @@ def test_pydantic_manager_ansible_show_checks_cli(_load):
"pydantic_validators": [
"pydantic_validators.models:manager1",
],
"data_file_search_directories": [
f"{FIXTURE_DIR}/test_validators_pydantic/inventory_fail"
],
"data_file_search_directories": [f"{FIXTURE_DIR}/test_validators_pydantic/inventory_fail"],
"ansible_inventory": f"{FIXTURE_DIR}/test_validators_pydantic/inventory_fail/inventory.yml",
}

Expand Down Expand Up @@ -311,3 +303,25 @@ def test_pydantic_manager_ansible_show_pass_cli_failure(_load):
Input is not a valid IPv6 address [type=ip_v6_address, input_value='2001:db8:16::yo', input_type=AnsibleUnicode]
"""
assert expected == result.output, result.output


@mock.patch("schema_enforcer.config.load")
def test_pydantic_manager_ansible_invalid_validator_package(_load):
runner = CliRunner()
with mock.patch(
"schema_enforcer.config.SETTINGS",
Settings(
**{
"pydantic_validators": [
"does_not_exist.models:manager1",
],
}
),
):
result = runner.invoke(cli.validate)
_load.assert_called_once()
assert result.exit_code == 1
expected = """Unable to load the validator does_not_exist.models:manager1, the module (does_not_exist.models) does not exist.
\x1b[31m ERROR |\x1b[0m No schemas were loaded
"""
assert expected == result.output, result.output

0 comments on commit e650f44

Please sign in to comment.