Skip to content

Commit 3e78074

Browse files
committed
test: assert the match budget deterministically instead of by wall-clock
1 parent 7f0b4fa commit 3e78074

1 file changed

Lines changed: 69 additions & 22 deletions

File tree

‎tests/test_robustness.py‎

Lines changed: 69 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,13 @@
2222
from docopt2._completion import _frontier
2323
from docopt2._parser import (
2424
MATCH_LIMIT,
25+
Tokens,
26+
_MatchBudgetExceededError,
2527
expand_options_shortcut,
28+
formal_tokens,
2629
formal_usage,
30+
match_budget,
31+
parse_argv,
2732
parse_defaults,
2833
parse_pattern,
2934
single_usage_section,
@@ -161,31 +166,73 @@ def test_completion_answers_a_real_sized_cli_without_a_visible_pause():
161166
assert_that(time.perf_counter() - start).described_as(f"complete({words})").is_less_than(1.0)
162167

163168

164-
def test_an_ambiguous_pattern_does_not_hang_on_a_long_argv():
165-
# 24 optional flags before a required <END>: an argv of all the flags and no END token has 2**24 ways
166-
# to assign the optionals, and none is complete. The per-Either cap does not bound this - the dead
167-
# branches yield nothing, so nothing is counted - and matching ran for tens of seconds. The global
168-
# match budget charges each exploration step, so an unmatchable argv is rejected in well under a second.
169+
def test_the_budget_stops_an_exponential_match_instead_of_running_it_to_exhaustion():
170+
# 24 optional flags before a required <END>: 2**24 ways to assign the optionals, none complete. The match
171+
# must be STOPPED by the budget, not explored to exhaustion. Asserted on the descents charged, not the
172+
# clock: wall-clock is neither deterministic nor portable here (coverage tracing alone slows the same
173+
# match ~15x, and it swings 20x run to run). Trip at a small count so the test is instant even under
174+
# coverage; docopt() must catch the exceeded budget and reject.
175+
charges = 0
176+
177+
def counting_spend() -> None:
178+
nonlocal charges
179+
if docopt2._parser._match_budget.get() is None:
180+
return # unbudgeted (near-miss scoring after the match): do not count or trip
181+
charges += 1
182+
if charges > 5000:
183+
raise docopt2._parser._MatchBudgetExceededError
184+
169185
flags = " ".join(f"[-{chr(97 + index)}]" for index in range(24))
170186
doc = f"usage: prog {flags} <END>"
171187
argv = [f"-{chr(97 + index)}" for index in range(24)]
172-
start = time.perf_counter()
173-
with pytest.raises(DocoptExit):
174-
docopt2.docopt(doc, argv, help=False)
175-
assert_that(time.perf_counter() - start).described_as("matching a 2**24-ambiguous argv").is_less_than(3.0)
176-
188+
original = docopt2._parser._spend_budget
189+
docopt2._parser._spend_budget = counting_spend
190+
try:
191+
with pytest.raises(DocoptExit):
192+
docopt2.docopt(doc, argv, help=False)
193+
finally:
194+
docopt2._parser._spend_budget = original
195+
assert_that(charges).described_as("descents before the budget stopped the match").is_equal_to(5001)
196+
197+
198+
def test_docopt_installs_one_fixed_match_ceiling_whatever_the_argv_length():
199+
# The whole match rides ONE fixed ceiling - MATCH_LIMIT - no matter how long the argv. A per-token budget
200+
# (an earlier, reverted attempt) would raise the ceiling for a long argv and let an exponential pattern
201+
# run for minutes; the fixed ceiling does not. Read the ceiling docopt() installs on the first descent,
202+
# then trip at once - deterministic and instant, where a wall-clock bound is neither.
203+
installed: list[int] = []
204+
205+
def probe_ceiling() -> None:
206+
budget = docopt2._parser._match_budget.get()
207+
if budget is None:
208+
return
209+
installed.append(budget[0]) # the ceiling as installed, before any decrement
210+
raise docopt2._parser._MatchBudgetExceededError
177211

178-
def test_an_exponential_pattern_with_a_long_argv_still_rejects_in_bounded_time():
179-
# The subtler cousin: many optional flags before a MISSING required option, then a long file tail. The
180-
# optionals' 2**n dead-ends fail at the missing option before they reach the files, so the fan is
181-
# independent of the argv - but the long argv would inflate an argv-scaled budget past that fan and let it
182-
# run for tens of seconds. The whole match stays on ONE fixed ceiling, so it rejects fast regardless.
183212
flags = " ".join(f"[-{chr(97 + index)}]" for index in range(24))
184213
doc = f"usage: prog {flags} --need <files>..."
185-
argv = [f"-{chr(97 + index)}" for index in range(24)] + [f"f{index}" for index in range(300)]
186-
start = time.perf_counter()
187-
with pytest.raises(DocoptExit):
188-
docopt2.docopt(doc, argv, help=False)
189-
# Generous bound: the fix rejects in ~1s (a few seconds on a loaded runner), a regression runs for tens
190-
# of seconds - so 10s separates them without flaking on a slow CI cell (a tight 3s did, on ubuntu 3.10).
191-
assert_that(time.perf_counter() - start).described_as("an exponential pattern with a long argv").is_less_than(10.0)
214+
argv = [f"-{chr(97 + index)}" for index in range(24)] + [f"f{index}" for index in range(2000)]
215+
original = docopt2._parser._spend_budget
216+
docopt2._parser._spend_budget = probe_ceiling
217+
try:
218+
with pytest.raises(DocoptExit):
219+
docopt2.docopt(doc, argv, help=False)
220+
finally:
221+
docopt2._parser._spend_budget = original
222+
assert_that(installed[0]).described_as("match ceiling installed for a long argv").is_equal_to(MATCH_LIMIT)
223+
224+
225+
def test_the_match_budget_raises_once_its_ceiling_is_spent():
226+
# The tests above stub out `_spend_budget`; this drives the real one to its ceiling. A small explicit
227+
# budget over an exponential, complete-match-less pattern (12 optionals before a required <END>) trips it
228+
# in a handful of descents - deterministic and cheap, no wall-clock and no 200k burn.
229+
doc = "usage: prog " + " ".join(f"[-{chr(97 + index)}]" for index in range(12)) + " <END>"
230+
usage = single_usage_section(doc)
231+
options = parse_defaults(doc)
232+
pattern = parse_pattern(formal_tokens(usage), options)
233+
expand_options_shortcut(pattern, options)
234+
pattern.fix()
235+
tokens = Tokens([f"-{chr(97 + index)}" for index in range(12)], usage=usage, exit_code=1)
236+
argv = parse_argv(tokens, list(options)) # options_first / negative_numbers / allow_abbrev keep defaults
237+
with pytest.raises(_MatchBudgetExceededError), match_budget(100):
238+
next(pattern.matches(argv, []), None)

0 commit comments

Comments
 (0)