django-spec-cli 0.5.0__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 (98) hide show
  1. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/CHANGELOG.md +33 -1
  2. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/CLAUDE.md +1 -1
  3. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/PKG-INFO +3 -3
  4. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/README.md +1 -1
  5. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/checks.py +10 -3
  6. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/spec_command.py +72 -6
  7. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/version.py +1 -1
  8. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/docs/index.md +17 -8
  9. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/pyproject.toml +5 -3
  10. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/test_checks.py +78 -0
  11. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/test_spec_command.py +227 -9
  12. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/uv.lock +56 -56
  13. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/.github/CODEOWNERS +0 -0
  14. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/.github/SECURITY.md +0 -0
  15. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/.github/dependabot.yml +0 -0
  16. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/.github/workflows/release.yml +0 -0
  17. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/.github/workflows/tests.yml +0 -0
  18. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/.github/workflows/upstream-drift.yml +0 -0
  19. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/.gitignore +0 -0
  20. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/.pre-commit-config.yaml +0 -0
  21. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/LICENSE +0 -0
  22. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/Makefile +0 -0
  23. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/__init__.py +0 -0
  24. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/apps.py +0 -0
  25. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/argv/__init__.py +0 -0
  26. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/argv/add_spec_arguments.py +0 -0
  27. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/argv/describe_parameters.py +0 -0
  28. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/argv/parameter_descriptor.py +0 -0
  29. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/argv/parsed_arguments.py +0 -0
  30. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/argv/utils.py +0 -0
  31. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/exit_code_for.py +0 -0
  32. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/py.typed +0 -0
  33. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/render/__init__.py +0 -0
  34. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/render/render_json_lines.py +0 -0
  35. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/render/render_result.py +0 -0
  36. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/render/render_table.py +0 -0
  37. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/render/utils.py +0 -0
  38. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/render/wants_table.py +0 -0
  39. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/report/__init__.py +0 -0
  40. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/report/unguarded_message.py +0 -0
  41. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/report/utils.py +0 -0
  42. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/settings.py +0 -0
  43. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/types/__init__.py +0 -0
  44. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/types/cli_transport.py +0 -0
  45. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/django_spec_cli/types/exit_code.py +0 -0
  46. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/docs/__init__.py +0 -0
  47. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/docs/examples/__init__.py +0 -0
  48. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/docs/examples/commands.py +0 -0
  49. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/docs/reference.md +0 -0
  50. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/mkdocs.yml +0 -0
  51. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/scripts/check-changelog +0 -0
  52. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/scripts/release-publish.sh +0 -0
  53. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/__init__.py +0 -0
  54. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/argv/__init__.py +0 -0
  55. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/argv/test_add_spec_arguments.py +0 -0
  56. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/argv/test_describe_parameters.py +0 -0
  57. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/argv/test_parameter_descriptor.py +0 -0
  58. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/argv/test_parsed_arguments.py +0 -0
  59. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/argv/test_utils.py +0 -0
  60. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/argv/utils.py +0 -0
  61. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/commands_app/__init__.py +0 -0
  62. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/commands_app/apps.py +0 -0
  63. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/commands_app/management/__init__.py +0 -0
  64. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/__init__.py +0 -0
  65. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/notes.py +0 -0
  66. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/unguarded_bad_parser.py +0 -0
  67. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/unguarded_broken_import.py +0 -0
  68. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/unguarded_broken_init.py +0 -0
  69. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/unguarded_empty.py +0 -0
  70. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/unguarded_no_registry.py +0 -0
  71. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/unguarded_plain.py +0 -0
  72. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/commands_app/management/commands/unguarded_report.py +0 -0
  73. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/commands_app/unguarded_registries.py +0 -0
  74. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/conftest.py +0 -0
  75. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/conftest_settings.py +0 -0
  76. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/dispatch_app/__init__.py +0 -0
  77. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/dispatch_app/apps.py +0 -0
  78. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/dispatch_app/models.py +0 -0
  79. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/docs/__init__.py +0 -0
  80. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/docs/test_commands_examples.py +0 -0
  81. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/docs/test_reference.py +0 -0
  82. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/render/__init__.py +0 -0
  83. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/render/test_render_json_lines.py +0 -0
  84. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/render/test_render_result.py +0 -0
  85. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/render/test_render_table.py +0 -0
  86. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/render/test_utils.py +0 -0
  87. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/render/test_wants_table.py +0 -0
  88. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/render/utils.py +0 -0
  89. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/report/__init__.py +0 -0
  90. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/report/test_unguarded_message.py +0 -0
  91. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/report/test_utils.py +0 -0
  92. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/test_apps.py +0 -0
  93. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/test_exit_code_for.py +0 -0
  94. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/test_init.py +0 -0
  95. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/test_settings.py +0 -0
  96. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/types/__init__.py +0 -0
  97. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/types/test_exit_code.py +0 -0
  98. {django_spec_cli-0.5.0 → django_spec_cli-0.6.0}/tests/utils.py +0 -0
@@ -7,6 +7,37 @@ 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
+
10
41
  ## [0.5.0] — 2026-11-08
11
42
 
12
43
  ### Added
@@ -155,7 +186,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
155
186
  `django-service-specs>=0.10.0`, the first kernel release with every name the
156
187
  command reads at its root.
157
188
 
158
- [Unreleased]: https://github.com/Artui/django-spec-cli/compare/v0.5.0...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
159
191
  [0.5.0]: https://github.com/Artui/django-spec-cli/compare/v0.4.1...v0.5.0
160
192
  [0.4.1]: https://github.com/Artui/django-spec-cli/compare/v0.4.0...v0.4.1
161
193
  [0.4.0]: https://github.com/Artui/django-spec-cli/compare/v0.3.1...v0.4.0
@@ -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.29.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.5.0
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.29.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.29.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.29.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
@@ -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
 
@@ -78,7 +78,7 @@ class SpecCommand(BaseCommand):
78
78
  [`add_spec_arguments`][django_spec_cli.argv.add_spec_arguments.add_spec_arguments]
79
79
  spells them, as the input schema a transport advertises asks for them
80
80
  ([`spec_caller_input_schema`][django_service_specs.schema.spec_caller_input_schema.spec_caller_input_schema]
81
- 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
82
82
  where dispatch refuses leaving it out.
83
83
  No option is matched by a prefix: an abbreviation is refused rather
84
84
  than read as whichever option it begins.
@@ -99,7 +99,14 @@ class SpecCommand(BaseCommand):
99
99
  and its refusal is raised as ``ImproperlyConfigured``, as a command
100
100
  naming no registry is: a traceback and ``ExitCode.ERROR``, whatever
101
101
  the options sent. The command's other specs still run, and the system
102
- 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).
103
110
  - ``--explain`` runs no spec: it lists the ones that authorize nothing,
104
111
  as the system check does.
105
112
 
@@ -119,10 +126,14 @@ class SpecCommand(BaseCommand):
119
126
  _command_label: str
120
127
  _parser: CommandParser
121
128
  _entries: dict[str, RegisteredSpec]
129
+ _refused_by_declaration: dict[str, ImproperlyConfigured]
122
130
 
123
131
  def create_parser(self, prog_name: str, subcommand: str, **kwargs: Any) -> CommandParser:
124
132
  """Django's parser, with this command's options added and no option read by its prefix."""
125
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 = {}
126
137
  parser = super().create_parser(prog_name, subcommand, **kwargs)
127
138
  # Django's own options are on this parser, and a prefix of one is no
128
139
  # more an option here than on a subcommand.
@@ -175,8 +186,24 @@ class SpecCommand(BaseCommand):
175
186
  )
176
187
  self._entries[name] = entry
177
188
  spec = kernel_spec(entry.spec)
178
- parameters = _sendable(spec, self.pool_seeds, _call_context())
179
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
180
207
  subparser = subparsers.add_parser(
181
208
  name,
182
209
  help=summary.replace("%", "%%"),
@@ -253,6 +280,9 @@ class SpecCommand(BaseCommand):
253
280
  # ``ExitCode.ERROR`` is published as the failure with a traceback.
254
281
  try:
255
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
256
286
  except ImproperlyConfigured as error:
257
287
  raise ImproperlyConfigured(
258
288
  refused_message(entry.name, self._command_label, error)
@@ -328,7 +358,12 @@ def _sendable(
328
358
  """What a caller may send, as the kernel's caller schema reads it: required only where dispatch demands it.
329
359
 
330
360
  The parameters ``spec_caller_input_schema`` offers under the command's
331
- ``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
332
367
  is annotated to fill (test_a_key_the_server_fills_is_no_option), and a
333
368
  reflected read a registered seed fills
334
369
  (test_a_reflected_read_a_seed_fills_is_an_option_only_without_the_seed).
@@ -342,7 +377,7 @@ def _sendable(
342
377
  (test_a_read_a_provider_requires_itself_is_required). Only
343
378
  relaxed, never made required, so a declared default still answers.
344
379
  """
345
- 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)
346
381
  required = frozenset(schema.get("required", ()))
347
382
  return Parameters.of(
348
383
  *(
@@ -353,6 +388,37 @@ def _sendable(
353
388
  )
354
389
 
355
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
+
356
422
  def _summary(spec: ServiceSpec | SelectorSpec, name: str) -> str:
357
423
  """The first paragraph of what the spec's callable says about itself, and whether it reads or writes.
358
424
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__: str = "0.5.0"
5
+ __version__: str = "0.6.0"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -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.29.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
 
@@ -323,13 +334,11 @@ docs/examples/commands.py:transport
323
334
  such an entry, as it does for a missing one, and `format` and `action` are
324
335
  never bound by name, so a spec written for HTTP runs here as it did.
325
336
  - **`--help` is read under a dict too.** The command builds a stamped dict of
326
- its own for each spec to describe a Validator's fields for the parser. The
327
- kernel's schema and seed checks that decide which options are offered are
328
- asked outside a call, as they are by every transport, and read no stamp. So
329
- the option names come from a transport-blind declaration: a field a
330
- Validator declares only when `call_transport(call_context) == "cli"` is
331
- never offered, and if it is required, every run is refused for it.
332
- Declare such a field for every transport, or not at all.
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.
333
342
  - **Seams.** The value is not configurable: a subclass cannot change what a
334
343
  run stamps. What a subclass can supply is `resolve_as_user`, who the run is
335
344
  made as, and `pool_seeds`, which adds a keyword to the pool every callable
@@ -49,8 +49,10 @@ dependencies = [
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
51
  # ``W001`` and ``W003``. 0.29.0 adds ``build_call_context``, which the
52
- # command builds the context it stamps ``cli`` on through.
53
- "django-service-specs>=0.29.0",
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",
54
56
  ]
55
57
 
56
58
  [project.urls]
@@ -150,7 +152,7 @@ skip-magic-trailing-comma = false
150
152
  root = ["./django_spec_cli"]
151
153
 
152
154
  [tool.bumpversion]
153
- current_version = "0.5.0"
155
+ current_version = "0.6.0"
154
156
  allow_dirty = false
155
157
  commit = false
156
158
  tag = false
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import importlib
12
12
  import io
13
+ from typing import Any
13
14
 
14
15
  import pytest
15
16
  from django.apps import apps
@@ -27,6 +28,9 @@ from django_service_specs import (
27
28
  UnguardedReason,
28
29
  UnguardedSpec,
29
30
  Unrestricted,
31
+ ValidationContext,
32
+ Validator,
33
+ call_transport,
30
34
  )
31
35
 
32
36
  from django_spec_cli.checks import check_unguarded_specs
@@ -263,6 +267,80 @@ class TestADeclarationTheKernelRefuses:
263
267
  assert seeded == []
264
268
 
265
269
 
270
+ class _SeedNamedUnderCli(Validator):
271
+ """Declares ``user``, a name dispatch seeds itself, only when the transport is ``cli``."""
272
+
273
+ def parameters(self, *, call_context: Any = None) -> Parameters:
274
+ declared = [Parameter("n", "integer", required=False)]
275
+ if call_transport(call_context) == "cli":
276
+ declared.append(Parameter("user", "string", required=False))
277
+ return Parameters.of(*declared)
278
+
279
+ def validate(self, arguments: Any, context: ValidationContext) -> dict[str, Any]:
280
+ return dict(arguments)
281
+
282
+
283
+ class TestADeclarationOnlyTheCliStampedReadRefuses:
284
+ """The read the parser makes under the stamped dict, which ``check_seeds`` never sees."""
285
+
286
+ def _walk(
287
+ self, monkeypatch: pytest.MonkeyPatch, registry: SpecRegistry
288
+ ) -> tuple[type, list[CheckMessage]]:
289
+ command = type("Command", (SpecCommand,), {"registry": registry})
290
+ monkeypatch.setattr("django_spec_cli.checks.get_commands", lambda: {"seeded": APP})
291
+ monkeypatch.setattr(
292
+ "django_spec_cli.checks.load_command_class", lambda app_name, name: command()
293
+ )
294
+ return command, check_unguarded_specs(None)
295
+
296
+ def test_the_spec_is_reported_as_w004_naming_it_and_the_command_is_no_w003(
297
+ self, monkeypatch: pytest.MonkeyPatch
298
+ ) -> None:
299
+ registry = SpecRegistry()
300
+ registry.register(
301
+ "bad",
302
+ ServiceSpec(service=_echo, permissions=[StaffOnly()], validator=_SeedNamedUnderCli()),
303
+ )
304
+ registry.register("good", ServiceSpec(service=_echo, permissions=[StaffOnly()]))
305
+ command, messages = self._walk(monkeypatch, registry)
306
+ assert [(m.id, m.obj) for m in messages] == [("django_spec_cli.W004", command)]
307
+ assert messages[0].msg.startswith(
308
+ "'bad' in manage.py seeded cannot run as declared, and a run of it is refused: "
309
+ "ServiceSpec.validator declares the parameter(s) ['user'], which dispatch seeds itself."
310
+ )
311
+
312
+ def test_a_command_overriding_add_arguments_without_super_is_still_checked(
313
+ self, monkeypatch: pytest.MonkeyPatch
314
+ ) -> None:
315
+ class Bare(SpecCommand):
316
+ registry = SpecRegistry()
317
+
318
+ def add_arguments(self, parser: Any) -> None: ...
319
+
320
+ registry = SpecRegistry()
321
+ registry.register("open", ServiceSpec(service=_echo, permissions=[Unrestricted()]))
322
+ Bare.registry = registry
323
+ monkeypatch.setattr("django_spec_cli.checks.get_commands", lambda: {"bare": APP})
324
+ monkeypatch.setattr("django_spec_cli.checks.load_command_class", lambda *_: Bare())
325
+ assert [m.id for m in check_unguarded_specs(None)] == ["django_spec_cli.W001"]
326
+
327
+ def test_a_spec_both_reads_refuse_is_reported_once(
328
+ self, monkeypatch: pytest.MonkeyPatch
329
+ ) -> None:
330
+ both = ServiceSpec(
331
+ service=_echo,
332
+ permissions=[StaffOnly()],
333
+ validator=_SeedNamedUnderCli(),
334
+ reads=Parameters.of(Parameter("project", "integer", required=False)),
335
+ kwargs=_needs_project,
336
+ )
337
+ registry = SpecRegistry()
338
+ registry.register("both", both)
339
+ _, messages = self._walk(monkeypatch, registry)
340
+ assert [m.id for m in messages] == ["django_spec_cli.W004"]
341
+ assert "requires the keyword(s) ['project']" in messages[0].msg
342
+
343
+
266
344
  class TestAppConfigs:
267
345
  """``manage.py check <app_label>`` checks the commands of the apps it names."""
268
346