|
|
@@ -0,0 +1,217 @@
|
|
|
+"""
|
|
|
+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]] = []
|
|
|
+
|
|
|
+ # print 'pattern:', pattern
|
|
|
+ while pos < len(pattern):
|
|
|
+ if escape and pattern[pos] == "\\":
|
|
|
+ pos += 2
|
|
|
+ continue
|
|
|
+ elif pattern[pos] == "{":
|
|
|
+ if bracketdepth == 0 and pos > start:
|
|
|
+ # print 'literal:', pattern[start:pos]
|
|
|
+ items.append([pattern[start:pos]])
|
|
|
+ start = pos
|
|
|
+ bracketdepth += 1
|
|
|
+ elif pattern[pos] == "}":
|
|
|
+ bracketdepth -= 1
|
|
|
+ if bracketdepth == 0:
|
|
|
+ # print 'expression:', pattern[start+1:pos]
|
|
|
+ expr = pattern[start + 1 : pos]
|
|
|
+ item = parse_expression(expr, escape)
|
|
|
+ if item is None: # not a range or sequence
|
|
|
+ items.extend([["{"], parse_pattern(expr, escape), ["}"]])
|
|
|
+ else:
|
|
|
+ items.append(item)
|
|
|
+ start = pos + 1 # skip the closing brace
|
|
|
+ pos += 1
|
|
|
+
|
|
|
+ if bracketdepth != 0: # unbalanced braces
|
|
|
+ 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]]:
|
|
|
+ # sequence -> chain(*sequence_items)
|
|
|
+ start = 0
|
|
|
+ pos = 0
|
|
|
+ bracketdepth = 0
|
|
|
+ items: list[Iterable[str]] = []
|
|
|
+
|
|
|
+ # print 'sequence:', seq
|
|
|
+ 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 # skip the comma
|
|
|
+ pos += 1
|
|
|
+
|
|
|
+ if bracketdepth != 0:
|
|
|
+ raise UnbalancedBracesError
|
|
|
+ if not items:
|
|
|
+ return None
|
|
|
+
|
|
|
+ # part after the last comma (may be the empty string)
|
|
|
+ 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)
|