django-spec-cli 0.4.1__tar.gz → 0.6.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 (99) hide show
  1. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/.github/workflows/release.yml +1 -1
  2. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/.github/workflows/tests.yml +4 -4
  3. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/.github/workflows/upstream-drift.yml +2 -2
  4. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/CHANGELOG.md +54 -1
  5. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/CLAUDE.md +1 -1
  6. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/PKG-INFO +3 -3
  7. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/README.md +1 -1
  8. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/__init__.py +2 -0
  9. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/checks.py +10 -3
  10. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/spec_command.py +95 -9
  11. django_spec_cli-0.6.0/django_spec_cli/types/__init__.py +9 -0
  12. django_spec_cli-0.6.0/django_spec_cli/types/cli_transport.py +13 -0
  13. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/version.py +1 -1
  14. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/docs/examples/commands.py +22 -2
  15. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/docs/index.md +50 -1
  16. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/docs/reference.md +1 -0
  17. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/pyproject.toml +6 -3
  18. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/docs/test_commands_examples.py +7 -0
  19. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/test_checks.py +78 -0
  20. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/test_spec_command.py +455 -0
  21. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/uv.lock +104 -104
  22. django_spec_cli-0.4.1/django_spec_cli/types/__init__.py +0 -7
  23. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/.github/CODEOWNERS +0 -0
  24. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/.github/SECURITY.md +0 -0
  25. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/.github/dependabot.yml +0 -0
  26. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/.gitignore +0 -0
  27. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/.pre-commit-config.yaml +0 -0
  28. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/LICENSE +0 -0
  29. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/Makefile +0 -0
  30. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/apps.py +0 -0
  31. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/argv/__init__.py +0 -0
  32. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/argv/add_spec_arguments.py +0 -0
  33. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/argv/describe_parameters.py +0 -0
  34. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/argv/parameter_descriptor.py +0 -0
  35. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/argv/parsed_arguments.py +0 -0
  36. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/argv/utils.py +0 -0
  37. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/exit_code_for.py +0 -0
  38. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/py.typed +0 -0
  39. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/render/__init__.py +0 -0
  40. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/render/render_json_lines.py +0 -0
  41. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/render/render_result.py +0 -0
  42. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/render/render_table.py +0 -0
  43. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/render/utils.py +0 -0
  44. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/render/wants_table.py +0 -0
  45. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/report/__init__.py +0 -0
  46. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/report/unguarded_message.py +0 -0
  47. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/report/utils.py +0 -0
  48. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/settings.py +0 -0
  49. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/django_spec_cli/types/exit_code.py +0 -0
  50. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/docs/__init__.py +0 -0
  51. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/docs/examples/__init__.py +0 -0
  52. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/mkdocs.yml +0 -0
  53. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/scripts/check-changelog +0 -0
  54. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/scripts/release-publish.sh +0 -0
  55. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/__init__.py +0 -0
  56. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/argv/__init__.py +0 -0
  57. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/argv/test_add_spec_arguments.py +0 -0
  58. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/argv/test_describe_parameters.py +0 -0
  59. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/argv/test_parameter_descriptor.py +0 -0
  60. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/argv/test_parsed_arguments.py +0 -0
  61. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/argv/test_utils.py +0 -0
  62. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/argv/utils.py +0 -0
  63. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/commands_app/__init__.py +0 -0
  64. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/commands_app/apps.py +0 -0
  65. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/commands_app/management/__init__.py +0 -0
  66. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/__init__.py +0 -0
  67. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/notes.py +0 -0
  68. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/unguarded_bad_parser.py +0 -0
  69. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/unguarded_broken_import.py +0 -0
  70. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/unguarded_broken_init.py +0 -0
  71. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/unguarded_empty.py +0 -0
  72. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/unguarded_no_registry.py +0 -0
  73. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/unguarded_plain.py +0 -0
  74. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/unguarded_report.py +0 -0
  75. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/commands_app/unguarded_registries.py +0 -0
  76. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/conftest.py +0 -0
  77. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/conftest_settings.py +0 -0
  78. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/dispatch_app/__init__.py +0 -0
  79. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/dispatch_app/apps.py +0 -0
  80. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/dispatch_app/models.py +0 -0
  81. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/docs/__init__.py +0 -0
  82. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/docs/test_reference.py +0 -0
  83. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/render/__init__.py +0 -0
  84. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/render/test_render_json_lines.py +0 -0
  85. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/render/test_render_result.py +0 -0
  86. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/render/test_render_table.py +0 -0
  87. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/render/test_utils.py +0 -0
  88. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/render/test_wants_table.py +0 -0
  89. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/render/utils.py +0 -0
  90. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/report/__init__.py +0 -0
  91. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/report/test_unguarded_message.py +0 -0
  92. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/report/test_utils.py +0 -0
  93. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/test_apps.py +0 -0
  94. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/test_exit_code_for.py +0 -0
  95. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/test_init.py +0 -0
  96. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/test_settings.py +0 -0
  97. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/types/__init__.py +0 -0
  98. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/types/test_exit_code.py +0 -0
  99. {django_spec_cli-0.4.1 → django_spec_cli-0.6.0}/tests/utils.py +0 -0
@@ -31,7 +31,7 @@ jobs:
31
31
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
32
32
  with:
33
33
  fetch-depth: 0
34
- - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
34
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
35
35
  with:
36
36
  enable-cache: true
37
37
  - name: Install
@@ -20,7 +20,7 @@ jobs:
20
20
  runs-on: ubuntu-latest
21
21
  steps:
22
22
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
23
- - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
23
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
24
24
  with:
25
25
  enable-cache: true
26
26
  - name: Install
@@ -37,7 +37,7 @@ jobs:
37
37
  runs-on: ubuntu-latest
38
38
  steps:
39
39
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
40
- - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
40
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
41
41
  with:
42
42
  enable-cache: true
43
43
  - name: Install with docs group
@@ -54,7 +54,7 @@ jobs:
54
54
  runs-on: ubuntu-latest
55
55
  steps:
56
56
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
57
- - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
57
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
58
58
  with:
59
59
  enable-cache: true
60
60
  # A floor is a claim about the *oldest* version that works, and a lockfile
@@ -171,7 +171,7 @@ jobs:
171
171
 
172
172
  steps:
173
173
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
174
- - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
174
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
175
175
  with:
176
176
  enable-cache: true
177
177
  - name: Install Python ${{ matrix.python-version }}
@@ -43,14 +43,14 @@ jobs:
43
43
  runs-on: ubuntu-latest
44
44
  steps:
45
45
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
46
- - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
46
+ - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
47
47
  with:
48
48
  enable-cache: false
49
49
 
50
50
  # One cell, not the matrix. The matrix answers "does this combination
51
51
  # work"; this answers "has an upstream moved under us", and the newest
52
52
  # supported Python is where a new release lands first.
53
- - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
53
+ - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
54
54
  with:
55
55
  python-version: "3.14"
56
56
 
@@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] — 2026-11-09
11
+
12
+ ### Fixed
13
+ - **A field a Validator declares only under `cli` is an option.** The command
14
+ asked the kernel's `spec_caller_input_schema` for the options to offer with no
15
+ `call_context`, so the schema was the declaration made to no transport at all,
16
+ and a field the Validator declares only when
17
+ `call_transport(call_context) == "cli"` got no option. If it was required,
18
+ every run was refused for it. The schema is now read under the same stamped
19
+ dict `--help` already hands the Validator, so such a field is offered, marked
20
+ `(required)` where it is, and a run leaving it out is refused with the shape
21
+ check's own message. A field declared to another transport is still no option.
22
+ The command line page no longer carries the caveat that asked for such a field
23
+ to be declared to every transport.
24
+
25
+ - **A field a Validator declares only under `cli` that dispatch seeds itself is
26
+ refused for its spec alone.** The read `--help` makes under the stamped dict
27
+ raised `ImproperlyConfigured` for such a field, for example `user`, while the
28
+ parser was built, so every spec of the command, `--help` and `--explain` died
29
+ with a traceback, and `django_spec_cli.W004` said nothing because
30
+ `check_seeds` takes no `call_context`. The parser now takes that refusal for
31
+ the one spec: its subcommand takes no option and its `--help` prints the
32
+ kernel's reason, a run of it raises the same sentence as `ImproperlyConfigured`
33
+ before `--as-user` is looked up, whatever was sent, and `W004` names it. The
34
+ command's other specs are unaffected.
35
+
36
+ ### Changed
37
+ - **The floor on `django-service-specs` rises from 0.29.0 to 0.30.0**, the
38
+ release that lets `spec_caller_input_schema` take a `call_context`. A project
39
+ on an older kernel stays on 0.5.x.
40
+
41
+ ## [0.5.0] — 2026-11-08
42
+
43
+ ### Added
44
+ - **A run stamps its transport: `"cli"`.** Every callable a `SpecCommand` run
45
+ reaches is now handed a `call_context` built by the kernel's
46
+ `build_call_context` and stamped last with `"cli"`, exported as
47
+ `CLI_TRANSPORT`, so a permission check, the Validator, a provider, the
48
+ service and the presenter read `call_transport(call_context) == "cli"` from
49
+ one dict. Before, the command handed `dispatch` none, and a callable reading
50
+ the key saw a missing one, which the kernel reads as an in-process caller.
51
+ The Validator's declaration the command reads for the options it parses is
52
+ asked under the stamped dict as well. `request`, `view`, `format` and
53
+ `action` are in the dict as `None`, which binds nothing different from a
54
+ missing key. The command line page says what a callable reads here.
55
+
56
+ ### Changed
57
+ - **The floor on `django-service-specs` rises from 0.25.0 to 0.29.0**, the
58
+ release that adds `build_call_context` and removes the shared `Transport`
59
+ enum. A project on an older kernel stays on 0.4.x.
60
+
10
61
  ## [0.4.1] — 2026-11-04
11
62
 
12
63
  ### Added
@@ -135,7 +186,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
135
186
  `django-service-specs>=0.10.0`, the first kernel release with every name the
136
187
  command reads at its root.
137
188
 
138
- [Unreleased]: https://github.com/Artui/django-spec-cli/compare/v0.4.1...HEAD
189
+ [Unreleased]: https://github.com/Artui/django-spec-cli/compare/v0.6.0...HEAD
190
+ [0.6.0]: https://github.com/Artui/django-spec-cli/compare/v0.5.0...v0.6.0
191
+ [0.5.0]: https://github.com/Artui/django-spec-cli/compare/v0.4.1...v0.5.0
139
192
  [0.4.1]: https://github.com/Artui/django-spec-cli/compare/v0.4.0...v0.4.1
140
193
  [0.4.0]: https://github.com/Artui/django-spec-cli/compare/v0.3.1...v0.4.0
141
194
  [0.3.1]: https://github.com/Artui/django-spec-cli/compare/v0.3.0...v0.3.1
@@ -139,7 +139,7 @@ configured and enforced. isort order is stdlib, third party, first party.
139
139
  | --- | --- | --- |
140
140
  | Python | 3.10 | 3.10 through 3.14 |
141
141
  | Django | 4.2 | 4.2, 5.0, 5.1, 5.2, 6.0, 6.1 |
142
- | django-service-specs | 0.25.0 | the newest release, and the floor in the `floor` job |
142
+ | django-service-specs | 0.30.0 | the newest release, and the floor in the `floor` job |
143
143
 
144
144
  The Python and Django floors are the kernel's, and cannot sit below them.
145
145
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: django-spec-cli
3
- Version: 0.4.1
3
+ Version: 0.6.0
4
4
  Summary: Run a django-service-specs registry from manage.py: one subcommand per spec, options generated from its parameters, exit codes for every refusal.
5
5
  Project-URL: Homepage, https://github.com/Artui/django-spec-cli
6
6
  Project-URL: Repository, https://github.com/Artui/django-spec-cli
@@ -30,7 +30,7 @@ Classifier: Programming Language :: Python :: 3.13
30
30
  Classifier: Programming Language :: Python :: 3.14
31
31
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
32
32
  Requires-Python: >=3.10
33
- Requires-Dist: django-service-specs>=0.25.0
33
+ Requires-Dist: django-service-specs>=0.30.0
34
34
  Requires-Dist: django>=4.2
35
35
  Description-Content-Type: text/markdown
36
36
 
@@ -61,7 +61,7 @@ the import path, the system check ids and the setting key are this package's.
61
61
  pip install django-spec-cli
62
62
  ```
63
63
 
64
- Python 3.10 or later, Django 4.2 or later, and `django-service-specs` 0.25.0 or
64
+ Python 3.10 or later, Django 4.2 or later, and `django-service-specs` 0.30.0 or
65
65
  later, which it installs.
66
66
 
67
67
  ## Quickstart
@@ -25,7 +25,7 @@ the import path, the system check ids and the setting key are this package's.
25
25
  pip install django-spec-cli
26
26
  ```
27
27
 
28
- Python 3.10 or later, Django 4.2 or later, and `django-service-specs` 0.25.0 or
28
+ Python 3.10 or later, Django 4.2 or later, and `django-service-specs` 0.30.0 or
29
29
  later, which it installs.
30
30
 
31
31
  ## Quickstart
@@ -11,11 +11,13 @@ from django_spec_cli.render.render_table import render_table
11
11
  from django_spec_cli.render.wants_table import wants_table
12
12
  from django_spec_cli.report.unguarded_message import unguarded_message
13
13
  from django_spec_cli.spec_command import SpecCommand
14
+ from django_spec_cli.types.cli_transport import CLI_TRANSPORT
14
15
  from django_spec_cli.types.exit_code import ExitCode
15
16
  from django_spec_cli.version import __version__
16
17
 
17
18
  __all__ = [
18
19
  "__version__",
20
+ "CLI_TRANSPORT",
19
21
  "ExitCode",
20
22
  "ParameterDescriptor",
21
23
  "SpecCommand",
@@ -108,10 +108,13 @@ def check_unguarded_specs(
108
108
 
109
109
 
110
110
  def _refused(command: SpecCommand) -> Iterator[tuple[str, ImproperlyConfigured]]:
111
- """Each spec of ``command`` that ``check_seeds`` refuses, with its refusal, in registry order.
111
+ """Each spec of ``command`` a run refuses as declared, with its refusal, in registry order.
112
112
 
113
- The same question ``SpecCommand.handle`` asks before a run, under the
114
- same ``pool_seeds``, so a spec reported here is one a run refuses.
113
+ Two questions, as ``SpecCommand.handle`` asks them and in its order:
114
+ ``check_seeds`` under the same ``pool_seeds``, and the read the parser
115
+ made under the cli-stamped dict, which ``check_seeds`` never sees. The
116
+ parser has been built by now (the walk builds it), and it kept the
117
+ second's refusals. A spec both refuse is reported once, for the first.
115
118
  Outside the walk's ``try``: a refusal names one spec, and the command
116
119
  loaded, so its other specs are still reported and it is no W003.
117
120
  """
@@ -120,6 +123,10 @@ def _refused(command: SpecCommand) -> Iterator[tuple[str, ImproperlyConfigured]]
120
123
  check_seeds(entry.spec, pool_seeds=command.pool_seeds)
121
124
  except ImproperlyConfigured as error:
122
125
  yield entry.name, error
126
+ continue
127
+ declared = command._refused_by_declaration.get(entry.name)
128
+ if declared is not None:
129
+ yield entry.name, declared
123
130
 
124
131
 
125
132
  def _spec_commands(
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import inspect
7
- from collections.abc import Mapping
7
+ from collections.abc import Mapping, Sequence
8
8
  from dataclasses import replace
9
9
  from typing import Any, TextIO, cast
10
10
 
@@ -26,6 +26,7 @@ from django_service_specs import (
26
26
  ServiceSpec,
27
27
  ServiceValidationError,
28
28
  SpecRegistry,
29
+ build_call_context,
29
30
  check_seeds,
30
31
  coerce_flat,
31
32
  dispatch,
@@ -46,6 +47,7 @@ from django_spec_cli.render.render_result import render_result
46
47
  from django_spec_cli.render.wants_table import wants_table
47
48
  from django_spec_cli.report.unguarded_message import unguarded_message
48
49
  from django_spec_cli.report.utils import refused_message
50
+ from django_spec_cli.types.cli_transport import CLI_TRANSPORT
49
51
  from django_spec_cli.types.exit_code import ExitCode
50
52
 
51
53
  _OWN_OPTIONS = frozenset({"--as-user", "--json", "--table"})
@@ -76,7 +78,7 @@ class SpecCommand(BaseCommand):
76
78
  [`add_spec_arguments`][django_spec_cli.argv.add_spec_arguments.add_spec_arguments]
77
79
  spells them, as the input schema a transport advertises asks for them
78
80
  ([`spec_caller_input_schema`][django_service_specs.schema.spec_caller_input_schema.spec_caller_input_schema]
79
- under ``pool_seeds``): less any key the server fills, and required only
81
+ under ``pool_seeds`` and the stamped ``call_context``): less any key the server fills, and required only
80
82
  where dispatch refuses leaving it out.
81
83
  No option is matched by a prefix: an abbreviation is refused rather
82
84
  than read as whichever option it begins.
@@ -97,7 +99,14 @@ class SpecCommand(BaseCommand):
97
99
  and its refusal is raised as ``ImproperlyConfigured``, as a command
98
100
  naming no registry is: a traceback and ``ExitCode.ERROR``, whatever
99
101
  the options sent. The command's other specs still run, and the system
100
- check reports the same spec as ``django_spec_cli.W004``.
102
+ check reports the same spec as ``django_spec_cli.W004``. The same holds for a spec
103
+ whose Validator declares, only to ``cli``, a parameter dispatch seeds
104
+ itself: ``check_seeds`` is asked outside a call and never sees it, the
105
+ read the parser makes under the stamped dict does, and the parser takes
106
+ that refusal for its spec alone. Its subcommand takes no option and its
107
+ ``--help`` gives the reason; a run raises it as above, and W004 reports
108
+ it too
109
+ (test_a_run_of_it_is_refused_with_the_kernel_s_reason_whatever_is_sent).
101
110
  - ``--explain`` runs no spec: it lists the ones that authorize nothing,
102
111
  as the system check does.
103
112
 
@@ -117,10 +126,14 @@ class SpecCommand(BaseCommand):
117
126
  _command_label: str
118
127
  _parser: CommandParser
119
128
  _entries: dict[str, RegisteredSpec]
129
+ _refused_by_declaration: dict[str, ImproperlyConfigured]
120
130
 
121
131
  def create_parser(self, prog_name: str, subcommand: str, **kwargs: Any) -> CommandParser:
122
132
  """Django's parser, with this command's options added and no option read by its prefix."""
123
133
  self._command_label = subcommand
134
+ # Before ``add_arguments``, and not in it: a subclass overriding that
135
+ # without calling ``super()`` still leaves the system check a dict to read.
136
+ self._refused_by_declaration = {}
124
137
  parser = super().create_parser(prog_name, subcommand, **kwargs)
125
138
  # Django's own options are on this parser, and a prefix of one is no
126
139
  # more an option here than on a subcommand.
@@ -173,8 +186,24 @@ class SpecCommand(BaseCommand):
173
186
  )
174
187
  self._entries[name] = entry
175
188
  spec = kernel_spec(entry.spec)
176
- parameters = _sendable(spec, self.pool_seeds)
177
189
  summary = _summary(spec, entry.name)
190
+ try:
191
+ parameters = _sendable(spec, self.pool_seeds, _call_context())
192
+ except ImproperlyConfigured as error:
193
+ # Only the read made under the stamped dict can raise here: a
194
+ # declaration the kernel refuses outside a call is
195
+ # ``check_seeds``' and is asked in ``handle``. Raising from the
196
+ # parser would stop every spec the command serves, ``--help``
197
+ # and ``--explain`` with them, so the refusal waits for a run of
198
+ # this spec, which raises it, and its subcommand takes no option.
199
+ self._refused_by_declaration[entry.name] = error
200
+ _add_refused_subparser(
201
+ subparsers,
202
+ name,
203
+ summary,
204
+ refused_message(entry.name, self._command_label, error),
205
+ )
206
+ continue
178
207
  subparser = subparsers.add_parser(
179
208
  name,
180
209
  help=summary.replace("%", "%%"),
@@ -251,12 +280,19 @@ class SpecCommand(BaseCommand):
251
280
  # ``ExitCode.ERROR`` is published as the failure with a traceback.
252
281
  try:
253
282
  check_seeds(entry.spec, pool_seeds=self.pool_seeds)
283
+ declared = self._refused_by_declaration.get(entry.name)
284
+ if declared is not None:
285
+ raise declared
254
286
  except ImproperlyConfigured as error:
255
287
  raise ImproperlyConfigured(
256
288
  refused_message(entry.name, self._command_label, error)
257
289
  ) from error
258
290
  spec = kernel_spec(entry.spec)
259
- parameters = spec.parameters()
291
+ # One dict for the whole run, built ahead of the first callable it
292
+ # asks: the declaration read here, then every stage ``dispatch`` runs,
293
+ # so a callable reading ``transport`` sees ``cli`` from either.
294
+ call_context = _call_context()
295
+ parameters = spec.parameters(call_context=call_context)
260
296
  raw = parsed_arguments(argparse.Namespace(**options), parameters)
261
297
  # Read before the run, not after it: a setting that names no format
262
298
  # raises, and raising once a write has committed reports a failure for
@@ -282,6 +318,7 @@ class SpecCommand(BaseCommand):
282
318
  principal=principal,
283
319
  arguments=coerce_flat(parameters, raw),
284
320
  pool_seeds=self.pool_seeds,
321
+ call_context=call_context,
285
322
  )
286
323
  except (DispatchError, ServiceError) as error:
287
324
  raise CommandError(_message(error), returncode=int(exit_code_for(error))) from error
@@ -304,11 +341,29 @@ class SpecCommand(BaseCommand):
304
341
  self.stdout.write(unguarded_message(unguarded, label))
305
342
 
306
343
 
307
- def _sendable(spec: ServiceSpec | SelectorSpec, pool_seeds: PoolSeeds) -> Parameters:
344
+ def _call_context() -> dict[str, Any]:
345
+ """A new context for the command's own reads and one run, stamped ``cli``.
346
+
347
+ ``request`` and ``view`` are there as ``None``, since a command line has
348
+ neither: the kernel hands a callable declaring either ``None`` for a
349
+ ``None`` entry exactly as for a missing one, so no binding changes.
350
+ ``format`` and ``action`` are no pool names and are never bound.
351
+ """
352
+ return build_call_context(CLI_TRANSPORT)
353
+
354
+
355
+ def _sendable(
356
+ spec: ServiceSpec | SelectorSpec, pool_seeds: PoolSeeds, call_context: dict[str, Any]
357
+ ) -> Parameters:
308
358
  """What a caller may send, as the kernel's caller schema reads it: required only where dispatch demands it.
309
359
 
310
360
  The parameters ``spec_caller_input_schema`` offers under the command's
311
- ``pool_seeds``, so no key the server fills is an option: one a provider
361
+ ``pool_seeds`` and the stamped ``call_context``, so a field a Validator
362
+ declares only to ``cli`` is an option and one declared to another
363
+ transport is not
364
+ (test_a_required_field_declared_only_under_cli_is_an_option_marked_required,
365
+ test_a_field_declared_under_another_transport_is_no_option), and no key the
366
+ server fills is an option: one a provider
312
367
  is annotated to fill (test_a_key_the_server_fills_is_no_option), and a
313
368
  reflected read a registered seed fills
314
369
  (test_a_reflected_read_a_seed_fills_is_an_option_only_without_the_seed).
@@ -322,17 +377,48 @@ def _sendable(spec: ServiceSpec | SelectorSpec, pool_seeds: PoolSeeds) -> Parame
322
377
  (test_a_read_a_provider_requires_itself_is_required). Only
323
378
  relaxed, never made required, so a declared default still answers.
324
379
  """
325
- schema = spec_caller_input_schema(spec, pool_seeds=pool_seeds)
380
+ schema = spec_caller_input_schema(spec, pool_seeds=pool_seeds, call_context=call_context)
326
381
  required = frozenset(schema.get("required", ()))
327
382
  return Parameters.of(
328
383
  *(
329
384
  param if param.name in required else replace(param, required=False)
330
- for param in spec.parameters()
385
+ for param in spec.parameters(call_context=call_context)
331
386
  if param.name in schema["properties"]
332
387
  )
333
388
  )
334
389
 
335
390
 
391
+ def _add_refused_subparser(subparsers: Any, name: str, summary: str, message: str) -> None:
392
+ """The subcommand of a spec whose declaration refuses under the stamped dict: its reason, and no option.
393
+
394
+ Whatever is sent after the name is taken and read by nothing, so a run is
395
+ refused with the reason whatever it carried, as a spec ``check_seeds``
396
+ refuses is. Only ``--help`` is read, and it prints the reason.
397
+ """
398
+ subparser = subparsers.add_parser(
399
+ name,
400
+ help=summary.replace("%", "%%"),
401
+ description=summary,
402
+ # argparse formats an epilog only where it names ``%(prog)``, so a
403
+ # ``%`` is doubled there and nowhere else
404
+ # (test_a_percent_sign_in_the_reason_is_printed_as_it_is).
405
+ epilog=message.replace("%", "%%") if "%(prog)" in message else message,
406
+ usage="%(prog)s [arguments ...]",
407
+ allow_abbrev=False,
408
+ )
409
+ reads_help = subparser.parse_known_args
410
+
411
+ def take_everything(
412
+ args: Sequence[str], namespace: argparse.Namespace | None = None
413
+ ) -> tuple[argparse.Namespace, list[str]]:
414
+ # ``_SubParsersAction`` hands a list and a ``None`` namespace.
415
+ if {"-h", "--help"} & set(args):
416
+ return reads_help(args, namespace)
417
+ return argparse.Namespace(), []
418
+
419
+ subparser.parse_known_args = take_everything
420
+
421
+
336
422
  def _summary(spec: ServiceSpec | SelectorSpec, name: str) -> str:
337
423
  """The first paragraph of what the spec's callable says about itself, and whether it reads or writes.
338
424
 
@@ -0,0 +1,9 @@
1
+ """Value types: the exit status a command's run ends with, and the transport name it stamps."""
2
+
3
+ from django_spec_cli.types.cli_transport import CLI_TRANSPORT
4
+ from django_spec_cli.types.exit_code import ExitCode
5
+
6
+ __all__ = [
7
+ "CLI_TRANSPORT",
8
+ "ExitCode",
9
+ ]
@@ -0,0 +1,13 @@
1
+ """``CLI_TRANSPORT`` - the transport name a ``SpecCommand`` stamps on every call's context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Final
6
+
7
+ CLI_TRANSPORT: Final = "cli"
8
+ """What a ``SpecCommand`` stamps under the kernel's ``TRANSPORT_KEY``: the plain string ``"cli"``.
9
+
10
+ A callable reads it with the kernel's ``call_transport``, or compares
11
+ ``call_context["transport"] == "cli"`` with nothing imported. This package owns
12
+ the string and the kernel lists no transports; it is a public contract, so it
13
+ never changes."""
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__: str = "0.4.1"
5
+ __version__: str = "0.6.0"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -1,5 +1,6 @@
1
- """A registry served as a management command, a command that looks its user up by username, and
2
- a command serving a spec the kernel refuses as declared.
1
+ """A registry served as a management command, a command that looks its user up by username, a
2
+ command whose service reads the transport it is called over, and a command serving a spec the
3
+ kernel refuses as declared.
3
4
 
4
5
  ``tests/docs/test_commands_examples.py`` runs every command line
5
6
  ``docs/index.md`` shows through this module's ``Command``, from argv as
@@ -31,6 +32,7 @@ from django_service_specs import (
31
32
  ServiceSpec,
32
33
  SpecRegistry,
33
34
  Unrestricted,
35
+ call_transport,
34
36
  )
35
37
 
36
38
  from django_spec_cli import SpecCommand
@@ -166,3 +168,21 @@ class Drafts(SpecCommand):
166
168
 
167
169
 
168
170
  # --8<-- [end:refused]
171
+
172
+
173
+ # --8<-- [start:transport]
174
+ def where(*, call_context: Any) -> str:
175
+ """The transport this call came over, as its context says."""
176
+ return call_transport(call_context) or "in-process"
177
+
178
+
179
+ where_registry = SpecRegistry()
180
+ where_registry.register("where", ServiceSpec(service=where, permissions=[IsSignedIn()]))
181
+
182
+
183
+ # yourapp/management/commands/where.py
184
+ class Where(SpecCommand):
185
+ registry = where_registry
186
+
187
+
188
+ # --8<-- [end:transport]
@@ -27,7 +27,7 @@ pip install django-spec-cli
27
27
  ```
28
28
 
29
29
  It installs [`django-service-specs`](https://artui.github.io/django-service-specs/), the kernel whose specs it serves,
30
- from 0.25.0, on Python 3.10 and Django 4.2 or later. The command line was the
30
+ from 0.30.0, on Python 3.10 and Django 4.2 or later. The command line was the
31
31
  kernel's `commands/` subpackage, and moved here so the kernel carries no
32
32
  transport.
33
33
 
@@ -264,6 +264,17 @@ with the same sentence, raised as `ImproperlyConfigured` (exit code 1,
264
264
  with a traceback) before `--as-user` is looked up, even when the options sent
265
265
  would let it run. The command's other specs are unaffected.
266
266
 
267
+ The same holds for a declaration only the command's own read reaches. A
268
+ Validator may declare a field only when `call_transport(call_context) == "cli"`,
269
+ and if that field is named for a key dispatch seeds itself, such as `user`,
270
+ `check_seeds` (asked outside a call) never sees it, and the read `--help`
271
+ makes under the stamped dict raises. That refusal belongs to the one spec: its
272
+ subcommand takes no option, its `--help` prints the kernel's reason, a run of it
273
+ ends with the same sentence as `ImproperlyConfigured` whatever was sent, and
274
+ `django_spec_cli.W004` names it. The command's other specs, `--help` and
275
+ `--explain` work as before. A spec both reads refuse is reported once, with the
276
+ `check_seeds` reason.
277
+
267
278
  A `Parameter` is optional unless it says `required=True`, so a read declared
268
279
  plainly over a keyword with no default is one such declaration:
269
280
 
@@ -296,6 +307,44 @@ preceded by the warnings for `whoami` and `retitle`. `call_command` skips
296
307
  system checks for every command but `check`, so a command run through it
297
308
  does not print them.
298
309
 
310
+ ## What a callable reads here
311
+
312
+ Every callable a run reaches is handed one `call_context`, built by the
313
+ kernel's
314
+ [`build_call_context`][django_service_specs.types.build_call_context.build_call_context]
315
+ and stamped last with this package's transport name, the plain string `"cli"`,
316
+ exported as `django_spec_cli.CLI_TRANSPORT`. Read it with the kernel's
317
+ [`call_transport`][django_service_specs.types.call_transport.call_transport],
318
+ or compare `call_context["transport"] == "cli"` with nothing imported:
319
+
320
+ ```python
321
+ --8<--
322
+ docs/examples/commands.py:transport
323
+ --8<--
324
+ ```
325
+
326
+ - **One dict per run.** The command builds it ahead of everything it asks of
327
+ a spec and hands the same object to the declaration it reads for the
328
+ options it parses, then to `dispatch`, so a permission check, the
329
+ Validator's `parameters()` and `validate()`, a provider, the service and the
330
+ presenter all read `"cli"` from one dict.
331
+ - **The standard entries are there as `None`.** `request`, `view`, `format`
332
+ and `action` are keys of the dict with no value, since a command line has
333
+ none. The kernel hands a callable declaring `request` or `view` `None` for
334
+ such an entry, as it does for a missing one, and `format` and `action` are
335
+ never bound by name, so a spec written for HTTP runs here as it did.
336
+ - **`--help` is read under a dict too.** The command builds a stamped dict of
337
+ its own for each spec, and the options come from the kernel's caller schema
338
+ read under it, as the Validator's declaration is. A field a Validator
339
+ declares only when `call_transport(call_context) == "cli"` is an option,
340
+ marked `(required)` where it is required, and one it declares to another
341
+ transport is none.
342
+ - **Seams.** The value is not configurable: a subclass cannot change what a
343
+ run stamps. What a subclass can supply is `resolve_as_user`, who the run is
344
+ made as, and `pool_seeds`, which adds a keyword to the pool every callable
345
+ may declare, so an entry a project needs in every call is a seed rather
346
+ than a key of the dict.
347
+
299
348
  ## What it does not do
300
349
 
301
350
  - **No payload file.** Arguments are options; an object or a list of rows has
@@ -60,3 +60,4 @@ Registered by `DjangoSpecCliConfig`, so a project gets them by listing
60
60
  ## Types
61
61
 
62
62
  ::: django_spec_cli.types.exit_code.ExitCode
63
+ ::: django_spec_cli.types.cli_transport.CLI_TRANSPORT
@@ -48,8 +48,11 @@ dependencies = [
48
48
  # ``PermissionCheckDenied`` in ``REFUSALS``, which
49
49
  # ``tests/test_exit_code_for.py`` decides by name. Below 0.11.0 the kernel
50
50
  # also still shipped its own copy of the command and registered its own
51
- # ``W001`` and ``W003``.
52
- "django-service-specs>=0.25.0",
51
+ # ``W001`` and ``W003``. 0.29.0 adds ``build_call_context``, which the
52
+ # command builds the context it stamps ``cli`` on through. 0.30.0 lets
53
+ # ``spec_caller_input_schema`` take that context, so the options offered are
54
+ # read under it.
55
+ "django-service-specs>=0.30.0",
53
56
  ]
54
57
 
55
58
  [project.urls]
@@ -149,7 +152,7 @@ skip-magic-trailing-comma = false
149
152
  root = ["./django_spec_cli"]
150
153
 
151
154
  [tool.bumpversion]
152
- current_version = "0.4.1"
155
+ current_version = "0.6.0"
153
156
  allow_dirty = false
154
157
  commit = false
155
158
  tag = false
@@ -172,3 +172,10 @@ def test_a_run_of_the_refused_spec_raises_the_page_s_sentence() -> None:
172
172
  def test_whoami_is_the_user_named(capsys: pytest.CaptureFixture[str]) -> None:
173
173
  assert _run_from_argv("whoami --as-user 1") == 0
174
174
  assert capsys.readouterr().out == '"ada"\n'
175
+
176
+
177
+ @pytest.mark.usefixtures("world")
178
+ def test_the_transport_example_reads_cli() -> None:
179
+ out = io.StringIO()
180
+ call_command(commands.Where(), "where", "--as-user", "1", stdout=out)
181
+ assert out.getvalue() == '"cli"\n'