|
22 | 22 | from docopt2._completion import _frontier |
23 | 23 | from docopt2._parser import ( |
24 | 24 | MATCH_LIMIT, |
| 25 | + Tokens, |
| 26 | + _MatchBudgetExceededError, |
25 | 27 | expand_options_shortcut, |
| 28 | + formal_tokens, |
26 | 29 | formal_usage, |
| 30 | + match_budget, |
| 31 | + parse_argv, |
27 | 32 | parse_defaults, |
28 | 33 | parse_pattern, |
29 | 34 | single_usage_section, |
@@ -161,31 +166,73 @@ def test_completion_answers_a_real_sized_cli_without_a_visible_pause(): |
161 | 166 | assert_that(time.perf_counter() - start).described_as(f"complete({words})").is_less_than(1.0) |
162 | 167 |
|
163 | 168 |
|
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 | + |
169 | 185 | flags = " ".join(f"[-{chr(97 + index)}]" for index in range(24)) |
170 | 186 | doc = f"usage: prog {flags} <END>" |
171 | 187 | 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 |
177 | 211 |
|
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. |
183 | 212 | flags = " ".join(f"[-{chr(97 + index)}]" for index in range(24)) |
184 | 213 | 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