docopt2 1.1.2__tar.gz → 1.2.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 (152) hide show
  1. {docopt2-1.1.2 → docopt2-1.2.0}/.github/workflows/ci.yml +10 -2
  2. docopt2-1.2.0/.github/workflows/codspeed.yml +40 -0
  3. docopt2-1.2.0/.github/workflows/fuzz.yml +50 -0
  4. docopt2-1.2.0/.github/workflows/nightly.yml +57 -0
  5. {docopt2-1.1.2 → docopt2-1.2.0}/PKG-INFO +10 -13
  6. {docopt2-1.1.2 → docopt2-1.2.0}/README.md +8 -11
  7. docopt2-1.2.0/benchmarks/test_perf.py +53 -0
  8. {docopt2-1.1.2 → docopt2-1.2.0}/docs/concepts/design-boundaries.md +1 -1
  9. {docopt2-1.1.2 → docopt2-1.2.0}/docs/guides/completion.md +3 -2
  10. docopt2-1.2.0/fuzz/docopt.dict +31 -0
  11. docopt2-1.2.0/fuzz/fuzz_docopt.py +81 -0
  12. {docopt2-1.1.2 → docopt2-1.2.0}/pyproject.toml +7 -2
  13. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/__main__.py +3 -1
  14. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/_compat.py +30 -7
  15. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/_completion.py +82 -25
  16. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/_core.py +76 -29
  17. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/_fmt.py +3 -1
  18. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/_generate.py +17 -4
  19. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/_help.py +33 -10
  20. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/_parser.py +73 -4
  21. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/_typed.py +43 -7
  22. docopt2-1.2.0/tests/corpus/awkward_but_legal.txt +18 -0
  23. docopt2-1.2.0/tests/corpus/layered_config.txt +14 -0
  24. docopt2-1.2.0/tests/corpus/many_flags.txt +35 -0
  25. docopt2-1.2.0/tests/corpus/naval_fate.txt +16 -0
  26. docopt2-1.2.0/tests/corpus/nested_subcommands.txt +25 -0
  27. docopt2-1.2.0/tests/golden/completion_nushell.txt +12 -0
  28. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_choices.py +46 -0
  29. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_cli.py +11 -0
  30. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_compat.py +16 -0
  31. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_config_template.py +27 -2
  32. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_core_extra.py +22 -0
  33. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_diagnostics.py +17 -3
  34. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_dispatch_and_completion.py +108 -1
  35. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_env.py +16 -0
  36. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_extended_features.py +12 -0
  37. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_fmt.py +12 -0
  38. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_help.py +40 -2
  39. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_property_differential.py +43 -2
  40. docopt2-1.2.0/tests/test_robustness.py +175 -0
  41. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_typed_api.py +17 -0
  42. {docopt2-1.1.2 → docopt2-1.2.0}/tools/regolden.py +1 -1
  43. {docopt2-1.1.2 → docopt2-1.2.0}/tools/shell_check.py +21 -6
  44. {docopt2-1.1.2 → docopt2-1.2.0}/uv.lock +42 -1
  45. docopt2-1.1.2/.github/workflows/nightly.yml +0 -32
  46. {docopt2-1.1.2 → docopt2-1.2.0}/.codecov.yml +0 -0
  47. {docopt2-1.1.2 → docopt2-1.2.0}/.gitattributes +0 -0
  48. {docopt2-1.1.2 → docopt2-1.2.0}/.github/dependabot.yml +0 -0
  49. {docopt2-1.1.2 → docopt2-1.2.0}/.github/workflows/codeql.yml +0 -0
  50. {docopt2-1.1.2 → docopt2-1.2.0}/.github/workflows/docs.yml +0 -0
  51. {docopt2-1.1.2 → docopt2-1.2.0}/.github/workflows/mutation.yml +0 -0
  52. {docopt2-1.1.2 → docopt2-1.2.0}/.github/workflows/publish.yml +0 -0
  53. {docopt2-1.1.2 → docopt2-1.2.0}/.github/workflows/scorecard.yml +0 -0
  54. {docopt2-1.1.2 → docopt2-1.2.0}/.github/workflows/zizmor.yml +0 -0
  55. {docopt2-1.1.2 → docopt2-1.2.0}/.gitignore +0 -0
  56. {docopt2-1.1.2 → docopt2-1.2.0}/CONTRIBUTING.md +0 -0
  57. {docopt2-1.1.2 → docopt2-1.2.0}/LICENSE +0 -0
  58. {docopt2-1.1.2 → docopt2-1.2.0}/NOTICE +0 -0
  59. {docopt2-1.1.2 → docopt2-1.2.0}/SECURITY.md +0 -0
  60. {docopt2-1.1.2 → docopt2-1.2.0}/docs/assets/check.png +0 -0
  61. {docopt2-1.1.2 → docopt2-1.2.0}/docs/assets/check.txt +0 -0
  62. {docopt2-1.1.2 → docopt2-1.2.0}/docs/assets/coercion.png +0 -0
  63. {docopt2-1.1.2 → docopt2-1.2.0}/docs/assets/coercion.txt +0 -0
  64. {docopt2-1.1.2 → docopt2-1.2.0}/docs/assets/diagnostic.png +0 -0
  65. {docopt2-1.1.2 → docopt2-1.2.0}/docs/assets/diagnostic.txt +0 -0
  66. {docopt2-1.1.2 → docopt2-1.2.0}/docs/assets/logo-dark.png +0 -0
  67. {docopt2-1.1.2 → docopt2-1.2.0}/docs/assets/logo.png +0 -0
  68. {docopt2-1.1.2 → docopt2-1.2.0}/docs/assets/rich-help.png +0 -0
  69. {docopt2-1.1.2 → docopt2-1.2.0}/docs/assets/rich-help.txt +0 -0
  70. {docopt2-1.1.2 → docopt2-1.2.0}/docs/getting-started.md +0 -0
  71. {docopt2-1.1.2 → docopt2-1.2.0}/docs/guides/check.md +0 -0
  72. {docopt2-1.1.2 → docopt2-1.2.0}/docs/guides/compat.md +0 -0
  73. {docopt2-1.1.2 → docopt2-1.2.0}/docs/guides/diagnostics.md +0 -0
  74. {docopt2-1.1.2 → docopt2-1.2.0}/docs/guides/dispatch.md +0 -0
  75. {docopt2-1.1.2 → docopt2-1.2.0}/docs/guides/examples.md +0 -0
  76. {docopt2-1.1.2 → docopt2-1.2.0}/docs/guides/fmt.md +0 -0
  77. {docopt2-1.1.2 → docopt2-1.2.0}/docs/guides/help.md +0 -0
  78. {docopt2-1.1.2 → docopt2-1.2.0}/docs/guides/round-trip.md +0 -0
  79. {docopt2-1.1.2 → docopt2-1.2.0}/docs/guides/stub.md +0 -0
  80. {docopt2-1.1.2 → docopt2-1.2.0}/docs/guides/typed-results.md +0 -0
  81. {docopt2-1.1.2 → docopt2-1.2.0}/docs/guides/usage-dsl.md +0 -0
  82. {docopt2-1.1.2 → docopt2-1.2.0}/docs/index.md +0 -0
  83. {docopt2-1.1.2 → docopt2-1.2.0}/docs/reference/check.md +0 -0
  84. {docopt2-1.1.2 → docopt2-1.2.0}/docs/reference/cli.md +0 -0
  85. {docopt2-1.1.2 → docopt2-1.2.0}/docs/reference/compat.md +0 -0
  86. {docopt2-1.1.2 → docopt2-1.2.0}/docs/reference/completion.md +0 -0
  87. {docopt2-1.1.2 → docopt2-1.2.0}/docs/reference/config-templates.md +0 -0
  88. {docopt2-1.1.2 → docopt2-1.2.0}/docs/reference/dispatch.md +0 -0
  89. {docopt2-1.1.2 → docopt2-1.2.0}/docs/reference/docopt.md +0 -0
  90. {docopt2-1.1.2 → docopt2-1.2.0}/docs/reference/examples.md +0 -0
  91. {docopt2-1.1.2 → docopt2-1.2.0}/docs/reference/exceptions.md +0 -0
  92. {docopt2-1.1.2 → docopt2-1.2.0}/docs/reference/fmt.md +0 -0
  93. {docopt2-1.1.2 → docopt2-1.2.0}/docs/reference/grammar.md +0 -0
  94. {docopt2-1.1.2 → docopt2-1.2.0}/docs/reference/overview.md +0 -0
  95. {docopt2-1.1.2 → docopt2-1.2.0}/docs/reference/stub.md +0 -0
  96. {docopt2-1.1.2 → docopt2-1.2.0}/docs/stylesheets/extra.css +0 -0
  97. {docopt2-1.1.2 → docopt2-1.2.0}/examples/cli.py +0 -0
  98. {docopt2-1.1.2 → docopt2-1.2.0}/examples/completion.py +0 -0
  99. {docopt2-1.1.2 → docopt2-1.2.0}/examples/dispatch.py +0 -0
  100. {docopt2-1.1.2 → docopt2-1.2.0}/examples/layered.py +0 -0
  101. {docopt2-1.1.2 → docopt2-1.2.0}/examples/naval_fate.py +0 -0
  102. {docopt2-1.1.2 → docopt2-1.2.0}/examples/rich_help.py +0 -0
  103. {docopt2-1.1.2 → docopt2-1.2.0}/examples/round_trip.py +0 -0
  104. {docopt2-1.1.2 → docopt2-1.2.0}/examples/typed.py +0 -0
  105. {docopt2-1.1.2 → docopt2-1.2.0}/mkdocs.yml +0 -0
  106. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/__init__.py +0 -0
  107. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/_diagnostics.py +0 -0
  108. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/_errors.py +0 -0
  109. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/_format.py +0 -0
  110. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/_lint.py +0 -0
  111. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/_spellcheck.py +0 -0
  112. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/_stub.py +0 -0
  113. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/hypothesis.py +0 -0
  114. {docopt2-1.1.2 → docopt2-1.2.0}/src/docopt2/py.typed +0 -0
  115. {docopt2-1.1.2 → docopt2-1.2.0}/tests/_strategies.py +0 -0
  116. {docopt2-1.1.2 → docopt2-1.2.0}/tests/_vendor/docopt_original.py +0 -0
  117. {docopt2-1.1.2 → docopt2-1.2.0}/tests/conftest.py +0 -0
  118. {docopt2-1.1.2 → docopt2-1.2.0}/tests/golden/completion_bash.txt +0 -0
  119. {docopt2-1.1.2 → docopt2-1.2.0}/tests/golden/completion_fish.txt +0 -0
  120. {docopt2-1.1.2 → docopt2-1.2.0}/tests/golden/completion_powershell.txt +0 -0
  121. {docopt2-1.1.2 → docopt2-1.2.0}/tests/golden/completion_zsh.txt +0 -0
  122. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_coercion_diagnostics.py +0 -0
  123. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_completion_vs_docopt.py +0 -0
  124. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_config.py +0 -0
  125. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_divergences.py +0 -0
  126. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_docopt_original.py +0 -0
  127. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_docs_blocks.py +0 -0
  128. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_examples.py +0 -0
  129. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_format.py +0 -0
  130. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_generate.py +0 -0
  131. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_getting_started.py +0 -0
  132. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_grammar_examples.py +0 -0
  133. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_hypothesis.py +0 -0
  134. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_import_cost.py +0 -0
  135. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_language_agnostic.docopt +0 -0
  136. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_lint.py +0 -0
  137. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_near_miss.py +0 -0
  138. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_package.py +0 -0
  139. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_parse_tree.py +0 -0
  140. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_property_coercion.py +0 -0
  141. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_property_config.py +0 -0
  142. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_property_config_template.py +0 -0
  143. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_property_extended.py +0 -0
  144. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_pydantic.py +0 -0
  145. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_readme.py +0 -0
  146. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_screenshots.py +0 -0
  147. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_source.py +0 -0
  148. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_spellcheck.py +0 -0
  149. {docopt2-1.1.2 → docopt2-1.2.0}/tests/test_stub.py +0 -0
  150. {docopt2-1.1.2 → docopt2-1.2.0}/tests/typing_surface/check_typed.py +0 -0
  151. {docopt2-1.1.2 → docopt2-1.2.0}/tools/mutation_report.py +0 -0
  152. {docopt2-1.1.2 → docopt2-1.2.0}/tools/screenshots.py +0 -0
@@ -86,10 +86,10 @@ jobs:
86
86
  run: uv sync --group typecheck --locked
87
87
 
88
88
  - name: Ruff check
89
- run: uv run ruff check src tests tools
89
+ run: uv run ruff check src tests tools fuzz benchmarks
90
90
 
91
91
  - name: Ruff format check
92
- run: uv run ruff format --check src tests tools
92
+ run: uv run ruff format --check src tests tools fuzz benchmarks
93
93
 
94
94
  - name: Type check (ty)
95
95
  run: uv run ty check
@@ -128,6 +128,14 @@ jobs:
128
128
  if: runner.os == 'Linux'
129
129
  run: sudo apt-get update && sudo apt-get install -y --no-install-recommends zsh fish
130
130
 
131
+ - name: Install nushell
132
+ # Not in the Ubuntu repos; pinned to the version the completer targets (`commandline complete`
133
+ # landed in 0.114.0). Drop the release tarball on PATH.
134
+ if: runner.os == 'Linux'
135
+ run: |
136
+ curl -sSfL https://github.com/nushell/nushell/releases/download/0.114.1/nu-0.114.1-x86_64-unknown-linux-gnu.tar.gz | tar xz -C "$HOME"
137
+ echo "$HOME/nu-0.114.1-x86_64-unknown-linux-gnu" >> "$GITHUB_PATH"
138
+
131
139
  - name: Complete in a real terminal
132
140
  run: uv run --locked python tools/shell_check.py
133
141
 
@@ -0,0 +1,40 @@
1
+ name: CodSpeed
2
+
3
+ # Continuous performance gate: run the micro-benchmarks (benchmarks/test_perf.py) under CodSpeed's CPU
4
+ # simulation, which measures a deterministic instruction count rather than wall time, so a runner's noise
5
+ # never flakes it. CodSpeed compares each commit to the base and reports a pass/fail on the PR. A public
6
+ # repo uploads over OIDC (id-token), so no token secret is needed - only the CodSpeed GitHub App installed.
7
+
8
+ on:
9
+ push:
10
+ branches: [main]
11
+ pull_request:
12
+ branches: [main]
13
+ workflow_dispatch:
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ concurrency:
19
+ group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
20
+ cancel-in-progress: true
21
+
22
+ jobs:
23
+ benchmarks:
24
+ runs-on: ubuntu-latest
25
+ permissions:
26
+ contents: read
27
+ id-token: write
28
+ steps:
29
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
30
+ with:
31
+ persist-credentials: false
32
+
33
+ - name: Install uv
34
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
35
+
36
+ - name: Run benchmarks under CodSpeed
37
+ uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5
38
+ with:
39
+ mode: simulation
40
+ run: uv run --locked --group benchmark pytest benchmarks/ --codspeed --no-cov
@@ -0,0 +1,50 @@
1
+ name: Fuzz
2
+
3
+ # Coverage-guided fuzzing of the public API with atheris, off the PR path. The invariant is the one
4
+ # tests/test_robustness.py states and Hypothesis checks on grammar-shaped input: no entry point may raise
5
+ # anything but DocoptExit / DocoptLanguageError for any input. The Nightly job explores well-formed usage
6
+ # with Hypothesis; this reaches the malformed byte shapes it cannot, under coverage feedback, and a
7
+ # per-input -timeout turns a runaway (a pathological slowdown or an outright hang) into a finding. Unlike
8
+ # Mutation (report-only), a crash or timeout here is a real bug: the job fails and uploads the reproducer,
9
+ # which becomes a committed regression test.
10
+ #
11
+ # Linux only: atheris ships no Windows wheel. Pinned to Python 3.12, which has a prebuilt manylinux wheel.
12
+
13
+ on:
14
+ schedule:
15
+ - cron: "41 4 * * 1"
16
+ workflow_dispatch:
17
+
18
+ permissions:
19
+ contents: read
20
+
21
+ concurrency:
22
+ group: ${{ github.workflow }}
23
+ cancel-in-progress: true
24
+
25
+ jobs:
26
+ fuzz:
27
+ runs-on: ubuntu-latest
28
+ timeout-minutes: 20
29
+ steps:
30
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
31
+ with:
32
+ persist-credentials: false
33
+
34
+ - name: Install uv
35
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
36
+
37
+ - name: Fuzz the public API for ten minutes
38
+ run: |
39
+ mkdir -p fuzz-findings
40
+ uv run --locked --no-dev --python 3.12 --with atheris \
41
+ python fuzz/fuzz_docopt.py \
42
+ -max_total_time=600 -timeout=30 -rss_limit_mb=4096 -artifact_prefix=fuzz-findings/
43
+
44
+ - name: Upload any crash or timeout reproducer
45
+ if: failure()
46
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
47
+ with:
48
+ name: fuzz-findings
49
+ path: fuzz-findings/
50
+ if-no-files-found: ignore
@@ -0,0 +1,57 @@
1
+ name: Nightly
2
+
3
+ # The gate runs Hypothesis derandomised at 300 examples, deliberately: a PR check that flakes is a check
4
+ # people learn to ignore. The cost is that every run sees the same inputs, so a rare violation can sit
5
+ # there forever. This explores 10,000 randomised examples per property, off the critical path, where a
6
+ # failure is a finding rather than a blocked merge.
7
+
8
+ on:
9
+ schedule:
10
+ - cron: "23 3 * * *"
11
+ workflow_dispatch:
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ concurrency:
17
+ group: ${{ github.workflow }}
18
+ cancel-in-progress: true
19
+
20
+ jobs:
21
+ explore:
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
25
+ with:
26
+ persist-credentials: false
27
+
28
+ - name: Install uv
29
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
30
+
31
+ - name: Property tests, wide and randomised
32
+ run: uv run --locked pytest --hypothesis-profile=explore -q --no-cov
33
+
34
+ # A red nightly is a real property violation, not noise (unlike the Mutation survivors), yet it is off
35
+ # the merge path, so nothing surfaces it - a failure sat unseen for days. On failure open, or bump, a
36
+ # single tracking issue so it is visible without anyone watching the schedule.
37
+ notify:
38
+ needs: explore
39
+ if: failure()
40
+ runs-on: ubuntu-latest
41
+ permissions:
42
+ issues: write
43
+ steps:
44
+ - name: Open or bump the tracking issue
45
+ env:
46
+ GH_TOKEN: ${{ github.token }}
47
+ REPO: ${{ github.repository }}
48
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
49
+ run: |
50
+ title="Nightly wide-hypothesis run is failing"
51
+ number=$(gh issue list --repo "$REPO" --state open --search "$title in:title" --json number --jq '.[0].number // empty')
52
+ if [ -n "$number" ]; then
53
+ gh issue comment "$number" --repo "$REPO" --body "Still failing: $RUN_URL"
54
+ else
55
+ gh issue create --repo "$REPO" --title "$title" \
56
+ --body "The scheduled wide Hypothesis run (10k examples) failed - it catches divergences the 300-example gate misses. Run: $RUN_URL"
57
+ fi
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docopt2
3
- Version: 1.1.2
3
+ Version: 1.2.0
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
@@ -11,7 +11,7 @@ License-Expression: MIT
11
11
  License-File: LICENSE
12
12
  License-File: NOTICE
13
13
  Keywords: argument-parser,cli,command-line,docopt,posix,typed,usage
14
- Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Development Status :: 4 - Beta
15
15
  Classifier: Environment :: Console
16
16
  Classifier: Intended Audience :: Developers
17
17
  Classifier: Operating System :: OS Independent
@@ -47,20 +47,16 @@ Description-Content-Type: text/markdown
47
47
  </p>
48
48
 
49
49
  <p align="center">
50
- <a href="https://github.com/Solganis/docopt2/actions/workflows/ci.yml"><img src="https://github.com/Solganis/docopt2/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
50
+ <a href="https://github.com/Solganis/docopt2/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Solganis/docopt2/ci.yml?branch=main&amp;label=CI" alt="CI"></a>
51
+ <a href="https://codecov.io/gh/Solganis/docopt2"><img src="https://codecov.io/gh/Solganis/docopt2/graph/badge.svg" alt="Coverage"></a>
51
52
  <a href="https://pypi.org/project/docopt2/"><img src="https://img.shields.io/pypi/v/docopt2" alt="PyPI version"></a>
52
53
  <a href="https://pypi.org/project/docopt2/"><img src="https://img.shields.io/pypi/pyversions/docopt2" alt="Python"></a>
53
- <a href="https://codecov.io/gh/Solganis/docopt2"><img src="https://codecov.io/gh/Solganis/docopt2/graph/badge.svg" alt="Coverage"></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/"><img src="https://img.shields.io/badge/Docs-online-black" alt="Documentation"></a>
57
- <a href="https://docs.astral.sh/ruff/"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff"></a>
58
- <a href="https://github.com/astral-sh/uv"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json" alt="uv"></a>
59
- <a href="https://github.com/astral-sh/ty"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json" alt="ty"></a>
60
- <a href="https://scorecard.dev/viewer/?uri=github.com/Solganis/docopt2"><img src="https://api.scorecard.dev/projects/github.com/Solganis/docopt2/badge" alt="OpenSSF Scorecard"></a>
61
- <br>
62
- <img src="https://img.shields.io/badge/runtime%20deps-0-2ea043" alt="zero runtime dependencies, pydantic support optional">
63
- <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">
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>
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
+ <a href="https://solganis.github.io/docopt2/"><img src="https://img.shields.io/badge/docs-online-black" alt="Documentation"></a>
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>
64
60
  </p>
65
61
 
66
62
  ---
@@ -95,7 +91,7 @@ Route each subcommand to its own handler - no <code>if args[...]</code> ladder.
95
91
  </td>
96
92
  <td valign="top">
97
93
  <a href="#shell-completion"><b>Shell completion</b></a><br>
98
- Tab-completion that knows your grammar: bash, zsh, fish, PowerShell.
94
+ Tab-completion that knows your grammar: bash, zsh, fish, PowerShell, nushell.
99
95
  </td>
100
96
  </tr>
101
97
  <tr>
@@ -274,6 +270,7 @@ app.run() # parse argv, call the matched command's handler
274
270
  <a href="https://www.zsh.org/"><img src="https://img.shields.io/badge/zsh-F15A24?logo=zsh&logoColor=white" alt="zsh"></a>
275
271
  <a href="https://fishshell.com/"><img src="https://img.shields.io/badge/fish-34C534?logo=fishshell&logoColor=white" alt="fish"></a>
276
272
  <a href="https://learn.microsoft.com/powershell/"><img src="https://img.shields.io/badge/PowerShell-5391FE" alt="PowerShell"></a>
273
+ <a href="https://www.nushell.sh/"><img src="https://img.shields.io/badge/nushell-4E9A06?logo=nushell&logoColor=white" alt="nushell"></a>
277
274
  </p>
278
275
 
279
276
  Generate the completion script for your shell; Tab then narrows to exactly what is valid at the cursor -<br>
@@ -11,20 +11,16 @@
11
11
  </p>
12
12
 
13
13
  <p align="center">
14
- <a href="https://github.com/Solganis/docopt2/actions/workflows/ci.yml"><img src="https://github.com/Solganis/docopt2/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
14
+ <a href="https://github.com/Solganis/docopt2/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Solganis/docopt2/ci.yml?branch=main&amp;label=CI" alt="CI"></a>
15
+ <a href="https://codecov.io/gh/Solganis/docopt2"><img src="https://codecov.io/gh/Solganis/docopt2/graph/badge.svg" alt="Coverage"></a>
15
16
  <a href="https://pypi.org/project/docopt2/"><img src="https://img.shields.io/pypi/v/docopt2" alt="PyPI version"></a>
16
17
  <a href="https://pypi.org/project/docopt2/"><img src="https://img.shields.io/pypi/pyversions/docopt2" alt="Python"></a>
17
- <a href="https://codecov.io/gh/Solganis/docopt2"><img src="https://codecov.io/gh/Solganis/docopt2/graph/badge.svg" alt="Coverage"></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/"><img src="https://img.shields.io/badge/Docs-online-black" alt="Documentation"></a>
21
- <a href="https://docs.astral.sh/ruff/"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff"></a>
22
- <a href="https://github.com/astral-sh/uv"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json" alt="uv"></a>
23
- <a href="https://github.com/astral-sh/ty"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json" alt="ty"></a>
24
- <a href="https://scorecard.dev/viewer/?uri=github.com/Solganis/docopt2"><img src="https://api.scorecard.dev/projects/github.com/Solganis/docopt2/badge" alt="OpenSSF Scorecard"></a>
25
- <br>
26
- <img src="https://img.shields.io/badge/runtime%20deps-0-2ea043" alt="zero runtime dependencies, pydantic support optional">
27
- <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">
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>
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
+ <a href="https://solganis.github.io/docopt2/"><img src="https://img.shields.io/badge/docs-online-black" alt="Documentation"></a>
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>
28
24
  </p>
29
25
 
30
26
  ---
@@ -59,7 +55,7 @@ Route each subcommand to its own handler - no <code>if args[...]</code> ladder.
59
55
  </td>
60
56
  <td valign="top">
61
57
  <a href="#shell-completion"><b>Shell completion</b></a><br>
62
- Tab-completion that knows your grammar: bash, zsh, fish, PowerShell.
58
+ Tab-completion that knows your grammar: bash, zsh, fish, PowerShell, nushell.
63
59
  </td>
64
60
  </tr>
65
61
  <tr>
@@ -238,6 +234,7 @@ app.run() # parse argv, call the matched command's handler
238
234
  <a href="https://www.zsh.org/"><img src="https://img.shields.io/badge/zsh-F15A24?logo=zsh&logoColor=white" alt="zsh"></a>
239
235
  <a href="https://fishshell.com/"><img src="https://img.shields.io/badge/fish-34C534?logo=fishshell&logoColor=white" alt="fish"></a>
240
236
  <a href="https://learn.microsoft.com/powershell/"><img src="https://img.shields.io/badge/PowerShell-5391FE" alt="PowerShell"></a>
237
+ <a href="https://www.nushell.sh/"><img src="https://img.shields.io/badge/nushell-4E9A06?logo=nushell&logoColor=white" alt="nushell"></a>
241
238
  </p>
242
239
 
243
240
  Generate the completion script for your shell; Tab then narrows to exactly what is valid at the cursor -<br>
@@ -0,0 +1,53 @@
1
+ # CodSpeed regression gate: micro-benchmarks of docopt2's own hot paths, measured commit-over-commit under
2
+ # CPU simulation. Run manually with `uv run --group benchmark pytest benchmarks/ --codspeed --no-cov`; the
3
+ # CodSpeed CI job runs the same. Not collected by the default suite (testpaths=tests), so it never gates on
4
+ # the machine's noise - only CodSpeed's simulated instruction count does.
5
+ from __future__ import annotations
6
+
7
+ import dataclasses
8
+
9
+ from docopt2 import complete, docopt
10
+
11
+ # A git/naval-shaped CLI: subcommands, a variadic, a default, and two mutex groups.
12
+ NAVAL = """Usage:
13
+ naval ship new <name>...
14
+ naval ship <name> move <x> <y> [--speed=<kn>]
15
+ naval mine (set | remove) <x> <y> [--moored | --drifting]
16
+ naval --help
17
+
18
+ Options:
19
+ --speed=<kn> Speed in knots [default: 10].
20
+ --moored Moored (anchored) mine.
21
+ --drifting Drifting mine.
22
+ """
23
+
24
+ # Eight optionals on one line: exercises the _combine search that the match budget bounds - a guard against
25
+ # a regression in the matcher's cost.
26
+ MANY_OPTIONALS = "usage: prog [-a] [-b] [-c] [-d] [-e] [-f] [-g] [-h] <src> <dst>"
27
+
28
+ TYPED_DOC = "Usage: app <host> <port> [--verbose] [--retries=<n>]"
29
+
30
+
31
+ @dataclasses.dataclass
32
+ class Config:
33
+ host: str
34
+ port: int
35
+ verbose: bool
36
+ retries: int
37
+
38
+
39
+ def test_single_shot_parse_and_match(benchmark):
40
+ # The realistic per-invocation cost: parse the usage and match one argv (docopt2 reparses every call).
41
+ benchmark(lambda: docopt(NAVAL, ["ship", "Titanic", "move", "1", "2", "--speed=20"], help=False))
42
+
43
+
44
+ def test_match_many_optionals(benchmark):
45
+ benchmark(lambda: docopt(MANY_OPTIONALS, ["-a", "-c", "-e", "in.txt", "out.txt"], help=False))
46
+
47
+
48
+ def test_typed_binding_and_coercion(benchmark):
49
+ benchmark(lambda: docopt(TYPED_DOC, ["localhost", "8080", "--verbose", "--retries=3"], schema=Config, help=False))
50
+
51
+
52
+ def test_completion_latency(benchmark):
53
+ benchmark(lambda: complete(NAVAL, ["ship"]))
@@ -118,7 +118,7 @@ the surrounding program or a dedicated library, layered on top of the parsed res
118
118
 
119
119
  The typed surface is plain typing with no runtime cost. The [`Cli`](../reference/cli.md) base class is
120
120
  the only decorator-shaped sugar, and it is deliberately a base class rather than a method-injecting
121
- decorator so the result keeps real static types under ty, mypy, and pyright.
121
+ decorator, so the result keeps real static types a type checker can see instead of degrading to `Any`.
122
122
 
123
123
  Typing is a layer over the drop-in core, not a rewrite of it. The same `docopt()` call runs the same
124
124
  parse; passing `schema=` adds one final bind step that coerces the string values to the field types you
@@ -1,7 +1,7 @@
1
1
  # Shell completion
2
2
 
3
3
  `generate_completion` emits a context-aware completion script for the CLI described by your usage, for
4
- bash, zsh, fish, and PowerShell - the same four Typer supports, and one more than Click. The script carries no grammar of
4
+ bash, zsh, fish, PowerShell, and nushell. The script carries no grammar of
5
5
  its own: it delegates every Tab back to your program, whose [`docopt`](../reference/docopt.md) call
6
6
  resolves the candidates from the usage. This guide covers generating and installing that script, and
7
7
  computing the candidates directly with `complete()`.
@@ -15,7 +15,7 @@ script = generate_completion(doc, prog="naval", shell="bash")
15
15
  ```
16
16
 
17
17
  The signature is `generate_completion(doc, prog, shell="bash") -> str`. `shell` is one of `"bash"`,
18
- `"zsh"`, `"fish"`, or `"powershell"`; any other value raises `ValueError`, as does a `prog` that is not a
18
+ `"zsh"`, `"fish"`, `"powershell"`, or `"nushell"`; any other value raises `ValueError`, as does a `prog` that is not a
19
19
  plain command name (letters, digits, `.`, `_`, `-`). A malformed `doc` fails here, loudly, rather than
20
20
  silently at Tab time.
21
21
 
@@ -67,6 +67,7 @@ have the program print it, then load it with the shell's standard mechanism:
67
67
  | zsh | `"zsh"` | `source` it from `~/.zshrc`, or save it as `_naval` on your `$fpath`. |
68
68
  | fish | `"fish"` | Save it as `~/.config/fish/completions/naval.fish`. |
69
69
  | PowerShell | `"powershell"` | Dot-source it from your `$PROFILE`. |
70
+ | nushell | `"nushell"` | Save it under a name that is **not** `naval.nu` (a module cannot export a command of its own name), then `use naval-completions.nu *` from your config, or drop it in a `$nu.vendor-autoload-dirs` directory. Needs nushell 0.108+. |
70
71
 
71
72
  !!! note
72
73
  Each Tab is a full launch of your program, so its startup cost (imports and so on) is the completion
@@ -0,0 +1,31 @@
1
+ # libFuzzer dictionary of docopt usage-grammar tokens. Without it the fuzzer almost never synthesises the
2
+ # word "usage:" from random bytes, so it bounces off the section detector; with it, libFuzzer splices these
3
+ # in during mutation and reaches the pattern matcher, coercion and the Options parser. "\x00" is the harness
4
+ # separator between the usage message and the argv tokens (see fuzz_docopt.py).
5
+ "usage:"
6
+ "Usage:"
7
+ "options:"
8
+ "Options:"
9
+ "arguments:"
10
+ "Arguments:"
11
+ "[options]"
12
+ "[default: 1]"
13
+ "[env: VAR]"
14
+ "[config: k.v]"
15
+ "--help"
16
+ "--version"
17
+ "-h"
18
+ "--"
19
+ "..."
20
+ " | "
21
+ "["
22
+ "]"
23
+ "("
24
+ ")"
25
+ "<arg>"
26
+ "ARG"
27
+ "=<val>"
28
+ "prog "
29
+ "\x0a"
30
+ "\x0a "
31
+ "\x00"
@@ -0,0 +1,81 @@
1
+ # Coverage-guided fuzzing of the public API with atheris (Linux/macOS only; there is no Windows wheel).
2
+ # The invariant is the one tests/test_robustness.py states and Hypothesis checks on grammar-shaped input:
3
+ # no entry point may raise anything but its own DocoptExit / DocoptLanguageError for ANY input. Hypothesis
4
+ # only ever builds well-formed usage; atheris reaches the malformed shapes it cannot - unbalanced brackets,
5
+ # NUL bytes, an option line run into its description - from arbitrary bytes under coverage feedback, and the
6
+ # libFuzzer -timeout turns a runaway (a pathological slowdown or an outright hang) into a reported finding.
7
+ #
8
+ # atheris has no Windows wheel; run on Linux/macOS. The Fuzz workflow is the canonical invocation (corpus
9
+ # seeds + dict + flags); replay a saved reproducer with: python fuzz/fuzz_docopt.py <reproducer-file>
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import sys
14
+
15
+ import atheris
16
+
17
+ with atheris.instrument_imports():
18
+ from docopt2 import (
19
+ DocoptExit,
20
+ DocoptLanguageError,
21
+ check,
22
+ complete,
23
+ docopt,
24
+ format_usage,
25
+ generate_completion,
26
+ generate_config_template,
27
+ generate_examples,
28
+ generate_stub,
29
+ parse_tree,
30
+ )
31
+
32
+ # DocoptExit (a SystemExit subclass, and the --help path) and DocoptLanguageError are the only errors the
33
+ # public API may raise. Anything else escaping is the finding. Mirrors _OWN_ERRORS in test_robustness.py so
34
+ # the fuzzer and the property suite can never disagree on what "correct" means.
35
+ _OWN_ERRORS = (DocoptExit, DocoptLanguageError, SystemExit)
36
+
37
+ # Every public entry point, driven with help and completion off so a fuzzed `--help` cannot exit the
38
+ # process mid-run. One input exercises all of them. check_compat is deliberately absent: it runs docopt()
39
+ # once per generated sample against argvs IT builds from the (fuzzer-maximised) grammar, so it manufactures
40
+ # a slow-but-bounded run that is a property of the sample count, not a defect - its own logic is
41
+ # deterministic and unit-tested. The runtime surface it shares (docopt, generate_examples) is fuzzed here.
42
+ _ENTRY_POINTS = (
43
+ lambda doc, argv: docopt(doc, argv, help=False, complete=False),
44
+ lambda doc, argv: check(doc),
45
+ lambda doc, argv: format_usage(doc),
46
+ lambda doc, argv: generate_stub(doc),
47
+ lambda doc, argv: generate_examples(doc, count=2, seed=0),
48
+ lambda doc, argv: generate_config_template(doc),
49
+ lambda doc, argv: generate_completion(doc, "prog"),
50
+ lambda doc, argv: complete(doc, argv),
51
+ lambda doc, argv: parse_tree(doc),
52
+ )
53
+
54
+
55
+ def test_one_input(data: bytes) -> None:
56
+ """Split the bytes on NUL into a usage message and argv tokens, then drive every entry point.
57
+
58
+ A plain split (rather than atheris.FuzzedDataProvider) keeps the encoding transparent: a seed file that
59
+ is a real usage message decodes back to itself, so the corpus in fuzz/corpus and the docopt.dict tokens
60
+ actually steer the fuzzer into the parser instead of leaving it to guess the word ``usage:`` from noise.
61
+ """
62
+ usage_bytes, *argv_bytes = data.split(b"\x00")
63
+ doc = usage_bytes.decode("utf-8", "replace")
64
+ # Cap the token count: the matcher is linear per step but each step scans the argv, so an ambiguous
65
+ # pattern against a many-hundred-token argv is bounded-but-slow. A real invocation is short (a long
66
+ # `<files>...` glob is the OneOrMore path, which stays linear), so 32 tokens covers the shapes worth
67
+ # fuzzing without letting the harness manufacture a slow run out of an unrealistic argv.
68
+ argv = [token.decode("utf-8", "replace") for token in argv_bytes[:32]]
69
+ for entry in _ENTRY_POINTS:
70
+ with contextlib.suppress(*_OWN_ERRORS):
71
+ entry(doc, argv)
72
+
73
+
74
+ def main() -> None:
75
+ """libFuzzer entry: flags (-max_total_time, -timeout, -artifact_prefix, ...) come in on sys.argv."""
76
+ atheris.Setup(sys.argv, test_one_input)
77
+ atheris.Fuzz()
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "docopt2"
3
- version = "1.1.2"
3
+ version = "1.2.0"
4
4
  description = "Typed successor to docopt: the usage message is the parser spec."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -11,7 +11,7 @@ authors = [
11
11
  ]
12
12
  keywords = ["docopt", "cli", "command-line", "argument-parser", "usage", "posix", "typed"]
13
13
  classifiers = [
14
- "Development Status :: 5 - Production/Stable",
14
+ "Development Status :: 4 - Beta",
15
15
  "Environment :: Console",
16
16
  "Intended Audience :: Developers",
17
17
  "Operating System :: OS Independent",
@@ -96,6 +96,11 @@ typecheck = [
96
96
  mutation = [
97
97
  "mutmut>=3.3.1",
98
98
  ]
99
+ # Performance regression gate. Own group: pytest-codspeed is only needed by the CodSpeed CI job and a
100
+ # manual `pytest benchmarks/ --codspeed`, not by the suite every contributor runs.
101
+ benchmark = [
102
+ "pytest-codspeed>=5",
103
+ ]
99
104
 
100
105
  [tool.mutmut]
101
106
  source_paths = ["src/docopt2/"]
@@ -59,7 +59,9 @@ def _read_usage(source: str) -> str:
59
59
  return sys.stdin.read()
60
60
  path = Path(source)
61
61
  try:
62
- text = path.read_text(encoding="utf-8")
62
+ # utf-8-sig, not utf-8: a leading BOM (Windows editors add one) is stripped, exactly as Python
63
+ # does for a source file it runs - otherwise `ast.parse` chokes on the U+FEFF a .py starts with.
64
+ text = path.read_text(encoding="utf-8-sig")
63
65
  except UnicodeDecodeError as error:
64
66
  raise _CliError(f"{source}: not a UTF-8 text file") from error
65
67
  if path.suffix != ".py":
@@ -34,35 +34,58 @@ def check_compat(old_doc: str, new_doc: str, *, samples: int = 300) -> list[str]
34
34
  """Report backward-incompatible changes from ``old_doc`` to ``new_doc``, most reliable part first.
35
35
 
36
36
  Every entry is a *definite* break - an invocation the old usage accepts that the new one rejects: a
37
- removed option or command (named, structural), or a concrete argument vector the new grammar no longer
38
- accepts (found by sampling the old grammar's accepted set and replaying it against the new).
37
+ removed option, a removed command, or a concrete argument vector the new grammar no longer accepts
38
+ (found by sampling the old grammar's accepted set and replaying it against the new).
39
39
 
40
40
  An empty list means **no break was found**, not a proof of compatibility: the accepted set is infinite
41
41
  and only ``samples`` invocations are checked, so read it like a passing test ("no breakage detected"),
42
42
  never as a guarantee. It never claims "compatible" - it only surfaces breaks it can prove.
43
43
  """
44
+ old_examples = generate_examples(old_doc, count=samples, seed=0)
44
45
  removed_options = _spellings(old_doc) - _spellings(new_doc)
45
- removed_commands = _usable(old_doc, Command) - _usable(new_doc, Command)
46
+ # A command literal absent from new is only a break if it actually rejects something. `(add|rm) <p>`
47
+ # generalized to `<cmd> <p>` drops the `add`/`rm` literals, yet new accepts every old invocation (they
48
+ # match `<cmd>`). Report a command only when a sampled old invocation that used it is rejected by new.
49
+ gone = _usable(old_doc, Command) - _usable(new_doc, Command)
50
+ removed_commands = {name for name in gone if _command_removal_breaks(new_doc, name, old_examples)}
46
51
  breaks = [f"option `{name}` removed" for name in sorted(removed_options)]
47
52
  breaks += [f"command `{name}` removed" for name in sorted(removed_commands)]
48
53
  removed = removed_options | removed_commands
49
54
  seen_shapes: set[tuple[str, ...]] = set()
50
55
  examples: list[str] = []
51
- for argv in generate_examples(old_doc, count=samples, seed=0):
56
+ for argv in old_examples:
52
57
  shape = tuple(re.sub(r"v\d+", "<val>", token) for token in argv) # collapse placeholder values
53
58
  if shape in seen_shapes or _explained(argv, removed): # one example per shape; skip structural repeats
54
59
  seen_shapes.add(shape)
55
60
  continue
56
61
  seen_shapes.add(shape)
57
- try:
58
- docopt(new_doc, argv, help=False, complete=False)
59
- except (DocoptExit, DocoptLanguageError):
62
+ if _rejects(new_doc, argv):
60
63
  examples.append(f"`{' '.join(argv)}` no longer accepted")
61
64
  if len(examples) >= _MAX_EXAMPLES:
62
65
  break
63
66
  return breaks + examples
64
67
 
65
68
 
69
+ def _rejects(doc: str, argv: list[str]) -> bool:
70
+ """Whether ``doc`` refuses ``argv`` (the shape a break takes: old accepted it, new does not)."""
71
+ try:
72
+ docopt(doc, argv, help=False, complete=False)
73
+ except (DocoptExit, DocoptLanguageError):
74
+ return True
75
+ return False
76
+
77
+
78
+ def _command_removal_breaks(new_doc: str, name: str, old_examples: list[list[str]]) -> bool:
79
+ """Whether dropping the command literal ``name`` rejects some invocation the old grammar accepted.
80
+
81
+ False when new absorbed the command into a positional (it still accepts those invocations); True when
82
+ the command is genuinely gone. ``generate_examples`` emits positional values as ``v1``, ``v2``... and
83
+ a command only by its name, so a command literal appears in a sampled argv iff it matched as that
84
+ command - a plain ``name in argv`` is enough. Bounded by the sampled examples, like the rest.
85
+ """
86
+ return any(name in argv and _rejects(new_doc, argv) for argv in old_examples)
87
+
88
+
66
89
  def _explained(argv: list[str], removed: set[str]) -> bool:
67
90
  """Whether the argv uses a removed option or command - so a named structural break already covers it."""
68
91
  return any(token.split("=", 1)[0] in removed or token in removed for token in argv)