Skip to content

Commit 090f514

Browse files
committed
add variables module to monitor utils
1 parent ee3c06d commit 090f514

File tree

5 files changed

+293
-2
lines changed

5 files changed

+293
-2
lines changed

docs/monitor.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,10 @@ To create a monitor, import specific dependencies from `monitor_utils`. Availabl
5656
- [`ValueRule`](#value-rule)
5757
- [`PriorityLevels`](#priority-levels)
5858

59-
**Functions**
59+
**Available functions and modules**
6060
- [`query`](#query)
6161
- [`read_file`](#read-file)
62+
- [`variables`](#variables)
6263

6364
To get started with a simple monitor, use the following imports:
6465

@@ -360,5 +361,37 @@ The function takes 2 parameters:
360361
content = read_file("search_query.sql")
361362
```
362363

364+
## Variables
365+
The `variables` module allows storing and retrieving variables that can be used across executions of the monitor. This is useful for maintaining state or configuration information.
366+
367+
Available functions are:
368+
**`get_variable`**
369+
370+
The function takes one parameter:
371+
- `name`: The name of the variable to retrieve.
372+
373+
Return the value of a variable. If the variable does not exist, returns `None`.
374+
375+
**`set_variable`**
376+
377+
The function takes two parameters:
378+
- `name`: The name of the variable to set.
379+
- `value`: The value to assign to the variable.
380+
381+
Sets the value of a variable. If the variable does not exist yet, it's created.
382+
383+
Both functions must be called from functions defined in the monitor base code. If they're called from any other Python file, they will raise an error as they won't be able to identify the monitor that's calling it.
384+
385+
```python
386+
from monitor_utils import variables
387+
388+
async def search() -> list[IssueDataType] | None:
389+
# Set a variable
390+
await variables.set_variable("my_var", "some_value")
391+
392+
async def update(issues_data: list[IssueDataType]) -> list[IssueDataType] | None:
393+
value = await variables.get_variable("my_var")
394+
```
395+
363396
# Registering
364397
After creating the monitor, the next step is to register it on Sentinela. Check the [Registering a monitor](monitor_registering.md) documentation for more information.

src/monitor_utils/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from databases import query
1717
from models.utils.priority import AlertPriority
1818

19+
from . import variables
1920
from .read_file import read_file
2021

2122
__all__ = [
@@ -31,4 +32,5 @@
3132
"ReactionOptions",
3233
"read_file",
3334
"ValueRule",
35+
"variables",
3436
]

src/monitor_utils/variables.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from types import ModuleType
2+
from typing import cast
3+
4+
from models import Variable
5+
from utils.stack import get_caller
6+
7+
"""The functions 'set_variable' and 'get_variable' must be called from inside a monitor to be able
8+
to identify the monitor ID. If called outside a monitor, they will raise an error."""
9+
10+
11+
def _get_monitor_id(monitor_module: ModuleType) -> int:
12+
"""Get the monitor ID from the monitor module"""
13+
monitor_id = getattr(monitor_module, "SENTINELA_MONITOR_ID", None)
14+
if monitor_id is None:
15+
raise ValueError("Function called outside a monitor or the monitor was not loaded properly")
16+
17+
return cast(int, monitor_id)
18+
19+
20+
async def set_variable(name: str, value: str | None) -> None:
21+
"""Set a variable for the monitor"""
22+
monitor_module, _ = get_caller()
23+
monitor_id = _get_monitor_id(monitor_module)
24+
25+
variable = await Variable.get_or_create(monitor_id=monitor_id, name=name)
26+
await variable.set(value)
27+
28+
29+
async def get_variable(name: str) -> str | None:
30+
"""Get a variable for the monitor, or None if it does not exist"""
31+
monitor_module, _ = get_caller()
32+
monitor_id = _get_monitor_id(monitor_module)
33+
34+
variable = await Variable.get(Variable.monitor_id == monitor_id, Variable.name == name)
35+
if variable is None:
36+
return None
37+
38+
return variable.value
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import pytest
2+
3+
from models import Monitor, Variable
4+
from monitor_utils.variables import _get_monitor_id, get_variable, set_variable
5+
6+
pytestmark = pytest.mark.asyncio(loop_scope="session")
7+
8+
9+
@pytest.mark.parametrize("monitor_id", [123, 456, 789])
10+
async def test_get_monitor_id(monitor_id):
11+
"""'_get_monitor_id' should return the monitor ID from the module if SENTINELA_MONITOR_ID is
12+
set"""
13+
14+
class Module:
15+
SENTINELA_MONITOR_ID = monitor_id
16+
17+
result = _get_monitor_id(Module())
18+
assert result == monitor_id
19+
20+
21+
async def test_get_monitor_id_with_none():
22+
"""'_get_monitor_id' should raise ValueError if SENTINELA_MONITOR_ID is None"""
23+
24+
class Module:
25+
SENTINELA_MONITOR_ID = None
26+
27+
expected_error_message = (
28+
"Function called outside a monitor or the monitor was not loaded properly"
29+
)
30+
with pytest.raises(ValueError, match=expected_error_message):
31+
_get_monitor_id(Module())
32+
33+
34+
async def test_get_monitor_id_without_attribute():
35+
"""'_get_monitor_id' should raise ValueError if SENTINELA_MONITOR_ID is not defined"""
36+
37+
class Module:
38+
pass
39+
40+
expected_error_message = (
41+
"Function called outside a monitor or the monitor was not loaded properly"
42+
)
43+
with pytest.raises(ValueError, match=expected_error_message):
44+
_get_monitor_id(Module())
45+
46+
47+
@pytest.mark.parametrize(
48+
"variable_name, variable_value",
49+
[("test_var", "test_value"), ("another_var", "another_value"), ("empty_var", None)],
50+
)
51+
async def test_set_variable(sample_monitor: Monitor, variable_name, variable_value):
52+
"""'set_variable' should set a variable for the monitor"""
53+
call_function = sample_monitor.code.call_function # type: ignore[attr-defined]
54+
55+
async def f() -> None:
56+
await call_function(set_variable, variable_name, variable_value)
57+
58+
await f()
59+
60+
variables = await Variable.get_all(Variable.monitor_id == sample_monitor.id)
61+
assert len(variables) == 1
62+
63+
assert variables[0].monitor_id == sample_monitor.id
64+
assert variables[0].name == variable_name
65+
assert variables[0].value == variable_value
66+
67+
68+
async def test_set_variable_multiple_variables(sample_monitor: Monitor):
69+
"""'set_variable' should set multiple different variables for the same monitor"""
70+
call_function = sample_monitor.code.call_function # type: ignore[attr-defined]
71+
72+
async def f() -> None:
73+
await call_function(set_variable, "var1", "value1")
74+
await call_function(set_variable, "var2", "value2")
75+
await call_function(set_variable, "var3", None)
76+
77+
await f()
78+
79+
variables = await Variable.get_all(Variable.monitor_id == sample_monitor.id)
80+
assert len(variables) == 3
81+
82+
variables_dict = {var.name: var.value for var in variables}
83+
expected_variables = {"var1": "value1", "var2": "value2", "var3": None}
84+
assert variables_dict == expected_variables
85+
86+
87+
async def test_set_variable_update_existing(sample_monitor: Monitor):
88+
"""'set_variable' should update an existing variable if it already exists"""
89+
await Variable.create(
90+
monitor_id=sample_monitor.id,
91+
name="test_var",
92+
value="initial_value",
93+
)
94+
95+
call_function = sample_monitor.code.call_function # type: ignore[attr-defined]
96+
97+
async def f() -> None:
98+
await call_function(set_variable, "test_var", "updated_value")
99+
100+
await f()
101+
102+
variables = await Variable.get_all(Variable.monitor_id == sample_monitor.id)
103+
assert len(variables) == 1
104+
assert variables[0].name == "test_var"
105+
assert variables[0].value == "updated_value"
106+
107+
108+
async def test_set_variable_monitor_id_none(monkeypatch, sample_monitor: Monitor):
109+
"""'set_variable' should raise 'ValueError' if 'SENTINELA_MONITOR_ID' is None"""
110+
monkeypatch.setattr(sample_monitor.code, "SENTINELA_MONITOR_ID", None, raising=False)
111+
112+
call_function = sample_monitor.code.call_function # type: ignore[attr-defined]
113+
114+
async def f() -> None:
115+
await call_function(set_variable, "test_var", "test_value")
116+
117+
expected_error_message = (
118+
"Function called outside a monitor or the monitor was not loaded properly"
119+
)
120+
with pytest.raises(ValueError, match=expected_error_message):
121+
await f()
122+
123+
124+
@pytest.mark.parametrize(
125+
"variable_name, variable_value",
126+
[("test_var", "test_value"), ("another_var", "another_value"), ("empty_var", None)],
127+
)
128+
async def test_get_variable(sample_monitor: Monitor, variable_name, variable_value):
129+
"""'get_variable' should get a variable for the monitor"""
130+
await Variable.create(
131+
monitor_id=sample_monitor.id,
132+
name=variable_name,
133+
value=variable_value,
134+
)
135+
136+
call_function = sample_monitor.code.call_function # type: ignore[attr-defined]
137+
138+
async def f() -> None:
139+
result = await call_function(get_variable, variable_name)
140+
assert result == variable_value
141+
142+
await f()
143+
144+
145+
async def test_get_variable_none_value(sample_monitor: Monitor):
146+
"""'get_variable' should be able to get a variable with 'None' value"""
147+
await Variable.create(
148+
monitor_id=sample_monitor.id,
149+
name="test_var",
150+
value=None,
151+
)
152+
153+
call_function = sample_monitor.code.call_function # type: ignore[attr-defined]
154+
155+
async def f() -> None:
156+
assert await call_function(get_variable, "test_var") is None
157+
158+
await f()
159+
160+
161+
async def test_get_variable_not_exists(sample_monitor: Monitor):
162+
"""'get_variable' should not raise an error if the variable does not exist and return None"""
163+
variable = await Variable.get(
164+
Variable.monitor_id == sample_monitor.id,
165+
Variable.name == "non_existent_var",
166+
)
167+
assert variable is None
168+
169+
call_function = sample_monitor.code.call_function # type: ignore[attr-defined]
170+
171+
async def f() -> None:
172+
assert await call_function(get_variable, "non_existent_var") is None
173+
174+
await f()
175+
176+
177+
async def test_get_variable_multiple_variables(sample_monitor: Monitor):
178+
"""'get_variable' should correctly retrieve specific variables when multiple exist"""
179+
await Variable.create(
180+
monitor_id=sample_monitor.id,
181+
name="var1",
182+
value="value1",
183+
)
184+
await Variable.create(
185+
monitor_id=sample_monitor.id,
186+
name="var2",
187+
value="value2",
188+
)
189+
190+
call_function = sample_monitor.code.call_function # type: ignore[attr-defined]
191+
192+
async def f() -> None:
193+
assert await call_function(get_variable, "var1") == "value1"
194+
assert await call_function(get_variable, "var2") == "value2"
195+
196+
await f()
197+
198+
199+
async def test_get_variable_monitor_id_none(monkeypatch, sample_monitor: Monitor):
200+
"""'get_variable' should raise ValueError if SENTINELA_MONITOR_ID is None"""
201+
monkeypatch.setattr(sample_monitor.code, "SENTINELA_MONITOR_ID", None)
202+
203+
call_function = sample_monitor.code.call_function # type: ignore[attr-defined]
204+
205+
async def f() -> None:
206+
await call_function(get_variable, "test_var")
207+
208+
expected_error_message = (
209+
"Function called outside a monitor or the monitor was not loaded properly"
210+
)
211+
with pytest.raises(ValueError, match=expected_error_message):
212+
await f()

tests/sample_monitor_code.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import TypedDict
1+
from typing import Any, Callable, TypedDict
22

33
from monitor_utils import IssueOptions, MonitorOptions
44

@@ -21,3 +21,9 @@ class IssueDataType(TypedDict):
2121
async def search() -> list[IssueDataType] | None: ...
2222
async def update(issues_data: list[IssueDataType]) -> list[IssueDataType] | None: ...
2323
def is_solved(issue_data: IssueDataType) -> bool: ...
24+
25+
26+
async def call_function(function: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
27+
"""Call a function and return the result. This function is used in tests to mock calls that must
28+
come from inside the monitor"""
29+
return await function(*args, **kwargs)

0 commit comments

Comments
 (0)