|
""" |
|
Bash-style brace expansion |
|
Copied from: https://github.com/trendels/braceexpand/blob/main/src/braceexpand/__init__.py |
|
License: MIT |
|
""" |
|
|
|
import re |
|
import string |
|
from itertools import chain, product |
|
from typing import Iterable, Iterator, Optional |
|
|
|
__all__ = ["braceexpand", "alphabet", "UnbalancedBracesError"] |
|
|
|
|
|
class UnbalancedBracesError(ValueError): |
|
pass |
|
|
|
|
|
alphabet = string.ascii_uppercase + string.ascii_lowercase |
|
|
|
int_range_re = re.compile(r"^(-?\d+)\.\.(-?\d+)(?:\.\.-?(\d+))?$") |
|
char_range_re = re.compile(r"^([A-Za-z])\.\.([A-Za-z])(?:\.\.-?(\d+))?$") |
|
escape_re = re.compile(r"\\(.)") |
|
|
|
|
|
def braceexpand(pattern: str, escape: bool = True) -> Iterator[str]: |
|
"""braceexpand(pattern) -> iterator over generated strings |
|
|
|
Returns an iterator over the strings resulting from brace expansion |
|
of pattern. This function implements Brace Expansion as described in |
|
bash(1), with the following limitations: |
|
|
|
* A pattern containing unbalanced braces will raise an |
|
UnbalancedBracesError exception. In bash, unbalanced braces will either |
|
be partly expanded or ignored. |
|
|
|
* A mixed-case character range like '{Z..a}' or '{a..Z}' will not |
|
include the characters '[]^_`' between 'Z' and 'a'. |
|
|
|
When escape is True (the default), characters in pattern can be |
|
prefixed with a backslash to cause them not to be interpreted as |
|
special characters for brace expansion (such as '{', '}', ','). |
|
To pass through a a literal backslash, double it ('\\\\'). |
|
|
|
When escape is False, backslashes in pattern have no special |
|
meaning and will be preserved in the output. |
|
|
|
Examples: |
|
|
|
>>> from braceexpand import braceexpand |
|
|
|
# Integer range |
|
>>> list(braceexpand('item{1..3}')) |
|
['item1', 'item2', 'item3'] |
|
|
|
# Character range |
|
>>> list(braceexpand('{a..c}')) |
|
['a', 'b', 'c'] |
|
|
|
# Sequence |
|
>>> list(braceexpand('index.html{,.backup}')) |
|
['index.html', 'index.html.backup'] |
|
|
|
# Nested patterns |
|
>>> list(braceexpand('python{2.{5..7},3.{2,3}}')) |
|
['python2.5', 'python2.6', 'python2.7', 'python3.2', 'python3.3'] |
|
|
|
# Prefixing an integer with zero causes all numbers to be padded to |
|
# the same width. |
|
>>> list(braceexpand('{07..10}')) |
|
['07', '08', '09', '10'] |
|
|
|
# An optional increment can be specified for ranges. |
|
>>> list(braceexpand('{a..g..2}')) |
|
['a', 'c', 'e', 'g'] |
|
|
|
# Ranges can go in both directions. |
|
>>> list(braceexpand('{4..1}')) |
|
['4', '3', '2', '1'] |
|
|
|
# Numbers can be negative |
|
>>> list(braceexpand('{2..-1}')) |
|
['2', '1', '0', '-1'] |
|
|
|
# Unbalanced braces raise an exception. |
|
>>> list(braceexpand('{1{2,3}')) |
|
Traceback (most recent call last): |
|
... |
|
UnbalancedBracesError: Unbalanced braces: '{1{2,3}' |
|
|
|
# By default, the backslash is the escape character. |
|
>>> list(braceexpand(r'{1\\{2,3}')) |
|
['1{2', '3'] |
|
|
|
# Setting 'escape' to False disables backslash escaping. |
|
>>> list(braceexpand(r'\\{1,2}', escape=False)) |
|
['\\\\1', '\\\\2'] |
|
|
|
""" |
|
return ( |
|
escape_re.sub(r"\1", s) if escape else s for s in parse_pattern(pattern, escape) |
|
) |
|
|
|
|
|
def parse_pattern(pattern: str, escape: bool) -> Iterator[str]: |
|
start = 0 |
|
pos = 0 |
|
bracketdepth = 0 |
|
items: list[Iterable[str]] = [] |
|
|
|
|
|
while pos < len(pattern): |
|
if escape and pattern[pos] == "\\": |
|
pos += 2 |
|
continue |
|
elif pattern[pos] == "{": |
|
if bracketdepth == 0 and pos > start: |
|
|
|
items.append([pattern[start:pos]]) |
|
start = pos |
|
bracketdepth += 1 |
|
elif pattern[pos] == "}": |
|
bracketdepth -= 1 |
|
if bracketdepth == 0: |
|
|
|
expr = pattern[start + 1 : pos] |
|
item = parse_expression(expr, escape) |
|
if item is None: |
|
items.extend([["{"], parse_pattern(expr, escape), ["}"]]) |
|
else: |
|
items.append(item) |
|
start = pos + 1 |
|
pos += 1 |
|
|
|
if bracketdepth != 0: |
|
raise UnbalancedBracesError("Unbalanced braces: '%s'" % pattern) |
|
|
|
if start < pos: |
|
items.append([pattern[start:]]) |
|
|
|
return ("".join(item) for item in product(*items)) |
|
|
|
|
|
def parse_expression(expr: str, escape: bool) -> Optional[Iterable[str]]: |
|
int_range_match = int_range_re.match(expr) |
|
if int_range_match: |
|
return make_int_range(*int_range_match.groups()) |
|
|
|
char_range_match = char_range_re.match(expr) |
|
if char_range_match: |
|
return make_char_range(*char_range_match.groups()) |
|
|
|
return parse_sequence(expr, escape) |
|
|
|
|
|
def parse_sequence(seq: str, escape: bool) -> Optional[Iterator[str]]: |
|
|
|
start = 0 |
|
pos = 0 |
|
bracketdepth = 0 |
|
items: list[Iterable[str]] = [] |
|
|
|
|
|
while pos < len(seq): |
|
if escape and seq[pos] == "\\": |
|
pos += 2 |
|
continue |
|
elif seq[pos] == "{": |
|
bracketdepth += 1 |
|
elif seq[pos] == "}": |
|
bracketdepth -= 1 |
|
elif seq[pos] == "," and bracketdepth == 0: |
|
items.append(parse_pattern(seq[start:pos], escape)) |
|
start = pos + 1 |
|
pos += 1 |
|
|
|
if bracketdepth != 0: |
|
raise UnbalancedBracesError |
|
if not items: |
|
return None |
|
|
|
|
|
items.append(parse_pattern(seq[start:], escape)) |
|
return chain(*items) |
|
|
|
|
|
def make_int_range(left: str, right: str, incr: Optional[str] = None) -> Iterator[str]: |
|
if any([s.startswith(("0", "-0")) for s in (left, right) if s not in ("0", "-0")]): |
|
padding = max(len(left), len(right)) |
|
else: |
|
padding = 0 |
|
step = (int(incr) or 1) if incr else 1 |
|
start = int(left) |
|
end = int(right) |
|
r = range(start, end + 1, step) if start < end else range(start, end - 1, -step) |
|
fmt = "%0{}d".format(padding) |
|
return (fmt % i for i in r) |
|
|
|
|
|
def make_char_range(left: str, right: str, incr: Optional[str] = None) -> str: |
|
step = (int(incr) or 1) if incr else 1 |
|
start = alphabet.index(left) |
|
end = alphabet.index(right) |
|
if start < end: |
|
return alphabet[start : end + 1 : step] |
|
else: |
|
end = end or -len(alphabet) |
|
return alphabet[start : end - 1 : -step] |
|
|
|
|
|
if __name__ == "__main__": |
|
import doctest |
|
import sys |
|
|
|
failed, _ = doctest.testmod(optionflags=doctest.IGNORE_EXCEPTION_DETAIL) |
|
if failed: |
|
sys.exit(1) |
|
|