Skip to content

Commit 3db3a6d

Browse files
authored
Merge pull request #643 from jkloetzke/resubst
Add regular expression substitution string function
2 parents 75f7eda + 97430d6 commit 3db3a6d

File tree

5 files changed

+128
-7
lines changed

5 files changed

+128
-7
lines changed

doc/manual/configuration.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,10 @@ The following built in string functions are supported:
396396
undefined in the tools :ref:`configuration-recipes-provideTools` environment
397397
definition unless the optional ``default`` is given, which is then used
398398
instead.
399+
* ``$(resubst,pattern,replacement,text[,flags])``: Replace all matches of the
400+
regular expression ``pattern`` in ``text`` with ``replacement``. Flags are
401+
optional. The only currently supported flag is ``i`` to ignore case while
402+
searching.
399403

400404
The following built in string functions are additionally supported in
401405
:ref:`package path queries <manpage-bobpaths>`. They cannot be used in recipes

pym/bob/input.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
from .scm import CvsScm, GitScm, ImportScm, SvnScm, UrlScm, ScmOverride, \
1111
auditFromDir, auditFromProperties, getScm, SYNTHETIC_SCM_PROPS
1212
from .state import BobState
13-
from .stringparser import checkGlobList, Env, DEFAULT_STRING_FUNS, IfExpression
13+
from .stringparser import checkGlobList, Env, DEFAULT_STRING_FUNS, IfExpression, \
14+
EXTRA_STRING_FUNS
1415
from .tty import InfoOnce, Warn, WarnOnce, setColorMode, setParallelTUIThreshold
1516
from .utils import asHexStr, joinScripts, compareVersion, binStat, \
1617
updateDicRecursive, hashString, getPlatformTag, getPlatformString, \
@@ -3757,6 +3758,18 @@ def __parse(self, envOverrides, platform, recipesRoot=""):
37573758
# Begin with root layer
37583759
allLayers = self.__parseLayer(LayerSpec(""), "9999", recipesRoot, None)
37593760

3761+
# Add string functions added after 1.0. We did not reserve a namespace
3762+
# and we better not break existing recipes.
3763+
collisions = set(self.__stringFunctions.keys()) & set(EXTRA_STRING_FUNS.keys())
3764+
if collisions:
3765+
for k, v in EXTRA_STRING_FUNS.items():
3766+
if k not in collisions:
3767+
self.__stringFunctions[k] = v
3768+
collisions = ", ".join(sorted(collisions))
3769+
Warn(f"Bob internal string functions shadowed by plugins: {collisions}").warn()
3770+
else:
3771+
self.__stringFunctions.update(EXTRA_STRING_FUNS)
3772+
37603773
# Parse all recipes and classes of all layers. Need to be done last
37613774
# because only by now we have loaded all plugins.
37623775
for layer, rootDir, scriptLanguage in allLayers:

pym/bob/stringparser.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -612,10 +612,13 @@ def funMatch(args, **options):
612612
else:
613613
raise ParseError('match only supports the ignore case flag "i"')
614614

615-
if re.search(args[1],args[0],flags):
616-
return "true"
617-
else:
618-
return "false"
615+
try:
616+
if re.search(args[1],args[0],flags):
617+
return "true"
618+
else:
619+
return "false"
620+
except re.error as e:
621+
raise ParseError("Invalid $(match) regex '{}': {}".format(e.pattern, e))
619622

620623
def funIfThenElse(args, **options):
621624
if len(args) != 3: raise ParseError("if-then-else expects three arguments")
@@ -682,6 +685,27 @@ def funMatchScm(args, **options):
682685

683686
return "false"
684687

688+
def funResubst(args, **options):
689+
try:
690+
[3, 4].index(len(args))
691+
except ValueError:
692+
raise ParseError("$(resubst) expects either three or four arguments")
693+
694+
flags = 0
695+
if len(args) == 4:
696+
if args[3] == 'i':
697+
flags = re.IGNORECASE
698+
else:
699+
raise ParseError('$(resubst) only supports the ignore case flag "i"')
700+
701+
try:
702+
return re.sub(args[0], args[1], args[2], flags=flags)
703+
except re.error as e:
704+
raise ParseError("Invalid $(resubst) regex '{}': {}".format(e.pattern, e))
705+
706+
# Attention: do *not* add any new functions here. That will break existing
707+
# plugins that define a function with the same name. Use EXTRA_STRING_FUNS for
708+
# new functions instead.
685709
DEFAULT_STRING_FUNS = {
686710
"eq" : funEqual,
687711
"or" : funOr,
@@ -697,3 +721,7 @@ def funMatchScm(args, **options):
697721
"match" : funMatch,
698722
"matchScm" : funMatchScm,
699723
}
724+
725+
EXTRA_STRING_FUNS = {
726+
"resubst" : funResubst,
727+
}

test/unit/test_input_recipeset.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from tempfile import NamedTemporaryFile, TemporaryDirectory
77
from unittest import TestCase
8-
from unittest.mock import Mock
8+
from unittest.mock import MagicMock, patch
99
import os
1010
import textwrap
1111
import yaml
@@ -69,6 +69,14 @@ def writeAlias(self, name, content, layer=[]):
6969
with open(os.path.join(path, name+".yaml"), "w") as f:
7070
f.write(textwrap.dedent(content))
7171

72+
def writePlugin(self, name, content, layer=[]):
73+
path = os.path.join("",
74+
*(os.path.join("layers", l) for l in layer),
75+
"plugins")
76+
os.makedirs(path, exist_ok=True)
77+
with open(os.path.join(path, name+".py"), "w") as f:
78+
f.write(textwrap.dedent(content))
79+
7280
def generate(self, sandboxEnabled=False, env={}):
7381
recipes = RecipeSet()
7482
recipes.parse(env)
@@ -2414,3 +2422,47 @@ def testSimpleDepsUpgrade(self):
24142422
self.assertEqual(p.getBuildStep().toolDepWeak, {"tool-c"})
24152423
self.assertEqual(p.getPackageStep().toolDep, {"tool-a", "tool-b", "tool-c"})
24162424
self.assertEqual(p.getPackageStep().toolDepWeak, set())
2425+
2426+
class TestPlugins(RecipesTmp, TestCase):
2427+
2428+
def testWarnStringFunCollision(self):
2429+
"""A warning is issued if a plugin defines post-1.0 string function"""
2430+
self.writeRecipe("root", """\
2431+
root: True
2432+
privateEnvironment:
2433+
VAR1: "$(resubst,X,Y,AXBX)"
2434+
VAR2: "$(custom)"
2435+
packageVars: [VAR1, VAR2]
2436+
""")
2437+
self.writeConfig({
2438+
"plugins" : [ "funs" ]
2439+
})
2440+
self.writePlugin("funs", """\
2441+
def resubst(args, **options):
2442+
return "plugin called"
2443+
def custom(args, **options):
2444+
return "custom called"
2445+
2446+
manifest = {
2447+
'apiVersion' : "1.0",
2448+
'stringFunctions' : {
2449+
"resubst" : resubst,
2450+
"custom" : custom,
2451+
}
2452+
}
2453+
""")
2454+
2455+
warnObjMock = MagicMock()
2456+
warnClassMock = MagicMock(return_value=warnObjMock)
2457+
2458+
# Patch "Warn" class import at bob.input module
2459+
with patch('bob.input.Warn', warnClassMock):
2460+
p = self.generate().walkPackagePath("root")
2461+
2462+
# The parser should warn about the "resubst" collision
2463+
warnObjMock.warn.assert_called()
2464+
self.assertIn("resubst", warnClassMock.call_args.args[0])
2465+
2466+
# Make sure the plugin is called instead of the internal implementation.
2467+
self.assertEqual(p.getPackageStep().getEnv(),
2468+
{"VAR1" : "plugin called", "VAR2" : "custom called"})

test/unit/test_input_stringparser.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from bob.stringparser import StringParser
1010
from bob.stringparser import funEqual, funNotEqual, funNot, funOr, \
1111
funAnd, funMatch, funIfThenElse, funSubst, funStrip, \
12-
funSandboxEnabled, funToolDefined, funToolEnv
12+
funSandboxEnabled, funToolDefined, funToolEnv, funResubst
1313
from bob.errors import ParseError
1414

1515
def echo(args, **options):
@@ -197,6 +197,10 @@ def testMatch(self):
197197
self.assertRaises(ParseError, funMatch, ["a","b","x"])
198198
self.assertRaises(ParseError, funMatch, ["a","b","i","y"])
199199

200+
# broken regex
201+
with self.assertRaises(ParseError):
202+
funMatch(["b", r'\c'])
203+
200204
def testIfThenElse(self):
201205
self.assertRaises(ParseError, funIfThenElse, ["a", "b"])
202206
self.assertEqual(funIfThenElse(["true", "a", "b"]), "a")
@@ -252,3 +256,23 @@ def testToolEnv(self):
252256
# Get real var
253257
self.assertEqual(funToolEnv(["foo", "bar"], __tools=tools), "baz")
254258
self.assertEqual(funToolEnv(["foo", "bar", "def"], __tools=tools), "baz")
259+
260+
def testResubst(self):
261+
# Wrong number of arguments
262+
with self.assertRaises(ParseError):
263+
funResubst(["foo", "bar"])
264+
with self.assertRaises(ParseError):
265+
funResubst(["foo", "bar", "baz", "extra", "toomuch"])
266+
267+
# Unsupported flag
268+
with self.assertRaises(ParseError):
269+
funResubst(["a", "b", "abc", "%"])
270+
271+
# broken regex
272+
with self.assertRaises(ParseError):
273+
funResubst([r'\c', "b", "abc"])
274+
275+
self.assertEqual(funResubst(["X", "Y", "AXBXCX"]), "AYBYCY")
276+
self.assertEqual(funResubst([r"\.[^.]+$", "", "1.2.3"]), "1.2")
277+
self.assertEqual(funResubst(["[X]", "Y", "AXBx"]), "AYBx")
278+
self.assertEqual(funResubst(["[x]", "Y", "AXBx", "i"]), "AYBY")

0 commit comments

Comments
 (0)