Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c579008
Add RestSend framework: RestSend, Results, ResponseHandler, Sender
allenrobel Mar 2, 2026
5ef139a
Add enums.py and shared unit test infrastructure
allenrobel Mar 2, 2026
eadf98a
Add nd_v2.py as a reference example
allenrobel Mar 3, 2026
d8d6fa9
Add Usage section
allenrobel Mar 3, 2026
c34cd0b
Wire NdV1Strategy into ResponseHandler via strategy pattern
allenrobel Mar 3, 2026
146b369
Remove references to ND 3.x and NDFC
allenrobel Mar 3, 2026
b413b10
Remove unused import: inspect
allenrobel Mar 3, 2026
6647191
Update docstrings to indicate optionality of register_task_result
allenrobel Mar 3, 2026
2002e08
Reorganize module_utils into common/ and rest/ subdirectories
allenrobel Mar 4, 2026
f2b6711
Lint, update docstrings, remove implements property
allenrobel Mar 4, 2026
3c3a9bf
Move NDErrorData and NDModuleError to common/exceptions.py
allenrobel Mar 4, 2026
0ff98fb
Add pyproject.toml and uv.lock from rest_send_integration
allenrobel Mar 4, 2026
b2ce688
Improve pydantic_compat.py: consolidate TYPE_CHECKING block, add requ…
allenrobel Mar 4, 2026
9ee6b7a
Remove self._current.state from conditional
allenrobel Mar 5, 2026
9b91c2c
Remove pyproject.toml and uv.lock
allenrobel Mar 5, 2026
e513963
Add pylint suppresession directives
allenrobel Mar 5, 2026
96fa949
Update RestSend unit tests (implements property)
allenrobel Mar 5, 2026
4be47fe
Remove too-many-positional-arguments pylint directive
allenrobel Mar 5, 2026
a879f03
Fix Python 3.7 compat: fallback import for Protocol/runtime_checkable
allenrobel Mar 5, 2026
f21a6b8
Fix Python 3.7 compat: add stub fallback for Protocol/runtime_checkable
allenrobel Mar 5, 2026
9499c13
Add tests/sanity/config.yml
allenrobel Mar 5, 2026
d7c9f36
Appease linters
allenrobel Mar 5, 2026
51beb08
Fix future-import-boilerplate sanity errors: use isort: off/on block
allenrobel Mar 5, 2026
8f6b51f
Move config.yml under tests/
allenrobel Mar 5, 2026
cb8a9f8
Fix black sanity errors: add fmt: off/on around future imports
allenrobel Mar 5, 2026
ba44bfa
Remove error_codes/is_error from response validation strategy
allenrobel Mar 9, 2026
b0032d7
Join all messages/errors into a single string in NdV1Strategy
allenrobel Mar 9, 2026
d22f1e7
Expand is_success to check full response, not just status code
allenrobel Mar 9, 2026
b08e34f
Honour modified response header when determining changed state
allenrobel Mar 9, 2026
6837fb1
Fix tests/sanity/requirements.txt: remove redundant python_version gu…
allenrobel Mar 9, 2026
8bbcc2e
Rename Pydantic models and method to use API-call-scoped names
allenrobel Mar 9, 2026
ec8d798
Derive changed/failed from registered tasks instead of duplicate sets
allenrobel Mar 9, 2026
e43db59
Add deprecation notices to response_data and add_response_data
allenrobel Mar 9, 2026
e7e14a3
Remove _response_data storage; derive from registered tasks
allenrobel Mar 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
Empty file.
146 changes: 146 additions & 0 deletions plugins/module_utils/common/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-

# Copyright: (c) 2026, Allen Robel (@arobel) <arobel@cisco.com>

# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
"""
# exceptions.py

Exception classes for the cisco.nd Ansible collection.
"""

# isort: off
# fmt: off
from __future__ import (absolute_import, division, print_function)
from __future__ import annotations
# fmt: on
# isort: on

# pylint: disable=invalid-name
__metaclass__ = type
# pylint: enable=invalid-name

from typing import Any, Optional

from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import (
BaseModel,
ConfigDict,
)


class NDErrorData(BaseModel):
"""
# Summary

Pydantic model for structured error data from NDModule requests.

This model provides type-safe error information that can be serialized
to a dict for use with Ansible's fail_json.

## Attributes

- msg: Human-readable error message (required)
- status: HTTP status code as integer (optional)
- request_payload: Request payload that was sent (optional)
- response_payload: Response payload from controller (optional)
- raw: Raw response content for non-JSON responses (optional)

## Raises

- None
"""

model_config = ConfigDict(extra="forbid")

msg: str
status: Optional[int] = None
request_payload: Optional[dict[str, Any]] = None
response_payload: Optional[dict[str, Any]] = None
raw: Optional[Any] = None


class NDModuleError(Exception):
"""
# Summary

Exception raised by NDModule when a request fails.

This exception wraps an NDErrorData Pydantic model, providing structured
error information that can be used by callers to build appropriate error
responses (e.g., Ansible fail_json).

## Usage Example

```python
try:
data = nd.request("/api/v1/endpoint", HttpVerbEnum.POST, payload)
except NDModuleError as e:
print(f"Error: {e.msg}")
print(f"Status: {e.status}")
if e.response_payload:
print(f"Response: {e.response_payload}")
# Use to_dict() for fail_json
module.fail_json(**e.to_dict())
```

## Raises

- None
"""

# pylint: disable=too-many-arguments
def __init__(
self,
msg: str,
status: Optional[int] = None,
request_payload: Optional[dict[str, Any]] = None,
response_payload: Optional[dict[str, Any]] = None,
raw: Optional[Any] = None,
) -> None:
self.error_data = NDErrorData(
msg=msg,
status=status,
request_payload=request_payload,
response_payload=response_payload,
raw=raw,
)
super().__init__(msg)

@property
def msg(self) -> str:
"""Human-readable error message."""
return self.error_data.msg

@property
def status(self) -> Optional[int]:
"""HTTP status code."""
return self.error_data.status

@property
def request_payload(self) -> Optional[dict[str, Any]]:
"""Request payload that was sent."""
return self.error_data.request_payload

@property
def response_payload(self) -> Optional[dict[str, Any]]:
"""Response payload from controller."""
return self.error_data.response_payload

@property
def raw(self) -> Optional[Any]:
"""Raw response content for non-JSON responses."""
return self.error_data.raw

def to_dict(self) -> dict[str, Any]:
"""
# Summary

Convert exception attributes to a dict for use with fail_json.

Returns a dict containing only non-None attributes.

## Raises

- None
"""
return self.error_data.model_dump(exclude_none=True)
243 changes: 243 additions & 0 deletions plugins/module_utils/common/pydantic_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
# -*- coding: utf-8 -*-

# Copyright: (c) 2026, Allen Robel (@arobel) <arobel@cisco.com>

# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)

# pylint: disable=too-few-public-methods
"""
# Summary
Pydantic compatibility layer.
This module provides a single location for Pydantic imports with fallback
implementations when Pydantic is not available. This ensures consistent
behavior across all modules and follows the DRY principle.
## Usage
### Importing
Rather than importing directly from pydantic, import from this module:
```python
from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import BaseModel
```
This ensure that Ansible sanity tests will not fail due to missing Pydantic dependencies.
"""

# isort: off
# fmt: off
from __future__ import (absolute_import, division, print_function)
from __future__ import annotations
# fmt: on
# isort: on

# pylint: disable=invalid-name
__metaclass__ = type
# pylint: enable=invalid-name

import traceback
from typing import TYPE_CHECKING, Any, Callable, Union

if TYPE_CHECKING:
# Type checkers always see the real Pydantic types
from pydantic import (
AfterValidator,
BaseModel,
BeforeValidator,
ConfigDict,
Field,
PydanticExperimentalWarning,
StrictBool,
ValidationError,
field_serializer,
field_validator,
model_validator,
validator,
)

HAS_PYDANTIC = True # pylint: disable=invalid-name
PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name
else:
# Runtime: try to import, with fallback
try:
from pydantic import (
AfterValidator,
BaseModel,
BeforeValidator,
ConfigDict,
Field,
PydanticExperimentalWarning,
StrictBool,
ValidationError,
field_serializer,
field_validator,
model_validator,
validator,
)
except ImportError:
HAS_PYDANTIC = False # pylint: disable=invalid-name
PYDANTIC_IMPORT_ERROR: Union[str, None] = traceback.format_exc() # pylint: disable=invalid-name

# Fallback: Minimal BaseModel replacement
class BaseModel:
"""Fallback BaseModel when pydantic is not available."""

model_config = {"validate_assignment": False, "use_enum_values": False}

def __init__(self, **kwargs):
"""Accept keyword arguments and set them as attributes."""
for key, value in kwargs.items():
setattr(self, key, value)

def model_dump(self, exclude_none: bool = False, exclude_defaults: bool = False) -> dict: # pylint: disable=unused-argument
"""Return a dictionary of field names and values.
Args:
exclude_none: If True, exclude fields with None values
exclude_defaults: Accepted for API compatibility but not implemented in fallback
"""
result = {}
for key, value in self.__dict__.items():
if exclude_none and value is None:
continue
result[key] = value
return result

# Fallback: ConfigDict that does nothing
def ConfigDict(**kwargs) -> dict: # pylint: disable=unused-argument,invalid-name
"""Pydantic ConfigDict fallback when pydantic is not available."""
return kwargs

# Fallback: Field that does nothing
def Field(**kwargs) -> Any: # pylint: disable=unused-argument,invalid-name
"""Pydantic Field fallback when pydantic is not available."""
if "default_factory" in kwargs:
return kwargs["default_factory"]()
return kwargs.get("default")

# Fallback: field_serializer decorator that does nothing
def field_serializer(*args, **kwargs): # pylint: disable=unused-argument
"""Pydantic field_serializer fallback when pydantic is not available."""

def decorator(func):
return func

return decorator

# Fallback: field_validator decorator that does nothing
def field_validator(*args, **kwargs) -> Callable[..., Any]: # pylint: disable=unused-argument,invalid-name
"""Pydantic field_validator fallback when pydantic is not available."""

def decorator(func):
return func

return decorator

# Fallback: AfterValidator that returns the function unchanged
def AfterValidator(func): # pylint: disable=invalid-name
"""Pydantic AfterValidator fallback when pydantic is not available."""
return func

# Fallback: BeforeValidator that returns the function unchanged
def BeforeValidator(func): # pylint: disable=invalid-name
"""Pydantic BeforeValidator fallback when pydantic is not available."""
return func

# Fallback: PydanticExperimentalWarning
PydanticExperimentalWarning = Warning

# Fallback: StrictBool
StrictBool = bool

# Fallback: ValidationError
class ValidationError(Exception):
"""
Pydantic ValidationError fallback when pydantic is not available.
"""

def __init__(self, message="A custom error occurred."):
self.message = message
super().__init__(self.message)

def __str__(self):
return f"ValidationError: {self.message}"

# Fallback: model_validator decorator that does nothing
def model_validator(*args, **kwargs): # pylint: disable=unused-argument
"""Pydantic model_validator fallback when pydantic is not available."""

def decorator(func):
return func

return decorator

# Fallback: validator decorator that does nothing
def validator(*args, **kwargs): # pylint: disable=unused-argument
"""Pydantic validator fallback when pydantic is not available."""

def decorator(func):
return func

return decorator

else:
HAS_PYDANTIC = True # pylint: disable=invalid-name
PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name


def require_pydantic(module) -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we discussed yesterday I would adapt this to raise an exception and not use module / fail_json in here specifically so we keep it all in the module itself.

Is there a clever way for us to know this is executed from main() and just raised the exception without forcing it to call this method in every module main()?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @lhercot , there's no clever solution here that reduces total boilerplate, rather just one that distributes it differently. The current approach is pragmatic for Ansible. The module parameter is a mild coupling but it gives us missing_required_lib's installation instructions, the traceback, and proper exit formatting for free.

There may be a way to use a @requires_pydantic decorator on main(), which would cleaner than an explicit call, but would require main() to accept an instantiated ansible_module as an argument, which isn't really standard Ansible practice (I'm not sure this would even work). Can we explore this in a separate PR (in the interest of time)?

  import functools

  def requires_pydantic(func):
      """Decorator for module main() functions that require pydantic."""
      @functools.wraps(func)
      def wrapper(module, *args, **kwargs):
          if not HAS_PYDANTIC:
              from ansible.module_utils.basic import missing_required_lib
              module.fail_json(msg=missing_required_lib("pydantic"), exception=PYDANTIC_IMPORT_ERROR)
          return func(module, *args, **kwargs)
      return wrapper

  Then in each module:

  from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import requires_pydantic

  @requires_pydantic
  def main(module):
      # pydantic is guaranteed available here
      ...

"""
# Summary
Call `module.fail_json` if pydantic is not installed.
Intended to be called once at the top of a module's `main()` function,
immediately after `AnsibleModule` is instantiated, to provide a clear
error message when pydantic is a required dependency.
## Example
```python
from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import require_pydantic
def main():
module = AnsibleModule(argument_spec=...)
require_pydantic(module)
```
## Raises
None
## Notes
- Does nothing if pydantic is installed.
- Uses Ansible's `missing_required_lib` to produce a standardized error
message that includes installation instructions.
"""
if not HAS_PYDANTIC:
from ansible.module_utils.basic import missing_required_lib # pylint: disable=import-outside-toplevel

module.fail_json(msg=missing_required_lib("pydantic"), exception=PYDANTIC_IMPORT_ERROR)


__all__ = [
"AfterValidator",
"BaseModel",
"BeforeValidator",
"ConfigDict",
"Field",
"HAS_PYDANTIC",
"PYDANTIC_IMPORT_ERROR",
"PydanticExperimentalWarning",
"StrictBool",
"ValidationError",
"field_serializer",
"field_validator",
"model_validator",
"require_pydantic",
"validator",
]
Loading
Loading