diff --git a/hyperglass/configuration/__init__.py b/hyperglass/configuration/__init__.py index c0334ba..4d42515 100644 --- a/hyperglass/configuration/__init__.py +++ b/hyperglass/configuration/__init__.py @@ -1,4 +1,5 @@ """hyperglass Configuration.""" + # Standard Library import typing as t diff --git a/hyperglass/exceptions/_common.py b/hyperglass/exceptions/_common.py index 0bec60f..e10b6e7 100644 --- a/hyperglass/exceptions/_common.py +++ b/hyperglass/exceptions/_common.py @@ -68,7 +68,6 @@ class HyperglassError(Exception): return template.format(**kwargs) def _parse_pydantic_errors(*errors: Dict[str, Any]) -> str: - errs = ("\n",) for err in errors: @@ -121,10 +120,16 @@ class PublicHyperglassError(HyperglassError): def __init__(self, **kwargs: str) -> None: """Format error message with keyword arguments.""" + from hyperglass.state import use_state + if "error" in kwargs: error = kwargs.pop("error") error = self._safe_format(str(error), **kwargs) kwargs["error"] = error + + (messages := use_state("params").messages) + if messages.has(self._message_template): + self._message_template = messages[self._message_template] self._message = self._safe_format(self._message_template, **kwargs) self._keywords = list(kwargs.values()) super().__init__(message=self._message, level=self._level, keywords=self._keywords) diff --git a/hyperglass/exceptions/public.py b/hyperglass/exceptions/public.py index 1d6b91d..9cc692b 100644 --- a/hyperglass/exceptions/public.py +++ b/hyperglass/exceptions/public.py @@ -3,8 +3,6 @@ # Standard Library from typing import TYPE_CHECKING, Any, Dict, Optional -# Project -from hyperglass.state import use_state # Local from ._common import PublicHyperglassError @@ -14,13 +12,10 @@ if TYPE_CHECKING: from hyperglass.models.api.query import Query from hyperglass.models.config.devices import Device -(MESSAGES := use_state("params").messages) -(TEXT := use_state("params").web.text) - class ScrapeError( PublicHyperglassError, - template=MESSAGES.connection_error, + template="connection_error", level="danger", ): """Raised when an SSH driver error occurs.""" @@ -30,7 +25,7 @@ class ScrapeError( super().__init__(error=str(error), device=device.name, proxy=device.proxy) -class AuthError(PublicHyperglassError, template=MESSAGES.authentication_error, level="danger"): +class AuthError(PublicHyperglassError, template="authentication_error", level="danger"): """Raised when authentication to a device fails.""" def __init__(self, *, error: BaseException, device: "Device"): @@ -38,7 +33,7 @@ class AuthError(PublicHyperglassError, template=MESSAGES.authentication_error, l super().__init__(error=str(error), device=device.name, proxy=device.proxy) -class RestError(PublicHyperglassError, template=MESSAGES.connection_error, level="danger"): +class RestError(PublicHyperglassError, template="connection_error", level="danger"): """Raised upon a rest API client error.""" def __init__(self, *, error: BaseException, device: "Device"): @@ -46,7 +41,7 @@ class RestError(PublicHyperglassError, template=MESSAGES.connection_error, level super().__init__(error=str(error), device=device.name) -class DeviceTimeout(PublicHyperglassError, template=MESSAGES.request_timeout, level="danger"): +class DeviceTimeout(PublicHyperglassError, template="request_timeout", level="danger"): """Raised when the connection to a device times out.""" def __init__(self, *, error: BaseException, device: "Device"): @@ -54,7 +49,7 @@ class DeviceTimeout(PublicHyperglassError, template=MESSAGES.request_timeout, le super().__init__(error=str(error), device=device.name, proxy=device.proxy) -class InvalidQuery(PublicHyperglassError, template=MESSAGES.invalid_query): +class InvalidQuery(PublicHyperglassError, template="request_timeout"): """Raised when input validation fails.""" def __init__( @@ -74,7 +69,7 @@ class InvalidQuery(PublicHyperglassError, template=MESSAGES.invalid_query): super().__init__(**kwargs) -class NotFound(PublicHyperglassError, template=MESSAGES.not_found): +class NotFound(PublicHyperglassError, template="not_found"): """Raised when an object is not found.""" def __init__(self, type: str, name: str, **kwargs: Dict[str, str]) -> None: @@ -87,8 +82,11 @@ class QueryLocationNotFound(NotFound): def __init__(self, location: Any, **kwargs: Dict[str, Any]) -> None: """Initialize a NotFound error for a query location.""" + from hyperglass.state import use_state - super().__init__(type=TEXT.query_location, name=str(location), **kwargs) + (text := use_state("params").web.text) + + super().__init__(type=text.query_location, name=str(location), **kwargs) class QueryTypeNotFound(NotFound): @@ -96,10 +94,13 @@ class QueryTypeNotFound(NotFound): def __init__(self, query_type: Any, **kwargs: Dict[str, Any]) -> None: """Initialize a NotFound error for a query type.""" - super().__init__(type=TEXT.query_type, name=str(query_type), **kwargs) + from hyperglass.state import use_state + + (text := use_state("params").web.text) + super().__init__(type=text.query_type, name=str(query_type), **kwargs) -class InputInvalid(PublicHyperglassError, template=MESSAGES.invalid_input): +class InputInvalid(PublicHyperglassError, template="invalid_input"): """Raised when input validation fails.""" def __init__( @@ -115,7 +116,7 @@ class InputInvalid(PublicHyperglassError, template=MESSAGES.invalid_input): super().__init__(**kwargs) -class InputNotAllowed(PublicHyperglassError, template=MESSAGES.target_not_allowed): +class InputNotAllowed(PublicHyperglassError, template="target_not_allowed"): """Raised when input validation fails due to a configured check.""" def __init__( @@ -135,7 +136,7 @@ class InputNotAllowed(PublicHyperglassError, template=MESSAGES.target_not_allowe super().__init__(**kwargs) -class ResponseEmpty(PublicHyperglassError, template=MESSAGES.no_output): +class ResponseEmpty(PublicHyperglassError, template="no_output"): """Raised when hyperglass can connect to the device but the response is empty.""" def __init__( diff --git a/hyperglass/execution/drivers/tests/test_construct.py b/hyperglass/execution/drivers/tests/test_construct.py index a45c332..6810d77 100644 --- a/hyperglass/execution/drivers/tests/test_construct.py +++ b/hyperglass/execution/drivers/tests/test_construct.py @@ -1,16 +1,14 @@ # Project from hyperglass.models.api import Query -from hyperglass.configuration import init_user_config -from hyperglass.models.directive import Directives -from hyperglass.models.config.devices import Devices +from hyperglass.state import use_state +from hyperglass.test import initialize_state # Local from .._construct import Construct def test_construct(): - - devices = Devices( + devices = [ { "name": "test1", "address": "127.0.0.1", @@ -19,15 +17,24 @@ def test_construct(): "attrs": {"source4": "192.0.2.1", "source6": "2001:db8::1"}, "directives": ["juniper_bgp_route"], } - ) - directives = Directives( - {"juniper_bgp_route": {"name": "BGP Route", "plugins": [], "rules": [], "groups": []}} - ) - init_user_config(devices=devices, directives=directives) + ] + directives = [ + { + "juniper_bgp_route": { + "name": "BGP Route", + "field": {"description": "test"}, + } + } + ] + + initialize_state(params={}, directives=directives, devices=devices) + + state = use_state() + query = Query( queryLocation="test1", queryTarget="192.0.2.0/24", queryType="juniper_bgp_route", ) - constructor = Construct(device=devices["test1"], query=query) + constructor = Construct(device=state.devices["test1"], query=query) assert constructor.target == "192.0.2.0/24" diff --git a/hyperglass/models/api/query.py b/hyperglass/models/api/query.py index 5d501e4..eef03cf 100644 --- a/hyperglass/models/api/query.py +++ b/hyperglass/models/api/query.py @@ -20,7 +20,6 @@ from hyperglass.exceptions.private import InputValidationError # Local from ..config.devices import Device -(TEXT := use_state("params").web.text) QueryLocation = constr(strip_whitespace=True, strict=True, min_length=1) QueryTarget = constr(strip_whitespace=True, min_length=1) diff --git a/hyperglass/models/config/devices.py b/hyperglass/models/config/devices.py index fc191e0..6b16a88 100644 --- a/hyperglass/models/config/devices.py +++ b/hyperglass/models/config/devices.py @@ -143,7 +143,6 @@ class Device(HyperglassModelWithId, extra="allow"): return self.platform def _validate_directive_attrs(self) -> None: - # Set of all keys except for built-in key `target`. keys = { key diff --git a/hyperglass/models/config/messages.py b/hyperglass/models/config/messages.py index 0b58baf..c9bfe4c 100644 --- a/hyperglass/models/config/messages.py +++ b/hyperglass/models/config/messages.py @@ -75,6 +75,17 @@ class Messages(HyperglassModel): description="Displayed when hyperglass can connect to a device and execute a query, but the response is empty.", ) + def has(self, attr: str) -> bool: + """Determine if message type exists in Messages model.""" + return attr in self.dict().keys() + + def __getitem__(self, attr: str) -> StrictStr: + """Make messages subscriptable.""" + + if not self.has(attr): + raise KeyError(f"'{attr}' does not exist on Messages model") + return getattr(self, attr) + class Config: """Pydantic model configuration.""" diff --git a/hyperglass/models/directive.py b/hyperglass/models/directive.py index 9fdc4a7..101b0f1 100644 --- a/hyperglass/models/directive.py +++ b/hyperglass/models/directive.py @@ -253,8 +253,8 @@ class Directive(HyperglassUniqueModel, unique_by=("id", "table_output")): id: StrictStr name: StrictStr - rules: t.List[RuleType] - field: t.Union[Text, Select, None] + rules: t.List[RuleType] = [RuleWithPattern(condition="*")] + field: t.Union[Text, Select] info: t.Optional[FilePath] plugins: t.List[StrictStr] = [] table_output: t.Optional[StrictStr] @@ -350,3 +350,13 @@ class Directives(MultiModel[Directive], model=Directive, unique_by="id"): if _directive.id == directive.table_output: return _directive return directive + + @classmethod + def new(cls, /, *raw_directives: t.Dict[str, t.Any]) -> "Directives": + """Create a new Directives collection from raw directive configurations.""" + directives = ( + Directive(id=name, **directive) + for raw_directive in raw_directives + for name, directive in raw_directive.items() + ) + return Directives(*directives) diff --git a/hyperglass/test/__init__.py b/hyperglass/test/__init__.py new file mode 100644 index 0000000..6642c90 --- /dev/null +++ b/hyperglass/test/__init__.py @@ -0,0 +1,4 @@ +"""Global test helpers.""" +from .state import initialize_state + +__all__ = ("initialize_state",) diff --git a/hyperglass/test/state.py b/hyperglass/test/state.py new file mode 100644 index 0000000..bf97bfb --- /dev/null +++ b/hyperglass/test/state.py @@ -0,0 +1,35 @@ +"""State-related test helpers.""" + +import typing as t + +from hyperglass.state import use_state +from hyperglass.models.config.params import Params +from hyperglass.models.config.devices import Devices +from hyperglass.models.directive import Directives +from hyperglass.configuration import init_ui_params + + +def initialize_state( + *, + params: t.Dict[str, t.Any], + directives: t.Sequence[t.Dict[str, t.Any]], + devices: t.Sequence[t.Dict[str, t.Any]], +) -> None: + """Test fixture to initialize Redis store.""" + state = use_state() + _params = Params(**params) + _directives = Directives.new(*directives) + + with state.cache.pipeline() as pipeline: + # Write params and directives to the cache first to avoid a race condition where ui_params + # or devices try to access params or directives before they're available. + pipeline.set("params", _params) + pipeline.set("directives", _directives) + + # _devices = Devices.new(*devices) + _devices = Devices(*devices) + ui_params = init_ui_params(params=_params, devices=_devices) + + with state.cache.pipeline() as pipeline: + pipeline.set("devices", _devices) + pipeline.set("ui_params", ui_params)