11from __future__ import annotations
22
3- import argparse
4- from pathlib import Path
5- from typing import Any
3+ import argparse
4+ from collections .abc import Mapping , Sequence
5+ from pathlib import Path
6+ from typing import Any
67
78from .features import compute_window_features
89from .io import (
1617 write_table ,
1718)
1819from .preprocess import normalize_events
19- from .rules import apply_rules
20- from .visualize import plot_outputs
21- from .windowing import build_windows
22-
23-
24- def main () -> None :
20+ from .rules import apply_rules
21+ from .visualize import plot_outputs
22+ from .windowing import build_windows
23+
24+ RUN_RULE_SECTION_NAMES = (
25+ "high_error_rate" ,
26+ "login_fail_burst" ,
27+ "high_severity_spike" ,
28+ "persistent_high_error" ,
29+ "source_spread_spike" ,
30+ "rare_event_repeat" ,
31+ )
32+
33+
34+ def main () -> None :
2535 parser = build_parser ()
2636 args = parser .parse_args ()
2737 args .func (args )
@@ -88,14 +98,15 @@ def build_parser() -> argparse.ArgumentParser:
8898 return parser
8999
90100
91- def run_command (args : argparse .Namespace ) -> None :
92- config_path = Path (args .config ).resolve ()
93- config = load_config (config_path )
94- time_config = config .get ("time" , {})
95- feature_config = config .get ("features" , {})
96- rules_config = config .get ("rules" ) or {}
97- input_path = resolve_config_path (config_path , config ["input_path" ])
98- output_dir = resolve_config_path (config_path , config .get ("output_dir" , "data/processed" ))
101+ def run_command (args : argparse .Namespace ) -> None :
102+ config_path = Path (args .config ).resolve ()
103+ config = load_config (config_path )
104+ run_config = _validate_run_config (config )
105+ time_config = run_config ["time" ]
106+ feature_config = run_config ["features" ]
107+ rules_config = run_config ["rules" ]
108+ input_path = resolve_config_path (config_path , run_config ["input_path" ])
109+ output_dir = resolve_config_path (config_path , run_config ["output_dir" ])
99110
100111 events = load_events (input_path )
101112 normalized = normalize_events (
@@ -104,19 +115,19 @@ def run_command(args: argparse.Namespace) -> None:
104115 error_statuses = feature_config .get ("error_statuses" ),
105116 high_severity_levels = feature_config .get ("severity_levels" ),
106117 )
107- windows = build_windows (
108- normalized ,
109- timestamp_col = time_config .get ("timestamp_col" , "timestamp" ),
110- window_size_seconds = int ( time_config . get ( "window_size_seconds" , 60 )) ,
111- step_size_seconds = int ( time_config . get ( "step_size_seconds" , 10 )) ,
112- )
118+ windows = build_windows (
119+ normalized ,
120+ timestamp_col = time_config .get ("timestamp_col" , "timestamp" ),
121+ window_size_seconds = time_config [ "window_size_seconds" ] ,
122+ step_size_seconds = time_config [ "step_size_seconds" ] ,
123+ )
113124 features = compute_window_features (
114125 normalized ,
115126 windows ,
116127 count_event_types = feature_config .get ("count_event_types" ),
117- )
118- alerts = apply_rules (features , rules_config )
119- cooldown_seconds = int ( rules_config . get ( "cooldown_seconds" , 0 ))
128+ )
129+ alerts = apply_rules (features , rules_config )
130+ cooldown_seconds = rules_config [ "cooldown_seconds" ]
120131
121132 feature_path = write_table (features , output_dir / "features.csv" )
122133 alert_path = write_table (alerts , output_dir / "alerts.csv" )
@@ -222,16 +233,145 @@ def run_config_change_demo_command(args: argparse.Namespace) -> None:
222233 print (f" - { name } : { _display_path (path )} " )
223234
224235
225- def _display_path (path : Path ) -> str :
236+ def _display_path (path : Path ) -> str :
226237 cwd = Path .cwd ().resolve ()
227238 resolved = path .resolve ()
228239 try :
229240 return resolved .relative_to (cwd ).as_posix ()
230241 except ValueError :
231- return resolved .as_posix ()
232-
233-
234- def _build_run_summary (
242+ return resolved .as_posix ()
243+
244+
245+ def _validate_run_config (config : Mapping [str , Any ]) -> dict [str , Any ]:
246+ time_config = _optional_mapping (config .get ("time" , {}), "time" )
247+ feature_config = _optional_mapping (config .get ("features" , {}), "features" )
248+ rules_config = _validate_rules_config (config .get ("rules" ))
249+
250+ return {
251+ "input_path" : _path_config_value (config .get ("input_path" ), "input_path" ),
252+ "output_dir" : _path_config_value (
253+ config .get ("output_dir" , "data/processed" ),
254+ "output_dir" ,
255+ ),
256+ "time" : {
257+ "timestamp_col" : _string_config_value (
258+ time_config .get ("timestamp_col" , "timestamp" ),
259+ "time.timestamp_col" ,
260+ ),
261+ "window_size_seconds" : _int_config_value (
262+ time_config .get ("window_size_seconds" , 60 ),
263+ "time.window_size_seconds" ,
264+ minimum = 1 ,
265+ ),
266+ "step_size_seconds" : _int_config_value (
267+ time_config .get ("step_size_seconds" , 10 ),
268+ "time.step_size_seconds" ,
269+ minimum = 1 ,
270+ ),
271+ },
272+ "features" : {
273+ "count_event_types" : _optional_string_sequence (
274+ feature_config .get ("count_event_types" ),
275+ "features.count_event_types" ,
276+ ),
277+ "error_statuses" : _optional_string_sequence (
278+ feature_config .get ("error_statuses" ),
279+ "features.error_statuses" ,
280+ ),
281+ "severity_levels" : _optional_string_sequence (
282+ feature_config .get ("severity_levels" ),
283+ "features.severity_levels" ,
284+ ),
285+ },
286+ "rules" : rules_config ,
287+ }
288+
289+
290+ def _validate_rules_config (raw_rules_config : Any ) -> dict [str , Any ]:
291+ rules_config = (
292+ {}
293+ if raw_rules_config is None
294+ else dict (_optional_mapping (raw_rules_config , "rules" ))
295+ )
296+ rules_config ["cooldown_seconds" ] = _int_config_value (
297+ rules_config .get ("cooldown_seconds" , 0 ),
298+ "rules.cooldown_seconds" ,
299+ minimum = 0 ,
300+ )
301+
302+ for rule_name in RUN_RULE_SECTION_NAMES :
303+ if rule_name in rules_config :
304+ rules_config [rule_name ] = dict (
305+ _optional_mapping (rules_config [rule_name ], f"rules.{ rule_name } " )
306+ )
307+
308+ rare_event_repeat = rules_config .get ("rare_event_repeat" )
309+ if isinstance (rare_event_repeat , dict ) and "event_types" in rare_event_repeat :
310+ rare_event_repeat ["event_types" ] = _string_sequence (
311+ rare_event_repeat ["event_types" ],
312+ "rules.rare_event_repeat.event_types" ,
313+ )
314+
315+ return rules_config
316+
317+
318+ def _optional_mapping (value : Any , field_name : str ) -> Mapping [str , Any ]:
319+ if not isinstance (value , Mapping ):
320+ raise ValueError (f"Config field '{ field_name } ' must be a mapping." )
321+ return value
322+
323+
324+ def _path_config_value (value : Any , field_name : str ) -> str :
325+ if not isinstance (value , str ) or not value .strip ():
326+ raise ValueError (f"Config field '{ field_name } ' must be a non-empty path string." )
327+ return value .strip ()
328+
329+
330+ def _string_config_value (value : Any , field_name : str ) -> str :
331+ if not isinstance (value , str ) or not value .strip ():
332+ raise ValueError (f"Config field '{ field_name } ' must be a non-empty string." )
333+ return value .strip ()
334+
335+
336+ def _int_config_value (value : Any , field_name : str , * , minimum : int ) -> int :
337+ if isinstance (value , bool ):
338+ raise ValueError (f"Config field '{ field_name } ' must be an integer." )
339+ if isinstance (value , int ):
340+ parsed = value
341+ elif isinstance (value , str ) and value .strip ().lstrip ("+-" ).isdigit ():
342+ parsed = int (value )
343+ else :
344+ raise ValueError (f"Config field '{ field_name } ' must be an integer." )
345+
346+ if parsed < minimum :
347+ qualifier = "positive" if minimum == 1 else f"at least { minimum } "
348+ raise ValueError (f"Config field '{ field_name } ' must be { qualifier } ." )
349+ return parsed
350+
351+
352+ def _optional_string_sequence (value : Any , field_name : str ) -> list [str ] | None :
353+ if value is None :
354+ return None
355+ return _string_sequence (value , field_name )
356+
357+
358+ def _string_sequence (value : Any , field_name : str ) -> list [str ]:
359+ if isinstance (value , str ) or not isinstance (value , Sequence ):
360+ raise ValueError (
361+ f"Config field '{ field_name } ' must be a list of non-empty strings."
362+ )
363+
364+ normalized : list [str ] = []
365+ for item in value :
366+ if not isinstance (item , str ) or not item .strip ():
367+ raise ValueError (
368+ f"Config field '{ field_name } ' must be a list of non-empty strings."
369+ )
370+ normalized .append (item .strip ())
371+ return normalized
372+
373+
374+ def _build_run_summary (
235375 input_path : Path ,
236376 output_dir : Path ,
237377 normalized : Any ,
0 commit comments