From 9325f6afe501754a8b96132d87ece305f677f61d Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Mon, 9 Sep 2024 17:19:56 -0700 Subject: [PATCH 01/33] placeholder --- .../rule_modules/voltage.yml | 7 +++ .../telemetry_config.py | 4 ++ .../telemetry_configs/nostromo_lv_426.yml | 5 ++ .../lib/sift_py/ingestion/config/telemetry.py | 13 +++-- .../lib/sift_py/ingestion/config/yaml/load.py | 53 +++++++++++++++++-- 5 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 python/examples/ingestion_with_yaml_config/rule_modules/voltage.yml diff --git a/python/examples/ingestion_with_yaml_config/rule_modules/voltage.yml b/python/examples/ingestion_with_yaml_config/rule_modules/voltage.yml new file mode 100644 index 00000000..2a2b5f27 --- /dev/null +++ b/python/examples/ingestion_with_yaml_config/rule_modules/voltage.yml @@ -0,0 +1,7 @@ +module_name: voltage + +rules: + - name: overvoltage + description: Checks for overvoltage while accelerating + expression: $1 == "Accelerating" && $2 > 80 + type: review \ No newline at end of file diff --git a/python/examples/ingestion_with_yaml_config/telemetry_config.py b/python/examples/ingestion_with_yaml_config/telemetry_config.py index aab6a1ed..2dee0de4 100644 --- a/python/examples/ingestion_with_yaml_config/telemetry_config.py +++ b/python/examples/ingestion_with_yaml_config/telemetry_config.py @@ -5,6 +5,7 @@ TELEMETRY_CONFIGS_DIR = Path().joinpath("telemetry_configs") EXPRESSION_MODULES_DIR = Path().joinpath("expression_modules") +RULE_MODULES_DIR = Path().joinpath("rule_modules") def nostromos_lv_426() -> TelemetryConfig: @@ -22,4 +23,7 @@ def nostromos_lv_426() -> TelemetryConfig: EXPRESSION_MODULES_DIR.joinpath("kinematics.yml"), EXPRESSION_MODULES_DIR.joinpath("string.yml"), ], + [ + RULE_MODULES_DIR.joinpath("voltage.yml") + ], ) diff --git a/python/examples/ingestion_with_yaml_config/telemetry_configs/nostromo_lv_426.yml b/python/examples/ingestion_with_yaml_config/telemetry_configs/nostromo_lv_426.yml index 1a938eff..0d89e1ab 100644 --- a/python/examples/ingestion_with_yaml_config/telemetry_configs/nostromo_lv_426.yml +++ b/python/examples/ingestion_with_yaml_config/telemetry_configs/nostromo_lv_426.yml @@ -88,6 +88,11 @@ rules: - failure - nostromo + - name: voltage.overvoltage + channel_references: + - $1: *vehicle_state_channel + - $2: *voltage_channel + flows: - name: readings channels: diff --git a/python/lib/sift_py/ingestion/config/telemetry.py b/python/lib/sift_py/ingestion/config/telemetry.py index ac1b1846..edda4f75 100644 --- a/python/lib/sift_py/ingestion/config/telemetry.py +++ b/python/lib/sift_py/ingestion/config/telemetry.py @@ -12,7 +12,7 @@ ChannelEnumType, _channel_fqn, ) -from sift_py.ingestion.config.yaml.load import load_named_expression_modules, read_and_validate +from sift_py.ingestion.config.yaml.load import load_named_expression_modules, load_named_rule_modules,read_and_validate from sift_py.ingestion.config.yaml.spec import TelemetryConfigYamlSpec from sift_py.ingestion.flow import FlowConfig from sift_py.ingestion.rule.config import ( @@ -111,6 +111,7 @@ def try_from_yaml( cls, path: Path, named_expression_modules: Optional[List[Path]] = None, + named_rule_modules: Optional[List[Path]] = None, ) -> Self: """ Initializes a telemetry config from a YAML file found at the provided `path` as well as optional @@ -119,17 +120,21 @@ def try_from_yaml( config_as_yaml = read_and_validate(path) + named_expressions = {} + named_rules = {} if named_expression_modules is not None: named_expressions = load_named_expression_modules(named_expression_modules) - return cls._from_yaml(config_as_yaml, named_expressions) - else: - return cls._from_yaml(config_as_yaml) + if named_rule_modules is not None: + named_rules = load_named_rule_modules(named_rule_modules) + + return cls._from_yaml(config_as_yaml, named_expressions, named_rules) @classmethod def _from_yaml( cls, config_as_yaml: TelemetryConfigYamlSpec, named_expressions: Dict[str, str] = {}, + named_rules: Dict[str, str] = {}, # TODO, do stuff with this ) -> Self: rules = [] flows = [] diff --git a/python/lib/sift_py/ingestion/config/yaml/load.py b/python/lib/sift_py/ingestion/config/yaml/load.py index 7a3ed58a..b99f2a93 100644 --- a/python/lib/sift_py/ingestion/config/yaml/load.py +++ b/python/lib/sift_py/ingestion/config/yaml/load.py @@ -1,6 +1,6 @@ import re from pathlib import Path -from typing import Any, Dict, List, Type, cast +from typing import Any, Dict, List, Type, Callable, cast import yaml @@ -52,6 +52,28 @@ def load_named_expression_modules(paths: List[Path]) -> Dict[str, str]: return named_expressions +def load_named_rule_modules(paths: List[Path]) -> Dict[str, str]: #TODO: Remove redundancy + """ + Takes in a list of paths to YAML files which contains named expressions and processes them into a `dict`. + The key is the name of the expression and the value is the expression itself. For more information on + named expression modules see `sift_py.ingestion/config/yaml/spec.py + """ + + named_rules = {} + + for path in paths: + named_expr_module = _read_named_rule_module_yaml(path) + + for name, expr in named_expr_module.items(): + if name in named_rules: + raise YamlConfigError( + f"Encountered rules with identical names being loaded, '{name}'." + ) + named_rules[name] = expr + + return named_rules + + def _read_named_expression_module_yaml(path: Path) -> Dict[str, str]: with open(path, "r") as f: named_expressions = cast(Dict[Any, Any], yaml.safe_load(f.read())) @@ -69,6 +91,28 @@ def _read_named_expression_module_yaml(path: Path) -> Dict[str, str]: return cast(Dict[str, str], named_expressions) +def _read_named_rule_module_yaml(path: Path) -> Dict[str, Any]: + with open(path, "r") as f: + named_expressions = cast(Dict[Any, Any], yaml.safe_load(f.read())) + + for key, value in named_expressions.items(): + if not isinstance(key, str): + raise YamlConfigError( + f"Expected '{key}' to be a string in named expression module '{path}'." + ) + if key == "module_name" and not isinstance(value, str): # TODO: Maybe make this nicer + raise YamlConfigError( + f"Expected expression of '{key}' to be a string in named expression module '{path}'." + ) + if key == "rules" and not isinstance(value, List): + raise YamlConfigError( + f"Expected expression of '{key}' to be a list in named expression module '{path}'." + ) + + + return cast(Dict[str, str], named_expressions) + + def _validate_yaml(raw_config: Dict[Any, Any]) -> TelemetryConfigYamlSpec: asset_name = raw_config.get("asset_name") @@ -105,14 +149,14 @@ def _validate_yaml(raw_config: Dict[Any, Any]) -> TelemetryConfigYamlSpec: if rules is not None: if not isinstance(rules, list): raise YamlConfigError._invalid_property( - channels, - "channels", + channels, # TODO: Should this be rules? + "channels", # TODO: Should this be rules? f"List[{_type_fqn(RuleYamlSpec)}]", None, ) for rule in cast(List[Any], rules): - _validate_rule(rule) + _validate_rule(rule) # TODO: Update what runs here ? flows = raw_config.get("flows") @@ -305,6 +349,7 @@ def _validate_rule(val: Any): " | ", ["rules"], ) + # TODO: If None, assume it's a module rule_type = rule.get("type") valid_rule_types = [kind.value for kind in RuleActionAnnotationKind] From b1f3388b690db6b1ec958e310e4b27b956be57e1 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Mon, 9 Sep 2024 17:24:54 -0700 Subject: [PATCH 02/33] lint & format --- .../ingestion_with_yaml_config/telemetry_config.py | 4 +--- python/lib/sift_py/ingestion/config/telemetry.py | 6 +++++- python/lib/sift_py/ingestion/config/yaml/load.py | 7 +++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/python/examples/ingestion_with_yaml_config/telemetry_config.py b/python/examples/ingestion_with_yaml_config/telemetry_config.py index 2dee0de4..faa53cda 100644 --- a/python/examples/ingestion_with_yaml_config/telemetry_config.py +++ b/python/examples/ingestion_with_yaml_config/telemetry_config.py @@ -23,7 +23,5 @@ def nostromos_lv_426() -> TelemetryConfig: EXPRESSION_MODULES_DIR.joinpath("kinematics.yml"), EXPRESSION_MODULES_DIR.joinpath("string.yml"), ], - [ - RULE_MODULES_DIR.joinpath("voltage.yml") - ], + [RULE_MODULES_DIR.joinpath("voltage.yml")], ) diff --git a/python/lib/sift_py/ingestion/config/telemetry.py b/python/lib/sift_py/ingestion/config/telemetry.py index edda4f75..9ad73e78 100644 --- a/python/lib/sift_py/ingestion/config/telemetry.py +++ b/python/lib/sift_py/ingestion/config/telemetry.py @@ -12,7 +12,11 @@ ChannelEnumType, _channel_fqn, ) -from sift_py.ingestion.config.yaml.load import load_named_expression_modules, load_named_rule_modules,read_and_validate +from sift_py.ingestion.config.yaml.load import ( + load_named_expression_modules, + load_named_rule_modules, + read_and_validate, +) from sift_py.ingestion.config.yaml.spec import TelemetryConfigYamlSpec from sift_py.ingestion.flow import FlowConfig from sift_py.ingestion.rule.config import ( diff --git a/python/lib/sift_py/ingestion/config/yaml/load.py b/python/lib/sift_py/ingestion/config/yaml/load.py index b99f2a93..f70362e6 100644 --- a/python/lib/sift_py/ingestion/config/yaml/load.py +++ b/python/lib/sift_py/ingestion/config/yaml/load.py @@ -1,6 +1,6 @@ import re from pathlib import Path -from typing import Any, Dict, List, Type, Callable, cast +from typing import Any, Dict, List, Type, cast import yaml @@ -52,7 +52,7 @@ def load_named_expression_modules(paths: List[Path]) -> Dict[str, str]: return named_expressions -def load_named_rule_modules(paths: List[Path]) -> Dict[str, str]: #TODO: Remove redundancy +def load_named_rule_modules(paths: List[Path]) -> Dict[str, str]: # TODO: Remove redundancy """ Takes in a list of paths to YAML files which contains named expressions and processes them into a `dict`. The key is the name of the expression and the value is the expression itself. For more information on @@ -109,7 +109,6 @@ def _read_named_rule_module_yaml(path: Path) -> Dict[str, Any]: f"Expected expression of '{key}' to be a list in named expression module '{path}'." ) - return cast(Dict[str, str], named_expressions) @@ -317,6 +316,7 @@ def _validate_bit_field_element(val: Any): def _validate_rule(val: Any): + # TODO: Only name and channel_references if referencing a module rule = cast(Dict[Any, Any], val) name = rule.get("name") @@ -349,7 +349,6 @@ def _validate_rule(val: Any): " | ", ["rules"], ) - # TODO: If None, assume it's a module rule_type = rule.get("type") valid_rule_types = [kind.value for kind in RuleActionAnnotationKind] From b06ac427603b3e8d3c84e1db24706dd6f0d8d0e5 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Tue, 17 Sep 2024 15:24:57 -0700 Subject: [PATCH 03/33] placeholder while I have internet --- .../rule_modules/voltage.yml | 2 +- .../telemetry_configs/nostromo_lv_426.yml | 3 +- .../lib/sift_py/ingestion/config/yaml/load.py | 57 ++++++++++++++----- .../ingestion/config/yaml/test_load.py | 12 ++++ 4 files changed, 57 insertions(+), 17 deletions(-) diff --git a/python/examples/ingestion_with_yaml_config/rule_modules/voltage.yml b/python/examples/ingestion_with_yaml_config/rule_modules/voltage.yml index 2a2b5f27..724ad92f 100644 --- a/python/examples/ingestion_with_yaml_config/rule_modules/voltage.yml +++ b/python/examples/ingestion_with_yaml_config/rule_modules/voltage.yml @@ -1,4 +1,4 @@ -module_name: voltage +namespace: voltage rules: - name: overvoltage diff --git a/python/examples/ingestion_with_yaml_config/telemetry_configs/nostromo_lv_426.yml b/python/examples/ingestion_with_yaml_config/telemetry_configs/nostromo_lv_426.yml index 0d89e1ab..f39ff665 100644 --- a/python/examples/ingestion_with_yaml_config/telemetry_configs/nostromo_lv_426.yml +++ b/python/examples/ingestion_with_yaml_config/telemetry_configs/nostromo_lv_426.yml @@ -88,7 +88,8 @@ rules: - failure - nostromo - - name: voltage.overvoltage + - namespace: voltage + name: overvoltage channel_references: - $1: *vehicle_state_channel - $2: *voltage_channel diff --git a/python/lib/sift_py/ingestion/config/yaml/load.py b/python/lib/sift_py/ingestion/config/yaml/load.py index f70362e6..3dbaefe9 100644 --- a/python/lib/sift_py/ingestion/config/yaml/load.py +++ b/python/lib/sift_py/ingestion/config/yaml/load.py @@ -57,6 +57,12 @@ def load_named_rule_modules(paths: List[Path]) -> Dict[str, str]: # TODO: Remov Takes in a list of paths to YAML files which contains named expressions and processes them into a `dict`. The key is the name of the expression and the value is the expression itself. For more information on named expression modules see `sift_py.ingestion/config/yaml/spec.py + + TODO: + check if file or directory + open file or iterate through files in directory + ensure no duplicated namespaces + reassemble into namespace.name format """ named_rules = {} @@ -92,6 +98,14 @@ def _read_named_expression_module_yaml(path: Path) -> Dict[str, str]: def _read_named_rule_module_yaml(path: Path) -> Dict[str, Any]: + """ + TODO + - ensure namespace is a string + - get list of rules + - ensure namespace key isn't in this list + - call _validate_rules() + - return dict of { namespace: { rules }} for reassembly + """ with open(path, "r") as f: named_expressions = cast(Dict[Any, Any], yaml.safe_load(f.read())) @@ -316,14 +330,41 @@ def _validate_bit_field_element(val: Any): def _validate_rule(val: Any): - # TODO: Only name and channel_references if referencing a module rule = cast(Dict[Any, Any], val) + namespace = rule.get("namespace") + if namespace is not None and not isinstance(namespace, str): + raise YamlConfigError._invalid_property( + namespace, + "- namespace", + "str", + ["rules"], + ) + name = rule.get("name") if not isinstance(name, str): raise YamlConfigError._invalid_property(name, "- name", "str", ["rules"]) + channel_references = rule.get("channel_references") + + if namespace or (channel_references is not None): + if not isinstance(channel_references, list): + raise YamlConfigError._invalid_property( + channel_references, + "- channel_references", + f"List[Dict[str, {_type_fqn(ChannelConfigYamlSpec)}]]", + ["rules"], + ) + + for channel_reference in cast(List[Any], channel_references): + _validate_channel_reference(channel_reference) + + if namespace: + # TODO: Probably should inform the user that the below keys + # belong in the referenced namespace instead of doing this + return + description = rule.get("description") if description is not None and not isinstance(description, str): @@ -381,20 +422,6 @@ def _validate_rule(val: Any): ["rules"], ) - channel_references = rule.get("channel_references") - - if channel_references is not None: - if not isinstance(channel_references, list): - raise YamlConfigError._invalid_property( - channel_references, - "- channel_references", - f"List[Dict[str, {_type_fqn(ChannelConfigYamlSpec)}]]", - ["rules"], - ) - - for channel_reference in cast(List[Any], channel_references): - _validate_channel_reference(channel_reference) - sub_expressions = rule.get("sub_expressions") if sub_expressions is not None: diff --git a/python/lib/sift_py/ingestion/config/yaml/test_load.py b/python/lib/sift_py/ingestion/config/yaml/test_load.py index ea80403b..f6cbf9e7 100644 --- a/python/lib/sift_py/ingestion/config/yaml/test_load.py +++ b/python/lib/sift_py/ingestion/config/yaml/test_load.py @@ -199,6 +199,18 @@ def test__validate_rule(): } ) + # Rule in referenced namespace + load._validate_rule( + { + "namespace": "voltage", + "name": "overvoltage_rule", + "channel_references": [ + {"$1": {"name": "voltage", "data_type": "double"}}, + {"$2": {"name": "vehicle_state", "data_type": "double"}}, + ], + } + ) + with pytest.raises(YamlConfigError, match="Expected 'name' to be but it is "): load._validate_rule( { From f13b3f2541611e0f0ed4a1916d1da42c9aa0757c Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Wed, 18 Sep 2024 09:05:06 -0700 Subject: [PATCH 04/33] placeholder while I have internet --- .../lib/sift_py/ingestion/config/telemetry.py | 23 +++-- .../lib/sift_py/ingestion/config/yaml/load.py | 88 ++++++++++--------- 2 files changed, 63 insertions(+), 48 deletions(-) diff --git a/python/lib/sift_py/ingestion/config/telemetry.py b/python/lib/sift_py/ingestion/config/telemetry.py index 9ad73e78..fcfd4155 100644 --- a/python/lib/sift_py/ingestion/config/telemetry.py +++ b/python/lib/sift_py/ingestion/config/telemetry.py @@ -14,7 +14,7 @@ ) from sift_py.ingestion.config.yaml.load import ( load_named_expression_modules, - load_named_rule_modules, + load_rule_namespaces, read_and_validate, ) from sift_py.ingestion.config.yaml.spec import TelemetryConfigYamlSpec @@ -125,20 +125,20 @@ def try_from_yaml( config_as_yaml = read_and_validate(path) named_expressions = {} - named_rules = {} + rule_namespaces = {} if named_expression_modules is not None: named_expressions = load_named_expression_modules(named_expression_modules) if named_rule_modules is not None: - named_rules = load_named_rule_modules(named_rule_modules) + rule_namespaces = load_rule_namespaces(named_rule_modules) - return cls._from_yaml(config_as_yaml, named_expressions, named_rules) + return cls._from_yaml(config_as_yaml, named_expressions, rule_namespaces) @classmethod def _from_yaml( cls, config_as_yaml: TelemetryConfigYamlSpec, named_expressions: Dict[str, str] = {}, - named_rules: Dict[str, str] = {}, # TODO, do stuff with this + rule_namespaces: Dict[str, List] = {}, # TODO, do stuff with this ) -> Self: rules = [] flows = [] @@ -188,6 +188,19 @@ def _from_yaml( ) for rule in config_as_yaml.get("rules", []): + # TODO: Handle rules here; assemble rules from namespace keys + namespace = rule.get("namespace") + + if namespace: + rule_namespace = rule_namespaces.get(str(namespace)) + if not rule_namespace: + # TODO: This is a placeholder. + # Maybe we should try and catch this earlier? + # Otherwise what happens if the namespace doesn't exist? + raise ValueError(f"Could not find namespace {namespace}") + for rule_from_namespace in rule_namespace: + pass # Match on name + annotation_type = RuleActionAnnotationKind.from_str(rule["type"]) tags = rule.get("tags") diff --git a/python/lib/sift_py/ingestion/config/yaml/load.py b/python/lib/sift_py/ingestion/config/yaml/load.py index 3dbaefe9..87a09afd 100644 --- a/python/lib/sift_py/ingestion/config/yaml/load.py +++ b/python/lib/sift_py/ingestion/config/yaml/load.py @@ -52,32 +52,32 @@ def load_named_expression_modules(paths: List[Path]) -> Dict[str, str]: return named_expressions -def load_named_rule_modules(paths: List[Path]) -> Dict[str, str]: # TODO: Remove redundancy +def load_rule_namespaces(paths: List[Path]) -> Dict[str, List]: """ - Takes in a list of paths to YAML files which contains named expressions and processes them into a `dict`. - The key is the name of the expression and the value is the expression itself. For more information on - named expression modules see `sift_py.ingestion/config/yaml/spec.py - - TODO: - check if file or directory - open file or iterate through files in directory - ensure no duplicated namespaces - reassemble into namespace.name format + TODO: A nice docstring """ - named_rules = {} + rule_namespaces: Dict[str, List] = {} - for path in paths: - named_expr_module = _read_named_rule_module_yaml(path) + def update_rule_namespaces(rule_module_path: Path): + rule_module = _read_rule_namespace_yaml(rule_module_path) - for name, expr in named_expr_module.items(): - if name in named_rules: + for key in rule_module.keys(): + if key in rule_namespaces: raise YamlConfigError( - f"Encountered rules with identical names being loaded, '{name}'." + f"Encountered rules with identical names being loaded, '{key}'." ) - named_rules[name] = expr - return named_rules + rule_namespaces.update(rule_module) + + for path in paths: + if path.is_dir(): + for rule_file in path.iterdir(): + update_rule_namespaces(rule_file) + elif path.is_file(): + update_rule_namespaces(path) + + return rule_namespaces def _read_named_expression_module_yaml(path: Path) -> Dict[str, str]: @@ -97,33 +97,35 @@ def _read_named_expression_module_yaml(path: Path) -> Dict[str, str]: return cast(Dict[str, str], named_expressions) -def _read_named_rule_module_yaml(path: Path) -> Dict[str, Any]: - """ - TODO - - ensure namespace is a string - - get list of rules - - ensure namespace key isn't in this list - - call _validate_rules() - - return dict of { namespace: { rules }} for reassembly - """ +def _read_rule_namespace_yaml(path: Path) -> Dict[str, List]: with open(path, "r") as f: - named_expressions = cast(Dict[Any, Any], yaml.safe_load(f.read())) + namespace_rules = cast(Dict[Any, Any], yaml.safe_load(f.read())) + namespace = namespace_rules.get("namespace") - for key, value in named_expressions.items(): - if not isinstance(key, str): - raise YamlConfigError( - f"Expected '{key}' to be a string in named expression module '{path}'." - ) - if key == "module_name" and not isinstance(value, str): # TODO: Maybe make this nicer - raise YamlConfigError( - f"Expected expression of '{key}' to be a string in named expression module '{path}'." - ) - if key == "rules" and not isinstance(value, List): - raise YamlConfigError( - f"Expected expression of '{key}' to be a list in named expression module '{path}'." - ) + if not isinstance(namespace, str): + raise YamlConfigError( + f"Expected '{namespace} to be a string in rule namespace yaml: '{path}'" + ) - return cast(Dict[str, str], named_expressions) + rules = namespace_rules.get("rules") + if not isinstance(namespace, str): + raise YamlConfigError( + f"Expected '{rules}' to be a list in rule namespace yaml: '{path}'" + ) + + for rule in cast(List[Any], rules): + nested_namespace = rule.get("namespace") + if nested_namespace: # TODO: Do we want to allow this? + raise YamlConfigError( + "Rules referencing other namespaces cannot be nested. " + f"Found nested namespace '{nested_namespace}' in '{path}'. " + ) + _validate_rule(rule) + + # TODO: This format just seemed easier to work with... it does seem to + # sort of break from the pattern since everything else I think keeps the + # YAML format intact. Will come back and give this a think. + return {namespace: cast(List[Any], rules)} def _validate_yaml(raw_config: Dict[Any, Any]) -> TelemetryConfigYamlSpec: @@ -169,7 +171,7 @@ def _validate_yaml(raw_config: Dict[Any, Any]) -> TelemetryConfigYamlSpec: ) for rule in cast(List[Any], rules): - _validate_rule(rule) # TODO: Update what runs here ? + _validate_rule(rule) flows = raw_config.get("flows") From a6359df80798ae59ec6af782a5a9f1688bb95347 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Wed, 18 Sep 2024 19:50:49 -0700 Subject: [PATCH 05/33] fill in examples a bit and add logic to telemetry --- .../rule_modules/velocity.yml | 11 ++++++++++ .../rule_modules/voltage.yml | 6 +++++- .../telemetry_configs/nostromo_lv_426.yml | 20 ++++++++++++++++++- .../lib/sift_py/ingestion/config/telemetry.py | 9 +++++++-- .../lib/sift_py/ingestion/config/yaml/load.py | 7 ++++--- 5 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml diff --git a/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml b/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml new file mode 100644 index 00000000..63a13edd --- /dev/null +++ b/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml @@ -0,0 +1,11 @@ +namespace: velocity + +rules: + - name: vehicle_stuck + description: Checks that vehicle velocity becomes nonzero 5s after entering accelerating state + expression: $1 == "Accelerating" && persistence($2 > 0, 5) + type: review + + - name: vehicle_not_stopped + description: Makes sure vehicle velocity remains 0 while stopped + expression: $1 == "Stopped" && $2 > 0 diff --git a/python/examples/ingestion_with_yaml_config/rule_modules/voltage.yml b/python/examples/ingestion_with_yaml_config/rule_modules/voltage.yml index 724ad92f..c99ae8bb 100644 --- a/python/examples/ingestion_with_yaml_config/rule_modules/voltage.yml +++ b/python/examples/ingestion_with_yaml_config/rule_modules/voltage.yml @@ -4,4 +4,8 @@ rules: - name: overvoltage description: Checks for overvoltage while accelerating expression: $1 == "Accelerating" && $2 > 80 - type: review \ No newline at end of file + type: review + + - name: undervoltage + description: Checks for undervoltage while accelerating + expression: $1 == "Accelerating" && $2 < 40 diff --git a/python/examples/ingestion_with_yaml_config/telemetry_configs/nostromo_lv_426.yml b/python/examples/ingestion_with_yaml_config/telemetry_configs/nostromo_lv_426.yml index f39ff665..b3c64a8b 100644 --- a/python/examples/ingestion_with_yaml_config/telemetry_configs/nostromo_lv_426.yml +++ b/python/examples/ingestion_with_yaml_config/telemetry_configs/nostromo_lv_426.yml @@ -93,7 +93,25 @@ rules: channel_references: - $1: *vehicle_state_channel - $2: *voltage_channel - + + - namespace: voltage + name: undervoltage + channel_references: + - $1: *vehicle_state_channel + - $2: *voltage_channel + + - namespace: velocity + name: vehicle_stuck + channel_references: + - $1: *vehicle_state_channel + - $2: *velocity_channel + + - namespace: velocity + name: vehicle_not_stopped + channel_references: + - $1: *vehicle_state_channel + - $2: *velocity_channel + flows: - name: readings channels: diff --git a/python/lib/sift_py/ingestion/config/telemetry.py b/python/lib/sift_py/ingestion/config/telemetry.py index fcfd4155..fb4c4b3f 100644 --- a/python/lib/sift_py/ingestion/config/telemetry.py +++ b/python/lib/sift_py/ingestion/config/telemetry.py @@ -188,7 +188,6 @@ def _from_yaml( ) for rule in config_as_yaml.get("rules", []): - # TODO: Handle rules here; assemble rules from namespace keys namespace = rule.get("namespace") if namespace: @@ -198,8 +197,14 @@ def _from_yaml( # Maybe we should try and catch this earlier? # Otherwise what happens if the namespace doesn't exist? raise ValueError(f"Could not find namespace {namespace}") + + found_rule = None for rule_from_namespace in rule_namespace: - pass # Match on name + if rule["name"] == rule_from_namespace["name"]: + found_rule = rule_from_namespace + if not found_rule: + # TODO: Maybe we should try and catch this earlier? + raise ValueError(f"Could not find rule name {rule['name']} in {namespace}") annotation_type = RuleActionAnnotationKind.from_str(rule["type"]) diff --git a/python/lib/sift_py/ingestion/config/yaml/load.py b/python/lib/sift_py/ingestion/config/yaml/load.py index 87a09afd..2cfb458d 100644 --- a/python/lib/sift_py/ingestion/config/yaml/load.py +++ b/python/lib/sift_py/ingestion/config/yaml/load.py @@ -122,9 +122,10 @@ def _read_rule_namespace_yaml(path: Path) -> Dict[str, List]: ) _validate_rule(rule) - # TODO: This format just seemed easier to work with... it does seem to - # sort of break from the pattern since everything else I think keeps the - # YAML format intact. Will come back and give this a think. + # TODO: This format just seemed easier to work with... it does feel like + # sort of breaks from patterns elsewhere here since everything else I think + # keeps the YAML format "intact" after reading into memory. + # Will come back and give this a think. return {namespace: cast(List[Any], rules)} From 1061b0a88d36f54ea6da636378ddd0fb17f5368f Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Wed, 18 Sep 2024 19:51:20 -0700 Subject: [PATCH 06/33] chore: fmt --- python/lib/sift_py/ingestion/config/telemetry.py | 8 ++++---- python/lib/sift_py/ingestion/config/yaml/load.py | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/python/lib/sift_py/ingestion/config/telemetry.py b/python/lib/sift_py/ingestion/config/telemetry.py index fb4c4b3f..61a3e7f0 100644 --- a/python/lib/sift_py/ingestion/config/telemetry.py +++ b/python/lib/sift_py/ingestion/config/telemetry.py @@ -193,9 +193,9 @@ def _from_yaml( if namespace: rule_namespace = rule_namespaces.get(str(namespace)) if not rule_namespace: - # TODO: This is a placeholder. - # Maybe we should try and catch this earlier? - # Otherwise what happens if the namespace doesn't exist? + # TODO: This is a placeholder. + # Maybe we should try and catch this earlier? + # Otherwise what happens if the namespace doesn't exist? raise ValueError(f"Could not find namespace {namespace}") found_rule = None @@ -203,7 +203,7 @@ def _from_yaml( if rule["name"] == rule_from_namespace["name"]: found_rule = rule_from_namespace if not found_rule: - # TODO: Maybe we should try and catch this earlier? + # TODO: Maybe we should try and catch this earlier? raise ValueError(f"Could not find rule name {rule['name']} in {namespace}") annotation_type = RuleActionAnnotationKind.from_str(rule["type"]) diff --git a/python/lib/sift_py/ingestion/config/yaml/load.py b/python/lib/sift_py/ingestion/config/yaml/load.py index 2cfb458d..ccdc8c1b 100644 --- a/python/lib/sift_py/ingestion/config/yaml/load.py +++ b/python/lib/sift_py/ingestion/config/yaml/load.py @@ -114,13 +114,13 @@ def _read_rule_namespace_yaml(path: Path) -> Dict[str, List]: ) for rule in cast(List[Any], rules): - nested_namespace = rule.get("namespace") - if nested_namespace: # TODO: Do we want to allow this? - raise YamlConfigError( - "Rules referencing other namespaces cannot be nested. " - f"Found nested namespace '{nested_namespace}' in '{path}'. " - ) - _validate_rule(rule) + nested_namespace = rule.get("namespace") + if nested_namespace: # TODO: Do we want to allow this? + raise YamlConfigError( + "Rules referencing other namespaces cannot be nested. " + f"Found nested namespace '{nested_namespace}' in '{path}'. " + ) + _validate_rule(rule) # TODO: This format just seemed easier to work with... it does feel like # sort of breaks from patterns elsewhere here since everything else I think From bf15663f6ad43b0e9fb6723dc678e93a6e3537c2 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Wed, 18 Sep 2024 20:14:50 -0700 Subject: [PATCH 07/33] fixed example --- .../ingestion_with_yaml_config/rule_modules/velocity.yml | 1 + .../ingestion_with_yaml_config/rule_modules/voltage.yml | 1 + .../examples/ingestion_with_yaml_config/telemetry_config.py | 4 ++-- python/lib/sift_py/ingestion/config/telemetry.py | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml b/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml index 63a13edd..2e82617b 100644 --- a/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml +++ b/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml @@ -9,3 +9,4 @@ rules: - name: vehicle_not_stopped description: Makes sure vehicle velocity remains 0 while stopped expression: $1 == "Stopped" && $2 > 0 + type: review diff --git a/python/examples/ingestion_with_yaml_config/rule_modules/voltage.yml b/python/examples/ingestion_with_yaml_config/rule_modules/voltage.yml index c99ae8bb..b47e7216 100644 --- a/python/examples/ingestion_with_yaml_config/rule_modules/voltage.yml +++ b/python/examples/ingestion_with_yaml_config/rule_modules/voltage.yml @@ -9,3 +9,4 @@ rules: - name: undervoltage description: Checks for undervoltage while accelerating expression: $1 == "Accelerating" && $2 < 40 + type: review diff --git a/python/examples/ingestion_with_yaml_config/telemetry_config.py b/python/examples/ingestion_with_yaml_config/telemetry_config.py index faa53cda..653e70db 100644 --- a/python/examples/ingestion_with_yaml_config/telemetry_config.py +++ b/python/examples/ingestion_with_yaml_config/telemetry_config.py @@ -16,12 +16,12 @@ def nostromos_lv_426() -> TelemetryConfig: telemetry_config_path = TELEMETRY_CONFIGS_DIR.joinpath(telemetry_config_name) - # Load your telemetry config with your reusable expressions modules + # Load your telemetry config with your reusable expressions modules and rule modules return TelemetryConfig.try_from_yaml( telemetry_config_path, [ EXPRESSION_MODULES_DIR.joinpath("kinematics.yml"), EXPRESSION_MODULES_DIR.joinpath("string.yml"), ], - [RULE_MODULES_DIR.joinpath("voltage.yml")], + [RULE_MODULES_DIR], ) diff --git a/python/lib/sift_py/ingestion/config/telemetry.py b/python/lib/sift_py/ingestion/config/telemetry.py index 61a3e7f0..1f805a8f 100644 --- a/python/lib/sift_py/ingestion/config/telemetry.py +++ b/python/lib/sift_py/ingestion/config/telemetry.py @@ -205,6 +205,7 @@ def _from_yaml( if not found_rule: # TODO: Maybe we should try and catch this earlier? raise ValueError(f"Could not find rule name {rule['name']} in {namespace}") + rule = found_rule annotation_type = RuleActionAnnotationKind.from_str(rule["type"]) From 8a57f752c5382c56fdc8c4b44903c5979aad2703 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Thu, 19 Sep 2024 12:01:24 -0500 Subject: [PATCH 08/33] chore: cleanups --- .vscode/settings.json | 7 +++++++ .../ingestion_with_yaml_config/rule_modules/velocity.yml | 2 +- python/lib/sift_py/ingestion/config/telemetry.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..ca73e537 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "python" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml b/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml index 2e82617b..8ecd08a7 100644 --- a/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml +++ b/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml @@ -3,7 +3,7 @@ namespace: velocity rules: - name: vehicle_stuck description: Checks that vehicle velocity becomes nonzero 5s after entering accelerating state - expression: $1 == "Accelerating" && persistence($2 > 0, 5) + expression: $1 == "Accelerating" && persistence($2 = 0, 5) type: review - name: vehicle_not_stopped diff --git a/python/lib/sift_py/ingestion/config/telemetry.py b/python/lib/sift_py/ingestion/config/telemetry.py index 1f805a8f..b739fe84 100644 --- a/python/lib/sift_py/ingestion/config/telemetry.py +++ b/python/lib/sift_py/ingestion/config/telemetry.py @@ -138,7 +138,7 @@ def _from_yaml( cls, config_as_yaml: TelemetryConfigYamlSpec, named_expressions: Dict[str, str] = {}, - rule_namespaces: Dict[str, List] = {}, # TODO, do stuff with this + rule_namespaces: Dict[str, List] = {}, ) -> Self: rules = [] flows = [] From f7b1d7ea15e78f101a53be707c22ecfd15e392cc Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Fri, 27 Sep 2024 11:21:04 -0700 Subject: [PATCH 09/33] chore: cleanups --- python/lib/sift_py/ingestion/config/yaml/load.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/lib/sift_py/ingestion/config/yaml/load.py b/python/lib/sift_py/ingestion/config/yaml/load.py index ccdc8c1b..1f1879cf 100644 --- a/python/lib/sift_py/ingestion/config/yaml/load.py +++ b/python/lib/sift_py/ingestion/config/yaml/load.py @@ -165,8 +165,8 @@ def _validate_yaml(raw_config: Dict[Any, Any]) -> TelemetryConfigYamlSpec: if rules is not None: if not isinstance(rules, list): raise YamlConfigError._invalid_property( - channels, # TODO: Should this be rules? - "channels", # TODO: Should this be rules? + rules, + "rules", f"List[{_type_fqn(RuleYamlSpec)}]", None, ) From e20b58e65be5d9edcde1206da6ed131e3ffa04d2 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Fri, 27 Sep 2024 11:26:08 -0700 Subject: [PATCH 10/33] chore: RuleModuleYamlSpec placeholder --- python/lib/sift_py/ingestion/config/yaml/load.py | 3 ++- python/lib/sift_py/ingestion/config/yaml/spec.py | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/python/lib/sift_py/ingestion/config/yaml/load.py b/python/lib/sift_py/ingestion/config/yaml/load.py index 1f1879cf..18367cb9 100644 --- a/python/lib/sift_py/ingestion/config/yaml/load.py +++ b/python/lib/sift_py/ingestion/config/yaml/load.py @@ -12,6 +12,7 @@ ChannelEnumTypeYamlSpec, FlowYamlSpec, RuleYamlSpec, + RuleModuleYamlSpec, TelemetryConfigYamlSpec, ) from sift_py.ingestion.rule.config import RuleActionAnnotationKind @@ -167,7 +168,7 @@ def _validate_yaml(raw_config: Dict[Any, Any]) -> TelemetryConfigYamlSpec: raise YamlConfigError._invalid_property( rules, "rules", - f"List[{_type_fqn(RuleYamlSpec)}]", + f"List[{_type_fqn(RuleYamlSpec)}] or List[{_type_fqn(RuleModuleYamlSpec)}]", None, ) diff --git a/python/lib/sift_py/ingestion/config/yaml/spec.py b/python/lib/sift_py/ingestion/config/yaml/spec.py index 4a024c91..11c229bd 100644 --- a/python/lib/sift_py/ingestion/config/yaml/spec.py +++ b/python/lib/sift_py/ingestion/config/yaml/spec.py @@ -90,6 +90,15 @@ class FlowYamlSpec(TypedDict): channels: List[ChannelConfigYamlSpec] +class RuleModuleYamlSpec(TypedDict): + """ + TODO: A nice docstring + """ + name: str + namespace: str + channel_references: NotRequired[List[Dict[str, ChannelConfigYamlSpec]]] + + class RuleYamlSpec(TypedDict): """ The formal definition of what a single rule looks like in YAML. From 84079e090de6546a141661e6a33a15ba820df20c Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Fri, 27 Sep 2024 11:33:13 -0700 Subject: [PATCH 11/33] chore: lint/fmt --- python/lib/sift_py/ingestion/config/yaml/load.py | 2 +- python/lib/sift_py/ingestion/config/yaml/spec.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/python/lib/sift_py/ingestion/config/yaml/load.py b/python/lib/sift_py/ingestion/config/yaml/load.py index 18367cb9..8a76e8e8 100644 --- a/python/lib/sift_py/ingestion/config/yaml/load.py +++ b/python/lib/sift_py/ingestion/config/yaml/load.py @@ -11,8 +11,8 @@ ChannelConfigYamlSpec, ChannelEnumTypeYamlSpec, FlowYamlSpec, - RuleYamlSpec, RuleModuleYamlSpec, + RuleYamlSpec, TelemetryConfigYamlSpec, ) from sift_py.ingestion.rule.config import RuleActionAnnotationKind diff --git a/python/lib/sift_py/ingestion/config/yaml/spec.py b/python/lib/sift_py/ingestion/config/yaml/spec.py index 11c229bd..8fb91a68 100644 --- a/python/lib/sift_py/ingestion/config/yaml/spec.py +++ b/python/lib/sift_py/ingestion/config/yaml/spec.py @@ -94,6 +94,7 @@ class RuleModuleYamlSpec(TypedDict): """ TODO: A nice docstring """ + name: str namespace: str channel_references: NotRequired[List[Dict[str, ChannelConfigYamlSpec]]] From 9d249d91876c5d6f3c8f0628e52bbd31360eff36 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Mon, 30 Sep 2024 15:13:10 -0700 Subject: [PATCH 12/33] chore: Add vscode files to gitignore --- .gitignore | 2 ++ .vscode/settings.json | 7 ------- 2 files changed, 2 insertions(+), 7 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index 360e31ef..94b79289 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ python/protos/**/* python/build python/docs/sift_py *.egg-info/ + +.vscode/* diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index ca73e537..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "python.testing.pytestArgs": [ - "python" - ], - "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true -} \ No newline at end of file From 890178925c38f3f6a420d344f9dcf8a585d42e18 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Tue, 1 Oct 2024 16:51:33 -0700 Subject: [PATCH 13/33] chore: Cleanup TODOs & add docs --- .../lib/sift_py/ingestion/config/telemetry.py | 8 +--- .../lib/sift_py/ingestion/config/yaml/load.py | 40 ++++++++++--------- .../lib/sift_py/ingestion/config/yaml/spec.py | 12 ++++-- 3 files changed, 32 insertions(+), 28 deletions(-) diff --git a/python/lib/sift_py/ingestion/config/telemetry.py b/python/lib/sift_py/ingestion/config/telemetry.py index b739fe84..a1a062a0 100644 --- a/python/lib/sift_py/ingestion/config/telemetry.py +++ b/python/lib/sift_py/ingestion/config/telemetry.py @@ -193,18 +193,14 @@ def _from_yaml( if namespace: rule_namespace = rule_namespaces.get(str(namespace)) if not rule_namespace: - # TODO: This is a placeholder. - # Maybe we should try and catch this earlier? - # Otherwise what happens if the namespace doesn't exist? - raise ValueError(f"Could not find namespace {namespace}") + raise TelemetryConfigValidationError(f"Could not find namespace {namespace}") found_rule = None for rule_from_namespace in rule_namespace: if rule["name"] == rule_from_namespace["name"]: found_rule = rule_from_namespace if not found_rule: - # TODO: Maybe we should try and catch this earlier? - raise ValueError(f"Could not find rule name {rule['name']} in {namespace}") + raise TelemetryConfigValidationError(f"Could not find rule name {rule['name']} in {namespace}") rule = found_rule annotation_type = RuleActionAnnotationKind.from_str(rule["type"]) diff --git a/python/lib/sift_py/ingestion/config/yaml/load.py b/python/lib/sift_py/ingestion/config/yaml/load.py index 8a76e8e8..01407be2 100644 --- a/python/lib/sift_py/ingestion/config/yaml/load.py +++ b/python/lib/sift_py/ingestion/config/yaml/load.py @@ -11,7 +11,7 @@ ChannelConfigYamlSpec, ChannelEnumTypeYamlSpec, FlowYamlSpec, - RuleModuleYamlSpec, + RuleNamespaceYamlSpec, RuleYamlSpec, TelemetryConfigYamlSpec, ) @@ -35,7 +35,7 @@ def load_named_expression_modules(paths: List[Path]) -> Dict[str, str]: """ Takes in a list of paths to YAML files which contains named expressions and processes them into a `dict`. The key is the name of the expression and the value is the expression itself. For more information on - named expression modules see `sift_py.ingestion/config/yaml/spec.py + named expression modules see `sift_py.ingestion/config/yaml/spec.py`. """ named_expressions = {} @@ -55,7 +55,9 @@ def load_named_expression_modules(paths: List[Path]) -> Dict[str, str]: def load_rule_namespaces(paths: List[Path]) -> Dict[str, List]: """ - TODO: A nice docstring + Takes in a list of paths which may either be directories or files containing rule namespace YAML files, + and processes them into a `dict`. For more information on rule namespaces see + RuleNamespaceYamlSpec in `sift_py.ingestion/config/yaml/spec.py`. """ rule_namespaces: Dict[str, List] = {} @@ -106,27 +108,25 @@ def _read_rule_namespace_yaml(path: Path) -> Dict[str, List]: if not isinstance(namespace, str): raise YamlConfigError( f"Expected '{namespace} to be a string in rule namespace yaml: '{path}'" + f"{_type_fqn(RuleNamespaceYamlSpec)}" ) rules = namespace_rules.get("rules") if not isinstance(namespace, str): raise YamlConfigError( f"Expected '{rules}' to be a list in rule namespace yaml: '{path}'" + f"{_type_fqn(RuleNamespaceYamlSpec)}" ) for rule in cast(List[Any], rules): nested_namespace = rule.get("namespace") - if nested_namespace: # TODO: Do we want to allow this? + if nested_namespace: raise YamlConfigError( "Rules referencing other namespaces cannot be nested. " f"Found nested namespace '{nested_namespace}' in '{path}'. " ) _validate_rule(rule) - # TODO: This format just seemed easier to work with... it does feel like - # sort of breaks from patterns elsewhere here since everything else I think - # keeps the YAML format "intact" after reading into memory. - # Will come back and give this a think. return {namespace: cast(List[Any], rules)} @@ -168,7 +168,7 @@ def _validate_yaml(raw_config: Dict[Any, Any]) -> TelemetryConfigYamlSpec: raise YamlConfigError._invalid_property( rules, "rules", - f"List[{_type_fqn(RuleYamlSpec)}] or List[{_type_fqn(RuleModuleYamlSpec)}]", + f"List[{_type_fqn(RuleYamlSpec)}]", None, ) @@ -364,17 +364,25 @@ def _validate_rule(val: Any): for channel_reference in cast(List[Any], channel_references): _validate_channel_reference(channel_reference) + description = rule.get("description") + expression = rule.get("expression") + rule_type = rule.get("type") + assignee = rule.get("assignee") + tags = rule.get("tags") + sub_expressions = rule.get("sub_expressions") + if namespace: - # TODO: Probably should inform the user that the below keys - # belong in the referenced namespace instead of doing this + if any([description, expression, rule_type, assignee, tags, sub_expressions]): + raise YamlConfigError( + f"Rule '{name}' is a namespace and should not have any other properties set. " + "Properties 'description', 'expression', 'type', 'assignee', 'tags', and 'sub_expressions' " + "may be defined in the referenced namespace." + ) return - description = rule.get("description") - if description is not None and not isinstance(description, str): raise YamlConfigError._invalid_property(description, "- description", "str", ["rules"]) - expression = rule.get("expression") if isinstance(expression, dict): expression_name = cast(Dict[Any, Any], expression).get("name") @@ -395,7 +403,6 @@ def _validate_rule(val: Any): ["rules"], ) - rule_type = rule.get("type") valid_rule_types = [kind.value for kind in RuleActionAnnotationKind] if rule_type not in valid_rule_types: @@ -406,7 +413,6 @@ def _validate_rule(val: Any): ["rules"], ) - assignee = rule.get("assignee") if assignee is not None and not isinstance(assignee, str): raise YamlConfigError._invalid_property( @@ -416,7 +422,6 @@ def _validate_rule(val: Any): ["rules"], ) - tags = rule.get("tags") if tags is not None and not isinstance(tags, list): raise YamlConfigError._invalid_property( @@ -426,7 +431,6 @@ def _validate_rule(val: Any): ["rules"], ) - sub_expressions = rule.get("sub_expressions") if sub_expressions is not None: if not isinstance(channel_references, list): diff --git a/python/lib/sift_py/ingestion/config/yaml/spec.py b/python/lib/sift_py/ingestion/config/yaml/spec.py index 8fb91a68..2b08dd9d 100644 --- a/python/lib/sift_py/ingestion/config/yaml/spec.py +++ b/python/lib/sift_py/ingestion/config/yaml/spec.py @@ -90,14 +90,16 @@ class FlowYamlSpec(TypedDict): channels: List[ChannelConfigYamlSpec] -class RuleModuleYamlSpec(TypedDict): +class RuleNamespaceYamlSpec(TypedDict): """ - TODO: A nice docstring + The formal definition of what a rule namespace looks like in YAML. + + `namespace`: Name of the namespace. + `rules`: A list of rules that belong to the namespace. """ - name: str namespace: str - channel_references: NotRequired[List[Dict[str, ChannelConfigYamlSpec]]] + rules: List[RuleYamlSpec] class RuleYamlSpec(TypedDict): @@ -105,6 +107,7 @@ class RuleYamlSpec(TypedDict): The formal definition of what a single rule looks like in YAML. `name`: Name of the rule. + `namespace`: Optional namespace of the rule. `description`: Description of rule. `expression`: Either an expression-string or a `sift_py.ingestion.config.yaml.spec.NamedExpressionYamlSpec` referencing a named expression. @@ -156,6 +159,7 @@ class RuleYamlSpec(TypedDict): """ name: str + namespace: NotRequired[str] description: NotRequired[str] expression: Union[str, NamedExpressionYamlSpec] type: Union[Literal["phase"], Literal["review"]] From 840382b124f326c873bff5b83dcbf8613d9f0e23 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Tue, 1 Oct 2024 16:59:20 -0700 Subject: [PATCH 14/33] chore: More docs --- .../lib/sift_py/ingestion/config/yaml/spec.py | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/python/lib/sift_py/ingestion/config/yaml/spec.py b/python/lib/sift_py/ingestion/config/yaml/spec.py index 2b08dd9d..33791873 100644 --- a/python/lib/sift_py/ingestion/config/yaml/spec.py +++ b/python/lib/sift_py/ingestion/config/yaml/spec.py @@ -107,7 +107,7 @@ class RuleYamlSpec(TypedDict): The formal definition of what a single rule looks like in YAML. `name`: Name of the rule. - `namespace`: Optional namespace of the rule. + `namespace`: Optional namespace of the rule. Only used if referencing a rule defined in a namespace. `description`: Description of rule. `expression`: Either an expression-string or a `sift_py.ingestion.config.yaml.spec.NamedExpressionYamlSpec` referencing a named expression. @@ -117,6 +117,26 @@ class RuleYamlSpec(TypedDict): `channel_references`: A list of channel references that maps to an actual channel. More below. `sub_expressions`: A list of sub-expressions which is a mapping of place-holders to sub-expressions. Only used if using named expressions. + Namespaces: + Rule may be defined in a separate YAML within a namespace. The reference to the namespace rule would look like the following: + ```yaml + rules: + - namespace: voltage + name: overvoltage + channel_references: + - $1: *vehicle_state_channel + - $2: *voltage_channel + ``` + With the corresponding rule being defined in a separate YAML file like the following: + ```yaml + namespace: voltage + rules: + - name: overvoltage + description: Checks for overvoltage while accelerating + expression: $1 == "Accelerating" && $2 > 80 + type: review + ``` + Channel references: A channel reference is a string containing a numerical value prefixed with "$". Examples include "$1", "$2", "$11", and so on. The channel reference is mapped to an actual channel config. In YAML it would look something like this: From 9061bcbcbc8b596022fd7e2aa83d1dccebe809d1 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Tue, 1 Oct 2024 18:09:03 -0700 Subject: [PATCH 15/33] chore: Handle nested directories --- python/lib/sift_py/ingestion/config/yaml/load.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/python/lib/sift_py/ingestion/config/yaml/load.py b/python/lib/sift_py/ingestion/config/yaml/load.py index 01407be2..7d5e1bdd 100644 --- a/python/lib/sift_py/ingestion/config/yaml/load.py +++ b/python/lib/sift_py/ingestion/config/yaml/load.py @@ -73,10 +73,16 @@ def update_rule_namespaces(rule_module_path: Path): rule_namespaces.update(rule_module) + def handle_dir(path: Path): + for file_in_dir in path.iterdir(): + if file_in_dir.is_dir(): + handle_dir(file_in_dir) + elif file_in_dir.is_file(): + update_rule_namespaces(file_in_dir) + for path in paths: if path.is_dir(): - for rule_file in path.iterdir(): - update_rule_namespaces(rule_file) + handle_dir(path) elif path.is_file(): update_rule_namespaces(path) From e8a094701c203bf9f0b46f2d24c736bb2c70b70a Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Thu, 3 Oct 2024 11:00:55 -0700 Subject: [PATCH 16/33] fix: Fix channel references for namespaced rules and syntax issue --- .../rule_modules/velocity.yml | 2 +- .../telemetry_config.py | 7 ++-- .../lib/sift_py/ingestion/config/telemetry.py | 34 ++++++++++--------- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml b/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml index 8ecd08a7..43c98e92 100644 --- a/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml +++ b/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml @@ -3,7 +3,7 @@ namespace: velocity rules: - name: vehicle_stuck description: Checks that vehicle velocity becomes nonzero 5s after entering accelerating state - expression: $1 == "Accelerating" && persistence($2 = 0, 5) + expression: $1 == "Accelerating" && persistence($2 == 0, 5) type: review - name: vehicle_not_stopped diff --git a/python/examples/ingestion_with_yaml_config/telemetry_config.py b/python/examples/ingestion_with_yaml_config/telemetry_config.py index 653e70db..d6fcf4a4 100644 --- a/python/examples/ingestion_with_yaml_config/telemetry_config.py +++ b/python/examples/ingestion_with_yaml_config/telemetry_config.py @@ -3,9 +3,10 @@ from sift_py.ingestion.service import TelemetryConfig -TELEMETRY_CONFIGS_DIR = Path().joinpath("telemetry_configs") -EXPRESSION_MODULES_DIR = Path().joinpath("expression_modules") -RULE_MODULES_DIR = Path().joinpath("rule_modules") +TEMP = Path().joinpath("python/examples/ingestion_with_yaml_config") +TELEMETRY_CONFIGS_DIR = Path().joinpath(TEMP).joinpath("telemetry_configs") +EXPRESSION_MODULES_DIR = Path().joinpath(TEMP).joinpath("expression_modules") +RULE_MODULES_DIR = Path().joinpath(TEMP).joinpath("rule_modules") def nostromos_lv_426() -> TelemetryConfig: diff --git a/python/lib/sift_py/ingestion/config/telemetry.py b/python/lib/sift_py/ingestion/config/telemetry.py index a1a062a0..d55cba1d 100644 --- a/python/lib/sift_py/ingestion/config/telemetry.py +++ b/python/lib/sift_py/ingestion/config/telemetry.py @@ -188,6 +188,24 @@ def _from_yaml( ) for rule in config_as_yaml.get("rules", []): + # Order matters -- capture the channel references before checking the namespace + channel_references: List[ + ExpressionChannelReference | ExpressionChannelReferenceChannelConfig + ] = [] + + for channel_reference in rule.get("channel_references", []): + for ref, val in channel_reference.items(): + name = val["name"] + component = val.get("component") + + channel_references.append( + { + "channel_reference": ref, + "channel_identifier": _channel_fqn(name, component), + } + ) + + print(channel_references) namespace = rule.get("namespace") if namespace: @@ -214,22 +232,6 @@ def _from_yaml( tags=tags, ) - channel_references: List[ - ExpressionChannelReference | ExpressionChannelReferenceChannelConfig - ] = [] - - for channel_reference in rule.get("channel_references", []): - for ref, val in channel_reference.items(): - name = val["name"] - component = val.get("component") - - channel_references.append( - { - "channel_reference": ref, - "channel_identifier": _channel_fqn(name, component), - } - ) - expression = rule["expression"] if isinstance(expression, str): rules.append( From b647159b3313dd6de99f4d0296297eee1db583fe Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Thu, 3 Oct 2024 15:47:39 -0700 Subject: [PATCH 17/33] chore: formatting fixes --- .../telemetry_configs/nostromo_lv_426.yml | 2 +- python/lib/sift_py/ingestion/config/telemetry.py | 4 +++- python/lib/sift_py/ingestion/config/yaml/load.py | 4 ---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/python/examples/ingestion_with_yaml_config/telemetry_configs/nostromo_lv_426.yml b/python/examples/ingestion_with_yaml_config/telemetry_configs/nostromo_lv_426.yml index b3c64a8b..f4999406 100644 --- a/python/examples/ingestion_with_yaml_config/telemetry_configs/nostromo_lv_426.yml +++ b/python/examples/ingestion_with_yaml_config/telemetry_configs/nostromo_lv_426.yml @@ -127,7 +127,7 @@ flows: - name: gpio_channel channels: - <<: *gpio_channel - + - name: logs channels: - <<: *log_channel diff --git a/python/lib/sift_py/ingestion/config/telemetry.py b/python/lib/sift_py/ingestion/config/telemetry.py index d55cba1d..ead95101 100644 --- a/python/lib/sift_py/ingestion/config/telemetry.py +++ b/python/lib/sift_py/ingestion/config/telemetry.py @@ -218,7 +218,9 @@ def _from_yaml( if rule["name"] == rule_from_namespace["name"]: found_rule = rule_from_namespace if not found_rule: - raise TelemetryConfigValidationError(f"Could not find rule name {rule['name']} in {namespace}") + raise TelemetryConfigValidationError( + f"Could not find rule name {rule['name']} in {namespace}" + ) rule = found_rule annotation_type = RuleActionAnnotationKind.from_str(rule["type"]) diff --git a/python/lib/sift_py/ingestion/config/yaml/load.py b/python/lib/sift_py/ingestion/config/yaml/load.py index 7d5e1bdd..fce7bca5 100644 --- a/python/lib/sift_py/ingestion/config/yaml/load.py +++ b/python/lib/sift_py/ingestion/config/yaml/load.py @@ -389,7 +389,6 @@ def _validate_rule(val: Any): if description is not None and not isinstance(description, str): raise YamlConfigError._invalid_property(description, "- description", "str", ["rules"]) - if isinstance(expression, dict): expression_name = cast(Dict[Any, Any], expression).get("name") @@ -419,7 +418,6 @@ def _validate_rule(val: Any): ["rules"], ) - if assignee is not None and not isinstance(assignee, str): raise YamlConfigError._invalid_property( assignee, @@ -428,7 +426,6 @@ def _validate_rule(val: Any): ["rules"], ) - if tags is not None and not isinstance(tags, list): raise YamlConfigError._invalid_property( tags, @@ -437,7 +434,6 @@ def _validate_rule(val: Any): ["rules"], ) - if sub_expressions is not None: if not isinstance(channel_references, list): raise YamlConfigError._invalid_property( From 79e33291f2ccecd54edf57a567e39bb93b03ba88 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Wed, 9 Oct 2024 17:51:34 -0700 Subject: [PATCH 18/33] test: Add rule namespace to nominal telemetry_test --- .../lib/sift_py/ingestion/config/telemetry.py | 1 - .../ingestion/config/telemetry_test.py | 54 +++++++++++++++++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/python/lib/sift_py/ingestion/config/telemetry.py b/python/lib/sift_py/ingestion/config/telemetry.py index ead95101..4e08139a 100644 --- a/python/lib/sift_py/ingestion/config/telemetry.py +++ b/python/lib/sift_py/ingestion/config/telemetry.py @@ -205,7 +205,6 @@ def _from_yaml( } ) - print(channel_references) namespace = rule.get("namespace") if namespace: diff --git a/python/lib/sift_py/ingestion/config/telemetry_test.py b/python/lib/sift_py/ingestion/config/telemetry_test.py index 54e58042..fa73df43 100644 --- a/python/lib/sift_py/ingestion/config/telemetry_test.py +++ b/python/lib/sift_py/ingestion/config/telemetry_test.py @@ -39,10 +39,19 @@ def test_telemetry_config_load_from_yaml(mocker: MockFixture): "kinetic_energy_gt": "0.5 * $mass * $1 * $1 > $threshold", } + mock_load_rule_namespaces = mocker.patch(_mock_path(sift_py.ingestion.config.yaml.load.load_rule_namespaces)) + mock_load_rule_namespaces.return_value = { + "velocity": [ + {"name": "vehicle_stuck", "description": "Checks that vehicle velocity becomes nonzero 5s after entering accelerating state", "expression": "$1 == \"Accelerating\" && persistence($2 == 0, 5)", "type": "review"}, + {"name": "vehicle_not_stopped", "description": "Makes sure vehicle velocity remains 0 while stopped", "expression": "$1 == \"Stopped\" && $2 > 0", "type": "review"} + ] + } + dummy_yaml_path = Path() dummy_named_expr_mod_path = Path() + dummy_rule_namespace_path = [Path()] - telemetry_config = TelemetryConfig.try_from_yaml(dummy_yaml_path, [dummy_named_expr_mod_path]) + telemetry_config = TelemetryConfig.try_from_yaml(dummy_yaml_path, [dummy_named_expr_mod_path], dummy_rule_namespace_path) assert telemetry_config.asset_name == "LunarVehicle426" assert telemetry_config.ingestion_client_key == "lunar_vehicle_426" @@ -105,9 +114,9 @@ def test_telemetry_config_load_from_yaml(mocker: MockFixture): assert gpio_channel.bit_field_elements[3].index == 7 assert gpio_channel.bit_field_elements[3].bit_count == 1 - assert len(telemetry_config.rules) == 4 + assert len(telemetry_config.rules) == 6 - overheating_rule, speeding_rule, failures_rule, kinetic_energy_rule = telemetry_config.rules + overheating_rule, speeding_rule, failures_rule, kinetic_energy_rule, vehicle_stuck, vehicle_not_stopped = telemetry_config.rules assert overheating_rule.name == "overheating" assert overheating_rule.description == "Checks for vehicle overheating" @@ -133,6 +142,18 @@ def test_telemetry_config_load_from_yaml(mocker: MockFixture): assert overheating_rule.action.kind() == RuleActionKind.ANNOTATION assert isinstance(kinetic_energy_rule.action, RuleActionCreateDataReviewAnnotation) + assert vehicle_stuck.name == "vehicle_stuck" + assert vehicle_stuck.description == "Checks that vehicle velocity becomes nonzero 5s after entering accelerating state" + assert vehicle_stuck.expression == "$1 == \"Accelerating\" && persistence($2 == 0, 5)" + assert vehicle_stuck.action.kind() == RuleActionKind.ANNOTATION + assert isinstance(vehicle_stuck.action, RuleActionCreateDataReviewAnnotation) + + assert vehicle_not_stopped.name == "vehicle_not_stopped" + assert vehicle_not_stopped.description == "Makes sure vehicle velocity remains 0 while stopped" + assert vehicle_not_stopped.expression == "$1 == \"Stopped\" && $2 > 0" + assert vehicle_not_stopped.action.kind() == RuleActionKind.ANNOTATION + assert isinstance(vehicle_not_stopped.action, RuleActionCreateDataReviewAnnotation) + def test_telemetry_config_err_if_duplicate_channels_in_flow(mocker: MockerFixture): """ @@ -361,6 +382,18 @@ def test_telemetry_config_validations_flows_with_same_name(): tags: - nostromo + - namespace: velocity + name: vehicle_stuck + channel_references: + - $1: *vehicle_state_channel + - $2: *velocity_channel + + - namespace: velocity + name: vehicle_not_stopped + channel_references: + - $1: *vehicle_state_channel + - $2: *velocity_channel + flows: - name: readings channels: @@ -398,3 +431,18 @@ def test_telemetry_config_validations_flows_with_same_name(): - <<: *velocity_channel - <<: *velocity_channel """ + +RULE_NAMESPACE_YAML_CONFIG = """ +namespace: velocity + +rules: + - name: vehicle_stuck + description: Checks that vehicle velocity becomes nonzero 5s after entering accelerating state + expression: $1 == "Accelerating" && persistence($2 == 0, 5) + type: review + + - name: vehicle_not_stopped + description: Makes sure vehicle velocity remains 0 while stopped + expression: $1 == "Stopped" && $2 > 0 + type: review +""" From e36637af40da04fa7e58b36a8c86daa25c3a1e5a Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Wed, 9 Oct 2024 18:02:36 -0700 Subject: [PATCH 19/33] chore: placeholders for other tests --- python/lib/sift_py/ingestion/config/telemetry_test.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/python/lib/sift_py/ingestion/config/telemetry_test.py b/python/lib/sift_py/ingestion/config/telemetry_test.py index fa73df43..4a109d62 100644 --- a/python/lib/sift_py/ingestion/config/telemetry_test.py +++ b/python/lib/sift_py/ingestion/config/telemetry_test.py @@ -283,6 +283,16 @@ def test_telemetry_config_validations_flows_with_same_name(): ) +def test_telemetry_config_validations_rules_with_same_namespace(): + pass + +def test_telemetry_config_validations_rule_missing_namespace(): + pass + +def test_telemetry_config_validations_rule_missing_from_namespace(): + pass + + TEST_YAML_CONFIG_STR = """ asset_name: LunarVehicle426 ingestion_client_key: lunar_vehicle_426 From 230f2b023d9aa02030584b3c0292b69a1e0f7a73 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Wed, 9 Oct 2024 18:07:00 -0700 Subject: [PATCH 20/33] chore: format --- .../ingestion/config/telemetry_test.py | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/python/lib/sift_py/ingestion/config/telemetry_test.py b/python/lib/sift_py/ingestion/config/telemetry_test.py index 4a109d62..cd285bc3 100644 --- a/python/lib/sift_py/ingestion/config/telemetry_test.py +++ b/python/lib/sift_py/ingestion/config/telemetry_test.py @@ -39,11 +39,23 @@ def test_telemetry_config_load_from_yaml(mocker: MockFixture): "kinetic_energy_gt": "0.5 * $mass * $1 * $1 > $threshold", } - mock_load_rule_namespaces = mocker.patch(_mock_path(sift_py.ingestion.config.yaml.load.load_rule_namespaces)) + mock_load_rule_namespaces = mocker.patch( + _mock_path(sift_py.ingestion.config.yaml.load.load_rule_namespaces) + ) mock_load_rule_namespaces.return_value = { "velocity": [ - {"name": "vehicle_stuck", "description": "Checks that vehicle velocity becomes nonzero 5s after entering accelerating state", "expression": "$1 == \"Accelerating\" && persistence($2 == 0, 5)", "type": "review"}, - {"name": "vehicle_not_stopped", "description": "Makes sure vehicle velocity remains 0 while stopped", "expression": "$1 == \"Stopped\" && $2 > 0", "type": "review"} + { + "name": "vehicle_stuck", + "description": "Checks that vehicle velocity becomes nonzero 5s after entering accelerating state", + "expression": '$1 == "Accelerating" && persistence($2 == 0, 5)', + "type": "review", + }, + { + "name": "vehicle_not_stopped", + "description": "Makes sure vehicle velocity remains 0 while stopped", + "expression": '$1 == "Stopped" && $2 > 0', + "type": "review", + }, ] } @@ -51,7 +63,9 @@ def test_telemetry_config_load_from_yaml(mocker: MockFixture): dummy_named_expr_mod_path = Path() dummy_rule_namespace_path = [Path()] - telemetry_config = TelemetryConfig.try_from_yaml(dummy_yaml_path, [dummy_named_expr_mod_path], dummy_rule_namespace_path) + telemetry_config = TelemetryConfig.try_from_yaml( + dummy_yaml_path, [dummy_named_expr_mod_path], dummy_rule_namespace_path + ) assert telemetry_config.asset_name == "LunarVehicle426" assert telemetry_config.ingestion_client_key == "lunar_vehicle_426" @@ -116,7 +130,14 @@ def test_telemetry_config_load_from_yaml(mocker: MockFixture): assert len(telemetry_config.rules) == 6 - overheating_rule, speeding_rule, failures_rule, kinetic_energy_rule, vehicle_stuck, vehicle_not_stopped = telemetry_config.rules + ( + overheating_rule, + speeding_rule, + failures_rule, + kinetic_energy_rule, + vehicle_stuck, + vehicle_not_stopped, + ) = telemetry_config.rules assert overheating_rule.name == "overheating" assert overheating_rule.description == "Checks for vehicle overheating" @@ -143,14 +164,17 @@ def test_telemetry_config_load_from_yaml(mocker: MockFixture): assert isinstance(kinetic_energy_rule.action, RuleActionCreateDataReviewAnnotation) assert vehicle_stuck.name == "vehicle_stuck" - assert vehicle_stuck.description == "Checks that vehicle velocity becomes nonzero 5s after entering accelerating state" - assert vehicle_stuck.expression == "$1 == \"Accelerating\" && persistence($2 == 0, 5)" + assert ( + vehicle_stuck.description + == "Checks that vehicle velocity becomes nonzero 5s after entering accelerating state" + ) + assert vehicle_stuck.expression == '$1 == "Accelerating" && persistence($2 == 0, 5)' assert vehicle_stuck.action.kind() == RuleActionKind.ANNOTATION assert isinstance(vehicle_stuck.action, RuleActionCreateDataReviewAnnotation) assert vehicle_not_stopped.name == "vehicle_not_stopped" assert vehicle_not_stopped.description == "Makes sure vehicle velocity remains 0 while stopped" - assert vehicle_not_stopped.expression == "$1 == \"Stopped\" && $2 > 0" + assert vehicle_not_stopped.expression == '$1 == "Stopped" && $2 > 0' assert vehicle_not_stopped.action.kind() == RuleActionKind.ANNOTATION assert isinstance(vehicle_not_stopped.action, RuleActionCreateDataReviewAnnotation) @@ -286,9 +310,11 @@ def test_telemetry_config_validations_flows_with_same_name(): def test_telemetry_config_validations_rules_with_same_namespace(): pass + def test_telemetry_config_validations_rule_missing_namespace(): pass + def test_telemetry_config_validations_rule_missing_from_namespace(): pass From ef3721bbf598755730d2e0df1df45d94dee7447d Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Thu, 10 Oct 2024 11:31:09 -0700 Subject: [PATCH 21/33] feat: Add RuleConfig interpolation of rules --- .../rule_modules/velocity.yml | 12 +++ .../rule_modules/voltage.yml | 12 +++ .../telemetry_config.py | 73 ++++++++++++++++++- python/lib/sift_py/ingestion/rule/config.py | 61 ++++++++++++++-- 4 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 python/examples/ingestion_with_python_config/rule_modules/velocity.yml create mode 100644 python/examples/ingestion_with_python_config/rule_modules/voltage.yml diff --git a/python/examples/ingestion_with_python_config/rule_modules/velocity.yml b/python/examples/ingestion_with_python_config/rule_modules/velocity.yml new file mode 100644 index 00000000..43c98e92 --- /dev/null +++ b/python/examples/ingestion_with_python_config/rule_modules/velocity.yml @@ -0,0 +1,12 @@ +namespace: velocity + +rules: + - name: vehicle_stuck + description: Checks that vehicle velocity becomes nonzero 5s after entering accelerating state + expression: $1 == "Accelerating" && persistence($2 == 0, 5) + type: review + + - name: vehicle_not_stopped + description: Makes sure vehicle velocity remains 0 while stopped + expression: $1 == "Stopped" && $2 > 0 + type: review diff --git a/python/examples/ingestion_with_python_config/rule_modules/voltage.yml b/python/examples/ingestion_with_python_config/rule_modules/voltage.yml new file mode 100644 index 00000000..b47e7216 --- /dev/null +++ b/python/examples/ingestion_with_python_config/rule_modules/voltage.yml @@ -0,0 +1,12 @@ +namespace: voltage + +rules: + - name: overvoltage + description: Checks for overvoltage while accelerating + expression: $1 == "Accelerating" && $2 > 80 + type: review + + - name: undervoltage + description: Checks for undervoltage while accelerating + expression: $1 == "Accelerating" && $2 < 40 + type: review diff --git a/python/examples/ingestion_with_python_config/telemetry_config.py b/python/examples/ingestion_with_python_config/telemetry_config.py index 3003a1a7..9f8fa059 100644 --- a/python/examples/ingestion_with_python_config/telemetry_config.py +++ b/python/examples/ingestion_with_python_config/telemetry_config.py @@ -7,13 +7,14 @@ ChannelEnumType, ) from sift_py.ingestion.config.telemetry import FlowConfig, TelemetryConfig -from sift_py.ingestion.config.yaml.load import load_named_expression_modules +from sift_py.ingestion.config.yaml.load import load_named_expression_modules, load_rule_namespaces from sift_py.ingestion.rule.config import ( RuleActionCreateDataReviewAnnotation, RuleConfig, ) EXPRESSION_MODULES_DIR = Path().joinpath("expression_modules") +RULE_NAMESPACES_DIR = Path().joinpath("rule_modules") def nostromos_lv_426() -> TelemetryConfig: @@ -24,6 +25,12 @@ def nostromos_lv_426() -> TelemetryConfig: ] ) + rule_namespaces = load_rule_namespaces( + [ + RULE_NAMESPACES_DIR, + ] + ) + log_channel = ChannelConfig( name="log", data_type=ChannelDataType.STRING, @@ -124,6 +131,70 @@ def nostromos_lv_426() -> TelemetryConfig: tags=["nostromo", "failure"], ), ), + RuleConfig( + name="overvoltage", + namespace="voltage", + namespace_rules=rule_namespaces, + channel_references=[ + # INFO: Can use either "channel_identifier" or "channel_config" + { + "channel_reference": "$1", + "channel_identifier": vehicle_state_channel.fqn(), + }, + { + "channel_reference": "$2", + "channel_config": voltage_channel, + }, + ], + ), + RuleConfig( + name="undervoltage", + namespace="voltage", + namespace_rules=rule_namespaces, + channel_references=[ + # INFO: Can use either "channel_identifier" or "channel_config" + { + "channel_reference": "$1", + "channel_identifier": vehicle_state_channel.fqn(), + }, + { + "channel_reference": "$2", + "channel_config": voltage_channel, + }, + ], + ), + RuleConfig( + name="vehicle_stuck", + namespace="velocity", + namespace_rules=rule_namespaces, + channel_references=[ + # INFO: Can use either "channel_identifier" or "channel_config" + { + "channel_reference": "$1", + "channel_identifier": vehicle_state_channel.fqn(), + }, + { + "channel_reference": "$2", + "channel_config": velocity_channel, + }, + ], + ), + RuleConfig( + name="vehicle_not_stopped", + namespace="velocity", + namespace_rules=rule_namespaces, + channel_references=[ + # INFO: Can use either "channel_identifier" or "channel_config" + { + "channel_reference": "$1", + "channel_identifier": vehicle_state_channel.fqn(), + }, + { + "channel_reference": "$2", + "channel_config": velocity_channel, + }, + ], + ), ], flows=[ FlowConfig( diff --git a/python/lib/sift_py/ingestion/rule/config.py b/python/lib/sift_py/ingestion/rule/config.py index 984d0f67..6677f5a0 100644 --- a/python/lib/sift_py/ingestion/rule/config.py +++ b/python/lib/sift_py/ingestion/rule/config.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from enum import Enum -from typing import Any, Dict, List, Optional, TypedDict, Union, cast +from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union, cast from sift.annotations.v1.annotations_pb2 import AnnotationType from sift.rules.v1.rules_pb2 import ActionKind @@ -32,13 +32,15 @@ class RuleConfig(AsJson): def __init__( self, name: str, - description: str, - expression: str, - action: RuleAction, channel_references: List[ Union[ExpressionChannelReference, ExpressionChannelReferenceChannelConfig] ], - sub_expressions: Dict[str, Any] = {}, + description: Optional[str] = "", + expression: Optional[str] = "", + action: Optional[RuleAction] = None, + sub_expressions: Optional[Dict[str, Any]] = {}, + namespace: Optional[str] = "", + namespace_rules: Optional[Dict[str, List[Dict]]] = {}, ): self.channel_references = [] @@ -65,9 +67,24 @@ def __init__( ) self.name = name - self.description = description - self.action = action - self.expression = self.__class__.interpolate_sub_expressions(expression, sub_expressions) + + if namespace and namespace_rules: + description, expression, action = self.__class__.interpolate_namespace_rule( + namespace, namespace_rules + ) + + if action: + self.action = action + + if description: + self.description = description + + if expression: + self.expression = expression + if sub_expressions: + self.expression = self.__class__.interpolate_sub_expressions( + expression, sub_expressions + ) def as_json(self) -> Any: """ @@ -114,6 +131,34 @@ def interpolate_sub_expressions(expression: str, sub_expressions: Dict[str, str] return expression + @staticmethod + def interpolate_namespace_rule( + namespace: str, namespace_rules: Dict[str, List[Dict]] + ) -> Tuple[str, str, RuleAction]: + rule_list = namespace_rules.get(namespace) + if not rule_list: + raise ValueError( + f"Couldn't find namespace '{namespace}' in namespace_rules: {namespace_rules}" + ) + + for rule in rule_list: + name = rule.get("name") + if name: + description = rule.get("description", "") + expression = rule.get("expression", "") + type = rule.get("type", "") + tags = rule.get("tags") + action: RuleAction = RuleActionCreatePhaseAnnotation(tags) + if RuleActionAnnotationKind.from_str(type) == RuleActionAnnotationKind.REVIEW: + action = RuleActionCreateDataReviewAnnotation( + assignee=rule.get("assignee"), tags=tags + ) + + if not name: + raise ValueError(f"Couldn't find rule name '{name}' in rule_list: {rule_list}") + + return description, expression, action + class RuleAction(ABC): @abstractmethod From 1f3a1bd2bce91c5b9d26f220752e5eba51cfcf02 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Thu, 10 Oct 2024 16:29:54 -0700 Subject: [PATCH 22/33] chore: Clean up optional handling --- python/lib/sift_py/ingestion/rule/config.py | 51 ++++++++++----------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/python/lib/sift_py/ingestion/rule/config.py b/python/lib/sift_py/ingestion/rule/config.py index 6677f5a0..354f2cb5 100644 --- a/python/lib/sift_py/ingestion/rule/config.py +++ b/python/lib/sift_py/ingestion/rule/config.py @@ -24,9 +24,9 @@ class RuleConfig(AsJson): """ name: str - description: str - expression: str - action: RuleAction + description: Optional[str] + expression: Optional[str] + action: Optional[RuleAction] channel_references: List[ExpressionChannelReference] def __init__( @@ -68,23 +68,17 @@ def __init__( self.name = name - if namespace and namespace_rules: + if namespace: description, expression, action = self.__class__.interpolate_namespace_rule( namespace, namespace_rules ) - if action: - self.action = action - - if description: - self.description = description - - if expression: - self.expression = expression - if sub_expressions: - self.expression = self.__class__.interpolate_sub_expressions( - expression, sub_expressions - ) + self.action = action + self.description = description + self.expression = expression + self.expression = self.__class__.interpolate_sub_expressions( + expression, sub_expressions + ) def as_json(self) -> Any: """ @@ -115,26 +109,31 @@ def as_json(self) -> Any: if self.action.tags is not None and len(self.action.tags) > 0: hash_map["tags"] = self.action.tags else: - raise TypeError(f"Unsupported rule action '{self.action.kind()}'.") + kind = self.action.kind() if self.action else self.action + raise TypeError(f"Unsupported rule action '{kind}'.") return hash_map @staticmethod - def interpolate_sub_expressions(expression: str, sub_expressions: Dict[str, str]) -> str: - for ref, expr in sub_expressions.items(): - if ref not in expression: - raise ValueError(f"Couldn't find '{ref}' in expression '{expression}'.") - if isinstance(expr, str): - expression = expression.replace(ref, f'"{expr}"') - else: - expression = expression.replace(ref, str(expr)) + def interpolate_sub_expressions(expression: Optional[str], sub_expressions: Optional[Dict[str, str]]) -> Optional[str]: + if expression and sub_expressions: + for ref, expr in sub_expressions.items(): + if ref not in expression: + raise ValueError(f"Couldn't find '{ref}' in expression '{expression}'.") + if isinstance(expr, str): + expression = expression.replace(ref, f'"{expr}"') + else: + expression = expression.replace(ref, str(expr)) return expression @staticmethod def interpolate_namespace_rule( - namespace: str, namespace_rules: Dict[str, List[Dict]] + namespace: str, namespace_rules: Optional[Dict[str, List[Dict]]] ) -> Tuple[str, str, RuleAction]: + if not namespace_rules: + raise ValueError(f"Namespace rules must be provided with namespace key. Got: {namespace_rules}") + rule_list = namespace_rules.get(namespace) if not rule_list: raise ValueError( From cd9fb8a5c548dad6a3ac7098756b41a001162f60 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Thu, 10 Oct 2024 16:32:08 -0700 Subject: [PATCH 23/33] chore: fmt --- python/lib/sift_py/ingestion/rule/config.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/python/lib/sift_py/ingestion/rule/config.py b/python/lib/sift_py/ingestion/rule/config.py index 354f2cb5..9a83452f 100644 --- a/python/lib/sift_py/ingestion/rule/config.py +++ b/python/lib/sift_py/ingestion/rule/config.py @@ -76,9 +76,7 @@ def __init__( self.action = action self.description = description self.expression = expression - self.expression = self.__class__.interpolate_sub_expressions( - expression, sub_expressions - ) + self.expression = self.__class__.interpolate_sub_expressions(expression, sub_expressions) def as_json(self) -> Any: """ @@ -115,7 +113,9 @@ def as_json(self) -> Any: return hash_map @staticmethod - def interpolate_sub_expressions(expression: Optional[str], sub_expressions: Optional[Dict[str, str]]) -> Optional[str]: + def interpolate_sub_expressions( + expression: Optional[str], sub_expressions: Optional[Dict[str, str]] + ) -> Optional[str]: if expression and sub_expressions: for ref, expr in sub_expressions.items(): if ref not in expression: @@ -132,7 +132,9 @@ def interpolate_namespace_rule( namespace: str, namespace_rules: Optional[Dict[str, List[Dict]]] ) -> Tuple[str, str, RuleAction]: if not namespace_rules: - raise ValueError(f"Namespace rules must be provided with namespace key. Got: {namespace_rules}") + raise ValueError( + f"Namespace rules must be provided with namespace key. Got: {namespace_rules}" + ) rule_list = namespace_rules.get(namespace) if not rule_list: From 430e7f065e2b142b95986a016751d68e2e6deac9 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Thu, 10 Oct 2024 17:02:39 -0700 Subject: [PATCH 24/33] chore: Use RuleConfig logic for assembling namespace rules from yaml --- .../lib/sift_py/ingestion/config/telemetry.py | 48 +++++++------------ 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/python/lib/sift_py/ingestion/config/telemetry.py b/python/lib/sift_py/ingestion/config/telemetry.py index 4e08139a..badd7002 100644 --- a/python/lib/sift_py/ingestion/config/telemetry.py +++ b/python/lib/sift_py/ingestion/config/telemetry.py @@ -188,7 +188,21 @@ def _from_yaml( ) for rule in config_as_yaml.get("rules", []): - # Order matters -- capture the channel references before checking the namespace + namespace = rule.get("namespace") + + if not namespace: + annotation_type = RuleActionAnnotationKind.from_str(rule["type"]) + tags = rule.get("tags") + + action: Optional[RuleAction] = RuleActionCreatePhaseAnnotation(tags) + if annotation_type == RuleActionAnnotationKind.REVIEW: + action = RuleActionCreateDataReviewAnnotation( + assignee=rule.get("assignee"), + tags=tags, + ) + else: + action = None + channel_references: List[ ExpressionChannelReference | ExpressionChannelReferenceChannelConfig ] = [] @@ -205,35 +219,7 @@ def _from_yaml( } ) - namespace = rule.get("namespace") - - if namespace: - rule_namespace = rule_namespaces.get(str(namespace)) - if not rule_namespace: - raise TelemetryConfigValidationError(f"Could not find namespace {namespace}") - - found_rule = None - for rule_from_namespace in rule_namespace: - if rule["name"] == rule_from_namespace["name"]: - found_rule = rule_from_namespace - if not found_rule: - raise TelemetryConfigValidationError( - f"Could not find rule name {rule['name']} in {namespace}" - ) - rule = found_rule - - annotation_type = RuleActionAnnotationKind.from_str(rule["type"]) - - tags = rule.get("tags") - - action: RuleAction = RuleActionCreatePhaseAnnotation(tags) - if annotation_type == RuleActionAnnotationKind.REVIEW: - action = RuleActionCreateDataReviewAnnotation( - assignee=rule.get("assignee"), - tags=tags, - ) - - expression = rule["expression"] + expression = rule.get("expression", "") if isinstance(expression, str): rules.append( RuleConfig( @@ -242,6 +228,8 @@ def _from_yaml( expression=expression, action=action, channel_references=channel_references, + namespace=namespace, + namespace_rules=rule_namespaces, ) ) else: From 4c7363713ad926fb2a85d63f6d22a2e87b401f07 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Thu, 10 Oct 2024 17:02:45 -0700 Subject: [PATCH 25/33] chore: cleanups --- .../ingestion_with_yaml_config/telemetry_config.py | 7 +++---- python/lib/sift_py/ingestion/rule/config.py | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/python/examples/ingestion_with_yaml_config/telemetry_config.py b/python/examples/ingestion_with_yaml_config/telemetry_config.py index d6fcf4a4..653e70db 100644 --- a/python/examples/ingestion_with_yaml_config/telemetry_config.py +++ b/python/examples/ingestion_with_yaml_config/telemetry_config.py @@ -3,10 +3,9 @@ from sift_py.ingestion.service import TelemetryConfig -TEMP = Path().joinpath("python/examples/ingestion_with_yaml_config") -TELEMETRY_CONFIGS_DIR = Path().joinpath(TEMP).joinpath("telemetry_configs") -EXPRESSION_MODULES_DIR = Path().joinpath(TEMP).joinpath("expression_modules") -RULE_MODULES_DIR = Path().joinpath(TEMP).joinpath("rule_modules") +TELEMETRY_CONFIGS_DIR = Path().joinpath("telemetry_configs") +EXPRESSION_MODULES_DIR = Path().joinpath("expression_modules") +RULE_MODULES_DIR = Path().joinpath("rule_modules") def nostromos_lv_426() -> TelemetryConfig: diff --git a/python/lib/sift_py/ingestion/rule/config.py b/python/lib/sift_py/ingestion/rule/config.py index 9a83452f..62180d20 100644 --- a/python/lib/sift_py/ingestion/rule/config.py +++ b/python/lib/sift_py/ingestion/rule/config.py @@ -75,7 +75,6 @@ def __init__( self.action = action self.description = description - self.expression = expression self.expression = self.__class__.interpolate_sub_expressions(expression, sub_expressions) def as_json(self) -> Any: From ff124b5738ce83fca6f8cfbe9ccb5cf4d1b17382 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Thu, 10 Oct 2024 17:04:16 -0700 Subject: [PATCH 26/33] chore: cleanup --- python/lib/sift_py/ingestion/config/telemetry.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/python/lib/sift_py/ingestion/config/telemetry.py b/python/lib/sift_py/ingestion/config/telemetry.py index badd7002..12a41fed 100644 --- a/python/lib/sift_py/ingestion/config/telemetry.py +++ b/python/lib/sift_py/ingestion/config/telemetry.py @@ -190,18 +190,17 @@ def _from_yaml( for rule in config_as_yaml.get("rules", []): namespace = rule.get("namespace") + action: Optional[RuleAction] = None if not namespace: annotation_type = RuleActionAnnotationKind.from_str(rule["type"]) tags = rule.get("tags") - action: Optional[RuleAction] = RuleActionCreatePhaseAnnotation(tags) + action = RuleActionCreatePhaseAnnotation(tags) if annotation_type == RuleActionAnnotationKind.REVIEW: action = RuleActionCreateDataReviewAnnotation( assignee=rule.get("assignee"), tags=tags, ) - else: - action = None channel_references: List[ ExpressionChannelReference | ExpressionChannelReferenceChannelConfig From 05196deb9c1fe35ecad26b03ba196bcc93d2d240 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Thu, 10 Oct 2024 17:57:15 -0700 Subject: [PATCH 27/33] fix: Bug in rule namespace interpolation and test cleanup --- .../lib/sift_py/ingestion/config/telemetry.py | 8 +++-- .../ingestion/config/telemetry_test.py | 33 ++----------------- python/lib/sift_py/ingestion/rule/config.py | 10 +++--- 3 files changed, 14 insertions(+), 37 deletions(-) diff --git a/python/lib/sift_py/ingestion/config/telemetry.py b/python/lib/sift_py/ingestion/config/telemetry.py index 12a41fed..e8438556 100644 --- a/python/lib/sift_py/ingestion/config/telemetry.py +++ b/python/lib/sift_py/ingestion/config/telemetry.py @@ -191,9 +191,11 @@ def _from_yaml( namespace = rule.get("namespace") action: Optional[RuleAction] = None + description: Optional[str] = "" if not namespace: annotation_type = RuleActionAnnotationKind.from_str(rule["type"]) tags = rule.get("tags") + description = rule.get("description", "") action = RuleActionCreatePhaseAnnotation(tags) if annotation_type == RuleActionAnnotationKind.REVIEW: @@ -223,7 +225,7 @@ def _from_yaml( rules.append( RuleConfig( name=rule["name"], - description=rule.get("description", ""), + description=description, expression=expression, action=action, channel_references=channel_references, @@ -251,11 +253,13 @@ def _from_yaml( rules.append( RuleConfig( name=rule["name"], - description=rule.get("description", ""), + description=description, expression=expr, action=action, channel_references=channel_references, sub_expressions=sub_exprs, + namespace=namespace, + namespace_rules=rule_namespaces, ) ) diff --git a/python/lib/sift_py/ingestion/config/telemetry_test.py b/python/lib/sift_py/ingestion/config/telemetry_test.py index cd285bc3..bc54768a 100644 --- a/python/lib/sift_py/ingestion/config/telemetry_test.py +++ b/python/lib/sift_py/ingestion/config/telemetry_test.py @@ -142,7 +142,7 @@ def test_telemetry_config_load_from_yaml(mocker: MockFixture): assert overheating_rule.name == "overheating" assert overheating_rule.description == "Checks for vehicle overheating" assert overheating_rule.expression == '$1 == "Accelerating" && $2 > 80' - assert overheating_rule.action.kind() == RuleActionKind.ANNOTATION + assert overheating_rule.action.kind() == RuleActionKind.ANNOTATION # type: ignore assert isinstance(overheating_rule.action, RuleActionCreateDataReviewAnnotation) assert speeding_rule.name == "speeding" @@ -169,13 +169,13 @@ def test_telemetry_config_load_from_yaml(mocker: MockFixture): == "Checks that vehicle velocity becomes nonzero 5s after entering accelerating state" ) assert vehicle_stuck.expression == '$1 == "Accelerating" && persistence($2 == 0, 5)' - assert vehicle_stuck.action.kind() == RuleActionKind.ANNOTATION + assert vehicle_stuck.action.kind() == RuleActionKind.ANNOTATION # type: ignore assert isinstance(vehicle_stuck.action, RuleActionCreateDataReviewAnnotation) assert vehicle_not_stopped.name == "vehicle_not_stopped" assert vehicle_not_stopped.description == "Makes sure vehicle velocity remains 0 while stopped" assert vehicle_not_stopped.expression == '$1 == "Stopped" && $2 > 0' - assert vehicle_not_stopped.action.kind() == RuleActionKind.ANNOTATION + assert vehicle_not_stopped.action.kind() == RuleActionKind.ANNOTATION # type: ignore assert isinstance(vehicle_not_stopped.action, RuleActionCreateDataReviewAnnotation) @@ -307,18 +307,6 @@ def test_telemetry_config_validations_flows_with_same_name(): ) -def test_telemetry_config_validations_rules_with_same_namespace(): - pass - - -def test_telemetry_config_validations_rule_missing_namespace(): - pass - - -def test_telemetry_config_validations_rule_missing_from_namespace(): - pass - - TEST_YAML_CONFIG_STR = """ asset_name: LunarVehicle426 ingestion_client_key: lunar_vehicle_426 @@ -467,18 +455,3 @@ def test_telemetry_config_validations_rule_missing_from_namespace(): - <<: *velocity_channel - <<: *velocity_channel """ - -RULE_NAMESPACE_YAML_CONFIG = """ -namespace: velocity - -rules: - - name: vehicle_stuck - description: Checks that vehicle velocity becomes nonzero 5s after entering accelerating state - expression: $1 == "Accelerating" && persistence($2 == 0, 5) - type: review - - - name: vehicle_not_stopped - description: Makes sure vehicle velocity remains 0 while stopped - expression: $1 == "Stopped" && $2 > 0 - type: review -""" diff --git a/python/lib/sift_py/ingestion/rule/config.py b/python/lib/sift_py/ingestion/rule/config.py index 62180d20..f63d0d48 100644 --- a/python/lib/sift_py/ingestion/rule/config.py +++ b/python/lib/sift_py/ingestion/rule/config.py @@ -70,7 +70,7 @@ def __init__( if namespace: description, expression, action = self.__class__.interpolate_namespace_rule( - namespace, namespace_rules + name, namespace, namespace_rules ) self.action = action @@ -128,7 +128,7 @@ def interpolate_sub_expressions( @staticmethod def interpolate_namespace_rule( - namespace: str, namespace_rules: Optional[Dict[str, List[Dict]]] + name: str, namespace: str, namespace_rules: Optional[Dict[str, List[Dict]]] ) -> Tuple[str, str, RuleAction]: if not namespace_rules: raise ValueError( @@ -142,8 +142,8 @@ def interpolate_namespace_rule( ) for rule in rule_list: - name = rule.get("name") - if name: + candidate_name = rule.get("name") + if candidate_name == name: description = rule.get("description", "") expression = rule.get("expression", "") type = rule.get("type", "") @@ -154,7 +154,7 @@ def interpolate_namespace_rule( assignee=rule.get("assignee"), tags=tags ) - if not name: + if not expression: raise ValueError(f"Couldn't find rule name '{name}' in rule_list: {rule_list}") return description, expression, action From be9d09502de670fdbaf145ac4429902cc58978f7 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Fri, 11 Oct 2024 11:14:46 -0700 Subject: [PATCH 28/33] test: Add config tests for namespace interpolation --- python/lib/sift_py/ingestion/rule/config.py | 8 +- .../lib/sift_py/ingestion/rule/config_test.py | 147 ++++++++++++++++++ 2 files changed, 151 insertions(+), 4 deletions(-) diff --git a/python/lib/sift_py/ingestion/rule/config.py b/python/lib/sift_py/ingestion/rule/config.py index f63d0d48..41b1a42e 100644 --- a/python/lib/sift_py/ingestion/rule/config.py +++ b/python/lib/sift_py/ingestion/rule/config.py @@ -153,11 +153,11 @@ def interpolate_namespace_rule( action = RuleActionCreateDataReviewAnnotation( assignee=rule.get("assignee"), tags=tags ) + return description, expression, action - if not expression: - raise ValueError(f"Couldn't find rule name '{name}' in rule_list: {rule_list}") - - return description, expression, action + raise ValueError( + f"Could not find rule '{rule}'. Does this rule exist in the namespace? {rule_list}" + ) class RuleAction(ABC): diff --git a/python/lib/sift_py/ingestion/rule/config_test.py b/python/lib/sift_py/ingestion/rule/config_test.py index 8ebc6cca..1ffe97bf 100644 --- a/python/lib/sift_py/ingestion/rule/config_test.py +++ b/python/lib/sift_py/ingestion/rule/config_test.py @@ -1,8 +1,11 @@ +import pytest + from sift_py.ingestion.channel import ChannelConfig, ChannelDataType from .config import ( RuleActionCreateDataReviewAnnotation, RuleActionCreatePhaseAnnotation, + RuleActionKind, RuleConfig, ) @@ -107,3 +110,147 @@ def test_rule_named_expressions(): }, ) assert rule_on_kinetic_energy.expression == "0.5 * 10 * $1 * $1 > 35" + + +def test_rule_namespace(): + namespace_rules = { + "valid_namespace": [ + { + "name": "valid_rule", + "description": "A rule in a namespace", + "expression": "$1 > 10", + "type": "review", + "assignee": "bob@example.com", + "tags": ["foo", "bar"], + }, + { + "name": "another_valid_rule", + "description": "Another rule in a namespace", + "expression": "$1 < 10", + "type": "review", + "assignee": "mary@example.com", + "tags": ["baz", "qux"], + }, + ] + } + + valid_namespace_rule = RuleConfig( + name="valid_rule", + namespace="valid_namespace", + namespace_rules=namespace_rules, + channel_references=[ + { + "channel_reference": "$1", + "channel_config": ChannelConfig( + name="a_channel", + data_type=ChannelDataType.DOUBLE, + ), + } + ], + ) + assert valid_namespace_rule.name == "valid_rule" + assert valid_namespace_rule.description == "A rule in a namespace" + assert valid_namespace_rule.expression == "$1 > 10" + assert valid_namespace_rule.action.assignee == "bob@example.com" + assert valid_namespace_rule.action.tags == ["foo", "bar"] + assert valid_namespace_rule.action.kind() == RuleActionKind.ANNOTATION + assert isinstance(valid_namespace_rule.action, RuleActionCreateDataReviewAnnotation) + + +def test_rule_namespace_missing_namespace_rules(): + with pytest.raises(ValueError, match="Namespace rules must be provided with namespace key."): + RuleConfig( + name="a_rule", + namespace="a_namespace", + channel_references=[ + { + "channel_reference": "$1", + "channel_config": ChannelConfig( + name="a_channel", + data_type=ChannelDataType.DOUBLE, + ), + } + ], + ) + + +def test_rule_namespace_missing_namespace(): + with pytest.raises(ValueError, match="Couldn't find namespace"): + namespace_rules = { + "a_namespace": [ + { + "name": "valid_rule", + "description": "A rule in a namespace", + "expression": "$1 > 10", + "type": "review", + "assignee": "bob@example.com", + "tags": ["foo", "bar"], + }, + ], + "another_namespace": [ + { + "name": "valid_rule", + "description": "A rule in a namespace", + "expression": "$1 > 10", + "type": "review", + "assignee": "bob@example.com", + "tags": ["foo", "bar"], + }, + ], + } + + RuleConfig( + name="valid_rule", + namespace="a_missing_namespace", + namespace_rules=namespace_rules, + channel_references=[ + { + "channel_reference": "$1", + "channel_config": ChannelConfig( + name="a_channel", + data_type=ChannelDataType.DOUBLE, + ), + } + ], + ) + + +def test_rule_namespace_missing_rule(): + with pytest.raises(ValueError, match="Does this rule exist in the namespace?"): + namespace_rules = { + "a_namespace": [ + { + "name": "a_rule_in_namespace", + "description": "A rule in a namespace", + "expression": "$1 > 10", + "type": "review", + "assignee": "bob@example.com", + "tags": ["foo", "bar"], + }, + ], + "another_namespace": [ + { + "name": "another_rule_in_namespace", + "description": "A rule in a namespace", + "expression": "$1 > 10", + "type": "review", + "assignee": "bob@example.com", + "tags": ["foo", "bar"], + }, + ], + } + + RuleConfig( + name="a_missing_rule", + namespace="a_namespace", + namespace_rules=namespace_rules, + channel_references=[ + { + "channel_reference": "$1", + "channel_config": ChannelConfig( + name="a_channel", + data_type=ChannelDataType.DOUBLE, + ), + } + ], + ) From 8fc01b91100edb5dddfe2e1175868b3f17389868 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Fri, 11 Oct 2024 14:58:58 -0700 Subject: [PATCH 29/33] test: Load test for misconfigured namespace rule with additional fields --- .../sift_py/ingestion/config/yaml/test_load.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/python/lib/sift_py/ingestion/config/yaml/test_load.py b/python/lib/sift_py/ingestion/config/yaml/test_load.py index f6cbf9e7..f207a2e4 100644 --- a/python/lib/sift_py/ingestion/config/yaml/test_load.py +++ b/python/lib/sift_py/ingestion/config/yaml/test_load.py @@ -327,6 +327,24 @@ def test__validate_rule(): } ) + with pytest.raises(YamlConfigError, match="should not have any other properties set"): + load._validate_rule( + { + "name": "overheat_rule", + "namespace": "my_namespace", + "description": "some_description", + "expression": "$1 > 10 && $2 > 10", + "type": "review", + "assignee": "homer@example.com", + "tags": ["foo", "bar"], + "channel_references": [ + {"$1": {"name": "voltage", "data_type": "double"}}, + {"$2": {"name": "vehicle_state", "data_type": "double"}}, + ], + } + ) + + def test__validate_flow(): load._validate_flow( From 2cc161db7ca33815ff5720a051283f4159aee5ca Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Fri, 11 Oct 2024 15:04:13 -0700 Subject: [PATCH 30/33] chore: format --- python/lib/sift_py/ingestion/config/yaml/test_load.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/lib/sift_py/ingestion/config/yaml/test_load.py b/python/lib/sift_py/ingestion/config/yaml/test_load.py index f207a2e4..4ffc75fe 100644 --- a/python/lib/sift_py/ingestion/config/yaml/test_load.py +++ b/python/lib/sift_py/ingestion/config/yaml/test_load.py @@ -345,7 +345,6 @@ def test__validate_rule(): ) - def test__validate_flow(): load._validate_flow( { From 98631a5cb07307490d0e74b20f674e9b832aa234 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Mon, 14 Oct 2024 15:00:28 -0700 Subject: [PATCH 31/33] chore: Clean up optionals in RuleConfig --- python/lib/sift_py/ingestion/rule/config.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/python/lib/sift_py/ingestion/rule/config.py b/python/lib/sift_py/ingestion/rule/config.py index 41b1a42e..4d948ccf 100644 --- a/python/lib/sift_py/ingestion/rule/config.py +++ b/python/lib/sift_py/ingestion/rule/config.py @@ -24,8 +24,8 @@ class RuleConfig(AsJson): """ name: str - description: Optional[str] - expression: Optional[str] + description: str + expression: str action: Optional[RuleAction] channel_references: List[ExpressionChannelReference] @@ -35,12 +35,12 @@ def __init__( channel_references: List[ Union[ExpressionChannelReference, ExpressionChannelReferenceChannelConfig] ], - description: Optional[str] = "", - expression: Optional[str] = "", + description: str = "", + expression: str = "", action: Optional[RuleAction] = None, - sub_expressions: Optional[Dict[str, Any]] = {}, - namespace: Optional[str] = "", - namespace_rules: Optional[Dict[str, List[Dict]]] = {}, + sub_expressions: Dict[str, Any] = {}, + namespace: str = "", + namespace_rules: Dict[str, List[Dict]] = {}, ): self.channel_references = [] @@ -113,9 +113,9 @@ def as_json(self) -> Any: @staticmethod def interpolate_sub_expressions( - expression: Optional[str], sub_expressions: Optional[Dict[str, str]] - ) -> Optional[str]: - if expression and sub_expressions: + expression: str, sub_expressions: Optional[Dict[str, str]] + ) -> str: + if sub_expressions: for ref, expr in sub_expressions.items(): if ref not in expression: raise ValueError(f"Couldn't find '{ref}' in expression '{expression}'.") From 275366cd5678e837bd5384d0775162858077e86c Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Mon, 14 Oct 2024 15:04:00 -0700 Subject: [PATCH 32/33] chore: type fixes --- python/lib/sift_py/ingestion/config/telemetry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/lib/sift_py/ingestion/config/telemetry.py b/python/lib/sift_py/ingestion/config/telemetry.py index e8438556..e17d25da 100644 --- a/python/lib/sift_py/ingestion/config/telemetry.py +++ b/python/lib/sift_py/ingestion/config/telemetry.py @@ -188,10 +188,10 @@ def _from_yaml( ) for rule in config_as_yaml.get("rules", []): - namespace = rule.get("namespace") + namespace = rule.get("namespace", "") action: Optional[RuleAction] = None - description: Optional[str] = "" + description: str = "" if not namespace: annotation_type = RuleActionAnnotationKind.from_str(rule["type"]) tags = rule.get("tags") From 08462a4f6b2e9b8ccb07e82cac77686faa9b2a51 Mon Sep 17 00:00:00 2001 From: Ailin Yu Date: Mon, 14 Oct 2024 16:31:41 -0700 Subject: [PATCH 33/33] chore: clarify example rule wording --- .../ingestion_with_python_config/rule_modules/velocity.yml | 4 ++-- .../ingestion_with_yaml_config/rule_modules/velocity.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/python/examples/ingestion_with_python_config/rule_modules/velocity.yml b/python/examples/ingestion_with_python_config/rule_modules/velocity.yml index 43c98e92..5505580a 100644 --- a/python/examples/ingestion_with_python_config/rule_modules/velocity.yml +++ b/python/examples/ingestion_with_python_config/rule_modules/velocity.yml @@ -2,11 +2,11 @@ namespace: velocity rules: - name: vehicle_stuck - description: Checks that vehicle velocity becomes nonzero 5s after entering accelerating state + description: Triggers if the vehicle velocity is not 0 for 5s after entering accelerating state expression: $1 == "Accelerating" && persistence($2 == 0, 5) type: review - name: vehicle_not_stopped - description: Makes sure vehicle velocity remains 0 while stopped + description: Triggers if the vehicle velocity does not remain 0 while stopped expression: $1 == "Stopped" && $2 > 0 type: review diff --git a/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml b/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml index 43c98e92..5505580a 100644 --- a/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml +++ b/python/examples/ingestion_with_yaml_config/rule_modules/velocity.yml @@ -2,11 +2,11 @@ namespace: velocity rules: - name: vehicle_stuck - description: Checks that vehicle velocity becomes nonzero 5s after entering accelerating state + description: Triggers if the vehicle velocity is not 0 for 5s after entering accelerating state expression: $1 == "Accelerating" && persistence($2 == 0, 5) type: review - name: vehicle_not_stopped - description: Makes sure vehicle velocity remains 0 while stopped + description: Triggers if the vehicle velocity does not remain 0 while stopped expression: $1 == "Stopped" && $2 > 0 type: review