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.
Files changed (34) hide show
  1. {bracex-2.7 → bracex-3.0}/PKG-INFO +1 -1
  2. {bracex-2.7 → bracex-3.0}/bracex/__init__.py +159 -125
  3. {bracex-2.7 → bracex-3.0}/bracex/__meta__.py +1 -1
  4. {bracex-2.7 → bracex-3.0}/docs/src/markdown/about/changelog.md +8 -0
  5. {bracex-2.7 → bracex-3.0}/docs/src/markdown/index.md +20 -13
  6. {bracex-2.7 → bracex-3.0}/tests/brace-cases.txt +7 -0
  7. {bracex-2.7 → bracex-3.0}/tests/brace-results.txt +9 -1
  8. {bracex-2.7 → bracex-3.0}/tests/generate.sh +1 -1
  9. {bracex-2.7 → bracex-3.0}/tests/test_brace.py +37 -2
  10. {bracex-2.7 → bracex-3.0}/.coveragerc +0 -0
  11. {bracex-2.7 → bracex-3.0}/.gitignore +0 -0
  12. {bracex-2.7 → bracex-3.0}/.pyspelling.yml +0 -0
  13. {bracex-2.7 → bracex-3.0}/LICENSE.md +0 -0
  14. {bracex-2.7 → bracex-3.0}/README.md +0 -0
  15. {bracex-2.7 → bracex-3.0}/bracex/__main__.py +0 -0
  16. {bracex-2.7 → bracex-3.0}/bracex/py.typed +0 -0
  17. {bracex-2.7 → bracex-3.0}/docs/src/markdown/.snippets/abbr.md +0 -0
  18. {bracex-2.7 → bracex-3.0}/docs/src/markdown/.snippets/links.md +0 -0
  19. {bracex-2.7 → bracex-3.0}/docs/src/markdown/.snippets/refs.md +0 -0
  20. {bracex-2.7 → bracex-3.0}/docs/src/markdown/about/contributing.md +0 -0
  21. {bracex-2.7 → bracex-3.0}/docs/src/markdown/about/development.md +0 -0
  22. {bracex-2.7 → bracex-3.0}/docs/src/markdown/about/license.md +0 -0
  23. {bracex-2.7 → bracex-3.0}/docs/theme/announce.html +0 -0
  24. {bracex-2.7 → bracex-3.0}/docs/theme/assets/pymdownx-extras/extra-95634471d6.css +0 -0
  25. {bracex-2.7 → bracex-3.0}/docs/theme/assets/pymdownx-extras/extra-loader-Ccztcqfq.js +0 -0
  26. {bracex-2.7 → bracex-3.0}/docs/theme/main.html +0 -0
  27. {bracex-2.7 → bracex-3.0}/hatch_build.py +0 -0
  28. {bracex-2.7 → bracex-3.0}/pyproject.toml +0 -0
  29. {bracex-2.7 → bracex-3.0}/requirements/docs.txt +0 -0
  30. {bracex-2.7 → bracex-3.0}/requirements/lint.txt +0 -0
  31. {bracex-2.7 → bracex-3.0}/requirements/test.txt +0 -0
  32. {bracex-2.7 → bracex-3.0}/tests/__init__.py +0 -0
  33. {bracex-2.7 → bracex-3.0}/tests/test_main.py +0 -0
  34. {bracex-2.7 → bracex-3.0}/tests/test_versions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bracex
3
- Version: 2.7
3
+ Version: 3.0
4
4
  Summary: Bash style brace expander.
5
5
  Project-URL: Homepage, https://github.com/facelessuser/bracex
6
6
  Author-email: Isaac Muse <Isaac.Muse@gmail.com>
@@ -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(string: AnyStr, keep_escapes: bool = False, limit: int = DEFAULT_LIMIT) -> list[AnyStr]:
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(string: AnyStr, keep_escapes: bool = False, limit: int = DEFAULT_LIMIT) -> Iterator[AnyStr]:
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__(self, keep_escapes: bool = False, limit: int = DEFAULT_LIMIT) -> None:
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 update_count_seq(self, count: list[int]) -> None:
139
- """Update the count from a list after evaluating a brace sequence and assert if count exceeds the max limit."""
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
- def update_count(self, count: int) -> None:
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
- yield ''.join(x) if isinstance(x, tuple) else x
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: Iterable[str]) -> Iterator[str]:
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 get_literals(self, c: str, i: StringIter, depth: int) -> Iterator[str] | None:
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 = iter([''])
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 = self.squash(result, [literal])
260
+ result.append(literal)
234
261
  literal = ''
235
262
 
236
263
  # Try and get the group
237
- index = i.index
238
264
  try:
239
- current_count = self.count
240
- seq = self.get_sequence(next(i), i, depth + 1)
241
- if seq:
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
- # Searched to end of string
252
- # and still didn't find it.
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 in (',', '}'):
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 = self.squash(result, [literal])
281
+ result.append(literal)
262
282
 
263
283
  i.rewind(1)
264
- if count:
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
- if self.is_expanding():
275
- return None
276
-
290
+ # Ensure we store any remaining literals
277
291
  if literal:
278
- # Squash the current set of literals.
279
- result = self.squash(result, [literal])
292
+ result.append(literal)
280
293
 
281
- if count:
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] | None:
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 = iter([]) # type: Iterator[str]
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
- # If there is no comma, we know the sequence is bogus.
325
+ self.release_expanding(release)
326
+
327
+ # Handle empty slot
316
328
  if is_empty:
317
- result = self.chain(result, [''])
329
+ result.append(EMPTY)
318
330
  if has_comma:
319
- self.update_count(1)
331
+ counts.append(1)
332
+
333
+ # Sequence is not valid
320
334
  if not has_comma:
321
- result = (''.join(['{', literal, '}']) for literal in result)
322
- self.release_expanding(release)
323
- return result
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 = self.chain(result, [''])
330
- self.update_count(1)
366
+ result.append(EMPTY)
367
+ counts.append(1)
331
368
  else:
332
369
  is_empty = True
333
370
 
334
371
  else:
335
- if c == '}':
336
- # Top level: If we didn't find a comma, we haven't
337
- # completed the top level group. Request more and
338
- # append to what we already have for the first slot.
339
- if is_empty and not has_comma:
340
- result = self.chain(result, [c])
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 = self.squash(result, [c])
343
- value = self.get_literals(next(i), i, depth)
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
- def get_range(self, i: StringIter) -> Iterator[str] | None:
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
- self.update_count(math.ceil(abs(((last + 1) - first) / inc)))
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
- self.update_count(math.ceil(abs(((first + 1) - last) / inc)))
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
- self.update_count(math.ceil(((last + 1) - first) / inc))
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
- else:
449
- self.update_count(math.ceil(((first + 1) - last) / inc))
450
- return itertools.islice(alpha, last, first + 1, inc)
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
- i = StringIter(string)
460
- value = self.get_literals(next(i), i, 0)
461
- if value is not None:
462
- for x in value:
463
- # We don't want to return trailing empty strings.
464
- # Store empty strings and output only when followed by a literal.
465
- if not x:
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(2, 7, 0, "final")
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
- ```pycon3
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
- ```pycon3
46
+ ```pycon
47
47
  >>> bracex.expand(r'-v{,,}')
48
48
  ['-v', '-v', '-v']
49
49
  ```
50
50
 
51
51
  Nested braces:
52
52
 
53
- ```pycon3
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
- ```pycon3
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
- ```pycon3
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
- ```pycon3
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
- ```pycon3
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
- ```pycon3
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
- ```pycon3
89
+ ```pycon
90
90
  >>> bracex.expand(r'file\{00..10..5}.jpg')
91
91
  ['file{00..10..5}.jpg']
92
92
  ```
93
93
 
94
- ```pycon3
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
- ```pycon3
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 will always return at least a single empty string
121
- `[""]`. By default, escapes will be resolved and the backslashes reduced accordingly, but `keep_escapes` will process
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
@@ -191,3 +191,10 @@ y{},a}x
191
191
  {1..10..-0}
192
192
  {a..d..0}
193
193
  {a..d..-0}
194
+ # empty slot cases
195
+ {,a,b}
196
+ {a,b,}
197
+ {a,,b}
198
+ # Open braces
199
+ x{
200
+ x{{
@@ -1109,4 +1109,12 @@ A{b,{d,e},{f,g}}Z
1109
1109
  [a]
1110
1110
  [b]
1111
1111
  [c]
1112
- [d]><><><><
1112
+ [d]><><><><{,a,b}
1113
+ [a]
1114
+ [b]><><><><{a,b,}
1115
+ [a]
1116
+ [b]><><><><{a,,b}
1117
+ [a]
1118
+ [b]><><><><x{
1119
+ [x{]><><><><x{{
1120
+ [x{{]><><><><
@@ -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-2])(\.\d+)? ]]; then
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