Skip to content

Commit

Permalink
Merge pull request #9 from pombredanne/bashisms
Browse files Browse the repository at this point in the history
  • Loading branch information
kojiromike authored Jul 2, 2021
2 parents e809139 + b2e4a05 commit 3efeb4e
Show file tree
Hide file tree
Showing 2 changed files with 171 additions and 9 deletions.
74 changes: 71 additions & 3 deletions parameter_expansion/pe.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@
"""
Given a string, expand that string using POSIX [parameter expansion][1].
Also support some minimal Bash extensions to expansion [3]:
- pattern substitution with `${foo/bar/baz}` (but only plain strings and not patterns)
- substring expansion with `${foo:4:2}
## Limitations
(Pull requests to remove limitations are welcome.)
- Nested expansions `${foo:-$bar}` are not supported.
- Only simple nested expansions of the forms $variable and ${variable} are
supported and not complex expansions such as in `${foo:-${bar:-$baz}}`
- Only ASCII alphanumeric characters and underscores are supported in parameter
names. (Per POSIX, parameter names may not begin with a numeral.)
- Assignment expansions do not mutate the real environment.
Expand All @@ -17,11 +23,12 @@
[1]: http://pubs.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html#tag_02_06_02
[2]: http://pubs.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html#tag_02_13
[3]: https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html
"""

import os
from fnmatch import fnmatchcase
from itertools import takewhile
from os import getenv
from shlex import shlex


Expand All @@ -30,6 +37,7 @@ def expand(s, env=None):
Uses the provided environment dict or the actual environment."""
if env is None:
env = dict(os.environ)
s = expand_simple(s, env)
return "".join(expand_tokens(s, env))


Expand Down Expand Up @@ -60,6 +68,20 @@ def follow_sigil(shl, env):
return env.get(param, "")


def expand_simple(s, env):
"""Expand a string containing shell variable substitutions.
This expands the forms $variable and ${variable} only.
Non-existent variables are left unchanged.
Uses the provided environment dict.
Similar to ``os.path.expandvars``.
"""
for name, value in env.items():
s = s.replace(f"${name}", value)
name = "{" + name + "}"
s = s.replace(f"${name}", value)
return s


def remove_affix(subst, shl, suffix=True):
"""
http://pubs.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html#tag_02_13
Expand Down Expand Up @@ -101,7 +123,6 @@ def follow_brace(shl, env):
return str(len(subst))
subst = env.get(param, "")
param_set_and_not_null = bool(subst and (param in env))
param_set_but_null = bool((param in env) and not subst)
param_unset = param not in env
try:
modifier = next(shl)
Expand Down Expand Up @@ -130,8 +151,55 @@ def follow_brace(shl, env):
if param_set_and_not_null:
return next(shl)
return subst # ""
elif modifier.isdigit():
# this is a Substring Expansion as in ${foo:4:2}
# This is a bash'ism, and not POSIX.
# see https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html
try:
start = int(modifier)
except ValueError as e:
raise ParameterExpansionParseError("Not a bash slice", shl) from e
if param_set_and_not_null:
subst = subst[start:]
# if this fails, we will StopIteration and we have a plain ${foo:4}
# and subst above will be returned
modifier = next(shl)
if modifier != ":":
raise ParameterExpansionParseError("Illegal slice argument", shl)
end = int(next(shl))
if param_set_and_not_null:
return subst[:end]
return subst
else:
raise ParameterExpansionParseError()
elif modifier == "/":
# this is a string replacement
arg1 = next(shl)
replace_all = False
if arg1 == "/":
# with // replace all occurences
replace_all = True
arg1 = next(shl)

# the repl of a replacement may not exist at all with no trailing
# slash or it may be empty
sep = next(shl, "/")
if sep != "/":
raise ParameterExpansionParseError("Illegal replacement syntax")

# the repl of a replacement may be empty
try:
arg2 = next(shl)
except StopIteration:
arg2 = ""

if param_set_and_not_null:
if replace_all:
return subst.replace(arg1, arg2)
else:
return subst.replace(arg1, arg2, 1)

return subst
else:
if modifier == "-":
word = next(shl)
Expand Down
106 changes: 100 additions & 6 deletions parameter_expansion/tests/test_pe.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,86 @@
),
}

substring_test_cases = {
# string, parameter, result
"-${parameter:0:2}-": (
"aa/bb/cc",
"-aa-",
),
"-${parameter:3:2}-": (
"aa/bb/cc",
"-bb-",
),
"-${parameter:6}-": (
"aa/bb/cc",
"-cc-",
),
}

replace_test_cases = {
# string, parameter, result
"-${parameter/aa/bb}-": (
"aa/bb/cc",
"-bb/bb/cc-",
),
"-${parameter/aa/}-": (
"aa/bb/cc",
"-/bb/cc-",
),
"-${parameter/aa/zz}-": (
"aa/bb/aa",
"-zz/bb/aa-",
),
"-${parameter//aa/zz}-": (
"aa/bb/aa",
"-zz/bb/zz-",
),
"-${parameter/aa}-": (
"aa/bb/aa",
"-/bb/aa-",
),
"-${parameter//aa}-": (
"aa/bb/aa",
"-/bb/-",
),
}

simple_test_cases = {
# string, env, result
"-${parameter/$aa/$bb}-": (
dict(
parameter="FOO/bb/cc",
aa="FOO",
bb="BAR",
),
"-BAR/bb/cc-",
),
"-$parameter/$aa/$bb-": (
dict(
parameter="aa/bb/cc",
aa="FOO",
bb="BAR",
),
"-aa/bb/cc/FOO/BAR-",
),
"-${parameter/${aa}/${bb}}-": (
dict(
parameter="FOO/bb/cc",
aa="FOO",
bb="BAR",
),
"-BAR/bb/cc-",
),
"-$parameter/$aa/${bb}-": (
dict(
parameter="aa/bb/cc",
aa="FOO",
bb="BAR",
),
"-aa/bb/cc/FOO/BAR-",
),
}

test_envs = (
{
"parameter": "set",
Expand Down Expand Up @@ -110,9 +190,23 @@ def test():
except pe.ParameterExpansionNullError:
assert tc[i] == "error", (string, tc[i], test_case_map[i])

for string, tc in affix_test_cases.items():
env = {
"parameter": tc[0],
}
result = string, pe.expand(string, env), tc
assert result[1] == tc[1], result
for string, (parameter, expected) in affix_test_cases.items():
env = {"parameter": parameter}
assert pe.expand(string, env) == expected


def test_substring():
for string, (parameter, expected) in substring_test_cases.items():
env = {"parameter": parameter}
assert pe.expand(string, env) == expected, (string, parameter)


def test_replace():
for string, (parameter, expected) in replace_test_cases.items():
env = {"parameter": parameter}
assert pe.expand(string, env) == expected, (string, parameter)


def test_simple():
for string, (env, expected) in simple_test_cases.items():
assert pe.expand(string, env) == expected, (string, env)

0 comments on commit 3efeb4e

Please sign in to comment.