Repository: CrowdStrike/caracara

Issue:
[ BUG ] IoaRuleGroup.__repr__() raises AttributeError
# Bug Report Template

## Describe the bug

Custom IOA Rule models raise an `AttributeError` when `repr()` is called on them when instantiated using `__init__()`.

## To Reproduce

```python
from caracara.modules.custom_ioa import IoaRuleGroup

irg = IoaRuleGroup("name", "description", "platform")

repr(irg)
```

```
Traceback (most recent call last):
  File "/home/zaci/PycharmProjects/Caracara-Reproduce/.venv/lib/python3.10/site-packages/caracara/modules/custom_ioa/rules.py", line 66, in __repr__
    f"<{self.__class__.__name__}(id_={repr(self.id_)}, version={repr(self.version)}, "
AttributeError: 'IoaRuleGroup' object has no attribute 'version'
```

## Expected behavior

There should be no state of IoaRuleGroup where a `repr()` cannot be displayed.

## Environment

### Operating System Version

Linux arch 6.11.2-arch1-1 #\1 SMP PREEMPT_DYNAMIC Fri, 04 Oct 2024 21:51:11 +0000 x86_64 GNU/Linux

### Python Version

- Python 3.10.14
- Python 3.12.6

### Poetry Version

Poetry (version 1.8.3)

### Python Package Versions

```
caracara==0.8.0
caracara-filters==0.2.0
crowdstrike-falconpy==1.4.5
```

## Additional context

If you are wanting to distinguish between unset and null variables then one approach could be to use a [sentinel object](https://python-patterns.guide/python/sentinel-object/).

```python
# sentinel.py
class Unset:
    def __str__(self):
        return ""

    def __repr__(self):
        return "<empty>"

    def __bool__(self):
        return False
```

```python
# my_model.py
UNSET = Unset()  # sentinel object


class MyModel:
    a: str = UNSET
    b: str = UNSET

    def __init__(self, a: str):
        self.a = a

    def __repr__(self):
        return f"<MyModel(a={self.a!r}, b={self.b!r})>"


m = MyModel("example")
assert repr(m) == "<MyModel(a='example', b=<empty>)>"
```

*edit: Added Sentinel class for tidier `__repr__`

Hints from issue comments:
@scottzach1: After some further playing around I noticed that in a similar note accessing the cloud fields on the models also raises an attribute error. This is documented in the inline comments but I think this would also be improved with sentinel values.

```python
from caracara.modules.custom_ioa import CustomIoaRule

rule = CustomIoaRule("name", "description", <rule_type>)
rule.deleted  # raises AttributeError
```@ChristopherHammond13: Thank you also for raising this issue, Zac! Assigning myself to this one, too. Going to think about how to architect this one a little better.

Solution patch:
diff --git a/caracara/modules/custom_ioa/rules.py b/caracara/modules/custom_ioa/rules.py
index 881636a..e552fad 100644
--- a/caracara/modules/custom_ioa/rules.py
+++ b/caracara/modules/custom_ioa/rules.py
@@ -23,9 +23,8 @@ class IoaRuleGroup:
     rules_to_delete: List[CustomIoaRule]
     group: IoaRuleGroup
 
-    # The fields below will only be populated if `exists_in_cloud()` returns `True`, with the
-    # exception of `id` which should be initialised to `None` if this object does not exist on the
-    # cloud
+    # The fields below will only be populated if `exists_in_cloud()` returns `True`,
+    # otherwise they will be `None`
     id_: str
     comment: str
     committed_on: datetime
@@ -60,7 +59,19 @@ class IoaRuleGroup:
         self.rules = []
         self.rules_to_delete = []
         self.group = None
+
         self.id_ = None
+        self.comment = None
+        self.committed_on = None
+        self.created_by = None
+        self.created_on = None
+        self.customer_id = None
+        self.deleted = None
+        self.enabled = None
+        self.modified_by = None
+        self.modified_on = None
+        self.rule_ids = None
+        self.version = None
 
     def __repr__(self):
         """Return an unambiguous string representation of the IOA and its ID, platform and name."""
@@ -301,9 +312,8 @@ class CustomIoaRule:
     # This field should exist if this object has been added to a group
     group: IoaRuleGroup
 
-    # All these fields should exist if `exists_in_cloud()` returns True, with the exception of
-    # instance_id which should exist and initialised to `None` if this object does not exist in the
-    # cloud
+    # The fields below will only be populated if `exists_in_cloud()` returns `True`,
+    # otherwise they will be `None`
     instance_id: str
     action_label: str
     comment: str
@@ -353,13 +363,29 @@ class CustomIoaRule:
         self.severity = severity
         self.rule_type = rule_type
         self.group = None
-        self.instance_id = None
 
         self.fields = {}
         for field_type in rule_type.fields:
             field = field_type.to_concrete_field()
             self.fields[(field["name"], field["type"])] = field
 
+        self.instance_id = None
+        self.action_label = None
+        self.comment = None
+        self.committed_on = None
+        self.created_by = None
+        self.created_on = None
+        self.customer_id = None
+        self.deleted = None
+        self.disposition_id = None
+        self.enabled = None
+        self.instance_version = None
+        self.magic_cookie = None
+        self.modified_by = None
+        self.modified_on = None
+        self.version_ids = None
+        self.pattern_id = None
+
     def __repr__(self):
         """Return an unambiguous string representation of the CustomIoaRule and its properties.
 
@@ -583,6 +609,8 @@ class CustomIoaRule:
 
         This object model is defined in the CrowdStrike API Swagger document.
         """
+        if not self.exists_in_cloud():
+            raise ValueError("This group does not exist in the cloud!")
         return {
             "customer_id": self.customer_id,
             "instance_id": self.instance_id,


Test patch:
diff --git a/tests/unit_tests/test_custom_ioas.py b/tests/unit_tests/test_custom_ioas.py
index 33a936b..0312283 100644
--- a/tests/unit_tests/test_custom_ioas.py
+++ b/tests/unit_tests/test_custom_ioas.py
@@ -849,3 +849,29 @@ def test_update_rule_group_with_new_rules(
         }
     )
     assert len([rule for rule in new_group.rules if rule.exists_in_cloud()]) == len(group.rules)
+
+
+def test_ioa_rule_group_repr():
+    """Tests the `__repr__` function of an IoaRuleGroup."""
+    irg = IoaRuleGroup("name", "desc", "platform")
+    assert (
+        str(irg) == "<IoaRuleGroup(id_=None, version=None, platform='platform', name='name', ...)>"
+    )
+
+
+def test_ioa_rule_repr(simple_rule_type: RuleType):
+    """Tests the `__repr__` function of an CustomIoaRule."""
+    ir = CustomIoaRule("name", "desc", "informational", simple_rule_type)
+    assert str(ir) == (
+        "<CustomIoaRule(group=None, instance_id=None, instance_version=None name='name', "
+        "ruletype=<RuleType(id_='test_rule_type_simple', "
+        "name='SimpleType', platform='windows', ...)>, ...)>"
+    )
+
+
+def test_rule_type_repr(simple_rule_type: RuleType):
+    """Tests the `__repr__` function of an RuleType."""
+    assert (
+        str(simple_rule_type)
+        == "<RuleType(id_='test_rule_type_simple', name='SimpleType', platform='windows', ...)>"
+    )