cactus-test-definitions 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of cactus-test-definitions might be problematic. Click here for more details.

Files changed (139) hide show
  1. cactus_test_definitions/__init__.py +39 -0
  2. cactus_test_definitions/__pycache__/__init__.cpython-312.pyc +0 -0
  3. cactus_test_definitions/__pycache__/actions.cpython-312.pyc +0 -0
  4. cactus_test_definitions/__pycache__/checks.cpython-312.pyc +0 -0
  5. cactus_test_definitions/__pycache__/csipaus.cpython-312.pyc +0 -0
  6. cactus_test_definitions/__pycache__/errors.cpython-312.pyc +0 -0
  7. cactus_test_definitions/__pycache__/events.cpython-312.pyc +0 -0
  8. cactus_test_definitions/__pycache__/parameters.cpython-312.pyc +0 -0
  9. cactus_test_definitions/__pycache__/schema.cpython-312.pyc +0 -0
  10. cactus_test_definitions/__pycache__/test_procedures.cpython-312-pytest-8.3.5.pyc +0 -0
  11. cactus_test_definitions/__pycache__/test_procedures.cpython-312.pyc +0 -0
  12. cactus_test_definitions/__pycache__/variable_expressions.cpython-312.pyc +0 -0
  13. cactus_test_definitions/__pycache__/version.cpython-312.pyc +0 -0
  14. cactus_test_definitions/client/__init__.py +26 -0
  15. cactus_test_definitions/client/__pycache__/__init__.cpython-312.pyc +0 -0
  16. cactus_test_definitions/client/__pycache__/actions.cpython-312.pyc +0 -0
  17. cactus_test_definitions/client/__pycache__/checks.cpython-312.pyc +0 -0
  18. cactus_test_definitions/client/__pycache__/events.cpython-312.pyc +0 -0
  19. cactus_test_definitions/client/__pycache__/test_procedures.cpython-312-pytest-8.3.5.pyc +0 -0
  20. cactus_test_definitions/client/__pycache__/validate.cpython-312.pyc +0 -0
  21. cactus_test_definitions/client/actions.py +98 -0
  22. cactus_test_definitions/client/checks.py +127 -0
  23. cactus_test_definitions/client/events.py +71 -0
  24. cactus_test_definitions/client/procedures/ALL-01.yaml +94 -0
  25. cactus_test_definitions/client/procedures/ALL-02.yaml +113 -0
  26. cactus_test_definitions/client/procedures/ALL-03-REJ.yaml +69 -0
  27. cactus_test_definitions/client/procedures/ALL-03.yaml +110 -0
  28. cactus_test_definitions/client/procedures/ALL-04.yaml +69 -0
  29. cactus_test_definitions/client/procedures/ALL-05.yaml +117 -0
  30. cactus_test_definitions/client/procedures/ALL-06.yaml +128 -0
  31. cactus_test_definitions/client/procedures/ALL-07.yaml +76 -0
  32. cactus_test_definitions/client/procedures/ALL-08.yaml +78 -0
  33. cactus_test_definitions/client/procedures/ALL-09.yaml +111 -0
  34. cactus_test_definitions/client/procedures/ALL-10.yaml +128 -0
  35. cactus_test_definitions/client/procedures/ALL-11.yaml +111 -0
  36. cactus_test_definitions/client/procedures/ALL-12.yaml +108 -0
  37. cactus_test_definitions/client/procedures/ALL-13.yaml +112 -0
  38. cactus_test_definitions/client/procedures/ALL-14.yaml +165 -0
  39. cactus_test_definitions/client/procedures/ALL-15.yaml +109 -0
  40. cactus_test_definitions/client/procedures/ALL-16.yaml +102 -0
  41. cactus_test_definitions/client/procedures/ALL-17.yaml +63 -0
  42. cactus_test_definitions/client/procedures/ALL-18.yaml +288 -0
  43. cactus_test_definitions/client/procedures/ALL-19.yaml +78 -0
  44. cactus_test_definitions/client/procedures/ALL-20.yaml +136 -0
  45. cactus_test_definitions/client/procedures/ALL-21.yaml +203 -0
  46. cactus_test_definitions/client/procedures/ALL-22.yaml +82 -0
  47. cactus_test_definitions/client/procedures/ALL-23.yaml +158 -0
  48. cactus_test_definitions/client/procedures/ALL-24.yaml +132 -0
  49. cactus_test_definitions/client/procedures/ALL-25-EXT.yaml +228 -0
  50. cactus_test_definitions/client/procedures/ALL-25.yaml +136 -0
  51. cactus_test_definitions/client/procedures/ALL-26.yaml +147 -0
  52. cactus_test_definitions/client/procedures/ALL-27.yaml +144 -0
  53. cactus_test_definitions/client/procedures/ALL-28.yaml +274 -0
  54. cactus_test_definitions/client/procedures/ALL-29.yaml +87 -0
  55. cactus_test_definitions/client/procedures/ALL-30.yaml +188 -0
  56. cactus_test_definitions/client/procedures/BES-01.yaml +136 -0
  57. cactus_test_definitions/client/procedures/BES-02.yaml +137 -0
  58. cactus_test_definitions/client/procedures/BES-03.yaml +135 -0
  59. cactus_test_definitions/client/procedures/BES-04.yaml +228 -0
  60. cactus_test_definitions/client/procedures/DRA-01.yaml +54 -0
  61. cactus_test_definitions/client/procedures/DRA-02.yaml +64 -0
  62. cactus_test_definitions/client/procedures/DRD-01.yaml +667 -0
  63. cactus_test_definitions/client/procedures/DRL-01.yaml +327 -0
  64. cactus_test_definitions/client/procedures/GEN-01.yaml +73 -0
  65. cactus_test_definitions/client/procedures/GEN-02.yaml +72 -0
  66. cactus_test_definitions/client/procedures/GEN-03.yaml +160 -0
  67. cactus_test_definitions/client/procedures/GEN-04.yaml +161 -0
  68. cactus_test_definitions/client/procedures/GEN-05.yaml +89 -0
  69. cactus_test_definitions/client/procedures/GEN-06.yaml +90 -0
  70. cactus_test_definitions/client/procedures/GEN-07.yaml +145 -0
  71. cactus_test_definitions/client/procedures/GEN-08.yaml +145 -0
  72. cactus_test_definitions/client/procedures/GEN-09.yaml +117 -0
  73. cactus_test_definitions/client/procedures/GEN-10.yaml +739 -0
  74. cactus_test_definitions/client/procedures/GEN-11.yaml +378 -0
  75. cactus_test_definitions/client/procedures/GEN-12.yaml +378 -0
  76. cactus_test_definitions/client/procedures/GEN-13.yaml +70 -0
  77. cactus_test_definitions/client/procedures/LOA-01.yaml +73 -0
  78. cactus_test_definitions/client/procedures/LOA-02.yaml +73 -0
  79. cactus_test_definitions/client/procedures/LOA-03.yaml +160 -0
  80. cactus_test_definitions/client/procedures/LOA-04.yaml +161 -0
  81. cactus_test_definitions/client/procedures/LOA-05.yaml +89 -0
  82. cactus_test_definitions/client/procedures/LOA-06.yaml +89 -0
  83. cactus_test_definitions/client/procedures/LOA-07.yaml +145 -0
  84. cactus_test_definitions/client/procedures/LOA-08.yaml +145 -0
  85. cactus_test_definitions/client/procedures/LOA-09.yaml +117 -0
  86. cactus_test_definitions/client/procedures/LOA-10.yaml +741 -0
  87. cactus_test_definitions/client/procedures/LOA-11.yaml +378 -0
  88. cactus_test_definitions/client/procedures/LOA-12.yaml +378 -0
  89. cactus_test_definitions/client/procedures/LOA-13.yaml +71 -0
  90. cactus_test_definitions/client/procedures/MUL-01.yaml +92 -0
  91. cactus_test_definitions/client/procedures/MUL-02.yaml +80 -0
  92. cactus_test_definitions/client/procedures/MUL-03.yaml +74 -0
  93. cactus_test_definitions/client/test_procedures.py +185 -0
  94. cactus_test_definitions/client/validate.py +98 -0
  95. cactus_test_definitions/csipaus.py +81 -0
  96. cactus_test_definitions/errors.py +15 -0
  97. cactus_test_definitions/parameters.py +153 -0
  98. cactus_test_definitions/py.typed +0 -0
  99. cactus_test_definitions/schema.py +22 -0
  100. cactus_test_definitions/server/README.md +170 -0
  101. cactus_test_definitions/server/__init__.py +23 -0
  102. cactus_test_definitions/server/__pycache__/__init__.cpython-312.pyc +0 -0
  103. cactus_test_definitions/server/__pycache__/actions.cpython-312.pyc +0 -0
  104. cactus_test_definitions/server/__pycache__/checks.cpython-312.pyc +0 -0
  105. cactus_test_definitions/server/__pycache__/test_procedures.cpython-312-pytest-8.3.5.pyc +0 -0
  106. cactus_test_definitions/server/__pycache__/validate.cpython-312.pyc +0 -0
  107. cactus_test_definitions/server/actions.py +139 -0
  108. cactus_test_definitions/server/checks.py +117 -0
  109. cactus_test_definitions/server/procedures/S-ALL-01.yaml +42 -0
  110. cactus_test_definitions/server/procedures/S-ALL-02.yaml +65 -0
  111. cactus_test_definitions/server/procedures/S-ALL-03.yaml +65 -0
  112. cactus_test_definitions/server/procedures/S-ALL-04.yaml +137 -0
  113. cactus_test_definitions/server/procedures/S-ALL-05.yaml +111 -0
  114. cactus_test_definitions/server/procedures/S-OPT-01.yaml +42 -0
  115. cactus_test_definitions/server/procedures/S-OPT-02.yaml +40 -0
  116. cactus_test_definitions/server/procedures/S-OPT-03.yaml +44 -0
  117. cactus_test_definitions/server/procedures/S-OPT-04.yaml +32 -0
  118. cactus_test_definitions/server/test_procedures.py +122 -0
  119. cactus_test_definitions/server/validate.py +30 -0
  120. cactus_test_definitions/variable_expressions.py +419 -0
  121. cactus_test_definitions-1.6.0.dist-info/METADATA +289 -0
  122. cactus_test_definitions-1.6.0.dist-info/RECORD +139 -0
  123. cactus_test_definitions-1.6.0.dist-info/WHEEL +5 -0
  124. cactus_test_definitions-1.6.0.dist-info/licenses/LICENSE.txt +22 -0
  125. cactus_test_definitions-1.6.0.dist-info/top_level.txt +2 -0
  126. tests/__init__.py +0 -0
  127. tests/unit/__init__.py +0 -0
  128. tests/unit/client/__init__.py +0 -0
  129. tests/unit/client/test_actions.py +72 -0
  130. tests/unit/client/test_checks.py +71 -0
  131. tests/unit/client/test_events.py +36 -0
  132. tests/unit/client/test_test_procedures.py +103 -0
  133. tests/unit/client/test_validate.py +153 -0
  134. tests/unit/server/__init__.py +0 -0
  135. tests/unit/server/test_test_procedures.py +60 -0
  136. tests/unit/server/test_validate.py +62 -0
  137. tests/unit/test_csipaus.py +49 -0
  138. tests/unit/test_parameters.py +197 -0
  139. tests/unit/test_variable_expressions.py +402 -0
@@ -0,0 +1,419 @@
1
+ import tokenize
2
+ import abc
3
+ from dataclasses import dataclass
4
+ from datetime import timedelta
5
+ from enum import IntEnum, auto
6
+ from io import StringIO
7
+ from re import match, search
8
+ from typing import Any
9
+
10
+ from cactus_test_definitions.errors import UnparseableVariableExpressionError
11
+
12
+ ConstantType = timedelta | int | float
13
+
14
+
15
+ @dataclass
16
+ class Token:
17
+ """Custom token implementaion
18
+
19
+ Attributes:
20
+ string: representation of original token from input
21
+ type: the kind of token found, an enum directly related from tokenize
22
+ line: the input line that the token belongs
23
+ start: coordinates of the token start wrt input
24
+ end: coordinates of the token end wrt input
25
+ param_key: optional to help with the special case of backfilling a self reference (i.e $this)
26
+ to its underlying named value
27
+ """
28
+
29
+ string: str
30
+ type: int
31
+ line: str
32
+ start: tuple[int, int]
33
+ end: tuple[int, int]
34
+ param_key: str | None = None
35
+
36
+ @staticmethod
37
+ def from_token_info(token_info: tokenize.TokenInfo, param_key: str | None = None) -> "Token":
38
+ """Takes a tokenize.TokenInfo and returns an internal Token"""
39
+ return Token(
40
+ string=token_info.string,
41
+ type=token_info.type,
42
+ line=token_info.line,
43
+ start=token_info.start,
44
+ end=token_info.start,
45
+ param_key=param_key,
46
+ )
47
+
48
+
49
+ class NamedVariableType(IntEnum):
50
+ # MUST resolve to a tz aware representation of the current datetime
51
+ # Referenced in a test definition as $(now)
52
+ NOW = auto()
53
+
54
+ # MUST resolve to the "DERSetting.setMaxW" of the current EndDevice under test. Value in Watts
55
+ # Referenced in a test definition as $(setMaxW)
56
+ DERSETTING_SET_MAX_W = auto()
57
+ DERSETTING_SET_MAX_VA = auto()
58
+ DERSETTING_SET_MAX_VAR = auto()
59
+ DERSETTING_SET_MAX_CHARGE_RATE_W = auto()
60
+ DERSETTING_SET_MAX_DISCHARGE_RATE_W = auto()
61
+ DERSETTING_SET_MAX_WH = auto()
62
+
63
+ # Must resolve to DERCapablity of the current EndDevice under test
64
+ DERCAPABILITY_RTG_MAX_VA = auto() # VA ( after multiplier applied), reference $rtgMaxVA
65
+ DERCAPABILITY_RTG_MAX_VAR = auto() # VAr ( atfer multiplier applied), reference $rtgMaxVar
66
+ DERCAPABILITY_RTG_MAX_W = auto() # W ( after multiplier applied), reference $rtgMaxW
67
+ DERCAPABILITY_RTG_MAX_CHARGE_RATE_W = auto() # W ( after multiiplier applied), reference $rtgMaxChargeRateW
68
+ DERCAPABILITY_RTG_MAX_DISCHARGE_RATE_W = auto() # W ( after multiplier applied), reference $rtgMaxDischargeRateW
69
+ DERCAPABILITY_RTG_MAX_WH = auto() # Wh ( after multiplier applied), reference $rtgMaxWh
70
+
71
+ # Storage extension
72
+ DERSETTING_SET_MIN_WH = auto()
73
+ DERCAPABILITY_NEG_RTG_MAX_CHARGE_RATE_W = auto() # -W (after multiplier applied), reference $negRtgMaxChargeRateW
74
+
75
+
76
+ class OperationType(IntEnum):
77
+ ADD = auto()
78
+ SUBTRACT = auto()
79
+ MULTIPLY = auto()
80
+ DIVIDE = auto()
81
+ EQ = auto()
82
+ NE = auto()
83
+ LT = auto()
84
+ LTE = auto()
85
+ GT = auto()
86
+ GTE = auto()
87
+
88
+
89
+ OPERATION_MAPPINGS = {
90
+ "+": OperationType.ADD,
91
+ "-": OperationType.SUBTRACT,
92
+ "*": OperationType.MULTIPLY,
93
+ "/": OperationType.DIVIDE,
94
+ "==": OperationType.EQ,
95
+ "!=": OperationType.NE,
96
+ "<": OperationType.LT,
97
+ "<=": OperationType.LTE,
98
+ ">": OperationType.GT,
99
+ ">=": OperationType.GTE,
100
+ }
101
+
102
+
103
+ def snake_to_camel(snake: str) -> str:
104
+ """Simple snake to camel case converter"""
105
+ temp = snake.replace("_", " ").title().replace(" ", "")
106
+ return temp[0].lower() + temp[1:]
107
+
108
+
109
+ def named_variable_repr(named_var: NamedVariableType) -> str:
110
+ """Takes named variable enum and turns its name into its recognisable 2030.5 form"""
111
+ name = named_var.name
112
+ if len(name.split("_")) == 1:
113
+ return snake_to_camel(name)
114
+ match name.split("_", 1):
115
+ case ["DERCAPABILITY", "NEG_RTG_MAX_CHARGE_RATE_W"]:
116
+ return "(-DERCapability.rtgMaxChargeRateW)"
117
+ case ["DERCAPABILITY", "RTG_MAX_VA"]:
118
+ return "DERCapability.rtgMaxVA"
119
+ case ["DERSETTING", "SET_MAX_VA"]:
120
+ return "DERSetting.setMaxVA"
121
+ case ["DERCAPABILITY", param_name]:
122
+ return f"DERCapability.{snake_to_camel(param_name)}"
123
+ case ["DERSETTING", param_name]:
124
+ return f"DERSetting.{snake_to_camel(param_name)}"
125
+
126
+ return snake_to_camel(name)
127
+
128
+
129
+ def operation_repr(op: OperationType) -> str:
130
+ """Takes an operation type and returns its string representation"""
131
+ operation_type_to_str_map: dict[OperationType, str] = {v: k for k, v in OPERATION_MAPPINGS.items()}
132
+ return operation_type_to_str_map[op]
133
+
134
+
135
+ class BaseExpression(abc.ABC):
136
+ """A base class for all expressions to inherit from"""
137
+
138
+ @abc.abstractmethod
139
+ def expression_representation(self) -> str:
140
+ """Method for representing an expression human readably without overriding magic methods"""
141
+ raise NotImplementedError
142
+
143
+
144
+ @dataclass
145
+ class Constant(BaseExpression):
146
+ """Represents a constant value that doesn't require any test execution time resolution"""
147
+
148
+ value: ConstantType # The parsed value
149
+
150
+ def expression_representation(self) -> str:
151
+ return f"{self.value}"
152
+
153
+
154
+ @dataclass
155
+ class NamedVariable(BaseExpression):
156
+ """A "NamedVariable" is value that can only be resolved at point during a test procedure execution (eg: as a
157
+ Step's action is being applied). There are a fixed set of known variable types defined by NamedVariableType.
158
+
159
+ Failures during resolving a variable (eg database doesn't have the data) MUST raise an exception
160
+ """
161
+
162
+ variable: NamedVariableType
163
+
164
+ def expression_representation(self) -> str:
165
+ return named_variable_repr(self.variable)
166
+
167
+
168
+ @dataclass
169
+ class Expression(BaseExpression):
170
+ """An expression is a simple combination of two values that combine to make a single constant value. The operands
171
+ can be constants or NamedVariables."""
172
+
173
+ operation: OperationType
174
+ lhs_operand: NamedVariable | Constant # left hand side operand
175
+ rhs_operand: NamedVariable | Constant # right hand side operand
176
+
177
+ def expression_representation(self) -> str:
178
+ return " ".join(
179
+ [
180
+ f"{self.lhs_operand.expression_representation()}",
181
+ f"{operation_repr(self.operation)}",
182
+ f"{self.rhs_operand.expression_representation()}",
183
+ ]
184
+ )
185
+
186
+
187
+ def parse_time_delta(var_body: str) -> timedelta:
188
+ """Parses a string like '5 minutes' into a representative timedelta"""
189
+
190
+ m = match(r"(['\"])([0-9\-\.]*)\s*([^']*)(['\"])", var_body)
191
+ if m is None:
192
+ raise UnparseableVariableExpressionError(f"{var_body} can't be parsed into a timedelta")
193
+
194
+ open_quote = m.group(1)
195
+ number_string = m.group(2)
196
+ time_unit_string = m.group(3).lower()
197
+ close_quote = m.group(4)
198
+
199
+ if open_quote != close_quote:
200
+ raise UnparseableVariableExpressionError(f"{var_body} can't be parsed into a timedelta. Mismatching quotes")
201
+
202
+ try:
203
+ number = float(number_string)
204
+ except ValueError:
205
+ raise UnparseableVariableExpressionError(
206
+ f"{var_body} can't be parsed into a timedelta. Bad number {number_string}"
207
+ )
208
+
209
+ if time_unit_string in {"day", "days"}:
210
+ return timedelta(days=number)
211
+ elif time_unit_string in {"hour", "hours", "hrs", "hr"}:
212
+ return timedelta(hours=number)
213
+ elif time_unit_string in {"minute", "minutes", "min", "mins"}:
214
+ return timedelta(minutes=number)
215
+ elif time_unit_string in {"second", "seconds", "sec", "secs"}:
216
+ return timedelta(seconds=number)
217
+ else:
218
+ raise UnparseableVariableExpressionError(
219
+ f"{var_body} can't be parsed into a timedelta. Unknown unit {time_unit_string}"
220
+ )
221
+
222
+
223
+ def parse_unary_expression(token: Token) -> Constant | NamedVariable:
224
+ """Parses a unary expression from a variable body"""
225
+
226
+ if token.type == tokenize.NAME:
227
+ # expect that a variable name is properly defined with correct case
228
+ match token.string:
229
+ case "now":
230
+ return NamedVariable(NamedVariableType.NOW)
231
+ case "this":
232
+ if token.param_key == "this" or token.param_key is None:
233
+ raise UnparseableVariableExpressionError(f"$this cannot resolve to parameter {token.param_key}")
234
+ # Modify token and maintain all other original data
235
+ token.string = token.param_key
236
+ token.param_key = None
237
+ return parse_unary_expression(token)
238
+ case "setMaxW":
239
+ return NamedVariable(NamedVariableType.DERSETTING_SET_MAX_W)
240
+ case "setMaxVA":
241
+ return NamedVariable(NamedVariableType.DERSETTING_SET_MAX_VA)
242
+ case "setMaxVar":
243
+ return NamedVariable(NamedVariableType.DERSETTING_SET_MAX_VAR)
244
+ case "setMaxChargeRateW":
245
+ return NamedVariable(NamedVariableType.DERSETTING_SET_MAX_CHARGE_RATE_W)
246
+ case "setMaxDischargeRateW":
247
+ return NamedVariable(NamedVariableType.DERSETTING_SET_MAX_DISCHARGE_RATE_W)
248
+ case "setMaxWh":
249
+ return NamedVariable(NamedVariableType.DERSETTING_SET_MAX_WH)
250
+ case "rtgMaxVA":
251
+ return NamedVariable(NamedVariableType.DERCAPABILITY_RTG_MAX_VA)
252
+ case "rtgMaxVar":
253
+ return NamedVariable(NamedVariableType.DERCAPABILITY_RTG_MAX_VAR)
254
+ case "rtgMaxW":
255
+ return NamedVariable(NamedVariableType.DERCAPABILITY_RTG_MAX_W)
256
+ case "rtgMaxChargeRateW":
257
+ return NamedVariable(NamedVariableType.DERCAPABILITY_RTG_MAX_CHARGE_RATE_W)
258
+ case "rtgMaxDischargeRateW":
259
+ return NamedVariable(NamedVariableType.DERCAPABILITY_RTG_MAX_DISCHARGE_RATE_W)
260
+ case "rtgMaxWh":
261
+ return NamedVariable(NamedVariableType.DERCAPABILITY_RTG_MAX_WH)
262
+ # Storage extension
263
+ case "setMinWh":
264
+ return NamedVariable(NamedVariableType.DERSETTING_SET_MIN_WH)
265
+ case "negRtgMaxChargeRateW":
266
+ return NamedVariable(NamedVariableType.DERCAPABILITY_NEG_RTG_MAX_CHARGE_RATE_W)
267
+
268
+ raise UnparseableVariableExpressionError(f"'{token.string}' isn't recognized as a named variable")
269
+
270
+ try:
271
+ if token.type == tokenize.NUMBER:
272
+ if "." in token.string:
273
+ return Constant(float(token.string))
274
+ else:
275
+ return Constant(int(token.string))
276
+ except ValueError:
277
+ raise UnparseableVariableExpressionError(f"'{token.string}' can't be converted to a number")
278
+
279
+ if token.type == tokenize.STRING:
280
+ return Constant(parse_time_delta(token.string))
281
+
282
+ raise UnparseableVariableExpressionError(f"Unable to parse token {token}")
283
+
284
+
285
+ def parse_binary_expression(lhs_token: Token, operation: Token, rhs_token: Token) -> Expression:
286
+
287
+ if operation.type != tokenize.OP:
288
+ raise UnparseableVariableExpressionError(f"Expected an operation (eg + - / *) but found {operation}")
289
+
290
+ operation_type = OPERATION_MAPPINGS.get(operation.string, None)
291
+ if operation_type is None:
292
+ raise UnparseableVariableExpressionError(f"Unable to parse operator {operation.string} into a OperationType")
293
+
294
+ lhs = parse_unary_expression(lhs_token)
295
+ rhs = parse_unary_expression(rhs_token)
296
+
297
+ return Expression(operation=operation_type, lhs_operand=lhs, rhs_operand=rhs)
298
+
299
+
300
+ def parse_variable_expression_body(var_body: str, param_key: str | None) -> NamedVariable | Expression | Constant:
301
+ """Given a variable definition: $(now - '5 seconds') - this function should be passed contents of that variable
302
+ definition (the string within the parentheses) eg: "now - '5 seconds'
303
+
304
+ Common variable patterns include,
305
+ $(now) - Will return a tz aware datetime corresponding to the current moment in time
306
+ $(now - '5 minute') - Same as above, but offset 5 minutes in the past
307
+ $(0.5 * setMaxW) - 50% of the currently configured setMaxW for the current EndDevice
308
+
309
+ Args:
310
+ var_body: parseable expression
311
+ param_key: the key that the expression body belongs e.g. setMaxW
312
+
313
+ Returns:
314
+ Parsed object
315
+
316
+ Raises:
317
+ UnparseableVariableExpressionError: on failed parsing attempt
318
+ """
319
+ if not var_body:
320
+ raise UnparseableVariableExpressionError("var_body is empty/None")
321
+
322
+ # Use the python parser to generate a simplified set of tokens representing the variable definition
323
+ # Convert these into the internal representation of a Token
324
+ try:
325
+ var_tokens = [
326
+ Token.from_token_info(t, param_key)
327
+ for t in tokenize.generate_tokens(StringIO(var_body).readline)
328
+ if t.type in {tokenize.NUMBER, tokenize.OP, tokenize.STRING, tokenize.NAME}
329
+ ]
330
+ except tokenize.TokenError as exc:
331
+ raise UnparseableVariableExpressionError(f"Error tokenizing '{var_body}': {exc}")
332
+
333
+ if len(var_tokens) == 1:
334
+ return parse_unary_expression(var_tokens[0])
335
+ elif len(var_tokens) == 3:
336
+ return parse_binary_expression(var_tokens[0], var_tokens[1], var_tokens[2])
337
+ else:
338
+ raise UnparseableVariableExpressionError(f"Unable to parse {var_body} into a simple binary/unary expression")
339
+
340
+
341
+ def try_extract_variable_expression(body: Any) -> str | None:
342
+ """Checks to see if a variable body (of any type) can be parsed by parse_variable_expression_body. If it can,
343
+ it will be returned as a string. Otherwise None will be returned
344
+
345
+ Can raise ValueError if body is a string appearing to contain a variable expression that is malformed (eg
346
+ mismatching parentheses)"""
347
+ if not isinstance(body, str):
348
+ return None
349
+
350
+ begin_variable_defn = body.find("$")
351
+ if begin_variable_defn < 0:
352
+ return None
353
+
354
+ # The $ variable definition can be escaped with \$ so ensure it is checked
355
+ if begin_variable_defn > 0 and (body[begin_variable_defn - 1] == "\\"):
356
+ return None
357
+
358
+ # At this point we are definitely parsing a variable expression. Failures here will raise ValueError
359
+ if begin_variable_defn >= (len(body) - 1):
360
+ raise ValueError(f"'{body}' appears to be a malformed variable definition. Try escaping $ like '\\$'")
361
+
362
+ start_expr_body: int
363
+ end_expr_body: int
364
+ end_variable_defn: int # First character after the end of the full variable definition
365
+ if body[begin_variable_defn + 1] == "(":
366
+ start_expr_body = begin_variable_defn + 2
367
+ end_expr_body = body.index(")", start_expr_body)
368
+ end_variable_defn = end_expr_body + 2
369
+ else:
370
+ start_expr_body = begin_variable_defn + 1
371
+
372
+ remainder = body[start_expr_body:]
373
+ match_variable = match(r"[_a-zA-Z0-9]*", remainder)
374
+ if match_variable is None:
375
+ raise ValueError(f"'{body}' appears to be a malformed variable definition")
376
+ else:
377
+ end_expr_body = start_expr_body + match_variable.end()
378
+ end_variable_defn = end_expr_body + 1
379
+
380
+ if start_expr_body == end_expr_body:
381
+ raise ValueError(f"'{body}' appears to be a malformed variable definition.")
382
+ variable_expression = body[start_expr_body:end_expr_body]
383
+
384
+ # One last validation to look for leading/trailing text which would indicate
385
+ leading_text = search(r"[^\s]", body[0:begin_variable_defn])
386
+ if leading_text is not None:
387
+ raise ValueError("Variable expressions must ONLY be a single variable with no other text")
388
+
389
+ trailing_text = search(r"[^\s]", body[end_variable_defn:])
390
+ if trailing_text is not None:
391
+ raise ValueError("Variable expressions must ONLY be a single variable with no other text")
392
+
393
+ return variable_expression
394
+
395
+
396
+ def is_resolvable_variable(v: Any) -> bool:
397
+ """Returns True if the supplied value is a variable definition that requires resolving"""
398
+ return isinstance(v, NamedVariable) or isinstance(v, Expression) or isinstance(v, Constant)
399
+
400
+
401
+ def has_named_variable(
402
+ parameter_value: NamedVariable | Expression | Constant, named_variable: NamedVariableType
403
+ ) -> bool:
404
+ """Return True if the supplied named variable is used in the the parameter 'parameter_value'"""
405
+
406
+ if isinstance(parameter_value, Constant):
407
+ return False
408
+
409
+ if isinstance(parameter_value, NamedVariable):
410
+ return parameter_value.variable == named_variable
411
+
412
+ if isinstance(parameter_value, Expression):
413
+ if isinstance(parameter_value.lhs_operand, NamedVariable):
414
+ if parameter_value.lhs_operand.variable == named_variable:
415
+ return True
416
+ if isinstance(parameter_value.rhs_operand, NamedVariable):
417
+ if parameter_value.rhs_operand.variable == named_variable:
418
+ return True
419
+ return False