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.
- strictcli-0.24.2/.github/workflows/ci-pypi.yml +29 -0
- strictcli-0.24.2/.github/workflows/publish.yml +152 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/.gitignore +2 -0
- strictcli-0.24.2/.rlsbl/config.json +18 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/.rlsbl/hashes.json +4 -3
- strictcli-0.24.2/.rlsbl/managed-files.json +11 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/PKG-INFO +1 -1
- {strictcli-0.23.0 → strictcli-0.24.2}/package-lock.json +2 -2
- {strictcli-0.23.0 → strictcli-0.24.2}/package.json +1 -1
- {strictcli-0.23.0 → strictcli-0.24.2}/pyproject.toml +1 -1
- {strictcli-0.23.0 → strictcli-0.24.2}/strictcli/__init__.py +100 -79
- strictcli-0.24.2/tests/test_arg_default_validation.py +79 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_check_runner.py +64 -2
- strictcli-0.24.2/tests/test_choices_none.py +163 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/uv.lock +1 -1
- strictcli-0.23.0/.github/workflows/publish.yml +0 -27
- strictcli-0.23.0/.rlsbl/config.json +0 -12
- strictcli-0.23.0/.rlsbl/version +0 -1
- strictcli-0.23.0/CHANGELOG.md +0 -52
- {strictcli-0.23.0 → strictcli-0.24.2}/.claude/settings.json +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/.github/workflows/ci.yml +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/.npmignore +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/.strictcli/schema.json +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/CLAUDE.md +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/LICENSE +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/README.md +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/index.js +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/postinstall.js +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_arg_default.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_at_prefix.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_auto_version.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_call.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_check_command.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_check_discovery.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_check_public_api.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_check_schema.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_check_types.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_choices.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_command_help_suggestion.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_command_tags.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_compound_types.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_config.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_config_fields.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_config_file_path.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_config_set_bugs.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_context.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_deep_nesting.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_dependencies.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_deprecated.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_dump_schema.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_e2e.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_env.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_exit_codes.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_flag_sets.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_float_type.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_global_flags.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_help.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_int_type.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_invoke.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_keyword_flags.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_mcp.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_mutex.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_nesting.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_parser.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_passthrough.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_registration.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_repeatable.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_tagdsl.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_toml_loading.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_tool_export.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_typed_args.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_unique.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_utilities.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_validate.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_variadic.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/tests/test_visibility.py +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/todo/.defer/deferred.md +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
- {strictcli-0.23.0 → strictcli-0.24.2}/todo/.done/original-idea.md +0 -0
- {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 }}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
".github/workflows/ci.yml": "b4e075171b131d25f9cce55a3e3a30732e95a2a794af00f354ee3200626d6a04",
|
|
3
|
-
".github/workflows/publish.yml": "
|
|
3
|
+
".github/workflows/publish.yml": "20db711b8a7745c3ab41814b827e946d60e937e82ee3984e86f39567717199b1",
|
|
4
4
|
"CHANGELOG.md": "7a6ec46141007c18090a0ff325e2dd68cd51588c4e0b5af7b580365e5eaaca85",
|
|
5
|
-
".gitignore": "
|
|
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,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "strictcli",
|
|
3
|
-
"version": "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.
|
|
9
|
+
"version": "0.24.2",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT"
|
|
12
12
|
}
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
3189
|
-
|
|
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.
|
|
3853
|
-
|
|
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
|
-
|
|
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.
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
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
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
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
|
-
|
|
5313
|
-
|
|
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
|
|
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"] == "
|
|
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
|
|
@@ -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 }}
|
strictcli-0.23.0/.rlsbl/version
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.92.0
|
strictcli-0.23.0/CHANGELOG.md
DELETED
|
@@ -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
|
|
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
|