Skip to content

Commit 3d2c281

Browse files
committed
feat: add click callback to parse sequence of regexes
1 parent 8091fd1 commit 3d2c281

2 files changed

Lines changed: 141 additions & 0 deletions

File tree

dandi/cli/base.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from functools import wraps
22
import os
3+
import re
4+
from typing import Optional
35

46
import click
57

@@ -147,3 +149,61 @@ def wrapper(obj, *args, **kwargs):
147149
map_to_click_exceptions._do_map = not bool( # type: ignore[attr-defined]
148150
os.environ.get("DANDI_DEVEL", None)
149151
)
152+
153+
154+
def _compile_regex(regex: str) -> re.Pattern:
155+
"""
156+
Helper to compile a regex pattern expressed as an `str` into a `re.Pattern`
157+
158+
Parameters
159+
----------
160+
regex : str
161+
The regex pattern expressed as a string.
162+
163+
Returns
164+
-------
165+
re.Pattern
166+
The compiled regex pattern.
167+
"""
168+
try:
169+
compiled_regex = re.compile(regex)
170+
except re.error as e:
171+
raise click.BadParameter(f"Invalid regex pattern {regex!r}: {e}") from e
172+
173+
return compiled_regex
174+
175+
176+
def parse_regexes(
177+
_ctx: click.Context, _param: click.Parameter, value: Optional[str]
178+
) -> Optional[list[re.Pattern]]:
179+
"""
180+
Callback to parse a string of comma-separated regex patterns
181+
182+
Parameters
183+
----------
184+
_ctx : click.Context
185+
The Click context (not used).
186+
187+
_param : click.Parameter
188+
The Click parameter (not used).
189+
190+
value : str | None
191+
The input string containing comma-separated regex patterns. It is assumed
192+
that none of the patterns contain commas themselves.
193+
194+
Returns
195+
-------
196+
list[re.Pattern]
197+
A list of compiled regex patterns.
198+
199+
Notes
200+
-----
201+
This callback is only suitable to parse patterns that do not contain commas.
202+
"""
203+
if value is None:
204+
# Handle the case where no value is provided
205+
return None
206+
207+
regexes = set(value.split(","))
208+
209+
return [_compile_regex(regex) for regex in regexes]

dandi/cli/tests/test_base.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import re
2+
3+
import click
4+
import pytest
5+
6+
from dandi.cli.base import _compile_regex, parse_regexes
7+
8+
DUMMY_CTX = click.Context(click.Command("dummy"))
9+
DUMMY_PARAM = click.Option(["--dummy"])
10+
11+
12+
class TestCompileRegex:
13+
@pytest.mark.parametrize(
14+
"pattern",
15+
[
16+
"abc",
17+
"[a-z]+",
18+
"^start$",
19+
r"a\.b",
20+
],
21+
)
22+
def test_valid_patterns_return_pattern(self, pattern):
23+
compiled = _compile_regex(pattern)
24+
assert isinstance(compiled, re.Pattern)
25+
26+
@pytest.mark.parametrize("pattern", ["(", "[a-z", "\\"])
27+
def test_invalid_patterns_raise_bad_parameter(self, pattern):
28+
with pytest.raises(click.BadParameter) as exc_info:
29+
_compile_regex(pattern)
30+
msg = str(exc_info.value)
31+
assert "Invalid regex pattern" in msg
32+
assert repr(pattern) in msg
33+
34+
35+
class TestParseRegexes:
36+
def test_none_returns_none(self):
37+
assert parse_regexes(DUMMY_CTX, DUMMY_PARAM, None) is None
38+
39+
@pytest.mark.parametrize(
40+
"value",
41+
[
42+
"abc",
43+
"[a-z]+",
44+
r"a\.b",
45+
r"",
46+
],
47+
)
48+
def test_single_pattern(self, value):
49+
expected_pattern = re.compile(value)
50+
51+
result = parse_regexes(DUMMY_CTX, DUMMY_PARAM, value)
52+
assert isinstance(result, list)
53+
assert len(result) == 1
54+
55+
(compiled,) = result
56+
assert isinstance(compiled, re.Pattern)
57+
assert compiled == expected_pattern
58+
59+
@pytest.mark.parametrize(
60+
"value, expected_patterns_in_strs",
61+
[
62+
("foo,,bar", ["foo", "", "bar"]),
63+
("^start$,end$", ["^start$", "end$"]),
64+
(r"a\.b,c+d", [r"a\.b", r"c+d"]),
65+
# duplicates should be collapsed by the internal set()
66+
("foo,foo,bar", ["foo", "bar"]),
67+
],
68+
)
69+
def test_multiple_patterns(self, value, expected_patterns_in_strs):
70+
result = parse_regexes(DUMMY_CTX, DUMMY_PARAM, value)
71+
assert isinstance(result, list)
72+
73+
expected_result_as_set = set(re.compile(p) for p in expected_patterns_in_strs)
74+
assert set(result) == expected_result_as_set
75+
76+
@pytest.mark.parametrize(
77+
"value, bad_pattern", [("(", "("), ("foo,(", "("), ("good,[a-z", "[a-z")]
78+
)
79+
def test_invalid_pattern_raises_bad_parameter(self, value, bad_pattern):
80+
with pytest.raises(click.BadParameter, match=re.escape(bad_pattern)):
81+
parse_regexes(DUMMY_CTX, DUMMY_PARAM, value)

0 commit comments

Comments
 (0)