django-spec-cli 0.4.0__tar.gz → 0.5.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.0 → django_spec_cli-0.5.0}/.github/workflows/release.yml +1 -1
  2. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/.github/workflows/tests.yml +4 -4
  3. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/.github/workflows/upstream-drift.yml +2 -2
  4. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/CHANGELOG.md +34 -1
  5. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/CLAUDE.md +1 -1
  6. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/PKG-INFO +3 -3
  7. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/README.md +1 -1
  8. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/__init__.py +2 -0
  9. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/spec_command.py +24 -4
  10. django_spec_cli-0.5.0/django_spec_cli/types/__init__.py +9 -0
  11. django_spec_cli-0.5.0/django_spec_cli/types/cli_transport.py +13 -0
  12. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/version.py +1 -1
  13. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/docs/examples/commands.py +50 -1
  14. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/docs/index.md +59 -2
  15. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/docs/reference.md +1 -0
  16. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/pyproject.toml +4 -3
  17. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/docs/test_commands_examples.py +63 -0
  18. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/test_spec_command.py +237 -0
  19. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/uv.lock +52 -52
  20. django_spec_cli-0.4.0/django_spec_cli/types/__init__.py +0 -7
  21. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/.github/CODEOWNERS +0 -0
  22. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/.github/SECURITY.md +0 -0
  23. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/.github/dependabot.yml +0 -0
  24. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/.gitignore +0 -0
  25. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/.pre-commit-config.yaml +0 -0
  26. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/LICENSE +0 -0
  27. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/Makefile +0 -0
  28. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/apps.py +0 -0
  29. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/argv/__init__.py +0 -0
  30. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/argv/add_spec_arguments.py +0 -0
  31. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/argv/describe_parameters.py +0 -0
  32. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/argv/parameter_descriptor.py +0 -0
  33. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/argv/parsed_arguments.py +0 -0
  34. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/argv/utils.py +0 -0
  35. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/checks.py +0 -0
  36. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/exit_code_for.py +0 -0
  37. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/py.typed +0 -0
  38. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/render/__init__.py +0 -0
  39. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/render/render_json_lines.py +0 -0
  40. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/render/render_result.py +0 -0
  41. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/render/render_table.py +0 -0
  42. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/render/utils.py +0 -0
  43. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/render/wants_table.py +0 -0
  44. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/report/__init__.py +0 -0
  45. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/report/unguarded_message.py +0 -0
  46. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/report/utils.py +0 -0
  47. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/settings.py +0 -0
  48. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/django_spec_cli/types/exit_code.py +0 -0
  49. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/docs/__init__.py +0 -0
  50. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/docs/examples/__init__.py +0 -0
  51. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/mkdocs.yml +0 -0
  52. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/scripts/check-changelog +0 -0
  53. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/scripts/release-publish.sh +0 -0
  54. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/__init__.py +0 -0
  55. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/argv/__init__.py +0 -0
  56. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/argv/test_add_spec_arguments.py +0 -0
  57. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/argv/test_describe_parameters.py +0 -0
  58. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/argv/test_parameter_descriptor.py +0 -0
  59. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/argv/test_parsed_arguments.py +0 -0
  60. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/argv/test_utils.py +0 -0
  61. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/argv/utils.py +0 -0
  62. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/commands_app/__init__.py +0 -0
  63. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/commands_app/apps.py +0 -0
  64. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/commands_app/management/__init__.py +0 -0
  65. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/commands_app/management/commands/__init__.py +0 -0
  66. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/commands_app/management/commands/notes.py +0 -0
  67. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/commands_app/management/commands/unguarded_bad_parser.py +0 -0
  68. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/commands_app/management/commands/unguarded_broken_import.py +0 -0
  69. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/commands_app/management/commands/unguarded_broken_init.py +0 -0
  70. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/commands_app/management/commands/unguarded_empty.py +0 -0
  71. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/commands_app/management/commands/unguarded_no_registry.py +0 -0
  72. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/commands_app/management/commands/unguarded_plain.py +0 -0
  73. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/commands_app/management/commands/unguarded_report.py +0 -0
  74. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/commands_app/unguarded_registries.py +0 -0
  75. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/conftest.py +0 -0
  76. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/conftest_settings.py +0 -0
  77. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/dispatch_app/__init__.py +0 -0
  78. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/dispatch_app/apps.py +0 -0
  79. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/dispatch_app/models.py +0 -0
  80. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/docs/__init__.py +0 -0
  81. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/docs/test_reference.py +0 -0
  82. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/render/__init__.py +0 -0
  83. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/render/test_render_json_lines.py +0 -0
  84. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/render/test_render_result.py +0 -0
  85. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/render/test_render_table.py +0 -0
  86. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/render/test_utils.py +0 -0
  87. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/render/test_wants_table.py +0 -0
  88. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/render/utils.py +0 -0
  89. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/report/__init__.py +0 -0
  90. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/report/test_unguarded_message.py +0 -0
  91. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/report/test_utils.py +0 -0
  92. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/test_apps.py +0 -0
  93. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/test_checks.py +0 -0
  94. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/test_exit_code_for.py +0 -0
  95. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/test_init.py +0 -0
  96. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/test_settings.py +0 -0
  97. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/types/__init__.py +0 -0
  98. {django_spec_cli-0.4.0 → django_spec_cli-0.5.0}/tests/types/test_exit_code.py +0 -0
  99. {django_spec_cli-0.4.0 → django_spec_cli-0.5.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,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] — 2026-11-08
11
+
12
+ ### Added
13
+ - **A run stamps its transport: `"cli"`.** Every callable a `SpecCommand` run
14
+ reaches is now handed a `call_context` built by the kernel's
15
+ `build_call_context` and stamped last with `"cli"`, exported as
16
+ `CLI_TRANSPORT`, so a permission check, the Validator, a provider, the
17
+ service and the presenter read `call_transport(call_context) == "cli"` from
18
+ one dict. Before, the command handed `dispatch` none, and a callable reading
19
+ the key saw a missing one, which the kernel reads as an in-process caller.
20
+ The Validator's declaration the command reads for the options it parses is
21
+ asked under the stamped dict as well. `request`, `view`, `format` and
22
+ `action` are in the dict as `None`, which binds nothing different from a
23
+ missing key. The command line page says what a callable reads here.
24
+
25
+ ### Changed
26
+ - **The floor on `django-service-specs` rises from 0.25.0 to 0.29.0**, the
27
+ release that adds `build_call_context` and removes the shared `Transport`
28
+ enum. A project on an older kernel stays on 0.4.x.
29
+
30
+ ## [0.4.1] — 2026-11-04
31
+
32
+ ### Added
33
+ - **The command line page shows a spec the kernel refuses.** Its system-check
34
+ section now includes an example command, `drafts`, serving one spec that
35
+ declares a plain `Parameter` over a keyword with no default, and prints the
36
+ `django_spec_cli.W004` sentence it gets. The page's tests assert that
37
+ `manage.py check` reports exactly that warning for the example and that a
38
+ run of the spec raises the same sentence, so the page cannot drift from
39
+ what the check and the command say. No change to the package.
40
+
10
41
  ## [0.4.0] — 2026-11-04
11
42
 
12
43
  ### Added
@@ -124,7 +155,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
124
155
  `django-service-specs>=0.10.0`, the first kernel release with every name the
125
156
  command reads at its root.
126
157
 
127
- [Unreleased]: https://github.com/Artui/django-spec-cli/compare/v0.4.0...HEAD
158
+ [Unreleased]: https://github.com/Artui/django-spec-cli/compare/v0.5.0...HEAD
159
+ [0.5.0]: https://github.com/Artui/django-spec-cli/compare/v0.4.1...v0.5.0
160
+ [0.4.1]: https://github.com/Artui/django-spec-cli/compare/v0.4.0...v0.4.1
128
161
  [0.4.0]: https://github.com/Artui/django-spec-cli/compare/v0.3.1...v0.4.0
129
162
  [0.3.1]: https://github.com/Artui/django-spec-cli/compare/v0.3.0...v0.3.1
130
163
  [0.3.0]: https://github.com/Artui/django-spec-cli/compare/v0.2.1...v0.3.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.25.0 | the newest release, and the floor in the `floor` job |
142
+ | django-service-specs | 0.29.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.0
3
+ Version: 0.5.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.29.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.29.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.29.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",
@@ -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"})
@@ -173,7 +175,7 @@ class SpecCommand(BaseCommand):
173
175
  )
174
176
  self._entries[name] = entry
175
177
  spec = kernel_spec(entry.spec)
176
- parameters = _sendable(spec, self.pool_seeds)
178
+ parameters = _sendable(spec, self.pool_seeds, _call_context())
177
179
  summary = _summary(spec, entry.name)
178
180
  subparser = subparsers.add_parser(
179
181
  name,
@@ -256,7 +258,11 @@ class SpecCommand(BaseCommand):
256
258
  refused_message(entry.name, self._command_label, error)
257
259
  ) from error
258
260
  spec = kernel_spec(entry.spec)
259
- parameters = spec.parameters()
261
+ # One dict for the whole run, built ahead of the first callable it
262
+ # asks: the declaration read here, then every stage ``dispatch`` runs,
263
+ # so a callable reading ``transport`` sees ``cli`` from either.
264
+ call_context = _call_context()
265
+ parameters = spec.parameters(call_context=call_context)
260
266
  raw = parsed_arguments(argparse.Namespace(**options), parameters)
261
267
  # Read before the run, not after it: a setting that names no format
262
268
  # raises, and raising once a write has committed reports a failure for
@@ -282,6 +288,7 @@ class SpecCommand(BaseCommand):
282
288
  principal=principal,
283
289
  arguments=coerce_flat(parameters, raw),
284
290
  pool_seeds=self.pool_seeds,
291
+ call_context=call_context,
285
292
  )
286
293
  except (DispatchError, ServiceError) as error:
287
294
  raise CommandError(_message(error), returncode=int(exit_code_for(error))) from error
@@ -304,7 +311,20 @@ class SpecCommand(BaseCommand):
304
311
  self.stdout.write(unguarded_message(unguarded, label))
305
312
 
306
313
 
307
- def _sendable(spec: ServiceSpec | SelectorSpec, pool_seeds: PoolSeeds) -> Parameters:
314
+ def _call_context() -> dict[str, Any]:
315
+ """A new context for the command's own reads and one run, stamped ``cli``.
316
+
317
+ ``request`` and ``view`` are there as ``None``, since a command line has
318
+ neither: the kernel hands a callable declaring either ``None`` for a
319
+ ``None`` entry exactly as for a missing one, so no binding changes.
320
+ ``format`` and ``action`` are no pool names and are never bound.
321
+ """
322
+ return build_call_context(CLI_TRANSPORT)
323
+
324
+
325
+ def _sendable(
326
+ spec: ServiceSpec | SelectorSpec, pool_seeds: PoolSeeds, call_context: dict[str, Any]
327
+ ) -> Parameters:
308
328
  """What a caller may send, as the kernel's caller schema reads it: required only where dispatch demands it.
309
329
 
310
330
  The parameters ``spec_caller_input_schema`` offers under the command's
@@ -327,7 +347,7 @@ def _sendable(spec: ServiceSpec | SelectorSpec, pool_seeds: PoolSeeds) -> Parame
327
347
  return Parameters.of(
328
348
  *(
329
349
  param if param.name in required else replace(param, required=False)
330
- for param in spec.parameters()
350
+ for param in spec.parameters(call_context=call_context)
331
351
  if param.name in schema["properties"]
332
352
  )
333
353
  )
@@ -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.0"
5
+ __version__: str = "0.5.0"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -1,4 +1,6 @@
1
- """A registry served as a management command, and a command that looks its user up by username.
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.
2
4
 
3
5
  ``tests/docs/test_commands_examples.py`` runs every command line
4
6
  ``docs/index.md`` shows through this module's ``Command``, from argv as
@@ -30,6 +32,7 @@ from django_service_specs import (
30
32
  ServiceSpec,
31
33
  SpecRegistry,
32
34
  Unrestricted,
35
+ call_transport,
33
36
  )
34
37
 
35
38
  from django_spec_cli import SpecCommand
@@ -137,3 +140,49 @@ class ByUsername(SpecCommand):
137
140
 
138
141
 
139
142
  # --8<-- [end:by_username]
143
+
144
+
145
+ # --8<-- [start:refused]
146
+ def retitle(*, title: str) -> str:
147
+ return title
148
+
149
+
150
+ drafts = SpecRegistry()
151
+ drafts.register(
152
+ "retitle",
153
+ ServiceSpec(
154
+ service=retitle,
155
+ permissions=[IsSignedIn()],
156
+ # Optional, as a Parameter is unless it says otherwise, over a keyword
157
+ # with no default: a call leaving it out could not run, so the kernel's
158
+ # check_seeds refuses the spec, and manage.py check reports it as
159
+ # django_spec_cli.W004.
160
+ reads=Parameters.of(Parameter("title", "string")),
161
+ ),
162
+ )
163
+
164
+
165
+ # yourapp/management/commands/drafts.py
166
+ class Drafts(SpecCommand):
167
+ registry = drafts
168
+
169
+
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.29.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,23 @@ 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
+ A `Parameter` is optional unless it says `required=True`, so a read declared
268
+ plainly over a keyword with no default is one such declaration:
269
+
270
+ ```python
271
+ --8<--
272
+ docs/examples/commands.py:refused
273
+ --8<--
274
+ ```
275
+
276
+ `manage.py check` reports it as `django_spec_cli.W004`, and
277
+ `manage.py drafts retitle --as-user 1 --title Final` is refused with the same
278
+ sentence:
279
+
280
+ ```text
281
+ 'retitle' in manage.py drafts cannot run as declared, and a run of it is refused: ServiceSpec.service requires the keyword(s) ['title'], declared as optional reads that nothing else fills, so a call leaving one out is refused as missing. Declare the read required, or give the keyword a default.
282
+ ```
283
+
267
284
  All three are warnings, so a spec nobody runs never stops `migrate`, and
268
285
  `SILENCED_SYSTEM_CHECKS` silences one by id. Silencing `W004` hides the report
269
286
  and does not let the spec run.
@@ -275,10 +292,50 @@ listing only `"django_service_specs"` gets no check for a command. List
275
292
  A `SpecCommand` keeps Django's system checks on, so on the command line the
276
293
  report is printed to stderr before every run of every command. The runs on
277
294
  this page are shown without it; in a project listing the app, each would be
278
- preceded by the warning for `whoami`. `call_command` skips
295
+ preceded by the warnings for `whoami` and `retitle`. `call_command` skips
279
296
  system checks for every command but `check`, so a command run through it
280
297
  does not print them.
281
298
 
299
+ ## What a callable reads here
300
+
301
+ Every callable a run reaches is handed one `call_context`, built by the
302
+ kernel's
303
+ [`build_call_context`][django_service_specs.types.build_call_context.build_call_context]
304
+ and stamped last with this package's transport name, the plain string `"cli"`,
305
+ exported as `django_spec_cli.CLI_TRANSPORT`. Read it with the kernel's
306
+ [`call_transport`][django_service_specs.types.call_transport.call_transport],
307
+ or compare `call_context["transport"] == "cli"` with nothing imported:
308
+
309
+ ```python
310
+ --8<--
311
+ docs/examples/commands.py:transport
312
+ --8<--
313
+ ```
314
+
315
+ - **One dict per run.** The command builds it ahead of everything it asks of
316
+ a spec and hands the same object to the declaration it reads for the
317
+ options it parses, then to `dispatch`, so a permission check, the
318
+ Validator's `parameters()` and `validate()`, a provider, the service and the
319
+ presenter all read `"cli"` from one dict.
320
+ - **The standard entries are there as `None`.** `request`, `view`, `format`
321
+ and `action` are keys of the dict with no value, since a command line has
322
+ none. The kernel hands a callable declaring `request` or `view` `None` for
323
+ such an entry, as it does for a missing one, and `format` and `action` are
324
+ never bound by name, so a spec written for HTTP runs here as it did.
325
+ - **`--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.
333
+ - **Seams.** The value is not configurable: a subclass cannot change what a
334
+ run stamps. What a subclass can supply is `resolve_as_user`, who the run is
335
+ made as, and `pool_seeds`, which adds a keyword to the pool every callable
336
+ may declare, so an entry a project needs in every call is a seed rather
337
+ than a key of the dict.
338
+
282
339
  ## What it does not do
283
340
 
284
341
  - **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,9 @@ 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.
53
+ "django-service-specs>=0.29.0",
53
54
  ]
54
55
 
55
56
  [project.urls]
@@ -149,7 +150,7 @@ skip-magic-trailing-comma = false
149
150
  root = ["./django_spec_cli"]
150
151
 
151
152
  [tool.bumpversion]
152
- current_version = "0.4.0"
153
+ current_version = "0.5.0"
153
154
  allow_dirty = false
154
155
  commit = false
155
156
  tag = false
@@ -7,6 +7,10 @@
7
7
  shows them - must be the lines printed under it, and its exit status the
8
8
  number an ``echo $?`` after it shows. So the page cannot show an option, an
9
9
  output or an exit code the command does not have.
10
+
11
+ The sentence the page prints for the example's ``Drafts``, whose spec the
12
+ kernel refuses, must be the ``django_spec_cli.W004`` the system check reports
13
+ and the refusal a run of the spec raises.
10
14
  """
11
15
 
12
16
  from __future__ import annotations
@@ -18,10 +22,13 @@ from pathlib import Path
18
22
  from typing import Any
19
23
 
20
24
  import pytest
25
+ from django.core.checks import Warning
26
+ from django.core.exceptions import ImproperlyConfigured
21
27
  from django.core.management import call_command
22
28
  from django.core.management.base import CommandError
23
29
  from django_service_specs import UnguardedReason, UnguardedSpec
24
30
 
31
+ from django_spec_cli.checks import check_unguarded_specs
25
32
  from django_spec_cli.report.unguarded_message import unguarded_message
26
33
  from docs.examples import commands
27
34
  from tests.dispatch_app.models import Note
@@ -30,6 +37,7 @@ pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("keep_the_test_conn
30
37
 
31
38
  PAGE = Path(__file__).resolve().parents[2] / "docs" / "index.md"
32
39
  PROMPT = "$ python manage.py notes "
40
+ RETITLE_ARGV = "retitle --as-user 1 --title Final"
33
41
 
34
42
 
35
43
  def _runs() -> list[tuple[str, list[str], int]]:
@@ -112,7 +120,62 @@ def test_the_username_lookup_runs_as_the_user_it_names() -> None:
112
120
  assert (caught.value.returncode, str(caught.value)) == (5, "No user named 'nobody'.")
113
121
 
114
122
 
123
+ def _refused_line() -> str:
124
+ """The line the page prints for the W004 example: the one opening with its spec and command.
125
+
126
+ Found by that opening rather than by its block, so another ``text`` block
127
+ on the page leaves it alone, and a page no longer printing it fails here.
128
+ """
129
+ (line,) = [
130
+ line
131
+ for line in PAGE.read_text().splitlines()
132
+ if line.startswith("'retitle' in manage.py drafts ")
133
+ ]
134
+ return line
135
+
136
+
137
+ def test_the_refused_spec_is_reported_as_w004_with_the_page_s_sentence(
138
+ monkeypatch: pytest.MonkeyPatch,
139
+ ) -> None:
140
+ # The examples are no installed app, so the walk is pointed at the one
141
+ # command; it would otherwise never load it, and no other test's check
142
+ # sees this command.
143
+ monkeypatch.setattr("django_spec_cli.checks.get_commands", lambda: {"drafts": "yourapp"})
144
+ monkeypatch.setattr(
145
+ "django_spec_cli.checks.load_command_class", lambda app_name, name: commands.Drafts()
146
+ )
147
+ assert check_unguarded_specs(None) == [
148
+ Warning(
149
+ _refused_line(),
150
+ hint=(
151
+ "Change the declaration as the message says. Until then a run of the spec "
152
+ "is refused, whether or not django_spec_cli.W004 is silenced."
153
+ ),
154
+ obj=commands.Drafts,
155
+ id="django_spec_cli.W004",
156
+ )
157
+ ]
158
+
159
+
160
+ @pytest.mark.usefixtures("world")
161
+ def test_a_run_of_the_refused_spec_raises_the_page_s_sentence() -> None:
162
+ # The run the page names, which sends every option the spec declares, and
163
+ # is still refused.
164
+ assert f"`manage.py drafts {RETITLE_ARGV}`" in PAGE.read_text()
165
+ command = commands.Drafts()
166
+ with pytest.raises(ImproperlyConfigured) as caught:
167
+ command.run_from_argv(["manage.py", "drafts", "--skip-checks", *shlex.split(RETITLE_ARGV)])
168
+ assert str(caught.value) == _refused_line()
169
+
170
+
115
171
  @pytest.mark.usefixtures("world")
116
172
  def test_whoami_is_the_user_named(capsys: pytest.CaptureFixture[str]) -> None:
117
173
  assert _run_from_argv("whoami --as-user 1") == 0
118
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'