bracex 2.7__tar.gz → 3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bracex-2.7 → bracex-3.0}/PKG-INFO +1 -1
- {bracex-2.7 → bracex-3.0}/bracex/__init__.py +159 -125
- {bracex-2.7 → bracex-3.0}/bracex/__meta__.py +1 -1
- {bracex-2.7 → bracex-3.0}/docs/src/markdown/about/changelog.md +8 -0
- {bracex-2.7 → bracex-3.0}/docs/src/markdown/index.md +20 -13
- {bracex-2.7 → bracex-3.0}/tests/brace-cases.txt +7 -0
- {bracex-2.7 → bracex-3.0}/tests/brace-results.txt +9 -1
- {bracex-2.7 → bracex-3.0}/tests/generate.sh +1 -1
- {bracex-2.7 → bracex-3.0}/tests/test_brace.py +37 -2
- {bracex-2.7 → bracex-3.0}/.coveragerc +0 -0
- {bracex-2.7 → bracex-3.0}/.gitignore +0 -0
- {bracex-2.7 → bracex-3.0}/.pyspelling.yml +0 -0
- {bracex-2.7 → bracex-3.0}/LICENSE.md +0 -0
- {bracex-2.7 → bracex-3.0}/README.md +0 -0
- {bracex-2.7 → bracex-3.0}/bracex/__main__.py +0 -0
- {bracex-2.7 → bracex-3.0}/bracex/py.typed +0 -0
- {bracex-2.7 → bracex-3.0}/docs/src/markdown/.snippets/abbr.md +0 -0
- {bracex-2.7 → bracex-3.0}/docs/src/markdown/.snippets/links.md +0 -0
- {bracex-2.7 → bracex-3.0}/docs/src/markdown/.snippets/refs.md +0 -0
- {bracex-2.7 → bracex-3.0}/docs/src/markdown/about/contributing.md +0 -0
- {bracex-2.7 → bracex-3.0}/docs/src/markdown/about/development.md +0 -0
- {bracex-2.7 → bracex-3.0}/docs/src/markdown/about/license.md +0 -0
- {bracex-2.7 → bracex-3.0}/docs/theme/announce.html +0 -0
- {bracex-2.7 → bracex-3.0}/docs/theme/assets/pymdownx-extras/extra-95634471d6.css +0 -0
- {bracex-2.7 → bracex-3.0}/docs/theme/assets/pymdownx-extras/extra-loader-Ccztcqfq.js +0 -0
- {bracex-2.7 → bracex-3.0}/docs/theme/main.html +0 -0
- {bracex-2.7 → bracex-3.0}/hatch_build.py +0 -0
- {bracex-2.7 → bracex-3.0}/pyproject.toml +0 -0
- {bracex-2.7 → bracex-3.0}/requirements/docs.txt +0 -0
- {bracex-2.7 → bracex-3.0}/requirements/lint.txt +0 -0
- {bracex-2.7 → bracex-3.0}/requirements/test.txt +0 -0
- {bracex-2.7 → bracex-3.0}/tests/__init__.py +0 -0
- {bracex-2.7 → bracex-3.0}/tests/test_main.py +0 -0
- {bracex-2.7 → bracex-3.0}/tests/test_versions.py +0 -0
|
@@ -22,10 +22,9 @@ from __future__ import annotations
|
|
|
22
22
|
import itertools
|
|
23
23
|
import math
|
|
24
24
|
import re
|
|
25
|
-
from typing import Iterator, Pattern, Match, Iterable, AnyStr
|
|
25
|
+
from typing import Iterator, Pattern, Match, Iterable, AnyStr, Any
|
|
26
26
|
from . import __meta__
|
|
27
27
|
|
|
28
|
-
|
|
29
28
|
__all__ = ('expand', 'iexpand')
|
|
30
29
|
|
|
31
30
|
__version__ = __meta__.__version__
|
|
@@ -40,24 +39,41 @@ RE_CHR_ITER = re.compile(r'([A-Za-z])\.{2}([A-Za-z])(?:\.{2}(-?\d+))?(?=\})')
|
|
|
40
39
|
DEFAULT_LIMIT = 1000
|
|
41
40
|
|
|
42
41
|
|
|
42
|
+
class Sentinel(str):
|
|
43
|
+
"""A sentinel string value."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
EMPTY = Sentinel('')
|
|
47
|
+
|
|
48
|
+
|
|
43
49
|
class ExpansionLimitException(Exception):
|
|
44
50
|
"""Brace expansion limit exception."""
|
|
45
51
|
|
|
46
52
|
|
|
47
|
-
def expand(
|
|
53
|
+
def expand(
|
|
54
|
+
string: AnyStr,
|
|
55
|
+
keep_escapes: bool = False,
|
|
56
|
+
limit: int = DEFAULT_LIMIT,
|
|
57
|
+
return_empty: bool = False
|
|
58
|
+
) -> list[AnyStr]:
|
|
48
59
|
"""Expand braces."""
|
|
49
60
|
|
|
50
|
-
return list(iexpand(string, keep_escapes, limit))
|
|
61
|
+
return list(iexpand(string, keep_escapes, limit, return_empty))
|
|
51
62
|
|
|
52
63
|
|
|
53
|
-
def iexpand(
|
|
64
|
+
def iexpand(
|
|
65
|
+
string: AnyStr,
|
|
66
|
+
keep_escapes: bool = False,
|
|
67
|
+
limit: int = DEFAULT_LIMIT,
|
|
68
|
+
return_empty: bool = False
|
|
69
|
+
) -> Iterator[AnyStr]:
|
|
54
70
|
"""Expand braces and return an iterator."""
|
|
55
71
|
|
|
56
72
|
if isinstance(string, bytes):
|
|
57
|
-
for entry in ExpandBrace(keep_escapes, limit).expand(string.decode('latin-1')):
|
|
73
|
+
for entry in ExpandBrace(keep_escapes, limit, return_empty).expand(string.decode('latin-1')):
|
|
58
74
|
yield entry.encode('latin-1')
|
|
59
75
|
else:
|
|
60
|
-
for entry in ExpandBrace(keep_escapes, limit).expand(string):
|
|
76
|
+
for entry in ExpandBrace(keep_escapes, limit, return_empty).expand(string):
|
|
61
77
|
yield entry
|
|
62
78
|
|
|
63
79
|
|
|
@@ -127,32 +143,27 @@ class StringIter:
|
|
|
127
143
|
class ExpandBrace:
|
|
128
144
|
"""Expand braces like in Bash."""
|
|
129
145
|
|
|
130
|
-
def __init__(
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
keep_escapes: bool = False,
|
|
149
|
+
limit: int = DEFAULT_LIMIT,
|
|
150
|
+
return_empty: bool = False
|
|
151
|
+
) -> None:
|
|
131
152
|
"""Initialize."""
|
|
132
153
|
|
|
133
154
|
self.max_limit = limit
|
|
134
|
-
self.count = 0
|
|
135
155
|
self.expanding = False
|
|
136
156
|
self.keep_escapes = keep_escapes
|
|
157
|
+
self.return_empty = return_empty
|
|
137
158
|
|
|
138
|
-
def
|
|
139
|
-
"""
|
|
140
|
-
|
|
141
|
-
self.count -= sum(count)
|
|
142
|
-
prod = 1
|
|
143
|
-
for c in count:
|
|
144
|
-
prod *= c
|
|
145
|
-
self.update_count(prod)
|
|
159
|
+
def account(self, count: int) -> int:
|
|
160
|
+
"""Ensure count is not exceeding the expectation."""
|
|
146
161
|
|
|
147
|
-
|
|
148
|
-
"""Update the count and assert if count exceeds the max limit."""
|
|
149
|
-
|
|
150
|
-
self.count += count
|
|
151
|
-
|
|
152
|
-
if self.max_limit > 0 and self.count > self.max_limit:
|
|
162
|
+
if self.max_limit > 0 and count > self.max_limit:
|
|
153
163
|
raise ExpansionLimitException(
|
|
154
164
|
f'Brace expansion has exceeded the limit of {self.max_limit:d}'
|
|
155
165
|
)
|
|
166
|
+
return count
|
|
156
167
|
|
|
157
168
|
def set_expanding(self) -> bool:
|
|
158
169
|
"""Set that we are expanding a sequence, and return whether a release is required by the caller."""
|
|
@@ -192,15 +203,33 @@ class ExpandBrace:
|
|
|
192
203
|
"""
|
|
193
204
|
|
|
194
205
|
for x in itertools.product(a, b):
|
|
195
|
-
|
|
206
|
+
if all(i is EMPTY for i in x):
|
|
207
|
+
yield EMPTY
|
|
208
|
+
else:
|
|
209
|
+
yield ''.join(x)
|
|
196
210
|
|
|
197
|
-
def chain(self, *iterables:
|
|
211
|
+
def chain(self, *iterables: Any) -> Iterator[str]:
|
|
198
212
|
"""Chain iterables."""
|
|
199
213
|
|
|
200
214
|
for iterable in iterables:
|
|
201
215
|
yield from iterable
|
|
202
216
|
|
|
203
|
-
def
|
|
217
|
+
def flatten(self, iterables: Any) -> Iterator[str]:
|
|
218
|
+
"""Flatten out results."""
|
|
219
|
+
|
|
220
|
+
for item in iterables:
|
|
221
|
+
if isinstance(item, list):
|
|
222
|
+
yield from self.flatten(item)
|
|
223
|
+
continue
|
|
224
|
+
yield item
|
|
225
|
+
|
|
226
|
+
def get_literals(
|
|
227
|
+
self,
|
|
228
|
+
c: str,
|
|
229
|
+
i: StringIter,
|
|
230
|
+
depth: int,
|
|
231
|
+
ignore_end: bool = False
|
|
232
|
+
) -> tuple[list[str | Iterator[str]], int]:
|
|
204
233
|
"""
|
|
205
234
|
Get a string literal.
|
|
206
235
|
|
|
@@ -208,11 +237,9 @@ class ExpandBrace:
|
|
|
208
237
|
Also gather chars between braces and commas within a group (is_expanding).
|
|
209
238
|
"""
|
|
210
239
|
|
|
211
|
-
result =
|
|
240
|
+
result = [] # type: list[str | Iterator[str]]
|
|
212
241
|
is_dollar = False
|
|
213
|
-
|
|
214
|
-
count = True
|
|
215
|
-
seq_count = []
|
|
242
|
+
count = 1
|
|
216
243
|
literal = ''
|
|
217
244
|
|
|
218
245
|
try:
|
|
@@ -230,61 +257,43 @@ class ExpandBrace:
|
|
|
230
257
|
elif not ignore_brace and c == '{':
|
|
231
258
|
|
|
232
259
|
if literal:
|
|
233
|
-
result
|
|
260
|
+
result.append(literal)
|
|
234
261
|
literal = ''
|
|
235
262
|
|
|
236
263
|
# Try and get the group
|
|
237
|
-
index = i.index
|
|
238
264
|
try:
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if self.max_limit > 0:
|
|
243
|
-
diff = self.count - current_count
|
|
244
|
-
seq_count.append(diff)
|
|
245
|
-
count = False
|
|
246
|
-
value = seq
|
|
247
|
-
result = self.squash(result, value)
|
|
248
|
-
else:
|
|
249
|
-
literal += c
|
|
265
|
+
seq, scount = self.get_sequence(next(i), i, depth + 1)
|
|
266
|
+
count *= scount
|
|
267
|
+
result.append(seq)
|
|
250
268
|
except StopIteration:
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
i.rewind(i.index - index)
|
|
269
|
+
# There are no characters after `{`
|
|
270
|
+
# Save `{` and stop parsing.
|
|
254
271
|
literal += c
|
|
272
|
+
raise
|
|
255
273
|
|
|
256
|
-
elif self.is_expanding() and c
|
|
274
|
+
elif self.is_expanding() and (c == ',' or (c == '}' and not ignore_end)):
|
|
257
275
|
# We are Expanding within a group and found a group delimiter
|
|
258
276
|
# Return what we gathered before the group delimiters.
|
|
259
277
|
|
|
278
|
+
ignore_end = False
|
|
279
|
+
|
|
260
280
|
if literal:
|
|
261
|
-
result
|
|
281
|
+
result.append(literal)
|
|
262
282
|
|
|
263
283
|
i.rewind(1)
|
|
264
|
-
|
|
265
|
-
self.update_count(1)
|
|
266
|
-
else:
|
|
267
|
-
self.update_count_seq(seq_count)
|
|
268
|
-
return result
|
|
284
|
+
break
|
|
269
285
|
else:
|
|
270
286
|
literal += c
|
|
271
287
|
|
|
272
288
|
c = next(i)
|
|
273
289
|
except StopIteration:
|
|
274
|
-
|
|
275
|
-
return None
|
|
276
|
-
|
|
290
|
+
# Ensure we store any remaining literals
|
|
277
291
|
if literal:
|
|
278
|
-
|
|
279
|
-
result = self.squash(result, [literal])
|
|
292
|
+
result.append(literal)
|
|
280
293
|
|
|
281
|
-
|
|
282
|
-
self.update_count(1)
|
|
283
|
-
else:
|
|
284
|
-
self.update_count_seq(seq_count)
|
|
285
|
-
return result
|
|
294
|
+
return result, self.account(count)
|
|
286
295
|
|
|
287
|
-
def get_sequence(self, c: str, i: StringIter, depth: int) -> Iterator[str]
|
|
296
|
+
def get_sequence(self, c: str, i: StringIter, depth: int) -> tuple[Iterator[str], int]:
|
|
288
297
|
"""
|
|
289
298
|
Get the sequence.
|
|
290
299
|
|
|
@@ -292,18 +301,19 @@ class ExpandBrace:
|
|
|
292
301
|
It will basically crawl to the end or find a valid series.
|
|
293
302
|
"""
|
|
294
303
|
|
|
295
|
-
result =
|
|
304
|
+
result = [] # type: list[str | Iterator[str] | Iterable[str | Iterator[str]]]
|
|
296
305
|
release = self.set_expanding()
|
|
297
306
|
has_comma = False # Used to indicate validity of group (`{1..2}` are an exception).
|
|
298
307
|
is_empty = True # Tracks whether the current slot is empty `{slot,slot,slot}`.
|
|
308
|
+
counts = []
|
|
299
309
|
|
|
300
310
|
# Detect numerical and alphabetic series: `{1..2}` etc.
|
|
301
311
|
i.rewind(1)
|
|
302
|
-
item = self.get_range(i)
|
|
312
|
+
item, count = self.get_range(i)
|
|
303
313
|
i.advance(1)
|
|
304
314
|
if item is not None:
|
|
305
315
|
self.release_expanding(release)
|
|
306
|
-
return item
|
|
316
|
+
return item, self.account(count)
|
|
307
317
|
|
|
308
318
|
try:
|
|
309
319
|
while True:
|
|
@@ -312,52 +322,79 @@ class ExpandBrace:
|
|
|
312
322
|
# completely ignored.
|
|
313
323
|
keep_looking = depth == 1 and not has_comma
|
|
314
324
|
if (c == '}' and (not keep_looking or i.index == 2)):
|
|
315
|
-
|
|
325
|
+
self.release_expanding(release)
|
|
326
|
+
|
|
327
|
+
# Handle empty slot
|
|
316
328
|
if is_empty:
|
|
317
|
-
result
|
|
329
|
+
result.append(EMPTY)
|
|
318
330
|
if has_comma:
|
|
319
|
-
|
|
331
|
+
counts.append(1)
|
|
332
|
+
|
|
333
|
+
# Sequence is not valid
|
|
320
334
|
if not has_comma:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
335
|
+
count = 1
|
|
336
|
+
temp = iter(['{'])
|
|
337
|
+
for r in self.flatten(result):
|
|
338
|
+
temp = self.squash(temp, [r] if isinstance(r, str) else r)
|
|
339
|
+
temp = self.squash(temp, ['}'])
|
|
340
|
+
return temp, self.account(math.prod(counts, start=1))
|
|
341
|
+
|
|
342
|
+
# Format return for a sequence
|
|
343
|
+
fin = iter([]) # type: Iterator[str]
|
|
344
|
+
start = 0
|
|
345
|
+
l = len(result)
|
|
346
|
+
for e, x in enumerate(result):
|
|
347
|
+
if not isinstance(x, str):
|
|
348
|
+
if e != start:
|
|
349
|
+
fin = self.chain(fin, result[start:e])
|
|
350
|
+
if isinstance(x, list):
|
|
351
|
+
temp = iter([EMPTY])
|
|
352
|
+
for y in x:
|
|
353
|
+
temp = self.squash(temp, [y] if isinstance(y, str) else y)
|
|
354
|
+
fin = self.chain(fin, temp)
|
|
355
|
+
else:
|
|
356
|
+
fin = self.chain(fin, result[e])
|
|
357
|
+
start = e + 1
|
|
358
|
+
if start < l:
|
|
359
|
+
fin = self.chain(fin, result[start:])
|
|
360
|
+
return fin, self.account(sum(counts))
|
|
324
361
|
|
|
325
362
|
elif c == ',':
|
|
326
363
|
# Must be the first element in the list.
|
|
327
364
|
has_comma = True
|
|
328
365
|
if is_empty:
|
|
329
|
-
result
|
|
330
|
-
|
|
366
|
+
result.append(EMPTY)
|
|
367
|
+
counts.append(1)
|
|
331
368
|
else:
|
|
332
369
|
is_empty = True
|
|
333
370
|
|
|
334
371
|
else:
|
|
335
|
-
if
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if
|
|
340
|
-
result
|
|
372
|
+
# Lower level: Try to find group, but give up if cannot acquire.
|
|
373
|
+
value, lcount = self.get_literals(c, i, depth, keep_looking)
|
|
374
|
+
counts.append(lcount)
|
|
375
|
+
if value is not None:
|
|
376
|
+
if len(value) > 1:
|
|
377
|
+
result.append(value)
|
|
341
378
|
else:
|
|
342
|
-
result
|
|
343
|
-
|
|
344
|
-
if value is not None:
|
|
345
|
-
result = self.squash(result, value)
|
|
346
|
-
is_empty = False
|
|
347
|
-
else:
|
|
348
|
-
# Lower level: Try to find group, but give up if cannot acquire.
|
|
349
|
-
value = self.get_literals(c, i, depth)
|
|
350
|
-
if value is not None:
|
|
351
|
-
result = self.chain(result, value)
|
|
352
|
-
is_empty = False
|
|
379
|
+
result.extend(value)
|
|
380
|
+
is_empty = False
|
|
353
381
|
|
|
354
382
|
c = next(i)
|
|
355
383
|
|
|
356
384
|
except StopIteration:
|
|
357
385
|
self.release_expanding(release)
|
|
358
|
-
raise
|
|
359
386
|
|
|
360
|
-
|
|
387
|
+
# Sequence is not valid
|
|
388
|
+
temp2 = iter(['{']) # type: Iterator[str]
|
|
389
|
+
l = len(result)
|
|
390
|
+
last_str = False
|
|
391
|
+
for r in self.flatten(result):
|
|
392
|
+
is_str = isinstance(r, str)
|
|
393
|
+
temp2 = self.squash(temp2, [(',' if last_str else '') + r] if is_str else r)
|
|
394
|
+
last_str = is_str
|
|
395
|
+
return temp2, self.account(math.prod(counts, start=1))
|
|
396
|
+
|
|
397
|
+
def get_range(self, i: StringIter) -> tuple[Iterator[str] | None, int]:
|
|
361
398
|
"""
|
|
362
399
|
Check and retrieve range if value is a valid range.
|
|
363
400
|
|
|
@@ -373,8 +410,6 @@ class ExpandBrace:
|
|
|
373
410
|
m = i.match(RE_CHR_ITER)
|
|
374
411
|
if m:
|
|
375
412
|
return self.get_char_range(*m.groups())
|
|
376
|
-
except ExpansionLimitException:
|
|
377
|
-
raise
|
|
378
413
|
except Exception: # pragma: no cover
|
|
379
414
|
# TODO: We really should never fail here,
|
|
380
415
|
# but if we do, assume the sequence range
|
|
@@ -382,7 +417,7 @@ class ExpandBrace:
|
|
|
382
417
|
# be removed in the future with more testing.
|
|
383
418
|
pass
|
|
384
419
|
|
|
385
|
-
return None
|
|
420
|
+
return None, 0
|
|
386
421
|
|
|
387
422
|
def format_values(self, values: Iterable[int], padding: int) -> Iterator[str]:
|
|
388
423
|
"""Get padding adjusting for negative values."""
|
|
@@ -390,7 +425,7 @@ class ExpandBrace:
|
|
|
390
425
|
for value in values:
|
|
391
426
|
yield "{:0{pad}d}".format(value, pad=padding) if padding else str(value)
|
|
392
427
|
|
|
393
|
-
def get_int_range(self, start: str, end: str, increment: str | None = None) -> Iterator[str]:
|
|
428
|
+
def get_int_range(self, start: str, end: str, increment: str | None = None) -> tuple[Iterator[str], int]:
|
|
394
429
|
"""Get an integer range between start and end and increments of increment."""
|
|
395
430
|
|
|
396
431
|
first, last = int(start), int(end)
|
|
@@ -415,15 +450,15 @@ class ExpandBrace:
|
|
|
415
450
|
padding = 0
|
|
416
451
|
|
|
417
452
|
if first < last:
|
|
418
|
-
|
|
453
|
+
count = math.ceil(abs(((last + 1) - first) / inc))
|
|
419
454
|
r = range(first, last + 1, -inc if inc < 0 else inc)
|
|
420
455
|
else:
|
|
421
|
-
|
|
456
|
+
count = math.ceil(abs(((first + 1) - last) / inc))
|
|
422
457
|
r = range(first, last - 1, inc if inc < 0 else -inc)
|
|
423
458
|
|
|
424
|
-
return self.format_values(r, padding)
|
|
459
|
+
return self.format_values(r, padding), count
|
|
425
460
|
|
|
426
|
-
def get_char_range(self, start: str, end: str, increment: str | None = None) -> Iterator[str]:
|
|
461
|
+
def get_char_range(self, start: str, end: str, increment: str | None = None) -> tuple[Iterator[str], int]:
|
|
427
462
|
"""Get a range of alphabetic characters."""
|
|
428
463
|
|
|
429
464
|
inc = int(increment) if increment else 1
|
|
@@ -442,35 +477,34 @@ class ExpandBrace:
|
|
|
442
477
|
last = alpha.index(end)
|
|
443
478
|
|
|
444
479
|
if first < last:
|
|
445
|
-
|
|
446
|
-
return itertools.islice(alpha, first, last + 1, inc)
|
|
480
|
+
count = math.ceil(((last + 1) - first) / inc)
|
|
481
|
+
return itertools.islice(alpha, first, last + 1, inc), count
|
|
482
|
+
count = math.ceil(((first + 1) - last) / inc)
|
|
483
|
+
return itertools.islice(alpha, last, first + 1, inc), count
|
|
447
484
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
485
|
+
def expand_str(self, string: str) -> Iterator[str]:
|
|
486
|
+
"""Expand the string."""
|
|
487
|
+
|
|
488
|
+
i = StringIter(string)
|
|
489
|
+
values, _ = self.get_literals(next(i), i, 0)
|
|
490
|
+
|
|
491
|
+
# Squash the nested list by calculating the combinations
|
|
492
|
+
results = iter([EMPTY]) # type: Iterator[str]
|
|
493
|
+
for v in values:
|
|
494
|
+
results = self.squash(results, [v] if isinstance(v, str) else v)
|
|
495
|
+
return results
|
|
451
496
|
|
|
452
497
|
def expand(self, string: str) -> Iterator[str]:
|
|
453
498
|
"""Expand."""
|
|
454
499
|
|
|
455
500
|
self.expanding = False
|
|
456
|
-
empties = []
|
|
457
501
|
found_literal = False
|
|
458
502
|
if string:
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
empties.append(x)
|
|
467
|
-
continue
|
|
468
|
-
found_literal = True
|
|
469
|
-
while empties:
|
|
470
|
-
yield empties.pop(0)
|
|
471
|
-
yield x
|
|
472
|
-
empties = []
|
|
473
|
-
|
|
474
|
-
# We found no literals so return an empty string
|
|
475
|
-
if not found_literal:
|
|
503
|
+
for x in self.expand_str(string):
|
|
504
|
+
if x is EMPTY:
|
|
505
|
+
continue
|
|
506
|
+
found_literal = True
|
|
507
|
+
yield x
|
|
508
|
+
|
|
509
|
+
if not found_literal and self.return_empty:
|
|
476
510
|
yield ""
|
|
@@ -193,5 +193,5 @@ def parse_version(ver: str) -> Version:
|
|
|
193
193
|
return Version(major, minor, micro, release, pre, post, dev)
|
|
194
194
|
|
|
195
195
|
|
|
196
|
-
__version_info__ = Version(
|
|
196
|
+
__version_info__ = Version(3, 0, 0, "final")
|
|
197
197
|
__version__ = __version_info__._get_canonical()
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changes
|
|
2
2
|
|
|
3
|
+
## 3.0
|
|
4
|
+
|
|
5
|
+
- **BREAK**: If there are no expansions, `bracex` will not return an empty string by default in the list. This matches
|
|
6
|
+
Bash. To get the old behavior, enable the new `return_empty` parameter to always return at least an empty string if
|
|
7
|
+
expansion yields no expansions.
|
|
8
|
+
- **FIX**: To better match Bash, empty expansions are not returned as empty strings.
|
|
9
|
+
- **FIX**: No longer backtrack if a sequence is evaluated and is deemed to be invalid.
|
|
10
|
+
|
|
3
11
|
## 2.7
|
|
4
12
|
|
|
5
13
|
- **NEW**: Drop support for Python 3.9.
|
|
@@ -12,7 +12,7 @@ ad bd cd
|
|
|
12
12
|
|
|
13
13
|
Bracex adds this ability to Python:
|
|
14
14
|
|
|
15
|
-
```
|
|
15
|
+
```pycon
|
|
16
16
|
>>> bracex.expand(r'file-{1,2,3}.txt')
|
|
17
17
|
['file-1.txt', 'file-2.txt', 'file-3.txt']
|
|
18
18
|
```
|
|
@@ -43,62 +43,62 @@ base/
|
|
|
43
43
|
|
|
44
44
|
More Examples:
|
|
45
45
|
|
|
46
|
-
```
|
|
46
|
+
```pycon
|
|
47
47
|
>>> bracex.expand(r'-v{,,}')
|
|
48
48
|
['-v', '-v', '-v']
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
Nested braces:
|
|
52
52
|
|
|
53
|
-
```
|
|
53
|
+
```pycon
|
|
54
54
|
>>> bracex.expand(r'file-{{a,b},c}d.txt')
|
|
55
55
|
['file-ad.txt', 'file-bd.txt', 'file-cd.txt']
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
Numerical sequences:
|
|
59
59
|
|
|
60
|
-
```
|
|
60
|
+
```pycon
|
|
61
61
|
>>> bracex.expand(r'file{0..3}.txt')
|
|
62
62
|
['file0.txt', 'file1.txt', 'file2.txt', 'file3.txt']
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
```
|
|
65
|
+
```pycon
|
|
66
66
|
>>> bracex.expand(r'file{0..6..2}.txt')
|
|
67
67
|
['file0.txt', 'file2.txt', 'file4.txt', 'file6.txt']
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
```
|
|
70
|
+
```pycon
|
|
71
71
|
>>> bracex.expand(r'file{00..10..5}.jpg')
|
|
72
72
|
['file00.jpg', 'file05.jpg', 'file10.jpg']
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
Alphabetic sequences:
|
|
76
76
|
|
|
77
|
-
```
|
|
77
|
+
```pycon
|
|
78
78
|
>>> bracex.expand(r'file{A..D}.txt')
|
|
79
79
|
['fileA.txt', 'fileB.txt', 'fileC.txt', 'fileD.txt']
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
```
|
|
82
|
+
```pycon
|
|
83
83
|
>>> bracex.expand(r'file{A..G..2}.txt')
|
|
84
84
|
['fileA.txt', 'fileC.txt', 'fileE.txt', 'fileG.txt']
|
|
85
85
|
```
|
|
86
86
|
|
|
87
87
|
Allows escaping:
|
|
88
88
|
|
|
89
|
-
```
|
|
89
|
+
```pycon
|
|
90
90
|
>>> bracex.expand(r'file\{00..10..5}.jpg')
|
|
91
91
|
['file{00..10..5}.jpg']
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
```
|
|
94
|
+
```pycon
|
|
95
95
|
>>> bracex.expand(r'file\{00..10..5}.jpg', keep_escapes=True)
|
|
96
96
|
['file\\{00..10..5}.jpg']
|
|
97
97
|
```
|
|
98
98
|
|
|
99
99
|
Bracex will **not** expand braces in the form of `${...}`:
|
|
100
100
|
|
|
101
|
-
```
|
|
101
|
+
```pycon
|
|
102
102
|
>>> bracex.expand(r'file${a,b,c}.jpg')
|
|
103
103
|
['file${a,b,c}.jpg']
|
|
104
104
|
```
|
|
@@ -117,8 +117,15 @@ $ pip install bracex
|
|
|
117
117
|
def expand(string, keep_escapes=False, limit=1000):
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
-
`expand` accepts a string and returns a list of expanded strings. It
|
|
121
|
-
|
|
120
|
+
`expand` accepts a string and returns a list of expanded strings. It expansions are all empty, an empty array will be
|
|
121
|
+
returned. If it is desired to always at least have an empty string, `return_empty` can be be enabled to always return
|
|
122
|
+
at least `[""]`.
|
|
123
|
+
|
|
124
|
+
> [!note] 3.0 Change in Behavior
|
|
125
|
+
> Prior to 3.0, `expand` always returned at least `[""]`, even if all expansions were empty. Now `return_empty` must be
|
|
126
|
+
> enabled to restore this behavior.
|
|
127
|
+
|
|
128
|
+
By default, escapes will be resolved and the backslashes reduced accordingly, but `keep_escapes` will process
|
|
122
129
|
the escapes without stripping them out.
|
|
123
130
|
|
|
124
131
|
By default, brace expansion growth is limited to `1000`. This limit can be configured via the `limit` option. If you
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
set -e
|
|
4
4
|
|
|
5
5
|
# Bash 4.3 - 5.0 because of arbitrary need to pick a single standard.
|
|
6
|
-
if ! [[ "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}" =~ (4\.[^1-2]|5\.[0-
|
|
6
|
+
if ! [[ "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}" =~ (4\.[^1-2]|5\.[0-3])(\.\d+)? ]]; then
|
|
7
7
|
echo "this script requires bash 4.3, 4.4, or 5.0" >&2
|
|
8
8
|
exit 1
|
|
9
9
|
fi
|
|
@@ -55,8 +55,8 @@ class TestBraces:
|
|
|
55
55
|
|
|
56
56
|
empty_cases = [
|
|
57
57
|
['-v{,,,,}', ['-v', '-v', '-v', '-v', '-v']],
|
|
58
|
-
['{,,}', [
|
|
59
|
-
['', [
|
|
58
|
+
['{,,}', []],
|
|
59
|
+
['', []]
|
|
60
60
|
]
|
|
61
61
|
|
|
62
62
|
negative_incr_cases = [
|
|
@@ -223,6 +223,34 @@ class TestMisc(unittest.TestCase):
|
|
|
223
223
|
finally:
|
|
224
224
|
sys.setrecursionlimit(old)
|
|
225
225
|
|
|
226
|
+
def test_unbalanced_braces_parse_is_not_exponential(self):
|
|
227
|
+
"""
|
|
228
|
+
Parsing N unmatched '{' must not invoke the parser 2**(N-1) times.
|
|
229
|
+
|
|
230
|
+
A bounded parser is roughly linear in input length.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
EB = bracex.ExpandBrace
|
|
234
|
+
original = EB.get_sequence
|
|
235
|
+
calls = [0]
|
|
236
|
+
|
|
237
|
+
def counting(self, *args, **kwargs):
|
|
238
|
+
calls[0] += 1
|
|
239
|
+
return original(self, *args, **kwargs)
|
|
240
|
+
|
|
241
|
+
EB.get_sequence = counting
|
|
242
|
+
try:
|
|
243
|
+
n = 16
|
|
244
|
+
bracex.expand("{" * n)
|
|
245
|
+
assert calls[0] < 100 * n, calls[0] # 2**15 == 32768 on a vulnerable build
|
|
246
|
+
finally:
|
|
247
|
+
EB.get_sequence = original
|
|
248
|
+
|
|
249
|
+
def test_empty_return(self):
|
|
250
|
+
"""Return at least `[""]` if there are no expansions."""
|
|
251
|
+
|
|
252
|
+
self.assertEqual(bracex.expand('{,,,}', return_empty=True), [''])
|
|
253
|
+
|
|
226
254
|
|
|
227
255
|
class TestExpansionLimit(unittest.TestCase):
|
|
228
256
|
"""Test brace expansion limit."""
|
|
@@ -463,6 +491,13 @@ class TestExpansionLimit(unittest.TestCase):
|
|
|
463
491
|
with self.assertRaises(bracex.ExpansionLimitException):
|
|
464
492
|
bracex.expand("{1..50}{" + "," * 30 + "}", limit=1000) # 1550 > 1000
|
|
465
493
|
|
|
494
|
+
def test_bogus_group_does_not_bypass_limit(self):
|
|
495
|
+
"""64 expansions at limit=63 must raise despite bogus group."""
|
|
496
|
+
|
|
497
|
+
self.assertEqual(len(bracex.expand("{12..9}{b}{3..6}{e..b}", limit=64)), 64)
|
|
498
|
+
with pytest.raises(bracex.ExpansionLimitException):
|
|
499
|
+
bracex.expand("{12..9}{b}{3..6}{e..b}", limit=63)
|
|
500
|
+
|
|
466
501
|
def test_many_commas_do_not_crash_the_interpreter(self):
|
|
467
502
|
"""
|
|
468
503
|
A deeply nested `itertools.chain` must not overflow the C stack.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|