-
Notifications
You must be signed in to change notification settings - Fork 25
Add RestSend framework, enums, and shared unit test infrastructure #185
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
43 commits
Select commit
Hold shift + click to select a range
c579008
Add RestSend framework: RestSend, Results, ResponseHandler, Sender
allenrobel 5ef139a
Add enums.py and shared unit test infrastructure
allenrobel eadf98a
Add nd_v2.py as a reference example
allenrobel d8d6fa9
Add Usage section
allenrobel c34cd0b
Wire NdV1Strategy into ResponseHandler via strategy pattern
allenrobel 146b369
Remove references to ND 3.x and NDFC
allenrobel b413b10
Remove unused import: inspect
allenrobel 6647191
Update docstrings to indicate optionality of register_task_result
allenrobel 2002e08
Reorganize module_utils into common/ and rest/ subdirectories
allenrobel f2b6711
Lint, update docstrings, remove implements property
allenrobel 3c3a9bf
Move NDErrorData and NDModuleError to common/exceptions.py
allenrobel 0ff98fb
Add pyproject.toml and uv.lock from rest_send_integration
allenrobel b2ce688
Improve pydantic_compat.py: consolidate TYPE_CHECKING block, add requ…
allenrobel 9ee6b7a
Remove self._current.state from conditional
allenrobel 9b91c2c
Remove pyproject.toml and uv.lock
allenrobel e513963
Add pylint suppresession directives
allenrobel 96fa949
Update RestSend unit tests (implements property)
allenrobel 4be47fe
Remove too-many-positional-arguments pylint directive
allenrobel a879f03
Fix Python 3.7 compat: fallback import for Protocol/runtime_checkable
allenrobel f21a6b8
Fix Python 3.7 compat: add stub fallback for Protocol/runtime_checkable
allenrobel 9499c13
Add tests/sanity/config.yml
allenrobel d7c9f36
Appease linters
allenrobel 51beb08
Fix future-import-boilerplate sanity errors: use isort: off/on block
allenrobel 8f6b51f
Move config.yml under tests/
allenrobel cb8a9f8
Fix black sanity errors: add fmt: off/on around future imports
allenrobel ba44bfa
Remove error_codes/is_error from response validation strategy
allenrobel b0032d7
Join all messages/errors into a single string in NdV1Strategy
allenrobel d22f1e7
Expand is_success to check full response, not just status code
allenrobel b08e34f
Honour modified response header when determining changed state
allenrobel 6837fb1
Fix tests/sanity/requirements.txt: remove redundant python_version gu…
allenrobel 8bbcc2e
Rename Pydantic models and method to use API-call-scoped names
allenrobel ec8d798
Derive changed/failed from registered tasks instead of duplicate sets
allenrobel e43db59
Add deprecation notices to response_data and add_response_data
allenrobel e7e14a3
Remove _response_data storage; derive from registered tasks
allenrobel 2002a82
Appease PEP8 linter
allenrobel 4abbbb4
Add path, verb, payload, and verbosity_level to Results system
allenrobel 55bf954
Fix docstrings: say "API calls" not "tasks" for per-call properties
allenrobel ddbf0de
Fix docstring typo
allenrobel f45ef80
Remove __metaclass__ = type from results.py and rest_send.py
allenrobel 1fdc4e4
Revert ddbf0de until after merge
allenrobel a19575f
Remove unused FinalResultData import from test_results.py
allenrobel 7d2838b
Replace asymmetric setters with public methods; pluralize list proper…
allenrobel 14b8c48
Remove deprecated add_response_data() and response_data property
allenrobel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Empty file.
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
|
allenrobel marked this conversation as resolved.
|
||
| 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: | ||
|
lhercot marked this conversation as resolved.
|
||
| """ | ||
| # 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", | ||
| ] | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.