Skip to content

Commit 3f964bf

Browse files
committed
Automation rules: allow passing extra arguments to match and action
Some times we may want to pass additional data around to the match and action functions. This is data that isn't available in the version object. Like the extra data needed to match external versions. We also need to return the response of the action, this is to allow the caller know what happened in the action. This was extracted from #7664
1 parent 8479e72 commit 3f964bf

File tree

4 files changed

+87
-76
lines changed

4 files changed

+87
-76
lines changed

readthedocs/api/v2/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
STABLE_VERBOSE_NAME,
1515
TAG,
1616
)
17-
from readthedocs.builds.models import RegexAutomationRule, Version
17+
from readthedocs.builds.models import Version, VersionAutomationRule
1818

1919
log = logging.getLogger(__name__)
2020

@@ -210,7 +210,7 @@ def run_automation_rules(project, added_versions, deleted_active_versions):
210210
Currently the versions aren't sorted in any way,
211211
the same order is keeped.
212212
"""
213-
class_ = RegexAutomationRule
213+
class_ = VersionAutomationRule
214214
actions = [
215215
(added_versions, class_.allowed_actions_on_create),
216216
(deleted_active_versions, class_.allowed_actions_on_delete),

readthedocs/builds/models.py

Lines changed: 37 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import re
77
from shutil import rmtree
88

9-
import regex
109
from django.conf import settings
1110
from django.core.files.storage import get_storage_class
1211
from django.db import models
@@ -62,6 +61,7 @@
6261
get_bitbucket_username_repo,
6362
get_github_username_repo,
6463
get_gitlab_username_repo,
64+
match_regex,
6565
)
6666
from readthedocs.builds.version_slug import VersionSlugField
6767
from readthedocs.config import LATEST_CONFIGURATION_VERSION
@@ -979,8 +979,17 @@ class VersionAutomationRule(PolymorphicModel, TimeStampedModel):
979979
(DELETE_VERSION_ACTION, _('Delete version (on branch/tag deletion)')),
980980
)
981981

982-
allowed_actions_on_create = {}
983-
allowed_actions_on_delete = {}
982+
allowed_actions_on_create = {
983+
ACTIVATE_VERSION_ACTION: actions.activate_version,
984+
HIDE_VERSION_ACTION: actions.hide_version,
985+
MAKE_VERSION_PUBLIC_ACTION: actions.set_public_privacy_level,
986+
MAKE_VERSION_PRIVATE_ACTION: actions.set_private_privacy_level,
987+
SET_DEFAULT_VERSION_ACTION: actions.set_default_version,
988+
}
989+
990+
allowed_actions_on_delete = {
991+
DELETE_VERSION_ACTION: actions.delete_version,
992+
}
984993

985994
project = models.ForeignKey(
986995
Project,
@@ -1047,32 +1056,35 @@ def get_match_arg(self):
10471056
)
10481057
return match_arg or self.match_arg
10491058

1050-
def run(self, version, *args, **kwargs):
1059+
def run(self, version, **kwargs):
10511060
"""
10521061
Run an action if `version` matches the rule.
10531062
10541063
:type version: readthedocs.builds.models.Version
1055-
:returns: True if the action was performed
1064+
:param kwargs: All extra keywords will be passed to the match and action functions.
1065+
:returns: A tuple of (boolean, ANY), where the first element
1066+
indicates if the action was performed, and the second is the result
1067+
returned by the action.
10561068
"""
10571069
if version.type == self.version_type:
1058-
match, result = self.match(version, self.get_match_arg())
1070+
match, result = self.match(version, self.get_match_arg(), **kwargs)
10591071
if match:
1060-
self.apply_action(version, result)
1061-
return True
1062-
return False
1072+
action_result = self.apply_action(version, result, **kwargs)
1073+
return True, action_result
1074+
return False, None
10631075

1064-
def match(self, version, match_arg):
1076+
def match(self, version, match_arg, **kwargs):
10651077
"""
10661078
Returns True and the match result if the version matches the rule.
10671079
10681080
:type version: readthedocs.builds.models.Version
10691081
:param str match_arg: Additional argument to perform the match
1070-
:returns: A tuple of (boolean, match_resul).
1082+
:returns: A tuple of (boolean, match_result).
10711083
The result will be passed to `apply_action`.
10721084
"""
10731085
return False, None
10741086

1075-
def apply_action(self, version, match_result):
1087+
def apply_action(self, version, match_result, **kwargs):
10761088
"""
10771089
Apply the action from allowed_actions_on_*.
10781090
@@ -1087,7 +1099,12 @@ def apply_action(self, version, match_result):
10871099
)
10881100
if action is None:
10891101
raise NotImplementedError
1090-
action(version, match_result, self.action_arg)
1102+
return action(
1103+
version=version,
1104+
match_result=match_result,
1105+
action_arg=self.action_arg,
1106+
**kwargs,
1107+
)
10911108

10921109
def move(self, steps):
10931110
"""
@@ -1189,52 +1206,16 @@ def __str__(self):
11891206

11901207
class RegexAutomationRule(VersionAutomationRule):
11911208

1192-
TIMEOUT = 1 # timeout in seconds
1193-
1194-
allowed_actions_on_create = {
1195-
VersionAutomationRule.ACTIVATE_VERSION_ACTION: actions.activate_version,
1196-
VersionAutomationRule.HIDE_VERSION_ACTION: actions.hide_version,
1197-
VersionAutomationRule.MAKE_VERSION_PUBLIC_ACTION: actions.set_public_privacy_level,
1198-
VersionAutomationRule.MAKE_VERSION_PRIVATE_ACTION: actions.set_private_privacy_level,
1199-
VersionAutomationRule.SET_DEFAULT_VERSION_ACTION: actions.set_default_version,
1200-
}
1201-
1202-
allowed_actions_on_delete = {
1203-
VersionAutomationRule.DELETE_VERSION_ACTION: actions.delete_version,
1204-
}
1205-
12061209
class Meta:
12071210
proxy = True
12081211

1209-
def match(self, version, match_arg):
1210-
"""
1211-
Find a match using regex.search.
1212-
1213-
.. note::
1214-
1215-
We use the regex module with the timeout
1216-
arg to avoid ReDoS.
1217-
1218-
We could use a finite state machine type of regex too,
1219-
but there isn't a stable library at the time of writting this code.
1220-
"""
1221-
try:
1222-
match = regex.search(
1223-
match_arg,
1224-
version.verbose_name,
1225-
# Compatible with the re module
1226-
flags=regex.VERSION0,
1227-
timeout=self.TIMEOUT,
1228-
)
1229-
return bool(match), match
1230-
except TimeoutError:
1231-
log.warning(
1232-
'Timeout while parsing regex. pattern=%s, input=%s',
1233-
match_arg, version.verbose_name,
1234-
)
1235-
except Exception as e:
1236-
log.info('Error parsing regex: %s', e)
1237-
return False, None
1212+
def match(self, version, match_arg, **kwargs):
1213+
version_name = version.verbose_name
1214+
if version.is_external:
1215+
version_data = kwargs['version_data']
1216+
version_name = version_data.source_branch
1217+
result = match_regex(match_arg, version_name)
1218+
return bool(result), result
12381219

12391220
def get_edit_url(self):
12401221
return reverse(

readthedocs/builds/utils.py

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Utilities for the builds app."""
2-
2+
import logging
33
from contextlib import contextmanager
44

5+
import regex
56
from celery.five import monotonic
67
from django.core.cache import cache
78

@@ -11,31 +12,32 @@
1112
GITLAB_REGEXS,
1213
)
1314

14-
LOCK_EXPIRE = 60 * 180 # Lock expires in 3 hours
15+
log = logging.getLogger(__name__)
1516

17+
LOCK_EXPIRE = 60 * 180 # Lock expires in 3 hours
1618

1719
def get_github_username_repo(url):
1820
if 'github' in url:
19-
for regex in GITHUB_REGEXS:
20-
match = regex.search(url)
21+
for pattern in GITHUB_REGEXS:
22+
match = pattern.search(url)
2123
if match:
2224
return match.groups()
2325
return (None, None)
2426

2527

2628
def get_bitbucket_username_repo(url=None):
2729
if 'bitbucket' in url:
28-
for regex in BITBUCKET_REGEXS:
29-
match = regex.search(url)
30+
for pattern in BITBUCKET_REGEXS:
31+
match = pattern.search(url)
3032
if match:
3133
return match.groups()
3234
return (None, None)
3335

3436

3537
def get_gitlab_username_repo(url=None):
3638
if 'gitlab' in url:
37-
for regex in GITLAB_REGEXS:
38-
match = regex.search(url)
39+
for pattern in GITLAB_REGEXS:
40+
match = pattern.search(url)
3941
if match:
4042
return match.groups()
4143
return (None, None)
@@ -61,3 +63,31 @@ def memcache_lock(lock_id, oid):
6163
# to lessen the chance of releasing an expired lock
6264
# owned by someone else.
6365
cache.delete(lock_id)
66+
67+
68+
def match_regex(pattern, text, timeout=1):
69+
"""
70+
Find a match using regex.search.
71+
72+
.. note::
73+
We use the regex module with the timeout arg to avoid ReDoS.
74+
We could use a finite state machine type of regex too,
75+
but there isn't a stable library at the time of writting this code.
76+
"""
77+
try:
78+
match = regex.search(
79+
pattern,
80+
text,
81+
# Compatible with the re module
82+
flags=regex.VERSION0,
83+
timeout=timeout,
84+
)
85+
return match
86+
except TimeoutError:
87+
log.exception(
88+
'Timeout while parsing regex. pattern=%s, input=%s',
89+
pattern, text,
90+
)
91+
except Exception as e:
92+
log.exception('Error parsing regex: %s', e)
93+
return None

readthedocs/rtd_tests/tests/test_automation_rules.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def test_match(
9393
action=VersionAutomationRule.ACTIVATE_VERSION_ACTION,
9494
version_type=version_type,
9595
)
96-
assert rule.run(version) is result
96+
assert rule.run(version) == (result, None)
9797

9898
@pytest.mark.parametrize(
9999
'version_name,result',
@@ -127,7 +127,7 @@ def test_predefined_match_all_versions(self, trigger_build, version_name, result
127127
action=VersionAutomationRule.ACTIVATE_VERSION_ACTION,
128128
version_type=version_type,
129129
)
130-
assert rule.run(version) is result
130+
assert rule.run(version) == (result, None)
131131

132132
@pytest.mark.parametrize(
133133
'version_name,result',
@@ -163,7 +163,7 @@ def test_predefined_match_semver_versions(self, trigger_build, version_name, res
163163
action=VersionAutomationRule.ACTIVATE_VERSION_ACTION,
164164
version_type=version_type,
165165
)
166-
assert rule.run(version) is result
166+
assert rule.run(version) == (result, None)
167167

168168
def test_action_activation(self, trigger_build):
169169
version = get(
@@ -181,7 +181,7 @@ def test_action_activation(self, trigger_build):
181181
action=VersionAutomationRule.ACTIVATE_VERSION_ACTION,
182182
version_type=TAG,
183183
)
184-
assert rule.run(version) is True
184+
assert rule.run(version) == (True, None)
185185
assert version.active is True
186186
trigger_build.assert_called_once()
187187

@@ -204,7 +204,7 @@ def test_action_delete_version(self, trigger_build, version_type):
204204
action=VersionAutomationRule.DELETE_VERSION_ACTION,
205205
version_type=version_type,
206206
)
207-
assert rule.run(version) is True
207+
assert rule.run(version) == (True, None)
208208
assert not self.project.versions.filter(slug=slug).exists()
209209

210210
@pytest.mark.parametrize('version_type', [BRANCH, TAG])
@@ -229,7 +229,7 @@ def test_action_delete_version_on_default_version(self, trigger_build, version_t
229229
action=VersionAutomationRule.DELETE_VERSION_ACTION,
230230
version_type=version_type,
231231
)
232-
assert rule.run(version) is True
232+
assert rule.run(version) == (True, None)
233233
assert self.project.versions.filter(slug=slug).exists()
234234

235235
def test_action_set_default_version(self, trigger_build):
@@ -249,7 +249,7 @@ def test_action_set_default_version(self, trigger_build):
249249
version_type=TAG,
250250
)
251251
assert self.project.get_default_version() == LATEST
252-
assert rule.run(version) is True
252+
assert rule.run(version) == (True, None)
253253
assert self.project.get_default_version() == version.slug
254254

255255
def test_version_hide_action(self, trigger_build):
@@ -269,7 +269,7 @@ def test_version_hide_action(self, trigger_build):
269269
action=VersionAutomationRule.HIDE_VERSION_ACTION,
270270
version_type=TAG,
271271
)
272-
assert rule.run(version) is True
272+
assert rule.run(version) == (True, None)
273273
assert version.active is True
274274
assert version.hidden is True
275275
trigger_build.assert_called_once()
@@ -292,7 +292,7 @@ def test_version_make_public_action(self, trigger_build):
292292
action=VersionAutomationRule.MAKE_VERSION_PUBLIC_ACTION,
293293
version_type=TAG,
294294
)
295-
assert rule.run(version) is True
295+
assert rule.run(version) == (True, None)
296296
assert version.privacy_level == PUBLIC
297297
trigger_build.assert_not_called()
298298

@@ -314,7 +314,7 @@ def test_version_make_private_action(self, trigger_build):
314314
action=VersionAutomationRule.MAKE_VERSION_PRIVATE_ACTION,
315315
version_type=TAG,
316316
)
317-
assert rule.run(version) is True
317+
assert rule.run(version) == (True, None)
318318
assert version.privacy_level == PRIVATE
319319
trigger_build.assert_not_called()
320320

0 commit comments

Comments
 (0)