docopt2 1.2.0__tar.gz → 1.2.1__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 (151) hide show
  1. {docopt2-1.2.0 → docopt2-1.2.1}/PKG-INFO +2 -2
  2. {docopt2-1.2.0 → docopt2-1.2.1}/README.md +1 -1
  3. {docopt2-1.2.0 → docopt2-1.2.1}/docs/getting-started.md +1 -2
  4. {docopt2-1.2.0 → docopt2-1.2.1}/docs/guides/examples.md +2 -2
  5. {docopt2-1.2.0 → docopt2-1.2.1}/docs/guides/round-trip.md +2 -2
  6. {docopt2-1.2.0 → docopt2-1.2.1}/docs/index.md +3 -4
  7. {docopt2-1.2.0 → docopt2-1.2.1}/pyproject.toml +1 -1
  8. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/_parser.py +33 -11
  9. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_property_differential.py +26 -0
  10. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_robustness.py +14 -0
  11. {docopt2-1.2.0 → docopt2-1.2.1}/uv.lock +1 -1
  12. {docopt2-1.2.0 → docopt2-1.2.1}/.codecov.yml +0 -0
  13. {docopt2-1.2.0 → docopt2-1.2.1}/.gitattributes +0 -0
  14. {docopt2-1.2.0 → docopt2-1.2.1}/.github/dependabot.yml +0 -0
  15. {docopt2-1.2.0 → docopt2-1.2.1}/.github/workflows/ci.yml +0 -0
  16. {docopt2-1.2.0 → docopt2-1.2.1}/.github/workflows/codeql.yml +0 -0
  17. {docopt2-1.2.0 → docopt2-1.2.1}/.github/workflows/codspeed.yml +0 -0
  18. {docopt2-1.2.0 → docopt2-1.2.1}/.github/workflows/docs.yml +0 -0
  19. {docopt2-1.2.0 → docopt2-1.2.1}/.github/workflows/fuzz.yml +0 -0
  20. {docopt2-1.2.0 → docopt2-1.2.1}/.github/workflows/mutation.yml +0 -0
  21. {docopt2-1.2.0 → docopt2-1.2.1}/.github/workflows/nightly.yml +0 -0
  22. {docopt2-1.2.0 → docopt2-1.2.1}/.github/workflows/publish.yml +0 -0
  23. {docopt2-1.2.0 → docopt2-1.2.1}/.github/workflows/scorecard.yml +0 -0
  24. {docopt2-1.2.0 → docopt2-1.2.1}/.github/workflows/zizmor.yml +0 -0
  25. {docopt2-1.2.0 → docopt2-1.2.1}/.gitignore +0 -0
  26. {docopt2-1.2.0 → docopt2-1.2.1}/CONTRIBUTING.md +0 -0
  27. {docopt2-1.2.0 → docopt2-1.2.1}/LICENSE +0 -0
  28. {docopt2-1.2.0 → docopt2-1.2.1}/NOTICE +0 -0
  29. {docopt2-1.2.0 → docopt2-1.2.1}/SECURITY.md +0 -0
  30. {docopt2-1.2.0 → docopt2-1.2.1}/benchmarks/test_perf.py +0 -0
  31. {docopt2-1.2.0 → docopt2-1.2.1}/docs/assets/check.png +0 -0
  32. {docopt2-1.2.0 → docopt2-1.2.1}/docs/assets/check.txt +0 -0
  33. {docopt2-1.2.0 → docopt2-1.2.1}/docs/assets/coercion.png +0 -0
  34. {docopt2-1.2.0 → docopt2-1.2.1}/docs/assets/coercion.txt +0 -0
  35. {docopt2-1.2.0 → docopt2-1.2.1}/docs/assets/diagnostic.png +0 -0
  36. {docopt2-1.2.0 → docopt2-1.2.1}/docs/assets/diagnostic.txt +0 -0
  37. {docopt2-1.2.0 → docopt2-1.2.1}/docs/assets/logo-dark.png +0 -0
  38. {docopt2-1.2.0 → docopt2-1.2.1}/docs/assets/logo.png +0 -0
  39. {docopt2-1.2.0 → docopt2-1.2.1}/docs/assets/rich-help.png +0 -0
  40. {docopt2-1.2.0 → docopt2-1.2.1}/docs/assets/rich-help.txt +0 -0
  41. {docopt2-1.2.0 → docopt2-1.2.1}/docs/concepts/design-boundaries.md +0 -0
  42. {docopt2-1.2.0 → docopt2-1.2.1}/docs/guides/check.md +0 -0
  43. {docopt2-1.2.0 → docopt2-1.2.1}/docs/guides/compat.md +0 -0
  44. {docopt2-1.2.0 → docopt2-1.2.1}/docs/guides/completion.md +0 -0
  45. {docopt2-1.2.0 → docopt2-1.2.1}/docs/guides/diagnostics.md +0 -0
  46. {docopt2-1.2.0 → docopt2-1.2.1}/docs/guides/dispatch.md +0 -0
  47. {docopt2-1.2.0 → docopt2-1.2.1}/docs/guides/fmt.md +0 -0
  48. {docopt2-1.2.0 → docopt2-1.2.1}/docs/guides/help.md +0 -0
  49. {docopt2-1.2.0 → docopt2-1.2.1}/docs/guides/stub.md +0 -0
  50. {docopt2-1.2.0 → docopt2-1.2.1}/docs/guides/typed-results.md +0 -0
  51. {docopt2-1.2.0 → docopt2-1.2.1}/docs/guides/usage-dsl.md +0 -0
  52. {docopt2-1.2.0 → docopt2-1.2.1}/docs/reference/check.md +0 -0
  53. {docopt2-1.2.0 → docopt2-1.2.1}/docs/reference/cli.md +0 -0
  54. {docopt2-1.2.0 → docopt2-1.2.1}/docs/reference/compat.md +0 -0
  55. {docopt2-1.2.0 → docopt2-1.2.1}/docs/reference/completion.md +0 -0
  56. {docopt2-1.2.0 → docopt2-1.2.1}/docs/reference/config-templates.md +0 -0
  57. {docopt2-1.2.0 → docopt2-1.2.1}/docs/reference/dispatch.md +0 -0
  58. {docopt2-1.2.0 → docopt2-1.2.1}/docs/reference/docopt.md +0 -0
  59. {docopt2-1.2.0 → docopt2-1.2.1}/docs/reference/examples.md +0 -0
  60. {docopt2-1.2.0 → docopt2-1.2.1}/docs/reference/exceptions.md +0 -0
  61. {docopt2-1.2.0 → docopt2-1.2.1}/docs/reference/fmt.md +0 -0
  62. {docopt2-1.2.0 → docopt2-1.2.1}/docs/reference/grammar.md +0 -0
  63. {docopt2-1.2.0 → docopt2-1.2.1}/docs/reference/overview.md +0 -0
  64. {docopt2-1.2.0 → docopt2-1.2.1}/docs/reference/stub.md +0 -0
  65. {docopt2-1.2.0 → docopt2-1.2.1}/docs/stylesheets/extra.css +0 -0
  66. {docopt2-1.2.0 → docopt2-1.2.1}/examples/cli.py +0 -0
  67. {docopt2-1.2.0 → docopt2-1.2.1}/examples/completion.py +0 -0
  68. {docopt2-1.2.0 → docopt2-1.2.1}/examples/dispatch.py +0 -0
  69. {docopt2-1.2.0 → docopt2-1.2.1}/examples/layered.py +0 -0
  70. {docopt2-1.2.0 → docopt2-1.2.1}/examples/naval_fate.py +0 -0
  71. {docopt2-1.2.0 → docopt2-1.2.1}/examples/rich_help.py +0 -0
  72. {docopt2-1.2.0 → docopt2-1.2.1}/examples/round_trip.py +0 -0
  73. {docopt2-1.2.0 → docopt2-1.2.1}/examples/typed.py +0 -0
  74. {docopt2-1.2.0 → docopt2-1.2.1}/fuzz/docopt.dict +0 -0
  75. {docopt2-1.2.0 → docopt2-1.2.1}/fuzz/fuzz_docopt.py +0 -0
  76. {docopt2-1.2.0 → docopt2-1.2.1}/mkdocs.yml +0 -0
  77. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/__init__.py +0 -0
  78. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/__main__.py +0 -0
  79. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/_compat.py +0 -0
  80. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/_completion.py +0 -0
  81. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/_core.py +0 -0
  82. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/_diagnostics.py +0 -0
  83. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/_errors.py +0 -0
  84. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/_fmt.py +0 -0
  85. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/_format.py +0 -0
  86. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/_generate.py +0 -0
  87. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/_help.py +0 -0
  88. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/_lint.py +0 -0
  89. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/_spellcheck.py +0 -0
  90. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/_stub.py +0 -0
  91. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/_typed.py +0 -0
  92. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/hypothesis.py +0 -0
  93. {docopt2-1.2.0 → docopt2-1.2.1}/src/docopt2/py.typed +0 -0
  94. {docopt2-1.2.0 → docopt2-1.2.1}/tests/_strategies.py +0 -0
  95. {docopt2-1.2.0 → docopt2-1.2.1}/tests/_vendor/docopt_original.py +0 -0
  96. {docopt2-1.2.0 → docopt2-1.2.1}/tests/conftest.py +0 -0
  97. {docopt2-1.2.0 → docopt2-1.2.1}/tests/corpus/awkward_but_legal.txt +0 -0
  98. {docopt2-1.2.0 → docopt2-1.2.1}/tests/corpus/layered_config.txt +0 -0
  99. {docopt2-1.2.0 → docopt2-1.2.1}/tests/corpus/many_flags.txt +0 -0
  100. {docopt2-1.2.0 → docopt2-1.2.1}/tests/corpus/naval_fate.txt +0 -0
  101. {docopt2-1.2.0 → docopt2-1.2.1}/tests/corpus/nested_subcommands.txt +0 -0
  102. {docopt2-1.2.0 → docopt2-1.2.1}/tests/golden/completion_bash.txt +0 -0
  103. {docopt2-1.2.0 → docopt2-1.2.1}/tests/golden/completion_fish.txt +0 -0
  104. {docopt2-1.2.0 → docopt2-1.2.1}/tests/golden/completion_nushell.txt +0 -0
  105. {docopt2-1.2.0 → docopt2-1.2.1}/tests/golden/completion_powershell.txt +0 -0
  106. {docopt2-1.2.0 → docopt2-1.2.1}/tests/golden/completion_zsh.txt +0 -0
  107. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_choices.py +0 -0
  108. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_cli.py +0 -0
  109. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_coercion_diagnostics.py +0 -0
  110. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_compat.py +0 -0
  111. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_completion_vs_docopt.py +0 -0
  112. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_config.py +0 -0
  113. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_config_template.py +0 -0
  114. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_core_extra.py +0 -0
  115. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_diagnostics.py +0 -0
  116. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_dispatch_and_completion.py +0 -0
  117. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_divergences.py +0 -0
  118. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_docopt_original.py +0 -0
  119. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_docs_blocks.py +0 -0
  120. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_env.py +0 -0
  121. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_examples.py +0 -0
  122. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_extended_features.py +0 -0
  123. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_fmt.py +0 -0
  124. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_format.py +0 -0
  125. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_generate.py +0 -0
  126. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_getting_started.py +0 -0
  127. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_grammar_examples.py +0 -0
  128. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_help.py +0 -0
  129. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_hypothesis.py +0 -0
  130. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_import_cost.py +0 -0
  131. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_language_agnostic.docopt +0 -0
  132. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_lint.py +0 -0
  133. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_near_miss.py +0 -0
  134. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_package.py +0 -0
  135. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_parse_tree.py +0 -0
  136. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_property_coercion.py +0 -0
  137. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_property_config.py +0 -0
  138. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_property_config_template.py +0 -0
  139. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_property_extended.py +0 -0
  140. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_pydantic.py +0 -0
  141. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_readme.py +0 -0
  142. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_screenshots.py +0 -0
  143. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_source.py +0 -0
  144. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_spellcheck.py +0 -0
  145. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_stub.py +0 -0
  146. {docopt2-1.2.0 → docopt2-1.2.1}/tests/test_typed_api.py +0 -0
  147. {docopt2-1.2.0 → docopt2-1.2.1}/tests/typing_surface/check_typed.py +0 -0
  148. {docopt2-1.2.0 → docopt2-1.2.1}/tools/mutation_report.py +0 -0
  149. {docopt2-1.2.0 → docopt2-1.2.1}/tools/regolden.py +0 -0
  150. {docopt2-1.2.0 → docopt2-1.2.1}/tools/screenshots.py +0 -0
  151. {docopt2-1.2.0 → docopt2-1.2.1}/tools/shell_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docopt2
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Typed successor to docopt: the usage message is the parser spec.
5
5
  Project-URL: Homepage, https://github.com/Solganis/docopt2
6
6
  Project-URL: Repository, https://github.com/Solganis/docopt2
@@ -53,7 +53,7 @@ Description-Content-Type: text/markdown
53
53
  <a href="https://pypi.org/project/docopt2/"><img src="https://img.shields.io/pypi/pyversions/docopt2" alt="Python"></a>
54
54
  <a href="https://pepy.tech/projects/docopt2"><img src="https://static.pepy.tech/badge/docopt2/month" alt="Downloads"></a>
55
55
  <br>
56
- <a href="https://solganis.github.io/docopt2/concepts/design-boundaries/"><img src="https://img.shields.io/badge/runtime%20deps-0-2ea043" alt="zero runtime dependencies, pydantic support optional"></a>
56
+ <a href="https://solganis.github.io/docopt2/concepts/design-boundaries/#zero-runtime-dependencies"><img src="https://img.shields.io/badge/runtime%20deps-0-2ea043" alt="zero runtime dependencies, pydantic support optional"></a>
57
57
  <a href="https://solganis.github.io/docopt2/guides/typed-results/"><img src="https://img.shields.io/badge/type--checked-ty%20%7C%20mypy%20%7C%20pyright-2ea043" alt="the typed API is checked by ty, mypy --strict, and pyright"></a>
58
58
  <a href="https://solganis.github.io/docopt2/"><img src="https://img.shields.io/badge/docs-online-black" alt="Documentation"></a>
59
59
  <a href="https://scorecard.dev/viewer/?uri=github.com/Solganis/docopt2"><img src="https://img.shields.io/ossf-scorecard/github.com/Solganis/docopt2?label=OpenSSF%20Scorecard" alt="OpenSSF Scorecard"></a>
@@ -17,7 +17,7 @@
17
17
  <a href="https://pypi.org/project/docopt2/"><img src="https://img.shields.io/pypi/pyversions/docopt2" alt="Python"></a>
18
18
  <a href="https://pepy.tech/projects/docopt2"><img src="https://static.pepy.tech/badge/docopt2/month" alt="Downloads"></a>
19
19
  <br>
20
- <a href="https://solganis.github.io/docopt2/concepts/design-boundaries/"><img src="https://img.shields.io/badge/runtime%20deps-0-2ea043" alt="zero runtime dependencies, pydantic support optional"></a>
20
+ <a href="https://solganis.github.io/docopt2/concepts/design-boundaries/#zero-runtime-dependencies"><img src="https://img.shields.io/badge/runtime%20deps-0-2ea043" alt="zero runtime dependencies, pydantic support optional"></a>
21
21
  <a href="https://solganis.github.io/docopt2/guides/typed-results/"><img src="https://img.shields.io/badge/type--checked-ty%20%7C%20mypy%20%7C%20pyright-2ea043" alt="the typed API is checked by ty, mypy --strict, and pyright"></a>
22
22
  <a href="https://solganis.github.io/docopt2/"><img src="https://img.shields.io/badge/docs-online-black" alt="Documentation"></a>
23
23
  <a href="https://scorecard.dev/viewer/?uri=github.com/Solganis/docopt2"><img src="https://img.shields.io/ossf-scorecard/github.com/Solganis/docopt2?label=OpenSSF%20Scorecard" alt="OpenSSF Scorecard"></a>
@@ -6,8 +6,7 @@
6
6
  pip install docopt2
7
7
  ```
8
8
 
9
- docopt2 has zero runtime dependencies: `[project.dependencies]` is empty, and the core needs nothing
10
- outside the Python standard library.
9
+ docopt2 has zero runtime dependencies: the core needs nothing outside the Python standard library.
11
10
  pydantic support is reflective and optional (`docopt2[pydantic]`).
12
11
 
13
12
  !!! tip "Coming from the original docopt?"
@@ -66,9 +66,9 @@ checks its happy paths.
66
66
  ```python
67
67
  generate_examples(doc, count=4, valid=False, seed=7)
68
68
  # [['mine', 'set', 'v1', 'v2', '--unknown'],
69
- # ['--help', '--unknown'],
70
69
  # ['ship', 'new', 'v3', '--unknown'],
71
- # ['ship', 'new', 'v4', 'v5', '--unknown']]
70
+ # ['ship', 'new', 'v4', 'v5', '--unknown'],
71
+ # ['ship', 'new', 'v6', '--unknown']]
72
72
  ```
73
73
 
74
74
  Every one of these raises [`DocoptExit`](../reference/exceptions.md) when passed to `docopt`.
@@ -41,8 +41,8 @@ typed. The canonical form is fixed and predictable:
41
41
 
42
42
  A result that no usage pattern can reproduce raises `ValueError` rather than return a wrong argv. That is a
43
43
  hand-built or inconsistent mapping, or a degenerate grammar in which one value is reachable through
44
- differently-shaped positions (`(<name> | <name> ...)`, `(-a | -b)...`, `[<name>] <path> <name>`) - a shape
45
- whose argv is genuinely ambiguous, so there is no single canonical form to return.
44
+ differently-shaped positions (`(-a | -b)...`, `[<name>] <path> <name>`) - a shape whose argv is genuinely
45
+ ambiguous, so there is no single canonical form to return.
46
46
 
47
47
  ## What it is for
48
48
 
@@ -11,10 +11,9 @@ it corrects three of the original's parsing bugs
11
11
  Diagnostics come with it. Everything else - typed results, linting, stubs, completion, dispatch - is opt-in.
12
12
 
13
13
  !!! note "Zero dependencies"
14
- The core needs nothing outside the Python standard library: `[project.dependencies]` is empty. (It uses
15
- `typing_extensions` for `Required`/`NotRequired` on Python 3.10 if you happen to have it installed, and
16
- works without it.) pydantic support is optional and reflective (`pip install docopt2[pydantic]`), needed
17
- only when you pass a pydantic model as a schema; the Hypothesis strategy needs `docopt2[hypothesis]`.
14
+ The core runs on the standard library alone - nothing to install, nothing to audit. pydantic and
15
+ Hypothesis are optional extras (`docopt2[pydantic]`, `docopt2[hypothesis]`), used only when you pass a
16
+ pydantic model as a schema or reach for the Hypothesis strategy.
18
17
 
19
18
  ## Why docopt2
20
19
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "docopt2"
3
- version = "1.2.0"
3
+ version = "1.2.1"
4
4
  description = "Typed successor to docopt: the usage message is the parser spec."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -28,10 +28,13 @@ ErrorType: TypeAlias = type[DocoptExit] | type[DocoptLanguageError]
28
28
  # (the budget below), because this per-node cap alone still compounds to minutes on a malformed pattern.
29
29
  MATCH_LIMIT = 200_000
30
30
 
31
- # MATCH_LIMIT is per Either, so nested alternatives still multiply it (200k per node -> minutes on a
32
- # malformed pattern). This budgets the WHOLE match instead: one shared ceiling across every Either, so an
33
- # adversarial argv rejects in bounded time. A legitimate usage materializes at most a few dozen outcomes
34
- # (measured across the corpus and generated argvs), so the ceiling is never approached in real use.
31
+ # The global budget bounds docopt()'s whole match with one shared ceiling: an adversarial or malformed
32
+ # pattern, whose `_combine` DFS backtracks exponentially when no complete match exists, rejects in bounded
33
+ # time. It stays a small FIXED number because `Either.matches` yields lazily (greedy-first, materializing its
34
+ # sorted tail only if a caller backtracks past it): an honest match threads a single greedy path - a few
35
+ # hundred descents even for a long `<files>...` argv - so it never approaches the ceiling. Only a search with
36
+ # no complete match, forced to explore its full 2**n fan, reaches it. (An EAGER Either that materialized every
37
+ # outcome to sort them made an honest match's descents grow with argv and false-rejected a real 200-file CLI.)
35
38
  _match_budget: contextvars.ContextVar[list[int] | None] = contextvars.ContextVar("docopt2_match_budget", default=None)
36
39
 
37
40
 
@@ -66,9 +69,10 @@ def _spend_budget() -> None:
66
69
 
67
70
  Charged per ``_combine`` descent - the exponential engine - so a DFS that dead-ends WITHOUT yielding
68
71
  (many optionals before an unmatchable required tail explores 2**n branches, none of them an outcome) is
69
- bounded too. Counting descents, not per-descent work, is what keeps a genuine match cheap: matching a
70
- long ``<files>...`` glob is a handful of descents (the repetition is iterative), so it never approaches
71
- the ceiling, while an adversarial pattern's exponential fan-out does. A no-op when no budget is set.
72
+ bounded too. A genuine match is cheap because ``Either`` yields greedy-first without materializing its
73
+ fan (see :meth:`Either.matches`), so an honest match threads one greedy path - a few hundred descents even
74
+ over a long ``<files>...`` glob - while an adversarial pattern's 2**n fan-out reaches the cap. A no-op when
75
+ no budget is set.
72
76
  """
73
77
  budget = _match_budget.get()
74
78
  if budget is not None:
@@ -484,10 +488,28 @@ class Either(BranchPattern):
484
488
  """Exactly one branch must match; the one leaving the fewest leaves wins."""
485
489
 
486
490
  def matches(self, left: list[Pattern], collected: list[Pattern]) -> Iterator[MatchOutcome]:
487
- lazy = (outcome for child in self.children for outcome in child.matches(left, collected))
488
- outcomes = list(_bounded(lazy))
489
- # the fewest-leaves-left branch wins first, matching the greedy matcher's choice
490
- yield from sorted(outcomes, key=lambda outcome: len(outcome[0]))
491
+ # Greedy-first, lazily. Each branch yields its own fewest-left outcome first, so the global
492
+ # fewest-left - the greedy result the matcher wants - is the min over the branches' first outcomes.
493
+ # Yield it WITHOUT materializing the rest: a caller that takes only the greedy outcome (the common
494
+ # case, and every complete match) stops here, so an honest match never builds the whole fan. The
495
+ # eager `sorted(list(all))` this replaces made that fan grow with argv and false-rejected long CLIs.
496
+ streams = [child.matches(left, collected) for child in self.children]
497
+ firsts = [next(stream, None) for stream in streams]
498
+ live = [index for index, outcome in enumerate(firsts) if outcome is not None]
499
+ if not live:
500
+ return
501
+ best_index = min(live, key=lambda index: len(cast("MatchOutcome", firsts[index])[0]))
502
+ yield cast("MatchOutcome", firsts[best_index])
503
+
504
+ def _rest() -> Iterator[MatchOutcome]:
505
+ # Every outcome except the one already yielded, in the branch order the eager version listed
506
+ # them, so `[best] + sorted(_rest())` reproduces `sorted(all)` exactly - tie order included.
507
+ for index, (first, stream) in enumerate(zip(firsts, streams, strict=True)):
508
+ if first is not None and index != best_index:
509
+ yield first
510
+ yield from stream
511
+
512
+ yield from sorted(_bounded(_rest()), key=lambda outcome: len(outcome[0]))
491
513
 
492
514
 
493
515
  def _accumulate_occurrences(node: Pattern, counts: dict[Pattern, int], multiplier: int) -> None:
@@ -163,6 +163,32 @@ def test_an_ambiguous_alternation_branch_choice_stays_compatible(doc, argv):
163
163
  _assert_compatible(doc, argv)
164
164
 
165
165
 
166
+ # A DEEP but honest argv - the kind a CI invocation produces, not an adversary: two options repeated many
167
+ # times plus a long variadic file list. An EAGER `Either` that materialized its whole outcome fan to sort it
168
+ # made an honest match's cost grow with argv, so a real 20-flag, 200-file run reached the match budget and
169
+ # was falsely rejected (1.1.3-1.2.0). `Either` now yields greedy-first lazily, threading one greedy path, so
170
+ # vanilla-accepted argvs stay accepted. Sizes exceed the old fixed budget - the regression's own trigger.
171
+ _DEEP_DOC = """Usage:
172
+ prog run [--verbose] [--jobs=<n>] [--tag=<t>]... [--exclude=<e>]... <suite> <files>...
173
+ prog report (html | json) [--out=<p>]
174
+ """
175
+
176
+
177
+ def _deep_argv(tags: int, excludes: int, files: int) -> list[str]:
178
+ return [
179
+ "run",
180
+ *[f"--tag=t{index}" for index in range(tags)],
181
+ *[f"--exclude=e{index}" for index in range(excludes)],
182
+ "smoke",
183
+ *[f"f{index}" for index in range(files)],
184
+ ]
185
+
186
+
187
+ @pytest.mark.parametrize(("tags", "excludes", "files"), [(20, 20, 150), (30, 10, 200), (12, 12, 300)])
188
+ def test_a_deep_honest_argv_is_not_falsely_rejected(tags, excludes, files):
189
+ _assert_compatible(_DEEP_DOC, _deep_argv(tags, excludes, files))
190
+
191
+
166
192
  # A PRISTINE oracle: the module above is deliberately patched with docopt2's two fixes, so it can no
167
193
  # longer see them. These tests need the original as shipped.
168
194
  _pristine_spec = importlib.util.spec_from_file_location("docopt_pristine", _oracle_path)
@@ -173,3 +173,17 @@ def test_an_ambiguous_pattern_does_not_hang_on_a_long_argv():
173
173
  with pytest.raises(DocoptExit):
174
174
  docopt2.docopt(doc, argv, help=False)
175
175
  assert_that(time.perf_counter() - start).described_as("matching a 2**24-ambiguous argv").is_less_than(3.0)
176
+
177
+
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
+ flags = " ".join(f"[-{chr(97 + index)}]" for index in range(24))
184
+ 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
+ assert_that(time.perf_counter() - start).described_as("an exponential pattern with a long argv").is_less_than(3.0)
@@ -314,7 +314,7 @@ toml = [
314
314
 
315
315
  [[package]]
316
316
  name = "docopt2"
317
- version = "1.2.0"
317
+ version = "1.2.1"
318
318
  source = { editable = "." }
319
319
 
320
320
  [package.optional-dependencies]
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
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
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