strictcli 0.23.0__tar.gz → 0.24.2__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 (80) hide show
  1. strictcli-0.24.2/.github/workflows/ci-pypi.yml +29 -0
  2. strictcli-0.24.2/.github/workflows/publish.yml +152 -0
  3. {strictcli-0.23.0 → strictcli-0.24.2}/.gitignore +2 -0
  4. strictcli-0.24.2/.rlsbl/config.json +18 -0
  5. {strictcli-0.23.0 → strictcli-0.24.2}/.rlsbl/hashes.json +4 -3
  6. strictcli-0.24.2/.rlsbl/managed-files.json +11 -0
  7. {strictcli-0.23.0 → strictcli-0.24.2}/PKG-INFO +1 -1
  8. {strictcli-0.23.0 → strictcli-0.24.2}/package-lock.json +2 -2
  9. {strictcli-0.23.0 → strictcli-0.24.2}/package.json +1 -1
  10. {strictcli-0.23.0 → strictcli-0.24.2}/pyproject.toml +1 -1
  11. {strictcli-0.23.0 → strictcli-0.24.2}/strictcli/__init__.py +100 -79
  12. strictcli-0.24.2/tests/test_arg_default_validation.py +79 -0
  13. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_check_runner.py +64 -2
  14. strictcli-0.24.2/tests/test_choices_none.py +163 -0
  15. {strictcli-0.23.0 → strictcli-0.24.2}/uv.lock +1 -1
  16. strictcli-0.23.0/.github/workflows/publish.yml +0 -27
  17. strictcli-0.23.0/.rlsbl/config.json +0 -12
  18. strictcli-0.23.0/.rlsbl/version +0 -1
  19. strictcli-0.23.0/CHANGELOG.md +0 -52
  20. {strictcli-0.23.0 → strictcli-0.24.2}/.claude/settings.json +0 -0
  21. {strictcli-0.23.0 → strictcli-0.24.2}/.github/workflows/ci.yml +0 -0
  22. {strictcli-0.23.0 → strictcli-0.24.2}/.npmignore +0 -0
  23. {strictcli-0.23.0 → strictcli-0.24.2}/.strictcli/schema.json +0 -0
  24. {strictcli-0.23.0 → strictcli-0.24.2}/CLAUDE.md +0 -0
  25. {strictcli-0.23.0 → strictcli-0.24.2}/LICENSE +0 -0
  26. {strictcli-0.23.0 → strictcli-0.24.2}/README.md +0 -0
  27. {strictcli-0.23.0 → strictcli-0.24.2}/index.js +0 -0
  28. {strictcli-0.23.0 → strictcli-0.24.2}/postinstall.js +0 -0
  29. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_arg_default.py +0 -0
  30. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_at_prefix.py +0 -0
  31. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_auto_version.py +0 -0
  32. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_call.py +0 -0
  33. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_check_command.py +0 -0
  34. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_check_discovery.py +0 -0
  35. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_check_public_api.py +0 -0
  36. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_check_schema.py +0 -0
  37. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_check_types.py +0 -0
  38. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_choices.py +0 -0
  39. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_command_help_suggestion.py +0 -0
  40. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_command_tags.py +0 -0
  41. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_compound_types.py +0 -0
  42. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_config.py +0 -0
  43. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_config_fields.py +0 -0
  44. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_config_file_path.py +0 -0
  45. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_config_set_bugs.py +0 -0
  46. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_context.py +0 -0
  47. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_deep_nesting.py +0 -0
  48. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_dependencies.py +0 -0
  49. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_deprecated.py +0 -0
  50. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_dump_schema.py +0 -0
  51. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_e2e.py +0 -0
  52. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_env.py +0 -0
  53. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_exit_codes.py +0 -0
  54. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_flag_sets.py +0 -0
  55. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_float_type.py +0 -0
  56. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_global_flags.py +0 -0
  57. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_help.py +0 -0
  58. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_int_type.py +0 -0
  59. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_invoke.py +0 -0
  60. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_keyword_flags.py +0 -0
  61. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_mcp.py +0 -0
  62. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_mutex.py +0 -0
  63. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_nesting.py +0 -0
  64. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_parser.py +0 -0
  65. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_passthrough.py +0 -0
  66. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_registration.py +0 -0
  67. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_repeatable.py +0 -0
  68. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_tagdsl.py +0 -0
  69. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_toml_loading.py +0 -0
  70. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_tool_export.py +0 -0
  71. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_typed_args.py +0 -0
  72. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_unique.py +0 -0
  73. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_utilities.py +0 -0
  74. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_validate.py +0 -0
  75. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_variadic.py +0 -0
  76. {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_visibility.py +0 -0
  77. {strictcli-0.23.0 → strictcli-0.24.2}/todo/.defer/deferred.md +0 -0
  78. {strictcli-0.23.0 → strictcli-0.24.2}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  79. {strictcli-0.23.0 → strictcli-0.24.2}/todo/.done/original-idea.md +0 -0
  80. {strictcli-0.23.0 → strictcli-0.24.2}/todo/.done/public-check-runner-api.md +0 -0
@@ -0,0 +1,29 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ workflow_dispatch:
9
+
10
+ # Per-SHA group: re-runs of the same commit dedupe, but a new commit never
11
+ # cancels an earlier commit's in-flight run (release CI conclusions stay intact).
12
+ concurrency:
13
+ group: ${{ github.workflow_ref }}-${{ github.sha }}
14
+ cancel-in-progress: true
15
+
16
+ jobs:
17
+ test:
18
+ runs-on: ubuntu-latest
19
+ strategy:
20
+ matrix:
21
+ # requires-python: >= 3.11
22
+ python-version: ["3.12", "3.13", "3.14"]
23
+ steps:
24
+ - uses: actions/checkout@v6
25
+ - uses: astral-sh/setup-uv@v7
26
+ - run: uv python install ${{ matrix.python-version }}
27
+ - run: uv sync --locked
28
+ - run: uv run python -c "import strictcli"
29
+ - run: uv run pytest
@@ -0,0 +1,152 @@
1
+ name: Publish
2
+ on:
3
+ release:
4
+ types: [published]
5
+ workflow_dispatch:
6
+ concurrency:
7
+ group: ${{ github.workflow_ref }}-${{ github.ref }}
8
+ cancel-in-progress: false
9
+ permissions:
10
+ contents: read
11
+ id-token: write
12
+ jobs:
13
+ gate:
14
+ name: Gate on CI
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ checks: read
18
+ env:
19
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
20
+ GATE_TIMEOUT_MINUTES: '20'
21
+ GATE_GRACE_MINUTES: '5'
22
+ GATE_POLL_SECONDS: '15'
23
+ CI_CHECK_REGEX: ^(test)( \(.*\))?$
24
+ steps:
25
+ - name: Wait for CI to succeed on the release commit
26
+ run: |
27
+ set -euo pipefail
28
+
29
+ # Ref-based commit resolution: GITHUB_SHA is the tag's commit for both
30
+ # release-triggered runs and workflow_dispatch retries at the tag ref.
31
+ # Never read the release event payload -- dispatch retries have none.
32
+ sha="$GITHUB_SHA"
33
+ echo "Publish gate: waiting for CI on $GITHUB_REF_NAME (commit $sha)"
34
+ echo "Check-run name filter: $CI_CHECK_REGEX"
35
+ # Timeout, grace window, and poll interval come from the job env above;
36
+ # edit them there if this repository's CI needs different limits.
37
+
38
+ now() { date +%s; }
39
+ start="$(now)"
40
+ deadline=$(( start + GATE_TIMEOUT_MINUTES * 60 ))
41
+ grace_deadline=$(( start + GATE_GRACE_MINUTES * 60 ))
42
+
43
+ while :; do
44
+ if ! resp="$(gh api --paginate "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?per_page=100")"; then
45
+ echo "Checks API request failed; retrying in ${GATE_POLL_SECONDS}s..."
46
+ sleep "$GATE_POLL_SECONDS"
47
+ continue
48
+ fi
49
+ # Match this project's CI check runs by name; exclude check runs that
50
+ # belong to THIS workflow run (the gate itself and the queued publish
51
+ # jobs would otherwise deadlock the poll loop).
52
+ runs="$(jq -s --arg re "$CI_CHECK_REGEX" --arg run_id "$GITHUB_RUN_ID" '
53
+ [ .[].check_runs[]
54
+ | select(.name | test($re))
55
+ | select((.details_url // "") | contains("/actions/runs/" + $run_id + "/") | not)
56
+ | {name, status, conclusion} ]' <<< "$resp")"
57
+ total="$(jq 'length' <<< "$runs")"
58
+
59
+ if [ "$total" -eq 0 ]; then
60
+ if [ "$(now)" -ge "$grace_deadline" ]; then
61
+ echo "::error::Publish gate: no CI check runs matching $CI_CHECK_REGEX appeared on $sha within $GATE_GRACE_MINUTES minutes."
62
+ echo "A scaffolded repository always has a CI workflow, so the release commit must produce CI check runs."
63
+ echo "If CI jobs were renamed, update CI_CHECK_REGEX in this workflow's gate job to match the new names."
64
+ exit 1
65
+ fi
66
+ echo "No matching CI check runs yet; retrying in ${GATE_POLL_SECONDS}s..."
67
+ sleep "$GATE_POLL_SECONDS"
68
+ continue
69
+ fi
70
+
71
+ pending="$(jq '[ .[] | select(.status != "completed") ] | length' <<< "$runs")"
72
+ if [ "$pending" -gt 0 ]; then
73
+ if [ "$(now)" -ge "$deadline" ]; then
74
+ echo "::error::Publish gate: timed out after $GATE_TIMEOUT_MINUTES minutes waiting for CI to complete on $sha."
75
+ jq -r '.[] | " \(.name): status=\(.status) conclusion=\(.conclusion // "none")"' <<< "$runs"
76
+ exit 1
77
+ fi
78
+ echo "$pending of $total matching CI check runs still running; retrying in ${GATE_POLL_SECONDS}s..."
79
+ sleep "$GATE_POLL_SECONDS"
80
+ continue
81
+ fi
82
+
83
+ not_success="$(jq '[ .[] | select(.conclusion != "success") ]' <<< "$runs")"
84
+ if [ "$(jq 'length' <<< "$not_success")" -gt 0 ]; then
85
+ echo "::error::Publish gate: CI did not pass on $sha -- refusing to publish."
86
+ jq -r '.[] | " \(.name): \(.conclusion)"' <<< "$not_success"
87
+ while IFS= read -r conclusion; do
88
+ case "$conclusion" in
89
+ failure|timed_out)
90
+ echo "CI concluded '$conclusion' on the release commit. Fix the failure, re-run the CI workflow to green on this exact commit (gh run rerun <run-id>), then re-dispatch this publish workflow at the tag ref: gh workflow run <publish workflow> --ref $GITHUB_REF_NAME"
91
+ ;;
92
+ cancelled)
93
+ echo "A CI check run was CANCELLED. A cancelled run proves nothing about the commit, so the gate treats it as a hard failure instead of waiting for a conclusion that will never come. Re-run the cancelled CI workflow (gh run rerun <run-id>), then re-dispatch this publish workflow at the tag ref."
94
+ ;;
95
+ skipped)
96
+ echo "A CI check run matching the filter was SKIPPED. The gate cannot treat a skipped check as passing: this project's own CI must actually run on the release commit. Check paths filters and job conditions, re-run CI on this commit, then re-dispatch this publish workflow at the tag ref."
97
+ ;;
98
+ *)
99
+ echo "CI check concluded '$conclusion' (not success). The gate only proceeds when every matching check concluded success."
100
+ ;;
101
+ esac
102
+ done <<< "$(jq -r '.[].conclusion' <<< "$not_success" | sort -u)"
103
+ exit 1
104
+ fi
105
+
106
+ echo "Publish gate: all $total matching CI check runs succeeded."
107
+ jq -r '.[] | " \(.name): \(.conclusion)"' <<< "$runs"
108
+ exit 0
109
+ done
110
+ pypi:
111
+ needs: gate
112
+ runs-on: ubuntu-latest
113
+ steps:
114
+ - uses: actions/checkout@v6
115
+ - uses: astral-sh/setup-uv@v7
116
+ - run: uv build --out-dir dist
117
+ - uses: pypa/gh-action-pypi-publish@release/v1
118
+ with:
119
+ skip-existing: true
120
+ npm:
121
+ needs: gate
122
+ runs-on: ubuntu-latest
123
+ steps:
124
+ - uses: actions/checkout@v6
125
+ - uses: actions/setup-node@v6
126
+ with:
127
+ node-version: 24
128
+ registry-url: https://registry.npmjs.org
129
+ - name: Check if already published
130
+ id: check-npm
131
+ run: |
132
+ PKG_NAME=$(node -p "require('./package.json').name")
133
+ PKG_VERSION=$(node -p "require('./package.json').version")
134
+ if npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null; then
135
+ echo "skip=true" >> "$GITHUB_OUTPUT"
136
+ echo "Already published: ${PKG_NAME}@${PKG_VERSION}"
137
+ fi
138
+ - name: Determine dist-tag
139
+ id: dist-tag
140
+ run: |
141
+ PKG_VERSION=$(node -p "require('./package.json').version")
142
+ if echo "$PKG_VERSION" | grep -q '-'; then
143
+ PREID=$(echo "$PKG_VERSION" | sed 's/[^-]*-//' | sed 's/\.[^.]*$//')
144
+ echo "tag=--tag $PREID" >> "$GITHUB_OUTPUT"
145
+ else
146
+ echo "tag=" >> "$GITHUB_OUTPUT"
147
+ fi
148
+ - run: npm publish --provenance --access public ${{
149
+ steps.dist-tag.outputs.tag }}
150
+ if: steps.check-npm.outputs.skip != 'true'
151
+ env:
152
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -13,3 +13,5 @@ dist/
13
13
  .env
14
14
  .env.local
15
15
  *.local-only
16
+
17
+ .rlsbl-monorepo/lock
@@ -0,0 +1,18 @@
1
+ {
2
+ "targets": [
3
+ "pypi",
4
+ "npm"
5
+ ],
6
+ "private": false,
7
+ "push_timeout": 300,
8
+ "pipelines": {
9
+ "pypi": {
10
+ "type": "pypi",
11
+ "local": false
12
+ },
13
+ "npm": {
14
+ "type": "npm",
15
+ "local": false
16
+ }
17
+ }
18
+ }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  ".github/workflows/ci.yml": "b4e075171b131d25f9cce55a3e3a30732e95a2a794af00f354ee3200626d6a04",
3
- ".github/workflows/publish.yml": "e77dcd0b5d1c1abae5424e5f3b73862f44f06b5d46209cbe71bc04d6baa6b5be",
3
+ ".github/workflows/publish.yml": "20db711b8a7745c3ab41814b827e946d60e937e82ee3984e86f39567717199b1",
4
4
  "CHANGELOG.md": "7a6ec46141007c18090a0ff325e2dd68cd51588c4e0b5af7b580365e5eaaca85",
5
- ".gitignore": "c89d76c6c43e7f0897b3472a15b7c0103e0be5b3a04c2b44e585fa48bdcda2bb",
5
+ ".gitignore": "c0582f9b1c640f9f9a8acbbe02cd19fff50e66cc595f25e129b7cc9b6cd357db",
6
6
  "LICENSE": "e958892abc1dd5d991fca6d528e695d335f25773d373d2e0be5f4a9507d8f8d5",
7
7
  "CLAUDE.md": "7294b791570185dc31020dadb092b5b79681ba8ee932fe9a3974e27eaae56c4f",
8
8
  ".rlsbl/hooks/pre-release.sh": "b2dc17c243d0b1a2c1e23c1c25db6f94878596df81dc76c41b32d95f740a7d44",
@@ -12,5 +12,6 @@
12
12
  ".rlsbl/lint/python.toml": "9cfbcef2e010d5fad243437bf2fd8df54a1fae70ee0762f9d11da70fe207501c",
13
13
  ".rlsbl/lint/go.toml": "807d5cf760baad6d571a5bfd43cb163efe356f7462ba9f1918d72de45f62339f",
14
14
  ".rlsbl/lint/npm.toml": "baa36014e65923d8cc5c8b83c77fa651da79351627570476402da66840027645",
15
- ".npmignore": "7c0eea9785c0318aeb39bf8b670ce8384f4a5b812fde69ce23d7d018057325a3"
15
+ ".npmignore": "7c0eea9785c0318aeb39bf8b670ce8384f4a5b812fde69ce23d7d018057325a3",
16
+ ".github/workflows/ci-pypi.yml": "6bde3f6dd33979636f6a8f51721f85519c4d2b6106cb9c9fa78e540df26471c4"
16
17
  }
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 1,
3
+ "files": {
4
+ ".github/workflows/ci-pypi.yml": "6bde3f6dd33979636f6a8f51721f85519c4d2b6106cb9c9fa78e540df26471c4",
5
+ ".npmignore": "7c0eea9785c0318aeb39bf8b670ce8384f4a5b812fde69ce23d7d018057325a3",
6
+ ".github/workflows/publish.yml": "20db711b8a7745c3ab41814b827e946d60e937e82ee3984e86f39567717199b1",
7
+ ".gitignore": "c0582f9b1c640f9f9a8acbbe02cd19fff50e66cc595f25e129b7cc9b6cd357db",
8
+ ".rlsbl/lint/python.toml": "9cfbcef2e010d5fad243437bf2fd8df54a1fae70ee0762f9d11da70fe207501c",
9
+ ".rlsbl/lint/npm.toml": "baa36014e65923d8cc5c8b83c77fa651da79351627570476402da66840027645"
10
+ }
11
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.23.0
3
+ Version: 0.24.2
4
4
  Summary: A strict, zero-dependency CLI framework for Python
5
5
  Project-URL: Homepage, https://github.com/smm-h/strictcli
6
6
  Project-URL: Repository, https://github.com/smm-h/strictcli
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "strictcli",
3
- "version": "0.23.0",
3
+ "version": "0.24.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "strictcli",
9
- "version": "0.23.0",
9
+ "version": "0.24.2",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT"
12
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strictcli",
3
- "version": "0.23.0",
3
+ "version": "0.24.2",
4
4
  "description": "A strict, zero-dependency CLI framework for Python (npm wrapper)",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.23.0"
7
+ version = "0.24.2"
8
8
  description = "A strict, zero-dependency CLI framework for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,8 +1,8 @@
1
- """A strict, zero-dependency CLI framework for Python."""
1
+ """A strict, zero-dependency CLI framework for Python with mandatory help text, type-safe flags, groups, and schema export."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.23.0"
5
+ __version__ = "0.24.2"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "FlagSet", "MutexGroup", "CoRequired", "Requires",
@@ -1484,7 +1484,38 @@ class Arg:
1484
1484
  )
1485
1485
  # Validate default type matches declared type
1486
1486
  if not isinstance(self.default, _MissingSentinel) and self.default is not None:
1487
- if self.type is int:
1487
+ if self.compound == "list":
1488
+ # self.type was normalized to the item type above; the
1489
+ # default itself must be a list of that item type.
1490
+ if not isinstance(self.default, list):
1491
+ raise ValueError(
1492
+ f'Arg "{self.name}": list arg default must be a list'
1493
+ )
1494
+ if len(self.default) == 0:
1495
+ raise ValueError(
1496
+ f'Arg "{self.name}": explicit empty default is '
1497
+ f"redundant for list args, omit the default"
1498
+ )
1499
+ type_name = {str: "str", int: "int", float: "float"}[self.type]
1500
+ for i, elem in enumerate(self.default):
1501
+ if self.type is str:
1502
+ valid = isinstance(elem, str)
1503
+ elif self.type is int:
1504
+ valid = isinstance(elem, int) and not isinstance(elem, bool)
1505
+ else: # float
1506
+ valid = (
1507
+ isinstance(elem, (int, float))
1508
+ and not isinstance(elem, bool)
1509
+ )
1510
+ if valid and isinstance(elem, int):
1511
+ # Auto-coerce int to float, mirroring list flag defaults
1512
+ self.default[i] = float(elem)
1513
+ if not valid:
1514
+ raise ValueError(
1515
+ f'Arg "{self.name}": default element {i} '
1516
+ f"is not of type {type_name}"
1517
+ )
1518
+ elif self.type is int:
1488
1519
  if not isinstance(self.default, int) or isinstance(self.default, bool):
1489
1520
  raise ValueError(
1490
1521
  f'Arg "{self.name}": type=int requires an int default, '
@@ -1502,6 +1533,12 @@ class Arg:
1502
1533
  f'Arg "{self.name}": type=bool requires a bool default, '
1503
1534
  f"got {type(self.default).__name__!r}"
1504
1535
  )
1536
+ elif self.type is str:
1537
+ if not isinstance(self.default, str):
1538
+ raise ValueError(
1539
+ f'Arg "{self.name}": type=str requires a str default, '
1540
+ f"got {type(self.default).__name__!r}"
1541
+ )
1505
1542
  # Validate default is in choices
1506
1543
  if self.choices is not None and not isinstance(self.default, _MissingSentinel) and self.default is not None:
1507
1544
  if self.default not in self.choices:
@@ -3185,21 +3222,8 @@ class App:
3185
3222
 
3186
3223
  # Validate choices for global flags
3187
3224
  for f in self._global_flags:
3188
- if f.choices is not None and f.name in cli_set:
3189
- if f.repeatable:
3190
- for val in cli_set[f.name]:
3191
- if val not in f.choices:
3192
- choices_str = ", ".join(str(c) for c in f.choices)
3193
- raise _ParseError(
3194
- f"--{f.name}: invalid value '{val}', must be one of: {choices_str}"
3195
- )
3196
- else:
3197
- val = cli_set[f.name]
3198
- if val not in f.choices:
3199
- choices_str = ", ".join(str(c) for c in f.choices)
3200
- raise _ParseError(
3201
- f"--{f.name}: invalid value '{val}', must be one of: {choices_str}"
3202
- )
3225
+ if f.name in cli_set:
3226
+ _validate_choices(f.name, cli_set[f.name], f.repeatable, f.choices)
3203
3227
 
3204
3228
  return cli_set, remaining
3205
3229
 
@@ -3754,6 +3778,39 @@ def _tokens_contain_help(tokens: list[str]) -> bool:
3754
3778
  return False
3755
3779
 
3756
3780
 
3781
+ def _validate_choices(
3782
+ name: str,
3783
+ val: object,
3784
+ repeatable: bool,
3785
+ choices: list | None,
3786
+ *,
3787
+ is_arg: bool = False,
3788
+ ) -> None:
3789
+ """Validate a resolved flag or arg value against its choices list.
3790
+
3791
+ Raises _ParseError on an invalid value. is_arg selects the message prefix
3792
+ ("argument 'name':" instead of "--name:"); the two f-strings are kept as
3793
+ full literals so conformance/check_error_parity.py can extract them.
3794
+ A None value is exempt from validation: None only arises when the flag or
3795
+ arg was not passed (an unset mutex flag, or default=None on an arg) -- a
3796
+ CLI-supplied value is never None.
3797
+ """
3798
+ if choices is None or val is None:
3799
+ return
3800
+ vals = val if repeatable else [val]
3801
+ for v in vals:
3802
+ if v not in choices:
3803
+ choices_str = ", ".join(str(c) for c in choices)
3804
+ if is_arg:
3805
+ raise _ParseError(
3806
+ f"argument '{name}': invalid value '{v}', "
3807
+ f"must be one of: {choices_str}"
3808
+ )
3809
+ raise _ParseError(
3810
+ f"--{name}: invalid value '{v}', must be one of: {choices_str}"
3811
+ )
3812
+
3813
+
3757
3814
  def _validate_and_build_kwargs(
3758
3815
  cmd: Command,
3759
3816
  cli_set: dict[str, object],
@@ -3849,21 +3906,8 @@ def _validate_and_build_kwargs(
3849
3906
 
3850
3907
  # Step 5.5: validate choices
3851
3908
  for f in cmd.flags:
3852
- if f.choices is not None and f.name in cli_set:
3853
- if f.repeatable:
3854
- for val in cli_set[f.name]:
3855
- if val not in f.choices:
3856
- choices_str = ", ".join(str(c) for c in f.choices)
3857
- raise _ParseError(
3858
- f"--{f.name}: invalid value '{val}', must be one of: {choices_str}"
3859
- )
3860
- else:
3861
- val = cli_set[f.name]
3862
- if val not in f.choices:
3863
- choices_str = ", ".join(str(c) for c in f.choices)
3864
- raise _ParseError(
3865
- f"--{f.name}: invalid value '{val}', must be one of: {choices_str}"
3866
- )
3909
+ if f.name in cli_set:
3910
+ _validate_choices(f.name, cli_set[f.name], f.repeatable, f.choices)
3867
3911
 
3868
3912
  # Step 5.6: custom validation
3869
3913
  for f in cmd.flags:
@@ -3874,7 +3918,9 @@ def _validate_and_build_kwargs(
3874
3918
  f.validate(val)
3875
3919
  except ValueError as e:
3876
3920
  raise _ParseError(f"--{f.name}: {e}")
3877
- else:
3921
+ elif cli_set[f.name] is not None:
3922
+ # None means the flag was not passed (an unset mutex flag) --
3923
+ # there is no value to validate.
3878
3924
  try:
3879
3925
  f.validate(cli_set[f.name])
3880
3926
  except ValueError as e:
@@ -3904,23 +3950,10 @@ def _validate_and_build_kwargs(
3904
3950
 
3905
3951
  # Step 6.5: validate arg choices
3906
3952
  for a in cmd.args:
3907
- if a.choices is not None and a.name in arg_values:
3908
- val = arg_values[a.name]
3909
- if a.variadic:
3910
- for v in val:
3911
- if v not in a.choices:
3912
- choices_str = ", ".join(str(c) for c in a.choices)
3913
- raise _ParseError(
3914
- f"argument '{a.name}': invalid value '{v}', "
3915
- f"must be one of: {choices_str}"
3916
- )
3917
- else:
3918
- if val not in a.choices:
3919
- choices_str = ", ".join(str(c) for c in a.choices)
3920
- raise _ParseError(
3921
- f"argument '{a.name}': invalid value '{val}', "
3922
- f"must be one of: {choices_str}"
3923
- )
3953
+ if a.name in arg_values:
3954
+ _validate_choices(
3955
+ a.name, arg_values[a.name], a.variadic, a.choices, is_arg=True,
3956
+ )
3924
3957
 
3925
3958
  # Step 7: build kwargs dict (command flags only)
3926
3959
  kwargs: dict[str, object] = {}
@@ -5274,15 +5307,22 @@ def _run_checks(
5274
5307
  warn with ignore_warnings=True), 1 otherwise.
5275
5308
  """
5276
5309
  results: list[tuple[str, CheckResult]] = []
5310
+ # Checks whose dependents should be cascade-skipped: only fail or
5311
+ # cascade-skip qualifies. A warn satisfies the dependency (dependents
5312
+ # still run) regardless of ignore_warnings -- it only affects the exit
5313
+ # code. Explicit skip from an impl is not a failure either.
5277
5314
  failed_checks: set[str] = set()
5278
5315
  exit_code = 0
5279
5316
 
5280
- # Build reverse dependency map for this execution set
5281
- dependents_of: dict[str, set[str]] = {name: set() for name in check_names}
5282
- for name in check_names:
5283
- for dep in check_defs[name].depends_on:
5284
- if dep in dependents_of:
5285
- dependents_of[dep].add(name)
5317
+ def record(name: str, result: CheckResult) -> None:
5318
+ nonlocal exit_code
5319
+ if result.status == "fail":
5320
+ failed_checks.add(name)
5321
+ exit_code = 1
5322
+ elif result.status == "warn":
5323
+ if not ignore_warnings:
5324
+ exit_code = 1
5325
+ # "skip" from an impl or adapter: no cascade, no exit code change.
5286
5326
 
5287
5327
  for name in check_names:
5288
5328
  cdef = check_defs[name]
@@ -5309,33 +5349,14 @@ def _run_checks(
5309
5349
  if cdef.scope and scope_adapter is not None:
5310
5350
  adapted = scope_adapter(context, cdef.scope)
5311
5351
  if isinstance(adapted, CheckResult):
5312
- result = adapted
5313
- results.append((name, result))
5314
- if result.status == "fail":
5315
- failed_checks.add(name)
5316
- exit_code = 1
5317
- elif result.status == "warn":
5318
- if not ignore_warnings:
5319
- failed_checks.add(name)
5320
- exit_code = 1
5321
- elif result.status == "skip":
5322
- pass
5352
+ results.append((name, adapted))
5353
+ record(name, adapted)
5323
5354
  continue
5324
5355
  check_context = adapted
5325
5356
 
5326
5357
  result = cdef.impl(check_context)
5327
5358
  results.append((name, result))
5328
-
5329
- if result.status == "fail":
5330
- failed_checks.add(name)
5331
- exit_code = 1
5332
- elif result.status == "warn":
5333
- if not ignore_warnings:
5334
- failed_checks.add(name)
5335
- exit_code = 1
5336
- elif result.status == "skip":
5337
- # Treat explicit skip from impl as non-failure (no cascade)
5338
- pass
5359
+ record(name, result)
5339
5360
 
5340
5361
  return results, exit_code
5341
5362
 
@@ -0,0 +1,79 @@
1
+ """Registration-time validation of arg defaults.
2
+
3
+ str args must reject non-string defaults, and list (variadic) args must
4
+ validate their default as a list with correctly-typed elements.
5
+ """
6
+
7
+ import pytest
8
+
9
+ import strictcli
10
+
11
+
12
+ def test_arg_str_default_type_validated():
13
+ """type=str requires a str default."""
14
+ with pytest.raises(ValueError, match="type=str requires a str default"):
15
+ strictcli.Arg(name="x", help="a value", required=False, default=42)
16
+
17
+
18
+ def test_arg_str_default_rejects_bool():
19
+ """type=str rejects a bool default."""
20
+ with pytest.raises(ValueError, match="type=str requires a str default"):
21
+ strictcli.Arg(name="x", help="a value", required=False, default=True)
22
+
23
+
24
+ def test_arg_list_default_must_be_list():
25
+ """A list arg default must be a list."""
26
+ with pytest.raises(ValueError, match="list arg default must be a list"):
27
+ strictcli.Arg(
28
+ name="items", help="the items", type=list[str],
29
+ variadic=True, required=False, default="nope",
30
+ )
31
+
32
+
33
+ def test_arg_list_default_empty_rejected():
34
+ """An explicit empty list default is redundant and rejected."""
35
+ with pytest.raises(
36
+ ValueError,
37
+ match="explicit empty default is redundant for list args",
38
+ ):
39
+ strictcli.Arg(
40
+ name="items", help="the items", type=list[str],
41
+ variadic=True, required=False, default=[],
42
+ )
43
+
44
+
45
+ def test_arg_list_default_element_type_validated():
46
+ """Element types of a list arg default are validated."""
47
+ with pytest.raises(ValueError, match="default element 1 is not of type str"):
48
+ strictcli.Arg(
49
+ name="items", help="the items", type=list[str],
50
+ variadic=True, required=False, default=["a", 2],
51
+ )
52
+
53
+
54
+ def test_arg_list_default_int_element_type_validated():
55
+ """Element types of an int list arg default are validated."""
56
+ with pytest.raises(ValueError, match="default element 0 is not of type int"):
57
+ strictcli.Arg(
58
+ name="nums", help="the numbers", type=list[int],
59
+ variadic=True, required=False, default=["1"],
60
+ )
61
+
62
+
63
+ def test_arg_list_default_valid_str():
64
+ """A well-typed list default is accepted."""
65
+ a = strictcli.Arg(
66
+ name="items", help="the items", type=list[str],
67
+ variadic=True, required=False, default=["a", "b"],
68
+ )
69
+ assert a.default == ["a", "b"]
70
+
71
+
72
+ def test_arg_list_default_float_coerces_int():
73
+ """Int elements in a float list default are coerced to float."""
74
+ a = strictcli.Arg(
75
+ name="ratios", help="the ratios", type=list[float],
76
+ variadic=True, required=False, default=[1, 2.5],
77
+ )
78
+ assert a.default == [1.0, 2.5]
79
+ assert isinstance(a.default[0], float)
@@ -267,7 +267,10 @@ class TestRunChecks:
267
267
  assert exit_code == 1
268
268
  assert results[0][1].status == "warn"
269
269
 
270
- def test_warn_cascades_skip_when_not_ignored(self, tmp_path, monkeypatch):
270
+ def test_warn_dependency_runs_dependent(self, tmp_path, monkeypatch):
271
+ # A warn satisfies a dependency: only fail (or cascade-skip) skips
272
+ # dependents. The warn still makes the run exit non-zero when
273
+ # ignore_warnings=False, but the dependent must run.
271
274
  defs = {
272
275
  "b": _make_check_def(
273
276
  "b",
@@ -286,7 +289,66 @@ class TestRunChecks:
286
289
  assert exit_code == 1
287
290
  statuses = {name: r.status for name, r in results}
288
291
  assert statuses["b"] == "warn"
289
- assert statuses["a"] == "skip"
292
+ assert statuses["a"] == "pass"
293
+
294
+ def test_warn_dependency_transitive_dependents_run(self, tmp_path, monkeypatch):
295
+ # warn -> dependent -> transitive dependent: the whole chain runs.
296
+ defs = {
297
+ "c": _make_check_def(
298
+ "c",
299
+ impl=lambda ctx: CheckResult(status="warn", message="Warning"),
300
+ ),
301
+ "b": _make_check_def(
302
+ "b",
303
+ depends_on=["c"],
304
+ impl=lambda ctx: CheckResult(status="pass", message="B OK"),
305
+ ),
306
+ "a": _make_check_def(
307
+ "a",
308
+ depends_on=["b"],
309
+ impl=lambda ctx: CheckResult(status="pass", message="A OK"),
310
+ ),
311
+ }
312
+ app = self._make_app_with_checks(defs, tmp_path, monkeypatch)
313
+ ctx = SimpleContext(project_root=tmp_path)
314
+ order = _resolve_check_order(app._check_defs, {"a", "b", "c"})
315
+ results, exit_code = _run_checks(app._check_defs, order, ctx, ignore_warnings=False)
316
+ assert exit_code == 1
317
+ statuses = {name: r.status for name, r in results}
318
+ assert statuses["c"] == "warn"
319
+ assert statuses["b"] == "pass"
320
+ assert statuses["a"] == "pass"
321
+
322
+ def test_warn_from_scope_adapter_runs_dependent(self, tmp_path, monkeypatch):
323
+ # When the scope adapter short-circuits a check with a warn result,
324
+ # the warn must also satisfy dependencies (no cascade-skip).
325
+ defs = {
326
+ "b": _make_check_def(
327
+ "b",
328
+ impl=lambda ctx: CheckResult(status="pass", message="unused"),
329
+ ),
330
+ "a": _make_check_def(
331
+ "a",
332
+ depends_on=["b"],
333
+ impl=lambda ctx: CheckResult(status="pass", message="A OK"),
334
+ ),
335
+ }
336
+ app = self._make_app_with_checks(defs, tmp_path, monkeypatch)
337
+ # Give "b" a scope so the adapter is consulted for it.
338
+ app._check_defs["b"].scope = "some-scope"
339
+
340
+ def adapter(context, scope):
341
+ return CheckResult(status="warn", message="adapter warning")
342
+
343
+ ctx = SimpleContext(project_root=tmp_path)
344
+ order = _resolve_check_order(app._check_defs, {"a", "b"})
345
+ results, exit_code = _run_checks(
346
+ app._check_defs, order, ctx, ignore_warnings=False, scope_adapter=adapter
347
+ )
348
+ assert exit_code == 1
349
+ statuses = {name: r.status for name, r in results}
350
+ assert statuses["b"] == "warn"
351
+ assert statuses["a"] == "pass"
290
352
 
291
353
  def test_warn_does_not_cascade_when_ignored(self, tmp_path, monkeypatch):
292
354
  defs = {
@@ -0,0 +1,163 @@
1
+ """Tests for choices/validate exemption of None values.
2
+
3
+ A None value only arises when a flag or arg was not passed (an unset mutex
4
+ flag, or an arg with default=None); a CLI-supplied value is never None.
5
+ Choices validation and custom validators must skip None instead of failing
6
+ with "invalid value 'None'".
7
+ """
8
+
9
+ import pytest
10
+
11
+ import strictcli
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Mutex flags: the unset member resolves to None
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ def _mutex_choices_app():
20
+ mg = strictcli.MutexGroup(
21
+ flags=[
22
+ strictcli.Flag(
23
+ name="format", type=str, help="output format",
24
+ default=None, choices=["text", "json"],
25
+ ),
26
+ strictcli.Flag(name="output", type=str, help="output path", default=None),
27
+ ],
28
+ )
29
+ app = strictcli.App(name="test", version="1.0.0", help="test app")
30
+
31
+ @app.command("cmd", help="a command", mutex=[mg])
32
+ def cmd(format, output):
33
+ print(f"format={format} output={output}")
34
+
35
+ return app
36
+
37
+
38
+ def test_mutex_flag_choices_unset_not_validated():
39
+ """Choices on an unset mutex flag must not fire when the other member is passed."""
40
+ app = _mutex_choices_app()
41
+ r = app.test(["cmd", "--output", "out.txt"])
42
+ assert r.exit_code == 0
43
+ assert "format=None output=out.txt" in r.stdout
44
+
45
+
46
+ def test_mutex_flag_choices_passed_valid():
47
+ """A valid choice on the mutex flag is still accepted."""
48
+ app = _mutex_choices_app()
49
+ r = app.test(["cmd", "--format", "json"])
50
+ assert r.exit_code == 0
51
+ assert "format=json output=None" in r.stdout
52
+
53
+
54
+ def test_mutex_flag_choices_passed_invalid():
55
+ """An invalid choice on the mutex flag is still rejected."""
56
+ app = _mutex_choices_app()
57
+ r = app.test(["cmd", "--format", "xml"])
58
+ assert r.exit_code == 1
59
+ assert "--format: invalid value 'xml', must be one of: text, json" in r.stderr
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Args: default=None (or no default) resolves to None
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def _arg_choices_app(**arg_kwargs):
68
+ app = strictcli.App(name="test", version="1.0.0", help="test app")
69
+
70
+ @app.command(
71
+ "cmd",
72
+ help="a command",
73
+ args=[strictcli.Arg(
74
+ name="env", help="target env", required=False,
75
+ choices=["dev", "staging", "prod"], **arg_kwargs,
76
+ )],
77
+ )
78
+ def cmd(env=None):
79
+ print(f"env={env}")
80
+
81
+ return app
82
+
83
+
84
+ def test_arg_none_default_choices_not_passed():
85
+ """Optional arg with default=None and choices, not passed -> succeeds."""
86
+ app = _arg_choices_app(default=None)
87
+ r = app.test(["cmd"])
88
+ assert r.exit_code == 0
89
+ assert "env=None" in r.stdout
90
+
91
+
92
+ def test_arg_optional_no_default_choices_not_passed():
93
+ """Optional arg with no default and choices, not passed -> succeeds."""
94
+ app = _arg_choices_app()
95
+ r = app.test(["cmd"])
96
+ assert r.exit_code == 0
97
+ assert "env=None" in r.stdout
98
+
99
+
100
+ def test_arg_none_default_choices_passed_valid():
101
+ """A valid choice on the optional arg is still accepted."""
102
+ app = _arg_choices_app(default=None)
103
+ r = app.test(["cmd", "prod"])
104
+ assert r.exit_code == 0
105
+ assert "env=prod" in r.stdout
106
+
107
+
108
+ def test_arg_none_default_choices_passed_invalid():
109
+ """An invalid choice on the optional arg is still rejected."""
110
+ app = _arg_choices_app(default=None)
111
+ r = app.test(["cmd", "local"])
112
+ assert r.exit_code == 1
113
+ assert (
114
+ "argument 'env': invalid value 'local', must be one of: dev, staging, prod"
115
+ in r.stderr
116
+ )
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Custom validators: not run for None (not-passed) values
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ def _name_validator(val):
125
+ if not isinstance(val, str):
126
+ raise ValueError(f"validator received non-string value {val!r}")
127
+ if val == "bad":
128
+ raise ValueError("bad name")
129
+
130
+
131
+ def _mutex_validate_app():
132
+ mg = strictcli.MutexGroup(
133
+ flags=[
134
+ strictcli.Flag(
135
+ name="name", type=str, help="a name",
136
+ default=None, validate=_name_validator,
137
+ ),
138
+ strictcli.Flag(name="id", type=str, help="an id", default=None),
139
+ ],
140
+ )
141
+ app = strictcli.App(name="test", version="1.0.0", help="test app")
142
+
143
+ @app.command("cmd", help="a command", mutex=[mg])
144
+ def cmd(name, id):
145
+ print(f"name={name} id={id}")
146
+
147
+ return app
148
+
149
+
150
+ def test_mutex_flag_validate_unset_not_called():
151
+ """A custom validator must not run for an unset mutex flag (value None)."""
152
+ app = _mutex_validate_app()
153
+ r = app.test(["cmd", "--id", "42"])
154
+ assert r.exit_code == 0
155
+ assert "name=None id=42" in r.stdout
156
+
157
+
158
+ def test_mutex_flag_validate_passed_still_runs():
159
+ """A passed value is still validated."""
160
+ app = _mutex_validate_app()
161
+ r = app.test(["cmd", "--name", "bad"])
162
+ assert r.exit_code == 1
163
+ assert "--name: bad name" in r.stderr
@@ -232,7 +232,7 @@ wheels = [
232
232
 
233
233
  [[package]]
234
234
  name = "strictcli"
235
- version = "0.23.0"
235
+ version = "0.24.2"
236
236
  source = { editable = "." }
237
237
 
238
238
  [package.dev-dependencies]
@@ -1,27 +0,0 @@
1
- name: Publish
2
- on:
3
- release:
4
- types: [published]
5
- workflow_dispatch:
6
- permissions:
7
- contents: read
8
- id-token: write
9
- jobs:
10
- pypi:
11
- runs-on: ubuntu-latest
12
- steps:
13
- - uses: actions/checkout@v6
14
- - uses: astral-sh/setup-uv@v7
15
- - run: uv build
16
- - uses: pypa/gh-action-pypi-publish@release/v1
17
- npm:
18
- runs-on: ubuntu-latest
19
- steps:
20
- - uses: actions/checkout@v6
21
- - uses: actions/setup-node@v6
22
- with:
23
- node-version: 24
24
- registry-url: https://registry.npmjs.org
25
- - run: npm publish --provenance --access public
26
- env:
27
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -1,12 +0,0 @@
1
- {
2
- "targets": [
3
- "pypi",
4
- "npm"
5
- ],
6
- "private": false,
7
- "push_timeout": 300,
8
- "pipelines": {
9
- "pypi": {"type": "pypi", "local": false},
10
- "npm": {"type": "npm", "local": false}
11
- }
12
- }
@@ -1 +0,0 @@
1
- 0.92.0
@@ -1,52 +0,0 @@
1
- <!-- Generated by rlsbl from .rlsbl/changes/ — do not edit -->
2
-
3
- # Changelog
4
-
5
- ## 0.23.0
6
-
7
- Registration-time ban on bare --force flag name and --no-* prefix
8
-
9
- <details>
10
- <summary>Context</summary>
11
-
12
- Flag names 'force' (exact) and names starting with 'no-' are now rejected at registration time. Bare --force encourages agents to bypass guardrails without thinking; qualified names like --force-overwrite make the intent explicit. The no- prefix is reserved for strictcli's auto-generated negation system (--no-flag for negatable bools).
13
-
14
- </details>
15
-
16
- ### Features
17
-
18
- - **New.** Registration-time ban on bare --force flag name and --no-* prefix. Flag names starting with no- are reserved for the negation system.
19
-
20
- ## 0.22.0
21
-
22
- ### Breaking
23
-
24
- - **Breaking.** Bool flags no longer auto-default to false. BoolFlag without explicit Default(false) is now required — user must pass --flag or --no-flag.
25
-
26
- ### Features
27
-
28
- - **New.** Schema dump validates project_id before writing — hard error if existing schema belongs to a different project.
29
- - **New.** Context type with output methods (Info, Warn, Debug, Error, Emit) for structured handler communication.
30
- - **New.** Comprehensive sub-project READMEs replacing severely stale documentation.
31
-
32
- ### Fixes
33
-
34
- - **Fix.** TagContract now checks global flags, not just command-level flags.
35
-
36
- ## 0.21.0
37
-
38
- ### Features
39
-
40
- - **New feature.** Programmatic invocation via `app.call()` and `app.acall()` with `InvokeError` exception for structured error handling.
41
- - **New feature.** Schema versioning with `schema_version` field, constraint serialization (mutex, CoRequired, Requires, Implies), tag contracts, and arg defaults in `--dump-schema` output.
42
- - **New feature.** Typed positional args with `type` (str/int/float/bool) and `choices` support, matching the flag type system.
43
- - **New feature.** Command visibility: `hidden` and `interactive` declarations with help text filtering and schema serialization.
44
- - **New feature.** First-class config fields with `config_field()` declarations, per-command binding, startup validation, `config init` template generation, and unknown-key rejection.
45
- - **New feature.** Compound types `list[T]` and `dict[str, T]` on flags and args with projection-specific CLI parsing.
46
- - **New feature.** Tool export: `json_schema()`, `as_tools()`, `Tool` dataclass, and router tool for AI agent integration.
47
- - **New feature.** MCP projection via `serve_mcp()` and `--mcp` flag for Model Context Protocol tool serving.
48
- - **New feature.** Optional `scope` field on check definitions for declarative pre-check context filtering, and `set_scope_adapter()` method on App for registering scope transformation callbacks.
49
-
50
- ### Fixes
51
-
52
- - **Fix.** Validate unknown kwargs and enforce required globals in passthrough invoke path.
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