11import logging
2- from typing import Any , Dict , List
2+ from typing import Any , Dict , List , Type , TypeVar
33
44from pydantic import ValidationError
55
66from pyoaev import exceptions as exc
77from pyoaev .apis .inject_expectation .model import (
88 DetectionExpectation ,
99 ExpectationTypeEnum ,
10+ LoggingExpectation ,
1011 PreventionExpectation ,
1112)
13+ from pyoaev .apis .inject_expectation .model .expectation import Expectation
1214from pyoaev .base import RESTManager , RESTObject
1315from pyoaev .mixins import ListMixin , UpdateMixin
1416from pyoaev .utils import RequiredOptional
1517
1618logger = logging .getLogger (__name__ )
1719
20+ ExpectationModel = TypeVar ("ExpectationModel" , bound = Expectation )
21+
22+ # Expectation models built by expectations_models_for_source, by expectation type.
23+ # The /assets route only returns these two types.
24+ _ASSET_EXPECTATION_MODELS : Dict [str , Type [Expectation ]] = {
25+ ExpectationTypeEnum .Detection .value : DetectionExpectation ,
26+ ExpectationTypeEnum .Prevention .value : PreventionExpectation ,
27+ }
28+ _LOGGING_EXPECTATION_MODELS : Dict [str , Type [LoggingExpectation ]] = {
29+ ExpectationTypeEnum .Logging .value : LoggingExpectation ,
30+ }
31+
32+
33+ def _as_list_of_dicts (
34+ raw : Any , path : str , label : str , item_label : str
35+ ) -> List [Dict [str , Any ]]:
36+ """Checks that a server response is a JSON list of objects.
37+
38+ :param label: what the list holds, in the plural (e.g. "AI expectations")
39+ :param item_label: the same, in the singular (e.g. "AI expectation")
40+
41+ :raises OpenAEVParsingError: if it is not
42+ """
43+ if not isinstance (raw , list ):
44+ raise exc .OpenAEVParsingError (
45+ error_message = (
46+ f"Expected a list of { label } from { path } , got { type (raw ).__name__ } "
47+ )
48+ )
49+ for item in raw :
50+ if not isinstance (item , dict ):
51+ raise exc .OpenAEVParsingError (
52+ error_message = (
53+ f"Expected { item_label } objects (dicts) from { path } , "
54+ f"got a list element of type { type (item ).__name__ } "
55+ )
56+ )
57+ return [item for item in raw ]
58+
1859
1960class InjectExpectation (RESTObject ):
2061 _id_attr = "inject_expectation_id"
@@ -24,13 +65,21 @@ class InjectExpectationManager(ListMixin, UpdateMixin, RESTManager):
2465 _path = "/injects/expectations"
2566 _obj_cls = InjectExpectation
2667 _update_attrs = RequiredOptional (required = ("collector_id" , "result" , "is_success" ))
68+ # Whether the missing LOGGING route of an older platform was already reported.
69+ _logging_route_missing_reported = False
2770
2871 @exc .on_http_error (exc .OpenAEVUpdateError )
2972 def expectations_assets_for_source (
3073 self , source_id : str , expiration_time : int = None , ** kwargs : Any
31- ) -> Dict [str , Any ]:
74+ ) -> List [Dict [str , Any ]]:
75+ """Returns the DETECTION and PREVENTION expectations on assets not yet
76+ filled for the given source.
77+
78+ :raises OpenAEVParsingError: if the server does not return a JSON list of dicts
79+ """
3280 path = f"{ self .path } /assets/" + source_id
33- result = self .openaev .http_get (
81+ # The route returns a JSON list; http_get is annotated Union[Dict, Response].
82+ raw : Any = self .openaev .http_get (
3483 path ,
3584 query_data = (
3685 {"expiration_time" : expiration_time }
@@ -43,7 +92,7 @@ def expectations_assets_for_source(
4392 ),
4493 ** kwargs ,
4594 )
46- return result
95+ return _as_list_of_dicts ( raw , path , "asset expectations" , "asset expectation" )
4796
4897 @exc .on_http_error (exc .OpenAEVListError )
4998 def ai_expectations_for_source (
@@ -60,27 +109,71 @@ def ai_expectations_for_source(
60109 """
61110 path = f"{ self .path } /ai/{ source_id } "
62111 # http_get is annotated Union[Dict, requests.Response]; widen to Any so the list
63- # validation below stays meaningful to type checkers, then build the result list
64- # explicitly to satisfy the declared return type.
112+ # validation stays meaningful to type checkers.
65113 raw : Any = self .openaev .http_get (path , ** kwargs )
66- if not isinstance (raw , list ):
67- raise exc .OpenAEVParsingError (
68- error_message = (
69- f"Expected a list of AI expectations from { path } , "
70- f"got { type (raw ).__name__ } "
71- )
72- )
73- for item in raw :
74- if not isinstance (item , dict ):
75- raise exc .OpenAEVParsingError (
76- error_message = (
77- f"Expected AI expectation objects (dicts) from { path } , "
78- f"got a list element of type { type (item ).__name__ } "
79- )
80- )
81- return [item for item in raw ]
114+ return _as_list_of_dicts (raw , path , "AI expectations" , "AI expectation" )
115+
116+ @exc .on_http_error (exc .OpenAEVListError )
117+ def logging_expectations_for_source (
118+ self , source_id : str , ** kwargs : Any
119+ ) -> List [Dict [str , Any ]]:
120+ """Returns the LOGGING expectations not yet filled for the given source.
121+
122+ Only collectors that declare LOGGING in their supported expectation
123+ types receive them. An older platform without the LOGGING route answers
124+ 404: it is treated as "no LOGGING expectation", with a warning.
125+
126+ :param source_id: the identifier of the collector requesting expectations
127+ :type source_id: str
128+ :raises OpenAEVParsingError: if the server does not return a JSON list of dicts
129+ :return: a list of logging expectation dicts
130+ :rtype: list[dict]
131+ """
132+ path = f"{ self .path } /logging/{ source_id } "
133+ try :
134+ raw : Any = self .openaev .http_get (path , ** kwargs )
135+ except exc .OpenAEVHttpError as err :
136+ if err .response_code != 404 :
137+ raise
138+ self ._report_missing_logging_route (path )
139+ return []
140+ return _as_list_of_dicts (
141+ raw , path , "Logging expectations" , "Logging expectation"
142+ )
143+
144+ def _report_missing_logging_route (self , path : str ) -> None :
145+ if self ._logging_route_missing_reported :
146+ logger .debug ("%s answered 404, no LOGGING expectation collected" , path )
147+ return
148+ self ._logging_route_missing_reported = True
149+ logger .warning (
150+ "%s answered 404: this OpenAEV platform does not support LOGGING "
151+ "expectations yet, none will be collected" ,
152+ path ,
153+ )
154+
155+ def logging_expectation_models_for_source (
156+ self , source_id : str , ** kwargs : Any
157+ ) -> List [LoggingExpectation ]:
158+ """Returns the LOGGING expectations from OpenAEV that have had no result
159+ yet from the source_id (e.g. collector), as models.
160+
161+ :param source_id: the identifier of the collector requesting expectations
162+ :type source_id: str
163+ :param kwargs: additional data to pass to the endpoint
164+ :type kwargs: dict, optional
165+
166+ :return: a list of expectation objects
167+ :rtype: list[LoggingExpectation]
168+ """
169+ return self ._models_from_dicts (
170+ self .logging_expectations_for_source (source_id = source_id , ** kwargs ),
171+ _LOGGING_EXPECTATION_MODELS ,
172+ )
82173
83- def expectations_models_for_source (self , source_id : str , ** kwargs : Any ):
174+ def expectations_models_for_source (
175+ self , source_id : str , ** kwargs : Any
176+ ) -> List [Expectation ]:
84177 """Returns all expectations from OpenAEV that have had no result yet
85178 from the source_id (e.g. collector).
86179
@@ -92,20 +185,36 @@ def expectations_models_for_source(self, source_id: str, **kwargs: Any):
92185 :return: a list of expectation objects
93186 :rtype: list[DetectionExpectation|PreventionExpectation]
94187 """
95- # TODO: we should implement a more clever mechanism to obtain
96- # specialised Expectation instances rather than just if/elseing
97- # through this list of possibilities.
98- expectations = []
99- for expectation_dict in self .expectations_assets_for_source (
100- source_id = source_id , ** kwargs
101- ):
102- if (
103- expectation_dict ["inject_expectation_type" ]
104- == ExpectationTypeEnum .Detection .value
105- ):
106- expectation_class = DetectionExpectation
107- else :
108- expectation_class = PreventionExpectation
188+ return self ._models_from_dicts (
189+ self .expectations_assets_for_source (source_id = source_id , ** kwargs ),
190+ _ASSET_EXPECTATION_MODELS ,
191+ )
192+
193+ def _models_from_dicts (
194+ self ,
195+ expectation_dicts : List [Dict [str , Any ]],
196+ model_by_type : Dict [str , Type [ExpectationModel ]],
197+ ) -> List [ExpectationModel ]:
198+ """Builds the model of each expectation dict, by expectation type.
199+
200+ A type missing from model_by_type is skipped with a warning, never
201+ mapped to another model: a collector must not answer an expectation
202+ type it does not handle.
203+ """
204+ expectations : List [ExpectationModel ] = []
205+ for expectation_dict in expectation_dicts :
206+ expectation_type : str = (
207+ expectation_dict .get ("inject_expectation_type" ) or ""
208+ )
209+ expectation_class = model_by_type .get (expectation_type )
210+ if expectation_class is None :
211+ logger .warning (
212+ "Skipping expectation %s, its type %s is not one of %s" ,
213+ expectation_dict .get ("inject_expectation_id" ),
214+ expectation_type ,
215+ ", " .join (model_by_type ),
216+ )
217+ continue
109218 # One invalid expectation (e.g. an unknown signature type) must not
110219 # hide the valid ones from the collector.
111220 try :
0 commit comments