django-spec-cli 0.3.0__tar.gz → 0.4.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 (97) hide show
  1. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/CHANGELOG.md +60 -1
  2. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/CLAUDE.md +10 -8
  3. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/PKG-INFO +8 -6
  4. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/README.md +6 -4
  5. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/apps.py +2 -2
  6. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/checks.py +42 -3
  7. django_spec_cli-0.4.0/django_spec_cli/report/utils.py +18 -0
  8. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/spec_command.py +54 -16
  9. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/version.py +1 -1
  10. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/docs/index.md +28 -8
  11. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/docs/reference.md +5 -0
  12. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/pyproject.toml +11 -9
  13. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/docs/test_reference.py +7 -2
  14. django_spec_cli-0.4.0/tests/report/test_utils.py +13 -0
  15. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/test_checks.py +119 -2
  16. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/test_spec_command.py +123 -4
  17. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/uv.lock +1 -1
  18. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/.github/CODEOWNERS +0 -0
  19. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/.github/SECURITY.md +0 -0
  20. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/.github/dependabot.yml +0 -0
  21. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/.github/workflows/release.yml +0 -0
  22. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/.github/workflows/tests.yml +0 -0
  23. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/.github/workflows/upstream-drift.yml +0 -0
  24. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/.gitignore +0 -0
  25. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/.pre-commit-config.yaml +0 -0
  26. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/LICENSE +0 -0
  27. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/Makefile +0 -0
  28. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/__init__.py +0 -0
  29. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/argv/__init__.py +0 -0
  30. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/argv/add_spec_arguments.py +0 -0
  31. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/argv/describe_parameters.py +0 -0
  32. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/argv/parameter_descriptor.py +0 -0
  33. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/argv/parsed_arguments.py +0 -0
  34. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/argv/utils.py +0 -0
  35. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/exit_code_for.py +0 -0
  36. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/py.typed +0 -0
  37. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/render/__init__.py +0 -0
  38. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/render/render_json_lines.py +0 -0
  39. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/render/render_result.py +0 -0
  40. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/render/render_table.py +0 -0
  41. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/render/utils.py +0 -0
  42. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/render/wants_table.py +0 -0
  43. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/report/__init__.py +0 -0
  44. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/report/unguarded_message.py +0 -0
  45. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/settings.py +0 -0
  46. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/types/__init__.py +0 -0
  47. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/django_spec_cli/types/exit_code.py +0 -0
  48. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/docs/__init__.py +0 -0
  49. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/docs/examples/__init__.py +0 -0
  50. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/docs/examples/commands.py +0 -0
  51. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/mkdocs.yml +0 -0
  52. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/scripts/check-changelog +0 -0
  53. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/scripts/release-publish.sh +0 -0
  54. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/__init__.py +0 -0
  55. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/argv/__init__.py +0 -0
  56. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/argv/test_add_spec_arguments.py +0 -0
  57. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/argv/test_describe_parameters.py +0 -0
  58. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/argv/test_parameter_descriptor.py +0 -0
  59. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/argv/test_parsed_arguments.py +0 -0
  60. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/argv/test_utils.py +0 -0
  61. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/argv/utils.py +0 -0
  62. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/commands_app/__init__.py +0 -0
  63. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/commands_app/apps.py +0 -0
  64. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/commands_app/management/__init__.py +0 -0
  65. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/commands_app/management/commands/__init__.py +0 -0
  66. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/commands_app/management/commands/notes.py +0 -0
  67. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/commands_app/management/commands/unguarded_bad_parser.py +0 -0
  68. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/commands_app/management/commands/unguarded_broken_import.py +0 -0
  69. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/commands_app/management/commands/unguarded_broken_init.py +0 -0
  70. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/commands_app/management/commands/unguarded_empty.py +0 -0
  71. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/commands_app/management/commands/unguarded_no_registry.py +0 -0
  72. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/commands_app/management/commands/unguarded_plain.py +0 -0
  73. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/commands_app/management/commands/unguarded_report.py +0 -0
  74. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/commands_app/unguarded_registries.py +0 -0
  75. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/conftest.py +0 -0
  76. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/conftest_settings.py +0 -0
  77. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/dispatch_app/__init__.py +0 -0
  78. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/dispatch_app/apps.py +0 -0
  79. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/dispatch_app/models.py +0 -0
  80. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/docs/__init__.py +0 -0
  81. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/docs/test_commands_examples.py +0 -0
  82. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/render/__init__.py +0 -0
  83. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/render/test_render_json_lines.py +0 -0
  84. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/render/test_render_result.py +0 -0
  85. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/render/test_render_table.py +0 -0
  86. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/render/test_utils.py +0 -0
  87. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/render/test_wants_table.py +0 -0
  88. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/render/utils.py +0 -0
  89. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/report/__init__.py +0 -0
  90. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/report/test_unguarded_message.py +0 -0
  91. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/test_apps.py +0 -0
  92. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/test_exit_code_for.py +0 -0
  93. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/test_init.py +0 -0
  94. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/test_settings.py +0 -0
  95. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/types/__init__.py +0 -0
  96. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/types/test_exit_code.py +0 -0
  97. {django_spec_cli-0.3.0 → django_spec_cli-0.4.0}/tests/utils.py +0 -0
@@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] — 2026-11-04
11
+
12
+ ### Added
13
+ - **A spec the kernel refuses as declared is reported, and never run.**
14
+ Every spec a `SpecCommand` serves is now put to the kernel's `check_seeds`
15
+ under the command's `pool_seeds`, which other transports already ask where
16
+ they register a spec. `manage.py check` reports each spec it refuses as
17
+ `django_spec_cli.W004`, a warning, naming the command, the spec and the
18
+ kernel's reason. A run of such a spec raises `ImproperlyConfigured` with the
19
+ same sentence, exit code 1 with a traceback, before `--as-user` is looked
20
+ up. The command's other specs, its `--help` and its `--explain` are
21
+ unaffected.
22
+
23
+ Three of the kernel's refusals stop runs that succeeded before, because
24
+ each fails only some calls. The first is a read declared without
25
+ `required=True`, which is `Parameter`'s default, over a keyword the
26
+ service, the selector or a callable such as a `kwargs` provider takes by
27
+ name with no default: `Parameter("title", "string")` over
28
+ `def rename(*, title)`, or `def needs_project(*, project: int)` as the
29
+ provider. A run sending the read worked, and one leaving it out was refused
30
+ as missing or crashed with the provider's `TypeError`; now every run is
31
+ refused, so declare such a read `required=True` or give the keyword a
32
+ default. The second is an array read declaring no `items` over a keyword
33
+ annotated with its element, such as `pks: list[int]`, which was handed the
34
+ command line's strings. The third is a `progress_reporter` requiring a
35
+ keyword nothing fills, which a run whose service never reports never
36
+ called. The others refuse a declaration on which every run that reached
37
+ the callable already failed, with its `TypeError` or the kernel's own
38
+ `ImproperlyConfigured`, which now names the spec and the command.
39
+
40
+ ### Changed
41
+ - **The kernel floor is `django-service-specs>=0.25.0`, the first kernel
42
+ release that keeps a read a provider requires itself demanded of the
43
+ caller.** From 0.3.1 `--help` reads its options, and which are marked
44
+ `(required)`, from the kernel's `spec_caller_input_schema`, so it repeats
45
+ what the installed kernel answers. On kernel 0.24.x a read declared
46
+ required that a `kwargs` provider takes by name with no default, such as
47
+ `def needs_project(*, project: int)`, was offered without `(required)`, and
48
+ a run leaving it out crashed in the provider with a `TypeError`. From 0.25.0
49
+ it is marked `(required)`, and that run is refused as invalid arguments,
50
+ exit code 3. The lowest-declared-versions job runs the suite on 0.25.0.
51
+
52
+ ## [0.3.1] — 2026-11-04
53
+
54
+ ### Fixed
55
+ - **`--help` offers and requires what dispatch asks a caller for.** A
56
+ subcommand's options, and which of them are marked `(required)`, are now
57
+ read from the kernel's `spec_caller_input_schema` under the command's
58
+ `pool_seeds`, rather than from `server_owned_keys` read without them. On any
59
+ kernel from 0.15.0, two parameters were shown wrongly. A read declared
60
+ required beside a provider that may fill it, such as a `kwargs` provider
61
+ whose return names no keys, was marked `(required)`, though a run leaving
62
+ it out was dispatched. A `reflected` read named like a seed the command's
63
+ `pool_seeds` registers was offered as an option and marked `(required)`,
64
+ and passing it was refused as an unknown argument, exit code 3. It is no
65
+ longer an option. No public name changes.
66
+
10
67
  ## [0.3.0] — 2026-11-04
11
68
 
12
69
  ### Changed
@@ -67,7 +124,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
67
124
  `django-service-specs>=0.10.0`, the first kernel release with every name the
68
125
  command reads at its root.
69
126
 
70
- [Unreleased]: https://github.com/Artui/django-spec-cli/compare/v0.3.0...HEAD
127
+ [Unreleased]: https://github.com/Artui/django-spec-cli/compare/v0.4.0...HEAD
128
+ [0.4.0]: https://github.com/Artui/django-spec-cli/compare/v0.3.1...v0.4.0
129
+ [0.3.1]: https://github.com/Artui/django-spec-cli/compare/v0.3.0...v0.3.1
71
130
  [0.3.0]: https://github.com/Artui/django-spec-cli/compare/v0.2.1...v0.3.0
72
131
  [0.2.1]: https://github.com/Artui/django-spec-cli/compare/v0.2.0...v0.2.1
73
132
  [0.2.0]: https://github.com/Artui/django-spec-cli/compare/v0.1.0...v0.2.0
@@ -8,8 +8,9 @@ The management-command transport for `django-service-specs`. A project
8
8
  subclasses `SpecCommand`, names a `SpecRegistry`, and `manage.py <command>`
9
9
  serves one subcommand per spec: an option for each parameter, a principal named
10
10
  by `--as-user`, the result as a table or JSON Lines, and an exit code for every
11
- refusal. Two system checks, `django_spec_cli.W001` and `W003`, report a served
12
- spec that authorizes nothing and a command that cannot be loaded.
11
+ refusal. Three system checks, `django_spec_cli.W001`, `W003` and `W004`,
12
+ report a served spec that authorizes nothing, a command that cannot be loaded,
13
+ and a spec the kernel's `check_seeds` refuses, whose run is refused too.
13
14
 
14
15
  It is the kernel's `commands/` subpackage, moved out with its history so that
15
16
  the kernel can carry no transport, and the kernel carries no copy of it from
@@ -82,11 +83,12 @@ so do the check ids and the setting key, `SPEC_CLI_OUTPUT`.
82
83
  Each of these reads as an oversight and is not.
83
84
 
84
85
  - **The checks are registered by this package's own `AppConfig`.** A project
85
- lists `"django_spec_cli"` in `INSTALLED_APPS` to get `W001` and `W003`. The
86
- kernel keeps the registry fact the check reads, `unguarded_specs`, because
87
- another transport can ask the same question of a registry. The kernel's own
88
- app stays importable and registers nothing, so listing only it gets no check
89
- (`tests/test_checks.py`, `test_the_kernel_s_app_registers_no_check_of_its_own`).
86
+ lists `"django_spec_cli"` in `INSTALLED_APPS` to get `W001`, `W003` and
87
+ `W004`. The kernel keeps the registry fact the check reads,
88
+ `unguarded_specs`, because another transport can ask the same question of a
89
+ registry. The kernel's own app stays importable and registers nothing, so
90
+ listing only it gets no check (`tests/test_checks.py`,
91
+ `test_the_kernel_s_app_registers_no_check_of_its_own`).
90
92
  - **The test settings list no kernel app.** The package needs nothing of the
91
93
  kernel switched on, and the suite shows it.
92
94
 
@@ -137,7 +139,7 @@ configured and enforced. isort order is stdlib, third party, first party.
137
139
  | --- | --- | --- |
138
140
  | Python | 3.10 | 3.10 through 3.14 |
139
141
  | Django | 4.2 | 4.2, 5.0, 5.1, 5.2, 6.0, 6.1 |
140
- | django-service-specs | 0.24.0 | the newest release, and the floor in the `floor` job |
142
+ | django-service-specs | 0.25.0 | the newest release, and the floor in the `floor` job |
141
143
 
142
144
  The Python and Django floors are the kernel's, and cannot sit below them.
143
145
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: django-spec-cli
3
- Version: 0.3.0
3
+ Version: 0.4.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.24.0
33
+ Requires-Dist: django-service-specs>=0.25.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.24.0 or
64
+ Python 3.10 or later, Django 4.2 or later, and `django-service-specs` 0.25.0 or
65
65
  later, which it installs.
66
66
 
67
67
  ## Quickstart
@@ -92,9 +92,11 @@ authorize nothing.
92
92
  ## The system check
93
93
 
94
94
  List `"django_spec_cli"` in `INSTALLED_APPS` and `manage.py check` reports
95
- every open spec a `SpecCommand` serves as `django_spec_cli.W001`, and a command
96
- that could not be loaded as `django_spec_cli.W003`. The command itself needs no
97
- app listed.
95
+ every open spec a `SpecCommand` serves as `django_spec_cli.W001`, a command
96
+ that could not be loaded as `django_spec_cli.W003`, and a spec the kernel's
97
+ `check_seeds` refuses under the command's `pool_seeds` as `django_spec_cli.W004`.
98
+ A run of a spec W004 reports is refused whether or not the app is listed. The
99
+ command itself needs no app listed.
98
100
 
99
101
  The kernel keeps no check of its own: its app registers nothing, so a project
100
102
  listing only `"django_service_specs"` gets no check for a command. List
@@ -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.24.0 or
28
+ Python 3.10 or later, Django 4.2 or later, and `django-service-specs` 0.25.0 or
29
29
  later, which it installs.
30
30
 
31
31
  ## Quickstart
@@ -56,9 +56,11 @@ authorize nothing.
56
56
  ## The system check
57
57
 
58
58
  List `"django_spec_cli"` in `INSTALLED_APPS` and `manage.py check` reports
59
- every open spec a `SpecCommand` serves as `django_spec_cli.W001`, and a command
60
- that could not be loaded as `django_spec_cli.W003`. The command itself needs no
61
- app listed.
59
+ every open spec a `SpecCommand` serves as `django_spec_cli.W001`, a command
60
+ that could not be loaded as `django_spec_cli.W003`, and a spec the kernel's
61
+ `check_seeds` refuses under the command's `pool_seeds` as `django_spec_cli.W004`.
62
+ A run of a spec W004 reports is refused whether or not the app is listed. The
63
+ command itself needs no app listed.
62
64
 
63
65
  The kernel keeps no check of its own: its app registers nothing, so a project
64
66
  listing only `"django_service_specs"` gets no check for a command. List
@@ -1,4 +1,4 @@
1
- """The app a project lists to have its open specs reported by ``manage.py check``."""
1
+ """The app a project lists to have its open and unrunnable specs reported by ``manage.py check``."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -9,7 +9,7 @@ from django_spec_cli.checks import check_unguarded_specs
9
9
 
10
10
 
11
11
  class DjangoSpecCliConfig(AppConfig):
12
- """Registers the unguarded-spec system check, and does nothing else.
12
+ """Registers the system check over every ``SpecCommand``'s specs, and does nothing else.
13
13
 
14
14
  Optional: the package works unlisted, and a project that does not list it
15
15
  gets no check and no sign that it is missing. It owns no models, so it
@@ -1,4 +1,4 @@
1
- """The system check reporting every spec a ``SpecCommand`` serves that authorizes nothing."""
1
+ """The system check reporting every spec a ``SpecCommand`` serves that authorizes nothing or cannot run."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -7,22 +7,28 @@ from typing import Any
7
7
 
8
8
  from django.apps import AppConfig
9
9
  from django.core.checks import CheckMessage, Warning
10
+ from django.core.exceptions import ImproperlyConfigured
10
11
  from django.core.management import get_commands, load_command_class
11
- from django_service_specs import UnguardedSpec, unguarded_specs
12
+ from django_service_specs import UnguardedSpec, check_seeds, unguarded_specs
12
13
 
13
14
  from django_spec_cli.report.unguarded_message import unguarded_message
15
+ from django_spec_cli.report.utils import refused_message
14
16
  from django_spec_cli.spec_command import SpecCommand
15
17
 
16
18
  _HINT = (
17
19
  "Declare a PermissionCheck that refuses someone in the spec's permissions, or add "
18
20
  "'django_spec_cli.W001' to SILENCED_SYSTEM_CHECKS if the spec is meant to be open."
19
21
  )
22
+ _REFUSED_HINT = (
23
+ "Change the declaration as the message says. Until then a run of the spec is refused, "
24
+ "whether or not django_spec_cli.W004 is silenced."
25
+ )
20
26
 
21
27
 
22
28
  def check_unguarded_specs(
23
29
  app_configs: Sequence[AppConfig] | None, **kwargs: Any
24
30
  ) -> list[CheckMessage]:
25
- """Warn once per spec that authorizes nothing, in every ``SpecCommand`` of the project.
31
+ """Warn once per spec that authorizes nothing or cannot run, in every ``SpecCommand`` of the project.
26
32
 
27
33
  Registered by the app's ``ready()``, so a project gets it by listing
28
34
  ``django_spec_cli`` in ``INSTALLED_APPS`` and not otherwise. There is
@@ -40,6 +46,15 @@ def check_unguarded_specs(
40
46
  spec is a decision or an accident the check cannot tell apart, and an
41
47
  ``Error`` would stop ``migrate`` on a project working as intended.
42
48
  ``SILENCED_SYSTEM_CHECKS`` silences it by id, so for every spec at once.
49
+ - ``django_spec_cli.W004``, one per spec that
50
+ [`check_seeds`][django_service_specs.dispatch.check_seeds.check_seeds]
51
+ refuses under the command's ``pool_seeds``, such as an optional read
52
+ that a ``kwargs`` provider takes by name with no default, with the
53
+ kernel's reason and the command class as ``obj``. A run of that spec
54
+ is refused with the same sentence, so this is the same refusal told
55
+ before anyone runs it. A ``Warning`` for the reason W003 is one: an
56
+ ``Error`` would stop ``migrate`` over a spec nobody runs, while the
57
+ command's other specs work.
43
58
  - ``django_spec_cli.W003``, one per command that could not be loaded or
44
59
  whose parser could not be built, naming it and the error. Without this
45
60
  app nothing loads that command until somebody runs it, so failing the
@@ -80,9 +95,33 @@ def check_unguarded_specs(
80
95
  )
81
96
  for spec in specs
82
97
  )
98
+ messages.extend(
99
+ Warning(
100
+ refused_message(spec_name, name, error),
101
+ hint=_REFUSED_HINT,
102
+ obj=type(command),
103
+ id="django_spec_cli.W004",
104
+ )
105
+ for spec_name, error in _refused(command)
106
+ )
83
107
  return messages
84
108
 
85
109
 
110
+ def _refused(command: SpecCommand) -> Iterator[tuple[str, ImproperlyConfigured]]:
111
+ """Each spec of ``command`` that ``check_seeds`` refuses, with its refusal, in registry order.
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.
115
+ Outside the walk's ``try``: a refusal names one spec, and the command
116
+ loaded, so its other specs are still reported and it is no W003.
117
+ """
118
+ for entry in command.registry:
119
+ try:
120
+ check_seeds(entry.spec, pool_seeds=command.pool_seeds)
121
+ except ImproperlyConfigured as error:
122
+ yield entry.name, error
123
+
124
+
86
125
  def _spec_commands(
87
126
  app_configs: Sequence[AppConfig] | None,
88
127
  ) -> Iterator[tuple[str, str, tuple[SpecCommand, tuple[UnguardedSpec, ...]] | Exception]]:
@@ -0,0 +1,18 @@
1
+ """Helpers the system check and ``SpecCommand`` share: the sentence for a spec the kernel refuses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django.core.exceptions import ImproperlyConfigured
6
+
7
+
8
+ def refused_message(spec_name: str, command_label: str, error: ImproperlyConfigured) -> str:
9
+ """The one-line report of ``spec_name``, which ``check_seeds`` refused with ``error``.
10
+
11
+ ``command_label`` is the name an operator types after ``manage.py``. The
12
+ system check and a run of the spec both print this, so the warning a
13
+ project sees before a run reads as the refusal the run ends with.
14
+ """
15
+ return (
16
+ f"{spec_name!r} in manage.py {command_label} cannot run as declared, and a run of it "
17
+ f"is refused: {error}"
18
+ )
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import inspect
7
7
  from collections.abc import Mapping
8
+ from dataclasses import replace
8
9
  from typing import Any, TextIO, cast
9
10
 
10
11
  from django.core.exceptions import ImproperlyConfigured
@@ -25,14 +26,14 @@ from django_service_specs import (
25
26
  ServiceSpec,
26
27
  ServiceValidationError,
27
28
  SpecRegistry,
29
+ check_seeds,
28
30
  coerce_flat,
29
31
  dispatch,
30
32
  kernel_spec,
31
33
  present,
32
34
  refusal_messages,
33
- relax_partial,
34
35
  resolve_principal,
35
- server_owned_keys,
36
+ spec_caller_input_schema,
36
37
  unguarded_specs,
37
38
  )
38
39
 
@@ -44,6 +45,7 @@ from django_spec_cli.exit_code_for import exit_code_for
44
45
  from django_spec_cli.render.render_result import render_result
45
46
  from django_spec_cli.render.wants_table import wants_table
46
47
  from django_spec_cli.report.unguarded_message import unguarded_message
48
+ from django_spec_cli.report.utils import refused_message
47
49
  from django_spec_cli.types.exit_code import ExitCode
48
50
 
49
51
  _OWN_OPTIONS = frozenset({"--as-user", "--json", "--table"})
@@ -72,8 +74,10 @@ class SpecCommand(BaseCommand):
72
74
  - **One subcommand per registry entry**, named after the entry with each
73
75
  ``_`` shown as ``-``. Its options are the spec's parameters, as
74
76
  [`add_spec_arguments`][django_spec_cli.argv.add_spec_arguments.add_spec_arguments]
75
- spells them, less any key the server fills
76
- ([`server_owned_keys`][django_service_specs.specs.server_owned_keys.server_owned_keys]).
77
+ spells them, as the input schema a transport advertises asks for them
78
+ ([`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
80
+ where dispatch refuses leaving it out.
77
81
  No option is matched by a prefix: an abbreviation is refused rather
78
82
  than read as whichever option it begins.
79
83
  - **``--as-user`` is required.** Its value goes through
@@ -87,6 +91,13 @@ class SpecCommand(BaseCommand):
87
91
  terminal ([`wants_table`][django_spec_cli.render.wants_table.wants_table]).
88
92
  - A refusal ends the run with a ``CommandError`` carrying its message and
89
93
  an [`ExitCode`][django_spec_cli.types.exit_code.ExitCode].
94
+ - **A spec the kernel refuses as declared is never run.**
95
+ [`check_seeds`][django_service_specs.dispatch.check_seeds.check_seeds],
96
+ under ``pool_seeds``, is asked of the spec named before anything else,
97
+ and its refusal is raised as ``ImproperlyConfigured``, as a command
98
+ naming no registry is: a traceback and ``ExitCode.ERROR``, whatever
99
+ the options sent. The command's other specs still run, and the system
100
+ check reports the same spec as ``django_spec_cli.W004``.
90
101
  - ``--explain`` runs no spec: it lists the ones that authorize nothing,
91
102
  as the system check does.
92
103
 
@@ -162,7 +173,7 @@ class SpecCommand(BaseCommand):
162
173
  )
163
174
  self._entries[name] = entry
164
175
  spec = kernel_spec(entry.spec)
165
- parameters = _sendable(spec)
176
+ parameters = _sendable(spec, self.pool_seeds)
166
177
  summary = _summary(spec, entry.name)
167
178
  subparser = subparsers.add_parser(
168
179
  name,
@@ -232,6 +243,18 @@ class SpecCommand(BaseCommand):
232
243
  else "name a spec to run, or pass --explain. --help lists the specs."
233
244
  )
234
245
  entry = self._entries[name]
246
+ # Asked of the one spec named, not of every entry where the parser is
247
+ # built: a raise there would stop every spec the command serves, and
248
+ # ``--help`` and ``--explain`` with them. Before the lookup, so a
249
+ # project's ``resolve_as_user`` never runs for a spec that cannot.
250
+ # Not a ``CommandError``: the declaration is wrong, not the call, and
251
+ # ``ExitCode.ERROR`` is published as the failure with a traceback.
252
+ try:
253
+ check_seeds(entry.spec, pool_seeds=self.pool_seeds)
254
+ except ImproperlyConfigured as error:
255
+ raise ImproperlyConfigured(
256
+ refused_message(entry.name, self._command_label, error)
257
+ ) from error
235
258
  spec = kernel_spec(entry.spec)
236
259
  parameters = spec.parameters()
237
260
  raw = parsed_arguments(argparse.Namespace(**options), parameters)
@@ -281,18 +304,33 @@ class SpecCommand(BaseCommand):
281
304
  self.stdout.write(unguarded_message(unguarded, label))
282
305
 
283
306
 
284
- def _sendable(spec: ServiceSpec | SelectorSpec) -> Parameters:
285
- """What a caller may send, as the shape check reads it: required only where dispatch demands it.
286
-
287
- Relaxed for ``partial`` by the shape check's own reading, so ``--help``
288
- marks ``(required)`` exactly where an omission is refused
289
- (test_a_partial_spec_requires_only_what_names_its_row), and without the
290
- keys the server fills, whose value a caller's would never replace
291
- (test_a_key_the_server_fills_is_no_option).
307
+ def _sendable(spec: ServiceSpec | SelectorSpec, pool_seeds: PoolSeeds) -> Parameters:
308
+ """What a caller may send, as the kernel's caller schema reads it: required only where dispatch demands it.
309
+
310
+ 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
312
+ is annotated to fill (test_a_key_the_server_fills_is_no_option), and a
313
+ reflected read a registered seed fills
314
+ (test_a_reflected_read_a_seed_fills_is_an_option_only_without_the_seed).
315
+ Each is relaxed where the schema does not require it, so ``--help``
316
+ marks ``(required)`` exactly where an omission is refused: on a
317
+ ``partial`` spec (test_a_partial_spec_requires_only_what_names_its_row)
318
+ and beside a provider that may fill it
319
+ (test_a_read_a_provider_may_fill_is_offered_and_not_required). A read
320
+ stays declared required where the provider takes it by name with no
321
+ default, since that provider cannot be the one to fill it
322
+ (test_a_read_a_provider_requires_itself_is_required). Only
323
+ relaxed, never made required, so a declared default still answers.
292
324
  """
293
- owned = server_owned_keys(spec)
294
- relaxed = relax_partial(spec, spec.parameters())
295
- return Parameters.of(*(param for param in relaxed if param.name not in owned))
325
+ schema = spec_caller_input_schema(spec, pool_seeds=pool_seeds)
326
+ required = frozenset(schema.get("required", ()))
327
+ return Parameters.of(
328
+ *(
329
+ param if param.name in required else replace(param, required=False)
330
+ for param in spec.parameters()
331
+ if param.name in schema["properties"]
332
+ )
333
+ )
296
334
 
297
335
 
298
336
  def _summary(spec: ServiceSpec | SelectorSpec, name: str) -> str:
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__: str = "0.3.0"
5
+ __version__: str = "0.4.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.24.0, on Python 3.10 and Django 4.2 or later. The command line was the
30
+ from 0.25.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
 
@@ -86,14 +86,22 @@ equals sign: `--tag=-x`.
86
86
  of its service's or selector's docstring and whether it reads or writes, or
87
87
  its registered name when the function says nothing. `manage.py notes
88
88
  rename-note --help` lists its options, with `(required)` where leaving one
89
- out is refused. On a `partial` spec, that is only what names the row: the
90
- Validator's fields may be left out, as dispatch allows.
89
+ out is refused. Both are read from the input schema a transport advertises,
90
+ [`spec_caller_input_schema`][django_service_specs.schema.spec_caller_input_schema.spec_caller_input_schema],
91
+ under the command's `pool_seeds`, so the command asks for what dispatch asks
92
+ for. On a `partial` spec, `(required)` is only what names the row: the
93
+ Validator's fields may be left out, as dispatch allows. A parameter a provider
94
+ may fill, such as a read beside a `kwargs` provider whose return names no keys,
95
+ is an option and is not required, unless the provider takes that read by name
96
+ with no default: only the caller can fill it then, so a read declared required
97
+ stays `(required)`.
91
98
 
92
99
  Some parameters have no option:
93
100
 
94
- - **A key the server fills**, one a `kwargs` provider is annotated to answer
95
- ([`server_owned_keys`][django_service_specs.specs.server_owned_keys.server_owned_keys]),
96
- since a value sent for it would be dropped.
101
+ - **A key the server fills**: one a `kwargs` provider is annotated to answer,
102
+ since a value sent for it would be dropped, and a `reflected` read named like
103
+ a seed `pool_seeds` registers, since the seed fills it and dispatch refuses an
104
+ argument of its name.
97
105
  - **A parameter with no flat spelling**: an object, an array of objects or of
98
106
  arrays, and a file. The subcommand's help names them. A spec that requires
99
107
  one, such as any `many` spec, cannot be run from the command line.
@@ -245,8 +253,20 @@ sentence from `manage.py check`, as `django_spec_cli.W001`, once per
245
253
  open spec of every `SpecCommand` in the project, and a command that could not
246
254
  be loaded to be checked as `django_spec_cli.W003`. There is no
247
255
  `W002`: a spec with an empty `permissions` is refused when it is built, so
248
- none reaches a registry. Both are warnings, and `SILENCED_SYSTEM_CHECKS`
249
- silences one by id.
256
+ none reaches a registry.
257
+
258
+ `django_spec_cli.W004` reports, once per spec, a declaration the kernel's
259
+ [`check_seeds`][django_service_specs.dispatch.check_seeds.check_seeds]
260
+ refuses under the command's `pool_seeds`: a keyword the callable requires
261
+ that nothing fills, or a read declared optional that a `kwargs` provider
262
+ takes by name with no default, among others. A run of that spec is refused
263
+ with the same sentence, raised as `ImproperlyConfigured` (exit code 1,
264
+ with a traceback) before `--as-user` is looked up, even when the options sent
265
+ would let it run. The command's other specs are unaffected.
266
+
267
+ All three are warnings, so a spec nobody runs never stops `migrate`, and
268
+ `SILENCED_SYSTEM_CHECKS` silences one by id. Silencing `W004` hides the report
269
+ and does not let the spec run.
250
270
 
251
271
  The kernel keeps no check of its own: its app registers nothing, so a project
252
272
  listing only `"django_service_specs"` gets no check for a command. List
@@ -28,6 +28,11 @@ Registered by `DjangoSpecCliConfig`, so a project gets them by listing
28
28
  open spec, worded by
29
29
  [`unguarded_message`][django_spec_cli.report.unguarded_message.unguarded_message].
30
30
  - **`django_spec_cli.W003`**: a command could not be loaded to be checked.
31
+ - **`django_spec_cli.W004`**: the kernel's
32
+ [`check_seeds`][django_service_specs.dispatch.check_seeds.check_seeds]
33
+ refuses a spec a `SpecCommand` serves, under the command's `pool_seeds`.
34
+ Once per refused spec. A run of it is refused with the same sentence,
35
+ whether or not the warning is silenced.
31
36
 
32
37
  ## The command
33
38
 
@@ -40,14 +40,16 @@ dependencies = [
40
40
  # kernel's specs, so this package cannot support a Django the kernel does
41
41
  # not.
42
42
  "django>=4.2",
43
- # The first kernel release with ``PermissionCheckDenied`` in ``REFUSALS``.
44
- # ``tests/test_exit_code_for.py`` holds the exit codes against that list
45
- # and decides the new refusal's code by name, so the suite, and the floor
46
- # job running it, cannot import below 0.24.0. The code itself is
47
- # ``NotPermitted``'s on every kernel. Below 0.11.0 the kernel also still
48
- # shipped its own copy of the command and registered its own ``W001`` and
49
- # ``W003``.
50
- "django-service-specs>=0.24.0",
43
+ # The first kernel release whose ``spec_caller_input_schema`` keeps a read
44
+ # a provider requires itself, by name and with no default, demanded of the
45
+ # caller. ``--help`` repeats that schema, so on 0.24.x such a read was not
46
+ # marked ``(required)`` and a run leaving it out crashed in the provider
47
+ # with a ``TypeError``; ``tests/test_spec_command.py`` holds it. 0.24.0 put
48
+ # ``PermissionCheckDenied`` in ``REFUSALS``, which
49
+ # ``tests/test_exit_code_for.py`` decides by name. Below 0.11.0 the kernel
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
53
  ]
52
54
 
53
55
  [project.urls]
@@ -147,7 +149,7 @@ skip-magic-trailing-comma = false
147
149
  root = ["./django_spec_cli"]
148
150
 
149
151
  [tool.bumpversion]
150
- current_version = "0.3.0"
152
+ current_version = "0.4.0"
151
153
  allow_dirty = false
152
154
  commit = false
153
155
  tag = false
@@ -27,7 +27,12 @@ def test_every_root_name_has_an_entry_for_the_root_s_own_object() -> None:
27
27
  assert documented[name] is getattr(django_spec_cli, name), name
28
28
 
29
29
 
30
- def test_the_setting_and_both_check_ids_are_documented() -> None:
30
+ def test_the_setting_and_every_check_id_is_documented() -> None:
31
31
  page = PAGE.read_text()
32
- for spelling in ("SPEC_CLI_OUTPUT", "django_spec_cli.W001", "django_spec_cli.W003"):
32
+ for spelling in (
33
+ "SPEC_CLI_OUTPUT",
34
+ "django_spec_cli.W001",
35
+ "django_spec_cli.W003",
36
+ "django_spec_cli.W004",
37
+ ):
33
38
  assert f"`{spelling}`" in page, spelling
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from django.core.exceptions import ImproperlyConfigured
4
+
5
+ from django_spec_cli.report.utils import refused_message
6
+
7
+
8
+ def test_names_the_spec_and_the_command_then_the_kernel_s_reason() -> None:
9
+ error = ImproperlyConfigured("ServiceSpec.service requires the keyword(s) ['tenant'].")
10
+ assert refused_message("greet", "notes", error) == (
11
+ "'greet' in manage.py notes cannot run as declared, and a run of it is refused: "
12
+ "ServiceSpec.service requires the keyword(s) ['tenant']."
13
+ )
@@ -1,4 +1,5 @@
1
- """The unguarded-spec system check, run over this suite's own command modules.
1
+ """The system checks, for an unguarded spec and a spec ``check_seeds`` refuses, run over
2
+ this suite's own command modules.
2
3
 
3
4
  Other tests add command modules to the same app, so every assertion here is
4
5
  filtered to the commands this file owns - the ``unguarded_`` ones - and none
@@ -16,10 +17,22 @@ from django.core.checks import CheckMessage, Warning
16
17
  from django.core.management import call_command, get_commands, load_command_class
17
18
  from django.core.management.base import SystemCheckError
18
19
  from django.test import override_settings
19
- from django_service_specs import UnguardedReason, UnguardedSpec
20
+ from django_service_specs import (
21
+ DEFAULT_POOL_SEEDS,
22
+ Parameter,
23
+ Parameters,
24
+ PoolSeeds,
25
+ ServiceSpec,
26
+ SpecRegistry,
27
+ UnguardedReason,
28
+ UnguardedSpec,
29
+ Unrestricted,
30
+ )
20
31
 
21
32
  from django_spec_cli.checks import check_unguarded_specs
22
33
  from django_spec_cli.report.unguarded_message import unguarded_message
34
+ from django_spec_cli.spec_command import SpecCommand
35
+ from tests.commands_app.unguarded_registries import StaffOnly
23
36
  from tests.utils import run_project
24
37
 
25
38
  APP = "tests.commands_app"
@@ -146,6 +159,110 @@ class TestACommandThatDoesNotLoad:
146
159
  assert len(_own_w001(check_unguarded_specs(None))) == 3
147
160
 
148
161
 
162
+ def _needs_project(*, project: int) -> dict[str, int]:
163
+ return {"project": project}
164
+
165
+
166
+ def _echo(**pool: object) -> object:
167
+ return pool
168
+
169
+
170
+ def _greet(*, tenant: str) -> str:
171
+ return f"hello {tenant}"
172
+
173
+
174
+ def _project_spec(*, required: bool) -> ServiceSpec:
175
+ """A read ``project`` beside a ``kwargs`` provider that takes it by name, with no default."""
176
+ return ServiceSpec(
177
+ service=_echo,
178
+ permissions=[StaffOnly()],
179
+ reads=Parameters.of(Parameter("project", "integer", required=required)),
180
+ kwargs=_needs_project,
181
+ )
182
+
183
+
184
+ class TestADeclarationTheKernelRefuses:
185
+ """``check_seeds``, under the command's own ``pool_seeds``, over every spec it serves.
186
+
187
+ Each test serves one command of its own through the walk, so nothing here
188
+ depends on what the suite's command modules declare.
189
+ """
190
+
191
+ REFUSED = (
192
+ "'optional_project' in manage.py seeded cannot run as declared, and a run of it is "
193
+ "refused: ServiceSpec.kwargs requires the keyword(s) ['project'], declared as optional "
194
+ "reads that nothing else fills, so a call leaving one out fails with the provider's "
195
+ "own TypeError. Declare the read required, or give the keyword a default."
196
+ )
197
+
198
+ def _walk(
199
+ self,
200
+ monkeypatch: pytest.MonkeyPatch,
201
+ registry: SpecRegistry,
202
+ pool_seeds: PoolSeeds = DEFAULT_POOL_SEEDS,
203
+ ) -> tuple[type, list[CheckMessage]]:
204
+ command = type("Command", (SpecCommand,), {"registry": registry, "pool_seeds": pool_seeds})
205
+ monkeypatch.setattr("django_spec_cli.checks.get_commands", lambda: {"seeded": APP})
206
+ monkeypatch.setattr(
207
+ "django_spec_cli.checks.load_command_class", lambda app_name, name: command()
208
+ )
209
+ return command, check_unguarded_specs(None)
210
+
211
+ def test_an_optional_read_a_provider_requires_is_reported(
212
+ self, monkeypatch: pytest.MonkeyPatch
213
+ ) -> None:
214
+ registry = SpecRegistry()
215
+ registry.register("optional_project", _project_spec(required=False))
216
+ command, messages = self._walk(monkeypatch, registry)
217
+ assert [m for m in messages if m.id == "django_spec_cli.W004"] == [
218
+ Warning(
219
+ self.REFUSED,
220
+ hint=(
221
+ "Change the declaration as the message says. Until then a run of the spec "
222
+ "is refused, whether or not django_spec_cli.W004 is silenced."
223
+ ),
224
+ obj=command,
225
+ id="django_spec_cli.W004",
226
+ )
227
+ ]
228
+
229
+ def test_a_spec_it_accepts_is_not_reported(self, monkeypatch: pytest.MonkeyPatch) -> None:
230
+ registry = SpecRegistry()
231
+ registry.register("required_project", _project_spec(required=True))
232
+ _, messages = self._walk(monkeypatch, registry)
233
+ assert messages == []
234
+
235
+ def test_one_warning_per_refused_spec_beside_the_command_s_other_reports(
236
+ self, monkeypatch: pytest.MonkeyPatch
237
+ ) -> None:
238
+ # Refusing one spec loses neither the W001 of an open spec in the same
239
+ # command nor the command itself to a W003.
240
+ registry = SpecRegistry()
241
+ registry.register("optional_project", _project_spec(required=False))
242
+ registry.register("required_project", _project_spec(required=True))
243
+ registry.register("open", ServiceSpec(service=_echo, permissions=[Unrestricted()]))
244
+ registry.register("unseeded", ServiceSpec(service=_greet, permissions=[StaffOnly()]))
245
+ _, messages = self._walk(monkeypatch, registry)
246
+ assert [(m.id, m.msg.split(" ")[0]) for m in messages] == [
247
+ ("django_spec_cli.W001", "'open'"),
248
+ ("django_spec_cli.W004", "'optional_project'"),
249
+ ("django_spec_cli.W004", "'unseeded'"),
250
+ ]
251
+ assert not any(message.is_serious() for message in messages)
252
+
253
+ def test_it_reads_the_command_s_pool_seeds(self, monkeypatch: pytest.MonkeyPatch) -> None:
254
+ # ``tenant`` is filled by nothing under the stock seeds, and by the seed
255
+ # the command registers.
256
+ registry = SpecRegistry()
257
+ registry.register("greet", ServiceSpec(service=_greet, permissions=[StaffOnly()]))
258
+ _, unseeded = self._walk(monkeypatch, registry)
259
+ assert [m.id for m in unseeded] == ["django_spec_cli.W004"]
260
+ assert "['tenant']" in unseeded[0].msg
261
+ seeds = DEFAULT_POOL_SEEDS.extend(tenant=lambda: "acme")
262
+ _, seeded = self._walk(monkeypatch, registry, pool_seeds=seeds)
263
+ assert seeded == []
264
+
265
+
149
266
  class TestAppConfigs:
150
267
  """``manage.py check <app_label>`` checks the commands of the apps it names."""
151
268
 
@@ -297,15 +297,102 @@ class TestSeeds:
297
297
  command = command_over(self._registry(), pool_seeds=seeds)
298
298
  assert run("greet", "--as-user", str(owner.pk), command=command) == '"hello acme"\n'
299
299
 
300
- def test_with_no_seeds_named_a_callable_reading_one_fails_as_dispatch_does(
301
- self, owner: Any
300
+ def test_a_reflected_read_a_seed_fills_is_an_option_only_without_the_seed(
301
+ self, capsys: pytest.CaptureFixture[str], owner: Any
302
302
  ) -> None:
303
+ # Offered under the command's seeds, it would be refused when passed,
304
+ # as an argument dispatch does not take.
305
+ def greet(*, tenant: str) -> str:
306
+ return f"hello {tenant}"
307
+
308
+ reads = Parameters.of(Parameter("tenant", "string", required=True, reflected=True))
309
+ spec = ServiceSpec(service=greet, permissions=[Unrestricted()], reads=reads)
310
+ registry = registry_of(greet=spec)
311
+ seeded = command_over(registry, pool_seeds=DEFAULT_POOL_SEEDS.extend(tenant=lambda: "acme"))
312
+ assert "--tenant" not in help_of(capsys, "greet", command=seeded)
313
+ assert run("greet", "--as-user", str(owner.pk), command=seeded) == '"hello acme"\n'
314
+ unseeded = " ".join(help_of(capsys, "greet", command=command_over(registry)).split())
315
+ assert "--tenant STRING (required)" in unseeded
316
+
317
+ def test_with_no_seeds_named_a_callable_reading_one_is_refused(self, owner: Any) -> None:
303
318
  # The default is the stock seeds, as ``dispatch``'s: a seed nobody
304
- # registered is the callable's own ``TypeError``, not a quiet default.
305
- with pytest.raises(TypeError, match="tenant"):
319
+ # registered is no quiet default, and the kernel's ``check_seeds``
320
+ # refuses the spec before it reaches the callable's own ``TypeError``.
321
+ with pytest.raises(ImproperlyConfigured, match=r"\['tenant'\], which nothing fills"):
306
322
  run("greet", "--as-user", str(owner.pk), command=command_over(self._registry()))
307
323
 
308
324
 
325
+ class TestADeclarationTheKernelRefuses:
326
+ """A spec ``check_seeds`` refuses under the command's seeds never reaches dispatch.
327
+
328
+ ``call_command`` handed an instance names it after this module, so the
329
+ sentence reads ``manage.py test_spec_command``.
330
+ """
331
+
332
+ REFUSAL = (
333
+ "'echo' in manage.py test_spec_command cannot run as declared, and a run of it is refused: "
334
+ "ServiceSpec.kwargs requires the keyword(s) ['project'], declared as optional reads "
335
+ "that nothing else fills, so a call leaving one out fails with the provider's own "
336
+ "TypeError. Declare the read required, or give the keyword a default."
337
+ )
338
+
339
+ def _command(self, **attributes: Any) -> SpecCommand:
340
+ def needs_project(*, project: int) -> dict[str, Any]:
341
+ return {"project": project}
342
+
343
+ spec = echo_spec(Parameter("project", "integer", required=False), kwargs=needs_project)
344
+ return command_over(registry_of(echo=spec, ping=open_spec(lambda: "pong")), **attributes)
345
+
346
+ @pytest.mark.parametrize("sent", [[], ["--project", "3"]], ids=["read-left-out", "read-sent"])
347
+ def test_a_run_of_it_is_refused_with_the_kernel_s_reason(
348
+ self, owner: Any, sent: list[str]
349
+ ) -> None:
350
+ # Sending the read would run: refused anyway, because the declaration
351
+ # says it is optional and every run leaving it out crashes.
352
+ with pytest.raises(ImproperlyConfigured) as caught:
353
+ run("echo", "--as-user", str(owner.pk), *sent, command=self._command())
354
+ assert str(caught.value) == self.REFUSAL
355
+
356
+ def test_it_is_refused_before_the_user_is_looked_up(self) -> None:
357
+ looked_up: list[str] = []
358
+
359
+ def resolve_as_user(self: SpecCommand, value: str) -> Any:
360
+ looked_up.append(value)
361
+ raise PrincipalUnavailable("nobody")
362
+
363
+ command = self._command(resolve_as_user=resolve_as_user)
364
+ with pytest.raises(ImproperlyConfigured):
365
+ run("echo", "--as-user", "7", command=command)
366
+ assert looked_up == []
367
+
368
+ def test_the_command_s_other_specs_still_run(self, owner: Any) -> None:
369
+ assert run("ping", "--as-user", str(owner.pk), command=self._command()) == '"pong"\n'
370
+
371
+ def test_help_and_explain_still_answer(self, capsys: pytest.CaptureFixture[str]) -> None:
372
+ # Asked of the spec run, never where the parser is built, so the
373
+ # command's listing and its report still work.
374
+ command = self._command()
375
+ parser = command.create_parser("manage.py", "notes")
376
+ with pytest.raises(SystemExit):
377
+ parser.parse_args(["--help"])
378
+ assert "echo" in capsys.readouterr().out
379
+ assert "'echo' in manage.py " in run("--explain", command=command)
380
+
381
+ @override_settings(SILENCED_SYSTEM_CHECKS=["django_spec_cli.W004"])
382
+ def test_silencing_the_warning_does_not_let_it_run(self, owner: Any) -> None:
383
+ with pytest.raises(ImproperlyConfigured):
384
+ run("echo", "--as-user", str(owner.pk), "--project", "3", command=self._command())
385
+
386
+ @pytest.mark.usefixtures("keep_the_test_connection")
387
+ def test_from_argv_it_propagates_for_a_traceback_and_not_an_exit_code(self, owner: Any) -> None:
388
+ # ``run_from_argv`` turns only a ``CommandError`` into ``sys.exit``;
389
+ # anything else reaches ``manage.py`` as a traceback and exit status 1,
390
+ # which is ``ExitCode.ERROR`` as the docs publish it.
391
+ argv = ["manage.py", "notes", "--skip-checks", "echo", "--as-user", str(owner.pk)]
392
+ with pytest.raises(ImproperlyConfigured, match="cannot run as declared"):
393
+ self._command().run_from_argv(argv)
394
+
395
+
309
396
  class TestAbbreviation:
310
397
  """A prefix of an option is never read as the option, the command's own or a spec's."""
311
398
 
@@ -428,6 +515,38 @@ class TestHelp:
428
515
  assert "--project" not in text
429
516
  assert "--n INTEGER" in text
430
517
 
518
+ def test_a_read_a_provider_may_fill_is_offered_and_not_required(
519
+ self, capsys: pytest.CaptureFixture[str], owner: Any
520
+ ) -> None:
521
+ # A return that says nothing about its keys may fill any read, so
522
+ # dispatch demands none of them of the caller and the help asks for none.
523
+ def may_fill(**_: Any) -> dict[str, Any]:
524
+ return {"project": 1}
525
+
526
+ spec = echo_spec(Parameter("project", "integer", required=True), kwargs=may_fill)
527
+ command = command_over(registry_of(echo=spec))
528
+ text = " ".join(help_of(capsys, "echo", command=command).split())
529
+ assert "--project INTEGER" in text
530
+ assert "(required)" not in text
531
+ assert lines(run("echo", "--as-user", str(owner.pk), command=command)) == [{"project": 1}]
532
+
533
+ def test_a_read_a_provider_requires_itself_is_required(
534
+ self, capsys: pytest.CaptureFixture[str], owner: Any
535
+ ) -> None:
536
+ # A provider that takes the read by name, with no default, cannot be the
537
+ # one to fill it, so dispatch demands it of the caller. A kernel before
538
+ # 0.25.0 relaxed it, and a run leaving it out crashed in the provider.
539
+ def needs_project(*, project: int) -> dict[str, Any]:
540
+ return {"project": project}
541
+
542
+ spec = echo_spec(Parameter("project", "integer", required=True), kwargs=needs_project)
543
+ command = command_over(registry_of(echo=spec))
544
+ text = " ".join(help_of(capsys, "echo", command=command).split())
545
+ assert "--project INTEGER (required)" in text
546
+ error = refused("echo", "--as-user", str(owner.pk), command=command)
547
+ assert error.returncode == 3
548
+ assert str(error).splitlines()[1:] == ["project: This field is required."]
549
+
431
550
 
432
551
  # --- output --------------------------------------------------------------------------------------
433
552
 
@@ -490,7 +490,7 @@ docs = [
490
490
  [package.metadata]
491
491
  requires-dist = [
492
492
  { name = "django", specifier = ">=4.2" },
493
- { name = "django-service-specs", specifier = ">=0.24.0" },
493
+ { name = "django-service-specs", specifier = ">=0.25.0" },
494
494
  ]
495
495
 
496
496
  [package.metadata.requires-dev]
File without changes