Skip to content

Commit 92adeef

Browse files
authored
test collection filter (#24)
1 parent 98ba8a4 commit 92adeef

File tree

7 files changed

+180
-23
lines changed

7 files changed

+180
-23
lines changed

.flake8

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[flake8]
22
max-line-length = 88
3-
extend-ignore = E203,E501,E701
3+
extend-ignore = E203,E266,E501,E701

lymbo/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from lymbo.resource_manager import scope_global
77
from lymbo.resource_manager import scope_module
88

9-
__version__ = "0.0.2"
9+
__version__ = "0.1.0"
1010

1111
__all__ = [
1212
"args",

lymbo/__main__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def lymbo_entry_point():
2828
)
2929

3030
print("==== collecting tests")
31-
test_plan = collect_tests(config.paths, config.groupby)
31+
test_plan = collect_tests(config.paths, config.groupby, config.filter)
3232

3333
if config.collect:
3434
test_plan_to_print, _ = test_plan.test_plan(show_status=False)

lymbo/collect.py

+84-19
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,55 @@
99

1010
from lymbo.config import GroupBy
1111
from lymbo.env import LYMBO_TEST_COLLECTION
12+
from lymbo.exception import LymboExceptionFilter
1213
from lymbo.item import TestItem
1314
from lymbo.item import TestPlan
1415
from lymbo.log import trace_call
16+
from lymbo.log import logger
17+
1518

1619
from lymbo.cm import args
1720

1821

22+
@trace_call
23+
def collect_tests(
24+
paths: list[Path], group_by: GroupBy, filter_by_path: str = ""
25+
) -> TestPlan:
26+
"""Collect all the functions/methods decorated with @lymbo.test."""
27+
28+
tests = []
29+
filtered_tests = []
30+
31+
os.environ[LYMBO_TEST_COLLECTION] = "1"
32+
33+
for path in list_python_files(paths):
34+
35+
tests += list_tests_from_file(path, group_by)
36+
37+
del os.environ[LYMBO_TEST_COLLECTION]
38+
39+
if filter_by_path:
40+
for group in tests:
41+
new_group = []
42+
for test in group:
43+
if match_filter(str(test), filter_by_path):
44+
new_group.append(test)
45+
else:
46+
logger().debug(f"collect_tests - {str(test)} has been filtered.")
47+
48+
if new_group:
49+
filtered_tests.append(new_group)
50+
else:
51+
filtered_tests = tests
52+
53+
test_plan = TestPlan(filtered_tests, group_by)
54+
55+
return test_plan
56+
57+
58+
## List tests
59+
60+
1961
@trace_call
2062
def list_python_files(paths: list[Path]) -> list[Path]:
2163
"""Walk into all directories and subdirectories to list the Python files."""
@@ -138,25 +180,6 @@ def parse_body(
138180
return collected_tests
139181

140182

141-
@trace_call
142-
def collect_tests(paths: list[Path], group_by: GroupBy) -> TestPlan:
143-
"""Collect all the functions/methods decorated with @lymbo.test."""
144-
145-
tests = []
146-
147-
os.environ[LYMBO_TEST_COLLECTION] = "1"
148-
149-
for path in list_python_files(paths):
150-
151-
tests += list_tests_from_file(path, group_by)
152-
153-
del os.environ[LYMBO_TEST_COLLECTION]
154-
155-
test_plan = TestPlan(tests, group_by)
156-
157-
return test_plan
158-
159-
160183
def eval_ast_call(call_node, global_vars, local_vars):
161184

162185
# local_vars and global_vars should be passed in the context
@@ -241,3 +264,45 @@ def dynamic_import_modules(imports: list[tuple[str, str]]) -> dict[str, str]:
241264
global_vars[alias] = importlib.import_module(full_name)
242265

243266
return global_vars
267+
268+
269+
## Filter tests
270+
271+
272+
def extract_words_from_filter(filter: str) -> list[str]:
273+
"""Parse a filter and returns only the words of the expression.
274+
Example: filter = "abc and not (def or ghi)"
275+
output = ["abc", "def", "ghi"]
276+
"""
277+
278+
# remove the stop words
279+
stop_words = ["(", ")", "and", "or", "not"]
280+
for stop_word in stop_words:
281+
filter = filter.replace(stop_word, " ")
282+
283+
# extract words
284+
words = set(filter.split(" "))
285+
286+
return [word for word in words if word != ""]
287+
288+
289+
def match_filter(item: str, filter: str) -> bool:
290+
"""Indicate if this item match with the filter"""
291+
words = {word: False for word in extract_words_from_filter(filter)}
292+
293+
for word in words:
294+
words[word] = word in item
295+
296+
# We sort the list of words to first replace the longer words to have partial
297+
# replacement if for example we have these two words: abc and abcdef.
298+
for word in sorted(words, key=len, reverse=True):
299+
filter = filter.replace(word, str(words[word]))
300+
301+
try:
302+
match = eval(filter)
303+
except Exception as ex:
304+
raise LymboExceptionFilter(
305+
f'The filter ["{filter}"] is broken. exception=[{str(ex)}]'
306+
)
307+
308+
return match

lymbo/config.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,14 @@ def parse_args() -> argparse.Namespace:
6161
"--workers",
6262
type=int,
6363
default=None,
64-
help="The number of workers in parrallel (default = number of CPU)",
64+
help="The number of workers in parrallel (default = number of CPU).",
65+
)
66+
67+
parser.add_argument(
68+
"--filter",
69+
type=str,
70+
default="",
71+
help="Select only the tests that match this filter (include full path and parameters).",
6572
)
6673

6774
return parser.parse_args()

lymbo/exception.py

+6
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,9 @@ class LymboExceptionScopeNested(Exception):
88
"""A shared resource is initialized from another shared resource."""
99

1010
pass
11+
12+
13+
class LymboExceptionFilter(Exception):
14+
"""The test collection filter is broken."""
15+
16+
pass

tests/test_collect.py

+79
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import unittest
44

55
from lymbo.collect import collect_tests
6+
from lymbo.collect import extract_words_from_filter
7+
from lymbo.collect import match_filter
8+
from lymbo.exception import LymboExceptionFilter
69
from lymbo.item import GroupBy
710

811
dir = os.path.dirname(os.path.abspath(__file__))
@@ -82,6 +85,82 @@ def test_collect_tests_two_files(self):
8285

8386
self.assertEqual(test_plan.count, (11, 11))
8487

88+
# filters
89+
90+
def test_extract_words(self):
91+
92+
params = {
93+
"abc": ["abc"],
94+
"not abc": ["abc"],
95+
"not (abc)": ["abc"],
96+
"(not abc)": ["abc"],
97+
"abc or abc": ["abc"],
98+
"abc or abcdef": ["abc", "abcdef"],
99+
"abc or def": ["abc", "def"],
100+
"abc and def": ["abc", "def"],
101+
"abc or (def and ijk)": ["abc", "def", "ijk"],
102+
"abc or not (def and ijk)": ["abc", "def", "ijk"],
103+
"( abc or ((def and (ijk)) )": ["abc", "def", "ijk"],
104+
}
105+
106+
for filter, words in params.items():
107+
with self.subTest(f'filter="{filter}" words={words}'):
108+
self.assertListEqual(
109+
sorted(extract_words_from_filter(filter)), sorted(words)
110+
)
111+
112+
def test_match_filter(self):
113+
114+
params = {
115+
"abc": "abc",
116+
"abc/def/ijk.py::func1": "abc",
117+
"abc/def/ijk.py::func2": "abc or abcdef",
118+
"abc/def/ijk.py::func3": "abcdef or abc",
119+
"abc/def/ijk.py::func4": "def",
120+
"abc/def/ijk.py::func5": "abc and def",
121+
"abc/def/ijk.py::func6": "abc and def and not ABC",
122+
}
123+
124+
for item, filter in params.items():
125+
with self.subTest(f'item="{item}" filter="{filter}"'):
126+
self.assertTrue(match_filter(item, filter))
127+
128+
def test_match_filter_not_match(self):
129+
130+
params = {
131+
"abc": "def",
132+
"abc/def/ijk.py::func1": "ABC",
133+
"abc/def/ijk.py::func2": "abc and abcdef",
134+
"abc/def/ijk.py::func3": "abcdef and abc",
135+
"abc/def/ijk.py::func4": "not def",
136+
"abc/def/ijk.py::func5": "abc and not def",
137+
"abc/def/ijk.py::func6": "not abc or (DEF and not ABC)",
138+
}
139+
140+
for item, filter in params.items():
141+
with self.subTest(f'item="{item}" filter="{filter}"'):
142+
self.assertFalse(match_filter(item, filter))
143+
144+
def test_collect_tests_filter(self):
145+
146+
test_plan = collect_tests(
147+
[Path(os.path.join(dir, "data_collect"))],
148+
GroupBy.MODULE,
149+
"second and not ((p=4) or (p=5))",
150+
)
151+
152+
self.assertEqual(test_plan.count, (5, 2))
153+
154+
def test_collect_tests_broken(self):
155+
156+
try:
157+
_ = collect_tests(
158+
[Path(os.path.join(dir, "data_collect"))], GroupBy.MODULE, "second )"
159+
)
160+
self.assertFalse(True, "No exception has been raised.")
161+
except Exception as ex:
162+
self.assertIsInstance(ex, LymboExceptionFilter)
163+
85164

86165
if __name__ == "__main__":
87166
unittest.main()

0 commit comments

Comments
 (0)