vantage6-algorithm-tools 5.0.3rc6__tar.gz → 5.1.0__tar.gz

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.
Files changed (54) hide show
  1. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/PKG-INFO +4 -8
  2. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/pyproject.toml +4 -11
  3. vantage6_algorithm_tools-5.1.0/tests/preprocessing/test_expression.py +207 -0
  4. vantage6_algorithm_tools-5.1.0/tests/tools/__init__.py +0 -0
  5. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/mock/client.py +4 -0
  6. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/preprocessing/column.py +19 -4
  7. vantage6_algorithm_tools-5.1.0/vantage6/algorithm/preprocessing/expression.py +259 -0
  8. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/preprocessing/filtering.py +19 -7
  9. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/.gitignore +0 -0
  10. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/Makefile +0 -0
  11. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/README.md +0 -0
  12. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/__init__.py +0 -0
  13. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/_test_deserialization.py +0 -0
  14. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/_test_docker_wrapper.py +0 -0
  15. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/_test_preprocessing.py +0 -0
  16. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/_test_serialization.py +0 -0
  17. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/algorithm/__init__.py +0 -0
  18. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/algorithm/test___init__.py +0 -0
  19. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/algorithm_module.py +0 -0
  20. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/base/__init__.py +0 -0
  21. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/mock/__init__.py +0 -0
  22. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/mock/test_algorithm_client.py +0 -0
  23. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/mock/test_network.py +0 -0
  24. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/mock/test_node.py +0 -0
  25. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/mock/test_server.py +0 -0
  26. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/mock/test_user_client.py +0 -0
  27. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/mock/test_util.py +0 -0
  28. {vantage6_algorithm_tools-5.0.3rc6/tests/tools → vantage6_algorithm_tools-5.1.0/tests/preprocessing}/__init__.py +0 -0
  29. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/tests/tools/test_error_handling.py +0 -0
  30. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/client/__init__.py +0 -0
  31. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/data_extraction/__init__.py +0 -0
  32. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/data_extraction/mock_extract.py +0 -0
  33. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/decorator/__init__.py +0 -0
  34. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/decorator/action.py +0 -0
  35. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/decorator/algorithm_client.py +0 -0
  36. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/decorator/data.py +0 -0
  37. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/decorator/metadata.py +0 -0
  38. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/decorator/ohdsi.py +0 -0
  39. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/mock/__init__.py +0 -0
  40. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/mock/globals.py +0 -0
  41. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/mock/hq.py +0 -0
  42. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/mock/network.py +0 -0
  43. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/mock/node.py +0 -0
  44. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/mock/util.py +0 -0
  45. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/preprocessing/__init__.py +0 -0
  46. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/preprocessing/aggregation.py +0 -0
  47. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/preprocessing/algorithm_json_data.py +0 -0
  48. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/preprocessing/datetime.py +0 -0
  49. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/preprocessing/encoding.py +0 -0
  50. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/tools/__init__.py +0 -0
  51. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/tools/error_handling.py +0 -0
  52. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/tools/exceptions.py +0 -0
  53. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/tools/util.py +0 -0
  54. {vantage6_algorithm_tools-5.0.3rc6 → vantage6_algorithm_tools-5.1.0}/vantage6/algorithm/tools/wrap.py +0 -0
@@ -1,24 +1,20 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vantage6-algorithm-tools
3
- Version: 5.0.3rc6
3
+ Version: 5.1.0
4
4
  Summary: Vantage6 algorithm tools
5
5
  Author: Vantage6 Team
6
6
  Maintainer-email: Frank Martin <f.martin@iknl.nl>, Bart van Beusekom <b.vanbeusekom@iknl.nl>
7
- License: MIT
7
+ License: Apache-2.0
8
8
  Requires-Python: >=3.13
9
9
  Requires-Dist: openpyxl>=3.0.0
10
10
  Requires-Dist: pandas>=2.2.3
11
11
  Requires-Dist: psycopg2-binary==2.9.10
12
12
  Requires-Dist: pyarrow>=16.1.0
13
13
  Requires-Dist: pyfiglet==1.0.4
14
- Requires-Dist: pyjwt==2.13.0
14
+ Requires-Dist: pyjwt==2.15.1
15
15
  Requires-Dist: sparqlwrapper>=2.0.0
16
16
  Requires-Dist: sqlalchemy>=1.4.46
17
- Requires-Dist: vantage6-common==5.0.3rc6
18
- Provides-Extra: dev
19
- Requires-Dist: black; extra == 'dev'
20
- Requires-Dist: coverage==7.10.2; extra == 'dev'
21
- Requires-Dist: pre-commit; extra == 'dev'
17
+ Requires-Dist: vantage6-common==5.1.0
22
18
  Description-Content-Type: text/markdown
23
19
 
24
20
  <h1 align="center">
@@ -4,10 +4,10 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vantage6-algorithm-tools"
7
- version = "5.0.3rc6"
7
+ version = "5.1.0"
8
8
  description = "Vantage6 algorithm tools"
9
9
  readme = "README.md"
10
- license = {text = "MIT"}
10
+ license = {text = "Apache-2.0"}
11
11
  authors = [
12
12
  {name = "Vantage6 Team"}
13
13
  ]
@@ -23,18 +23,11 @@ dependencies = [
23
23
  # running SQL queries on a postgres database.
24
24
  "psycopg2-binary==2.9.10",
25
25
  "pyarrow>=16.1.0",
26
- "pyjwt==2.13.0",
26
+ "pyjwt==2.15.1",
27
27
  "pyfiglet==1.0.4",
28
28
  "SPARQLWrapper>=2.0.0",
29
29
  "sqlalchemy>=1.4.46",
30
- "vantage6-common==5.0.3rc6",
31
- ]
32
-
33
- [project.optional-dependencies]
34
- dev = [
35
- "coverage==7.10.2",
36
- "black",
37
- "pre-commit",
30
+ "vantage6-common==5.1.0",
38
31
  ]
39
32
 
40
33
  [tool.hatch.build.targets.wheel]
@@ -0,0 +1,207 @@
1
+ import inspect
2
+ import unittest
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ from vantage6.algorithm.tools.exceptions import UserInputError
8
+
9
+ from vantage6.algorithm.preprocessing.column import assign_column, redefine_column
10
+ from vantage6.algorithm.preprocessing.expression import (
11
+ MAX_EXPRESSION_LENGTH,
12
+ evaluate_expression,
13
+ )
14
+ from vantage6.algorithm.preprocessing.filtering import select_rows
15
+
16
+ # The decorators require a running algorithm container, so test the wrapped functions
17
+ _assign_column = inspect.unwrap(assign_column)
18
+ _redefine_column = inspect.unwrap(redefine_column)
19
+ _select_rows = inspect.unwrap(select_rows)
20
+
21
+
22
+ def _df() -> pd.DataFrame:
23
+ return pd.DataFrame(
24
+ {
25
+ "a": [1, 2, 3, 4, 5],
26
+ "b": [6, 7, 8, 9, 10],
27
+ "c": [11, 12, 13, 14, 15],
28
+ "s": ["x", "y", "x", "z", "y"],
29
+ }
30
+ )
31
+
32
+
33
+ class TestEvaluateExpression(unittest.TestCase):
34
+ def assert_series_equal(self, expression: str, expected: pd.Series) -> None:
35
+ pd.testing.assert_series_equal(
36
+ evaluate_expression(_df(), expression), expected, check_names=False
37
+ )
38
+
39
+ def test_arithmetic(self):
40
+ df = _df()
41
+ self.assert_series_equal("a + b * 2", df.a + df.b * 2)
42
+ self.assert_series_equal("(a - b) / c", (df.a - df.b) / df.c)
43
+ self.assert_series_equal("c // a % 3", df.c // df.a % 3)
44
+ self.assert_series_equal("-a", -df.a)
45
+ self.assert_series_equal("+a", df.a)
46
+
47
+ def test_comparisons(self):
48
+ df = _df()
49
+ self.assert_series_equal("a > 2", df.a > 2)
50
+ self.assert_series_equal("a == 3", df.a == 3)
51
+ self.assert_series_equal("a != 3", df.a != 3)
52
+ self.assert_series_equal("s == 'x'", df.s == "x")
53
+ self.assert_series_equal("2 <= a < 5", (df.a >= 2) & (df.a < 5))
54
+
55
+ def test_membership(self):
56
+ df = _df()
57
+ self.assert_series_equal("a in [1, 5]", df.a.isin([1, 5]))
58
+ self.assert_series_equal("s not in ('x', 'y')", ~df.s.isin(["x", "y"]))
59
+
60
+ def test_logical_operators(self):
61
+ df = _df()
62
+ self.assert_series_equal("a > 2 and b < 10", (df.a > 2) & (df.b < 10))
63
+ self.assert_series_equal("a > 4 or c < 12", (df.a > 4) | (df.c < 12))
64
+ self.assert_series_equal("not a > 2", ~(df.a > 2))
65
+ self.assert_series_equal("(a > 2) & (b < 10)", (df.a > 2) & (df.b < 10))
66
+ self.assert_series_equal("(a > 4) | (c < 12)", (df.a > 4) | (df.c < 12))
67
+ self.assert_series_equal("~(a > 2)", ~(df.a > 2))
68
+
69
+ def test_functions(self):
70
+ df = _df()
71
+ for name, function in [
72
+ ("abs", np.abs),
73
+ ("sqrt", np.sqrt),
74
+ ("log", np.log),
75
+ ("log2", np.log2),
76
+ ("log10", np.log10),
77
+ ("exp", np.exp),
78
+ ("floor", np.floor),
79
+ ("ceil", np.ceil),
80
+ ]:
81
+ with self.subTest(function=name):
82
+ self.assert_series_equal(f"{name}(a / 3)", function(df.a / 3))
83
+ self.assert_series_equal("round(a / 3)", np.round(df.a / 3))
84
+ self.assert_series_equal("round(a / 3, 2)", np.round(df.a / 3, 2))
85
+ self.assert_series_equal("sqrt(log(b) + 1)", np.sqrt(np.log(df.b) + 1))
86
+
87
+ def test_text(self):
88
+ df = _df()
89
+ self.assert_series_equal("s + '_1'", df.s + "_1")
90
+ for expression in [
91
+ "s * 999999999",
92
+ "'x' * 999999999",
93
+ "999999999 * s",
94
+ "s - 'x'",
95
+ ]:
96
+ with (
97
+ self.subTest(expression=expression),
98
+ self.assertRaises(UserInputError),
99
+ ):
100
+ evaluate_expression(df, expression)
101
+
102
+ def test_constants(self):
103
+ self.assertEqual(evaluate_expression(_df(), "1 + 2"), 3)
104
+ self.assertEqual(evaluate_expression(_df(), "not True"), False)
105
+ self.assertEqual(evaluate_expression(_df(), " 1 "), 1)
106
+
107
+ def test_rejects_code_execution(self):
108
+ rejected = [
109
+ # attribute chain to reach the os module
110
+ "a.__class__.__init__.__globals__.get('sys').modules.get('os').getpid()",
111
+ "a.sum()",
112
+ "a.values",
113
+ "log(a).real",
114
+ "__import__('os')",
115
+ "eval('1')",
116
+ "exec('1')",
117
+ "open('/etc/passwd')",
118
+ "getattr(a, 'sum')()",
119
+ "(lambda: 1)()",
120
+ "log(a)(1)",
121
+ ]
122
+ for expression in rejected:
123
+ with (
124
+ self.subTest(expression=expression),
125
+ self.assertRaises(UserInputError),
126
+ ):
127
+ evaluate_expression(_df(), expression)
128
+
129
+ def test_rejects_unsupported_syntax(self):
130
+ rejected = [
131
+ "a ** 2",
132
+ "a @ b",
133
+ "a << 1",
134
+ "a[0]",
135
+ "a is None",
136
+ "[1, 2]",
137
+ "{'x': 1}",
138
+ "f'{a}'",
139
+ "a if b else c",
140
+ "b'bytes'",
141
+ "1j",
142
+ "...",
143
+ "a in b",
144
+ "a in [b]",
145
+ "x := 1",
146
+ "@a",
147
+ "`a`",
148
+ "log(a, 2)",
149
+ "log()",
150
+ "log(a=1)",
151
+ "round(a, 1.5)",
152
+ "round(a, True)",
153
+ "round(a, b)",
154
+ "round(a, 1, 2)",
155
+ "unknown_column + 1",
156
+ "",
157
+ "a +",
158
+ ]
159
+ for expression in rejected:
160
+ with (
161
+ self.subTest(expression=expression),
162
+ self.assertRaises(UserInputError),
163
+ ):
164
+ evaluate_expression(_df(), expression)
165
+
166
+ def test_rejects_non_string(self):
167
+ with self.assertRaises(UserInputError):
168
+ evaluate_expression(_df(), 1)
169
+
170
+ def test_maximum_length(self):
171
+ expression = "a" + " + a" * ((MAX_EXPRESSION_LENGTH - 1) // 4)
172
+ self.assertLessEqual(len(expression), MAX_EXPRESSION_LENGTH)
173
+ evaluate_expression(_df(), expression)
174
+ with self.assertRaises(UserInputError):
175
+ evaluate_expression(_df(), expression + " + a")
176
+
177
+
178
+ class TestPreprocessingFunctions(unittest.TestCase):
179
+ def test_assign_column(self):
180
+ df = _assign_column(_df(), "d", "a + b")
181
+ self.assertEqual(df["d"].tolist(), [7, 9, 11, 13, 15])
182
+
183
+ def test_assign_column_rejects_code(self):
184
+ with self.assertRaises(UserInputError):
185
+ _assign_column(_df(), "d", "a.sum()")
186
+
187
+ def test_redefine_column(self):
188
+ df = _redefine_column(_df(), "c", "a * b")
189
+ self.assertEqual(df["c"].tolist(), [6, 14, 24, 36, 50])
190
+
191
+ def test_redefine_column_rejects_code(self):
192
+ with self.assertRaises(UserInputError):
193
+ _redefine_column(_df(), "c", "a.sum()")
194
+
195
+ def test_select_rows(self):
196
+ df = _select_rows(_df(), "a > 2 and s != 'z'")
197
+ self.assertEqual(df["a"].tolist(), [3, 5])
198
+ self.assertEqual(df.index.tolist(), [2, 4])
199
+
200
+ def test_select_rows_rejects_code(self):
201
+ with self.assertRaises(UserInputError):
202
+ _select_rows(_df(), "a.sum() > 0")
203
+
204
+ def test_select_rows_requires_boolean(self):
205
+ for query in ["a + 1", "True", "s"]:
206
+ with self.subTest(query=query), self.assertRaises(UserInputError):
207
+ _select_rows(_df(), query)
File without changes
@@ -40,7 +40,9 @@ class MockBaseClient:
40
40
  "_download_run_data_from_hq",
41
41
  "_fetch_and_decrypt_run_data",
42
42
  "_multi_page_request",
43
+ "_obtain_client_credentials_tokens",
43
44
  "_refresh_token",
45
+ "_store_access_token",
44
46
  "_upload_run_data_to_hq",
45
47
  "AlgorithmStoreSubClientBase",
46
48
  "auth_url",
@@ -60,6 +62,7 @@ class MockBaseClient:
60
62
  "hq_url",
61
63
  "session_id",
62
64
  "setup_encryption",
65
+ "store_headers",
63
66
  "store_id",
64
67
  "study_id",
65
68
  "token",
@@ -511,6 +514,7 @@ class MockUserClient(MockBaseClient):
511
514
  self.set_missing_attributes(
512
515
  [
513
516
  "_get_logger",
517
+ "_obtain_tokens_per_audience",
514
518
  "auth_client",
515
519
  "auth_realm",
516
520
  "authenticate_service_account",
@@ -10,6 +10,7 @@ from vantage6.algorithm.tools.error_handling import handle_data_errors
10
10
  from vantage6.algorithm.tools.exceptions import UserInputError
11
11
 
12
12
  from vantage6.algorithm.decorator.action import preprocessing
13
+ from vantage6.algorithm.preprocessing.expression import evaluate_expression
13
14
 
14
15
 
15
16
  @handle_data_errors
@@ -68,7 +69,14 @@ def assign_column(df: pd.DataFrame, column_name: str, expression: str) -> pd.Dat
68
69
  column_name : str
69
70
  The name of the new column.
70
71
  expression : str
71
- The expression used to create the new column.
72
+ The expression used to create the new column, e.g. ``weight / (height *
73
+ height)``. Supported are column names, numbers, strings, ``True``, ``False``,
74
+ ``None``, the operators ``+ - * / // %`` (only ``+`` for text), comparisons
75
+ (``== != < <= > >=``), ``in``/``not in`` a list of constants,
76
+ ``and or not & | ~`` and the functions
77
+ ``abs, sqrt, log, log2, log10, exp, floor, ceil, round``. Other functions,
78
+ attribute access, indexing and ``**`` are not supported. The expression may
79
+ be at most 250 characters long.
72
80
 
73
81
  Returns
74
82
  -------
@@ -92,7 +100,7 @@ def assign_column(df: pd.DataFrame, column_name: str, expression: str) -> pd.Dat
92
100
  if column_name in df.columns:
93
101
  raise UserInputError(f"Column '{column_name}' already exists.")
94
102
 
95
- df[column_name] = df.eval(expression)
103
+ df[column_name] = evaluate_expression(df, expression)
96
104
  return df
97
105
 
98
106
 
@@ -111,7 +119,14 @@ def redefine_column(
111
119
  column_name : str
112
120
  The name of the existing column to modify.
113
121
  expression : str
114
- The expression used to modify the existing column.
122
+ The expression used to modify the existing column, e.g. ``round(age / 10)``.
123
+ Supported are column names, numbers, strings, ``True``, ``False``, ``None``,
124
+ the operators ``+ - * / // %`` (only ``+`` for text), comparisons
125
+ (``== != < <= > >=``), ``in``/``not in`` a list of constants,
126
+ ``and or not & | ~`` and the functions
127
+ ``abs, sqrt, log, log2, log10, exp, floor, ceil, round``. Other functions,
128
+ attribute access, indexing and ``**`` are not supported. The expression may
129
+ be at most 250 characters long.
115
130
 
116
131
  Returns
117
132
  -------
@@ -136,7 +151,7 @@ def redefine_column(
136
151
  if column_name not in df.columns:
137
152
  raise UserInputError(f"Column '{column_name}' does not exist.")
138
153
 
139
- df[column_name] = df.eval(expression)
154
+ df[column_name] = evaluate_expression(df, expression)
140
155
  return df
141
156
 
142
157
 
@@ -0,0 +1,259 @@
1
+ """
2
+ Safe evaluation of expressions on pandas DataFrames.
3
+
4
+ Expressions are provided by users as task arguments, so they must not be evaluated with
5
+ ``pandas.DataFrame.eval`` or ``pandas.DataFrame.query``: those allow attribute access and
6
+ method calls, and can be used to execute arbitrary code. Instead, the expression is
7
+ parsed with :mod:`ast` and evaluated by walking the syntax tree, supporting only:
8
+
9
+ - column names, e.g. ``age`` (columns must be valid Python identifiers)
10
+ - numbers, strings, ``True``, ``False`` and ``None``
11
+ - arithmetic: ``+``, ``-``, ``*``, ``/``, ``//``, ``%`` (only ``+`` for text)
12
+ - comparisons: ``==``, ``!=``, ``<``, ``<=``, ``>``, ``>=``, also chained (``1 < a < 5``)
13
+ - membership in a list of constants: ``a in [1, 2]``, ``a not in ['x', 'y']``
14
+ - logical operators: ``and``, ``or``, ``not``, ``&``, ``|``, ``~``
15
+ - the functions ``abs``, ``sqrt``, ``log``, ``log2``, ``log10``, ``exp``, ``floor``,
16
+ ``ceil`` and ``round`` (``round(a)`` or ``round(a, 2)``)
17
+
18
+ Anything else, such as other functions, attribute access (``a.b``), indexing
19
+ (``a[0]``) and exponentiation (``**``), is rejected. Expressions may be at most
20
+ ``MAX_EXPRESSION_LENGTH`` characters long.
21
+ """
22
+
23
+ import ast
24
+ import operator
25
+ from collections.abc import Callable
26
+ from typing import Any
27
+
28
+ import numpy as np
29
+ import pandas as pd
30
+
31
+ from vantage6.algorithm.tools.exceptions import UserInputError
32
+
33
+ MAX_EXPRESSION_LENGTH = 250
34
+
35
+ _BINARY_OPERATORS: dict[type[ast.operator], Callable] = {
36
+ ast.Add: operator.add,
37
+ ast.Sub: operator.sub,
38
+ ast.Mult: operator.mul,
39
+ ast.Div: operator.truediv,
40
+ ast.FloorDiv: operator.floordiv,
41
+ ast.Mod: operator.mod,
42
+ ast.BitAnd: operator.and_,
43
+ ast.BitOr: operator.or_,
44
+ }
45
+
46
+ _COMPARISON_OPERATORS: dict[type[ast.cmpop], Callable] = {
47
+ ast.Eq: operator.eq,
48
+ ast.NotEq: operator.ne,
49
+ ast.Lt: operator.lt,
50
+ ast.LtE: operator.le,
51
+ ast.Gt: operator.gt,
52
+ ast.GtE: operator.ge,
53
+ }
54
+
55
+ # Functions that may be called in expressions, with the number of arguments they take
56
+ _FUNCTIONS: dict[str, tuple[Callable, int]] = {
57
+ "abs": (np.abs, 1),
58
+ "sqrt": (np.sqrt, 1),
59
+ "log": (np.log, 1),
60
+ "log2": (np.log2, 1),
61
+ "log10": (np.log10, 1),
62
+ "exp": (np.exp, 1),
63
+ "floor": (np.floor, 1),
64
+ "ceil": (np.ceil, 1),
65
+ }
66
+ # `round` is handled separately, as its second argument must be an integer constant
67
+ _SUPPORTED_FUNCTIONS = sorted([*_FUNCTIONS, "round"])
68
+
69
+ _CONSTANT_TYPES = (int, float, str, bool, type(None))
70
+
71
+
72
+ def evaluate_expression(df: pd.DataFrame, expression: str) -> Any:
73
+ """
74
+ Evaluate an expression on a DataFrame.
75
+
76
+ See the module docstring for the supported syntax.
77
+
78
+ Parameters
79
+ ----------
80
+ df : pandas.DataFrame
81
+ The DataFrame whose columns may be used in the expression.
82
+ expression : str
83
+ The expression to evaluate.
84
+
85
+ Returns
86
+ -------
87
+ Any
88
+ The result of the expression: usually a pandas Series, or a scalar if the
89
+ expression does not refer to any column.
90
+
91
+ Raises
92
+ ------
93
+ UserInputError
94
+ If the expression is too long, cannot be parsed or uses unsupported syntax.
95
+ """
96
+ if not isinstance(expression, str):
97
+ raise UserInputError("Expression must be a string.")
98
+ if len(expression) > MAX_EXPRESSION_LENGTH:
99
+ raise UserInputError(
100
+ f"Expression is too long (maximum is {MAX_EXPRESSION_LENGTH} characters)."
101
+ )
102
+ try:
103
+ tree = ast.parse(expression.strip(), mode="eval")
104
+ except (SyntaxError, ValueError, RecursionError, MemoryError):
105
+ raise UserInputError("Expression could not be parsed.") from None
106
+ return _Evaluator(df).visit(tree.body)
107
+
108
+
109
+ class _Evaluator:
110
+ """Evaluate the nodes of a parsed expression that are explicitly supported."""
111
+
112
+ def __init__(self, df: pd.DataFrame) -> None:
113
+ self.df = df
114
+
115
+ def visit(self, node: ast.AST) -> Any:
116
+ if isinstance(node, ast.Name):
117
+ return self._visit_name(node)
118
+ if isinstance(node, ast.Constant):
119
+ return self._visit_constant(node)
120
+ if isinstance(node, ast.BinOp):
121
+ return self._visit_binop(node)
122
+ if isinstance(node, ast.UnaryOp):
123
+ return self._visit_unaryop(node)
124
+ if isinstance(node, ast.BoolOp):
125
+ return self._visit_boolop(node)
126
+ if isinstance(node, ast.Compare):
127
+ return self._visit_compare(node)
128
+ if isinstance(node, ast.Call):
129
+ return self._visit_call(node)
130
+ raise UserInputError(_unsupported(node))
131
+
132
+ def _visit_name(self, node: ast.Name) -> pd.Series:
133
+ if node.id not in self.df.columns:
134
+ raise UserInputError(f"Column '{node.id}' does not exist.")
135
+ return self.df[node.id]
136
+
137
+ @staticmethod
138
+ def _visit_constant(node: ast.Constant) -> Any:
139
+ if not isinstance(node.value, _CONSTANT_TYPES):
140
+ raise UserInputError(_unsupported(node))
141
+ return node.value
142
+
143
+ def _visit_binop(self, node: ast.BinOp) -> Any:
144
+ op = _BINARY_OPERATORS.get(type(node.op))
145
+ if op is None:
146
+ raise UserInputError(_unsupported(node.op))
147
+ left, right = self.visit(node.left), self.visit(node.right)
148
+ # only allow joining text, as e.g. repeating text (`'x' * 999999999`) could
149
+ # use excessive memory
150
+ if not isinstance(node.op, ast.Add) and (_is_text(left) or _is_text(right)):
151
+ raise UserInputError("Only '+' is supported for text.")
152
+ return op(left, right)
153
+
154
+ def _visit_unaryop(self, node: ast.UnaryOp) -> Any:
155
+ operand = self.visit(node.operand)
156
+ if isinstance(node.op, ast.USub):
157
+ return -operand
158
+ if isinstance(node.op, ast.UAdd):
159
+ return +operand
160
+ if isinstance(node.op, (ast.Not, ast.Invert)):
161
+ # like pandas, `not` and `~` are element-wise negations of booleans
162
+ if isinstance(operand, (bool, np.bool_)):
163
+ return not operand
164
+ return ~operand
165
+ raise UserInputError(_unsupported(node.op))
166
+
167
+ def _visit_boolop(self, node: ast.BoolOp) -> Any:
168
+ # like pandas, `and` and `or` are evaluated element-wise
169
+ op = operator.and_ if isinstance(node.op, ast.And) else operator.or_
170
+ result = self.visit(node.values[0])
171
+ for value in node.values[1:]:
172
+ result = op(result, self.visit(value))
173
+ return result
174
+
175
+ def _visit_compare(self, node: ast.Compare) -> Any:
176
+ result = None
177
+ left = self.visit(node.left)
178
+ for op, comparator in zip(node.ops, node.comparators):
179
+ if isinstance(op, (ast.In, ast.NotIn)):
180
+ right = self._visit_constant_list(comparator)
181
+ comparison = _isin(left, right)
182
+ if isinstance(op, ast.NotIn):
183
+ comparison = (
184
+ not comparison if isinstance(comparison, bool) else ~comparison
185
+ )
186
+ else:
187
+ compare = _COMPARISON_OPERATORS.get(type(op))
188
+ if compare is None:
189
+ raise UserInputError(_unsupported(op))
190
+ right = self.visit(comparator)
191
+ comparison = compare(left, right)
192
+ # chained comparisons (e.g. `1 < a < 5`) must all be true
193
+ result = comparison if result is None else operator.and_(result, comparison)
194
+ left = right
195
+ return result
196
+
197
+ def _visit_constant_list(self, node: ast.AST) -> list:
198
+ if not isinstance(node, (ast.List, ast.Tuple)) or not all(
199
+ isinstance(element, ast.Constant) for element in node.elts
200
+ ):
201
+ raise UserInputError(
202
+ "Only a list of constants is supported after 'in' or 'not in'."
203
+ )
204
+ return [self._visit_constant(element) for element in node.elts]
205
+
206
+ def _visit_call(self, node: ast.Call) -> Any:
207
+ if (
208
+ not isinstance(node.func, ast.Name)
209
+ or node.func.id not in _SUPPORTED_FUNCTIONS
210
+ ):
211
+ raise UserInputError(
212
+ f"Only the functions {', '.join(_SUPPORTED_FUNCTIONS)} are supported."
213
+ )
214
+ name = node.func.id
215
+ if node.keywords:
216
+ raise UserInputError(
217
+ f"Function '{name}' does not accept keyword arguments."
218
+ )
219
+ if name == "round":
220
+ return self._visit_round(node)
221
+ function, n_args = _FUNCTIONS[name]
222
+ if len(node.args) != n_args:
223
+ raise UserInputError(f"Function '{name}' takes {n_args} argument(s).")
224
+ return function(*(self.visit(arg) for arg in node.args))
225
+
226
+ def _visit_round(self, node: ast.Call) -> Any:
227
+ if len(node.args) not in (1, 2):
228
+ raise UserInputError("Function 'round' takes 1 or 2 arguments.")
229
+ decimals = 0
230
+ if len(node.args) == 2:
231
+ arg = node.args[1]
232
+ if not (
233
+ isinstance(arg, ast.Constant)
234
+ and isinstance(arg.value, int)
235
+ and not isinstance(arg.value, bool)
236
+ ):
237
+ raise UserInputError(
238
+ "The number of decimals of 'round' must be an integer."
239
+ )
240
+ decimals = arg.value
241
+ return np.round(self.visit(node.args[0]), decimals)
242
+
243
+
244
+ def _is_text(value: Any) -> bool:
245
+ if isinstance(value, pd.Series):
246
+ return pd.api.types.is_string_dtype(value) or pd.api.types.is_object_dtype(
247
+ value
248
+ )
249
+ return isinstance(value, str)
250
+
251
+
252
+ def _isin(value: Any, options: list) -> Any:
253
+ if isinstance(value, pd.Series):
254
+ return value.isin(options)
255
+ return value in options
256
+
257
+
258
+ def _unsupported(node: ast.AST) -> str:
259
+ return f"'{type(node).__name__}' is not supported in expressions."
@@ -6,25 +6,31 @@ pandas DataFrames, such as selecting and filtering rows and columns.
6
6
  import pandas as pd
7
7
 
8
8
  from vantage6.algorithm.tools.error_handling import handle_data_errors
9
+ from vantage6.algorithm.tools.exceptions import UserInputError
9
10
 
10
11
  from vantage6.algorithm.decorator.action import preprocessing
12
+ from vantage6.algorithm.preprocessing.expression import evaluate_expression
11
13
 
12
14
 
13
15
  @handle_data_errors
14
16
  @preprocessing
15
17
  def select_rows(df: pd.DataFrame, query: str) -> pd.DataFrame:
16
18
  """
17
- Select rows from the data based on a query. It uses the
18
- pandas.DataFrame.query function to filter the data.
19
-
20
- See the documentation of that function for more information on the query syntax.
19
+ Select the rows of the data for which the query is true.
21
20
 
22
21
  Parameters
23
22
  ----------
24
23
  df : pd.DataFrame
25
24
  The data to filter.
26
25
  query : str
27
- The query string to filter on.
26
+ The condition that rows must meet, e.g. ``age >= 18 and sex == 'F'``.
27
+ Supported are column names, numbers, strings, ``True``, ``False``, ``None``,
28
+ the operators ``+ - * / // %`` (only ``+`` for text), comparisons
29
+ (``== != < <= > >=``), ``in``/``not in`` a list of constants,
30
+ ``and or not & | ~`` and the functions
31
+ ``abs, sqrt, log, log2, log10, exp, floor, ceil, round``. Other functions,
32
+ attribute access, indexing and ``**`` are not supported. The query may be at
33
+ most 250 characters long.
28
34
 
29
35
  Returns
30
36
  -------
@@ -73,9 +79,15 @@ def select_rows(df: pd.DataFrame, query: str) -> pd.DataFrame:
73
79
  3 4 9 14
74
80
  4 5 10 15
75
81
 
76
- for more examples, see the documentation of pandas.DataFrame.query
82
+ >>> select_rows(df, "a in [1, 5]")
83
+ a b c
84
+ 0 1 6 11
85
+ 4 5 10 15
77
86
  """
78
- return df.query(query)
87
+ mask = evaluate_expression(df, query)
88
+ if not isinstance(mask, pd.Series) or not pd.api.types.is_bool_dtype(mask):
89
+ raise UserInputError("The query must evaluate to true or false for each row.")
90
+ return df[mask]
79
91
 
80
92
 
81
93
  @handle_data_errors