Skip to content

Commit 3d79ce9

Browse files
authored
Merge pull request #61 from python-parsy/until_method_rebased
Added method 'until'
2 parents a8cc92b + 7d3f31a commit 3d79ce9

File tree

6 files changed

+126
-2
lines changed

6 files changed

+126
-2
lines changed

RELEASE.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ How to do releases
1919

2020
* Release to PyPI::
2121

22-
$ ./release.sh
22+
$ ./release.sh
2323

2424

2525
Post release

docs/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@ help:
1717
# Catch-all target: route all unknown targets to Sphinx using the new
1818
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
1919
%: Makefile
20-
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
20+
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

docs/history.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ History and release notes
88
----------------
99

1010
* Dropped support for Python < 3.6
11+
* Added :meth:`Parsy.until`. Thanks `@mcdeoliveira <https://github.com/mcdeoliveira>`_!
1112

1213
1.4.0 - 2021-11-15
1314
------------------

docs/ref/methods_and_combinators.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,25 @@ can be used and manipulated as below.
111111
Returns a parser that expects the initial parser at least ``n`` times, and
112112
produces a list of the results.
113113

114+
.. method:: until(other_parser, [min=0, max=inf, consume_other=False])
115+
116+
Returns a parser that expects the initial parser followed by ``other_parser``.
117+
The initial parser is expected at least ``min`` times and at most ``max`` times.
118+
By default, it does not consume ``other_parser`` and it produces a list of the
119+
results excluding ``other_parser``. If ``consume_other`` is ``True`` then
120+
``other_parser`` is consumed and its result is included in the list of results.
121+
122+
.. code:: python
123+
124+
>>> seq(string('A').until(string('B')), string('BC')).parse('AAABC')
125+
[['A','A','A'], 'BC']
126+
>>> string('A').until(string('B')).then(string('BC')).parse('AAABC')
127+
'BC'
128+
>>> string('A').until(string('BC'), consume_other=True).parse('AAABC')
129+
['A', 'A', 'A', 'BC']
130+
131+
.. versionadded:: 2.0
132+
114133
.. method:: optional()
115134

116135
Returns a parser that expects the initial parser zero or once, and maps

src/parsy/__init__.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,43 @@ def at_least(self, n):
181181
def optional(self):
182182
return self.times(0, 1).map(lambda v: v[0] if v else None)
183183

184+
def until(self, other, min=0, max=float("inf"), consume_other=False):
185+
@Parser
186+
def until_parser(stream, index):
187+
values = []
188+
times = 0
189+
while True:
190+
191+
# try parser first
192+
res = other(stream, index)
193+
if res.status and times >= min:
194+
if consume_other:
195+
# consume other
196+
values.append(res.value)
197+
index = res.index
198+
return Result.success(index, values)
199+
200+
# exceeded max?
201+
if times >= max:
202+
# return failure, it matched parser more than max times
203+
return Result.failure(index, f"at most {max} items")
204+
205+
# failed, try parser
206+
result = self(stream, index)
207+
if result.status:
208+
# consume
209+
values.append(result.value)
210+
index = result.index
211+
times += 1
212+
elif times >= min:
213+
# return failure, parser is not followed by other
214+
return Result.failure(index, "did not find other parser")
215+
else:
216+
# return failure, it did not match parser at least min times
217+
return Result.failure(index, f"at least {min} items; got {times} item(s)")
218+
219+
return until_parser
220+
184221
def sep_by(self, sep, *, min=0, max=float("inf")):
185222
zero_times = success([])
186223
if max == 0:

tests/test_parsy.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,73 @@ def test_at_most(self):
347347
self.assertEqual(ab.at_most(2).parse("abab"), ["ab", "ab"])
348348
self.assertRaises(ParseError, ab.at_most(2).parse, "ababab")
349349

350+
def test_until(self):
351+
352+
until = string("s").until(string("x"))
353+
354+
s = "ssssx"
355+
self.assertEqual(until.parse_partial(s), (4 * ["s"], "x"))
356+
self.assertEqual(seq(until, string("x")).parse(s), [4 * ["s"], "x"])
357+
self.assertEqual(until.then(string("x")).parse(s), "x")
358+
359+
s = "ssssxy"
360+
self.assertEqual(until.parse_partial(s), (4 * ["s"], "xy"))
361+
self.assertEqual(seq(until, string("x")).parse_partial(s), ([4 * ["s"], "x"], "y"))
362+
self.assertEqual(until.then(string("x")).parse_partial(s), ("x", "y"))
363+
364+
self.assertRaises(ParseError, until.parse, "ssssy")
365+
self.assertRaises(ParseError, until.parse, "xssssxy")
366+
367+
self.assertEqual(until.parse_partial("xxx"), ([], "xxx"))
368+
369+
until = regex(".").until(string("x"))
370+
self.assertEqual(until.parse_partial("xxxx"), ([], "xxxx"))
371+
372+
def test_until_with_consume_other(self):
373+
374+
until = string("s").until(string("x"), consume_other=True)
375+
376+
self.assertEqual(until.parse("ssssx"), 4 * ["s"] + ["x"])
377+
self.assertEqual(until.parse_partial("ssssxy"), (4 * ["s"] + ["x"], "y"))
378+
379+
self.assertEqual(until.parse_partial("xxx"), (["x"], "xx"))
380+
381+
self.assertRaises(ParseError, until.parse, "ssssy")
382+
self.assertRaises(ParseError, until.parse, "xssssxy")
383+
384+
def test_until_with_min(self):
385+
386+
until = string("s").until(string("x"), min=3)
387+
388+
self.assertEqual(until.parse_partial("sssx"), (3 * ["s"], "x"))
389+
self.assertEqual(until.parse_partial("sssssx"), (5 * ["s"], "x"))
390+
391+
self.assertRaises(ParseError, until.parse_partial, "ssx")
392+
393+
def test_until_with_max(self):
394+
395+
# until with max
396+
until = string("s").until(string("x"), max=3)
397+
398+
self.assertEqual(until.parse_partial("ssx"), (2 * ["s"], "x"))
399+
self.assertEqual(until.parse_partial("sssx"), (3 * ["s"], "x"))
400+
401+
self.assertRaises(ParseError, until.parse_partial, "ssssx")
402+
403+
def test_until_with_min_max(self):
404+
405+
until = string("s").until(string("x"), min=3, max=5)
406+
407+
self.assertEqual(until.parse_partial("sssx"), (3 * ["s"], "x"))
408+
self.assertEqual(until.parse_partial("sssssx"), (5 * ["s"], "x"))
409+
410+
with self.assertRaises(ParseError) as cm:
411+
until.parse_partial("ssx")
412+
assert cm.exception.args[0] == frozenset({"at least 3 items; got 2 item(s)"})
413+
with self.assertRaises(ParseError) as cm:
414+
until.parse_partial("ssssssx")
415+
assert cm.exception.args[0] == frozenset({"at most 5 items"})
416+
350417
def test_optional(self):
351418
p = string("a").optional()
352419
self.assertEqual(p.parse("a"), "a")

0 commit comments

Comments
 (0)