django-spec-cli 0.6.0__tar.gz → 0.7.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 (108) hide show
  1. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/.github/workflows/release.yml +1 -1
  2. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/.github/workflows/tests.yml +4 -4
  3. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/.github/workflows/upstream-drift.yml +1 -1
  4. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/CHANGELOG.md +39 -1
  5. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/CLAUDE.md +22 -1
  6. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/PKG-INFO +3 -3
  7. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/README.md +1 -1
  8. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/__init__.py +2 -2
  9. django_spec_cli-0.7.0/django_spec_cli/apps.py +15 -0
  10. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/argv/__init__.py +0 -2
  11. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/argv/add_spec_arguments.py +2 -19
  12. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/argv/describe_parameters.py +4 -22
  13. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/argv/parameter_descriptor.py +0 -2
  14. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/argv/parsed_arguments.py +1 -7
  15. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/argv/utils.py +0 -2
  16. django_spec_cli-0.7.0/django_spec_cli/checks.py +99 -0
  17. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/exit_code_for.py +0 -2
  18. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/render/__init__.py +2 -2
  19. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/render/render_json_lines.py +0 -2
  20. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/render/render_result.py +0 -11
  21. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/render/render_table.py +4 -29
  22. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/render/utils.py +1 -6
  23. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/render/wants_table.py +0 -6
  24. django_spec_cli-0.7.0/django_spec_cli/render/write_file.py +51 -0
  25. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/report/__init__.py +0 -2
  26. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/report/unguarded_message.py +0 -2
  27. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/report/utils.py +1 -8
  28. django_spec_cli-0.7.0/django_spec_cli/settings.py +20 -0
  29. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/spec_command.py +85 -102
  30. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/types/__init__.py +0 -2
  31. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/types/cli_transport.py +0 -2
  32. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/types/exit_code.py +20 -15
  33. django_spec_cli-0.7.0/django_spec_cli/version.py +5 -0
  34. django_spec_cli-0.7.0/docs/examples/__init__.py +1 -0
  35. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/docs/examples/commands.py +19 -0
  36. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/docs/index.md +66 -5
  37. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/docs/reference.md +3 -1
  38. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/pyproject.toml +5 -4
  39. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/argv/test_add_spec_arguments.py +6 -13
  40. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/argv/test_describe_parameters.py +5 -10
  41. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/argv/test_parsed_arguments.py +4 -11
  42. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/commands_app/management/commands/notes.py +1 -13
  43. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/commands_app/management/commands/unguarded_bad_parser.py +0 -2
  44. django_spec_cli-0.7.0/tests/commands_app/management/commands/unguarded_broken_import.py +3 -0
  45. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/commands_app/management/commands/unguarded_broken_init.py +0 -2
  46. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/commands_app/management/commands/unguarded_empty.py +0 -2
  47. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/commands_app/management/commands/unguarded_no_registry.py +0 -2
  48. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/commands_app/management/commands/unguarded_plain.py +0 -2
  49. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/commands_app/management/commands/unguarded_report.py +0 -2
  50. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/commands_app/unguarded_registries.py +0 -7
  51. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/conftest.py +0 -6
  52. django_spec_cli-0.7.0/tests/conftest_settings.py +28 -0
  53. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/dispatch_app/models.py +0 -8
  54. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/docs/test_commands_examples.py +22 -33
  55. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/docs/test_reference.py +0 -2
  56. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/render/test_render_json_lines.py +5 -13
  57. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/render/test_render_result.py +6 -16
  58. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/render/test_render_table.py +8 -28
  59. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/render/test_wants_table.py +2 -8
  60. django_spec_cli-0.7.0/tests/render/test_write_file.py +65 -0
  61. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/render/utils.py +1 -5
  62. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/report/test_unguarded_message.py +1 -2
  63. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/test_apps.py +3 -8
  64. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/test_checks.py +28 -38
  65. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/test_exit_code_for.py +3 -9
  66. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/test_init.py +4 -11
  67. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/test_settings.py +1 -4
  68. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/test_spec_command.py +233 -72
  69. django_spec_cli-0.7.0/tests/types/__init__.py +0 -0
  70. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/types/test_exit_code.py +1 -2
  71. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/utils.py +2 -8
  72. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/uv.lock +228 -249
  73. django_spec_cli-0.6.0/django_spec_cli/apps.py +0 -31
  74. django_spec_cli-0.6.0/django_spec_cli/checks.py +0 -179
  75. django_spec_cli-0.6.0/django_spec_cli/settings.py +0 -36
  76. django_spec_cli-0.6.0/django_spec_cli/version.py +0 -7
  77. django_spec_cli-0.6.0/docs/examples/__init__.py +0 -1
  78. django_spec_cli-0.6.0/tests/commands_app/management/commands/unguarded_broken_import.py +0 -5
  79. django_spec_cli-0.6.0/tests/conftest_settings.py +0 -46
  80. django_spec_cli-0.6.0/tests/docs/__init__.py +0 -1
  81. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/.github/CODEOWNERS +0 -0
  82. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/.github/SECURITY.md +0 -0
  83. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/.github/dependabot.yml +0 -0
  84. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/.gitignore +0 -0
  85. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/.pre-commit-config.yaml +0 -0
  86. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/LICENSE +0 -0
  87. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/Makefile +0 -0
  88. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/django_spec_cli/py.typed +0 -0
  89. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/docs/__init__.py +0 -0
  90. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/mkdocs.yml +0 -0
  91. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/scripts/check-changelog +0 -0
  92. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/scripts/release-publish.sh +0 -0
  93. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/__init__.py +0 -0
  94. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/argv/__init__.py +0 -0
  95. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/argv/test_parameter_descriptor.py +0 -0
  96. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/argv/test_utils.py +0 -0
  97. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/argv/utils.py +0 -0
  98. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/commands_app/__init__.py +0 -0
  99. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/commands_app/apps.py +0 -0
  100. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/commands_app/management/__init__.py +0 -0
  101. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/commands_app/management/commands/__init__.py +0 -0
  102. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/dispatch_app/__init__.py +0 -0
  103. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/dispatch_app/apps.py +0 -0
  104. {django_spec_cli-0.6.0/tests/render → django_spec_cli-0.7.0/tests/docs}/__init__.py +0 -0
  105. {django_spec_cli-0.6.0/tests/report → django_spec_cli-0.7.0/tests/render}/__init__.py +0 -0
  106. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/render/test_utils.py +0 -0
  107. {django_spec_cli-0.6.0/tests/types → django_spec_cli-0.7.0/tests/report}/__init__.py +0 -0
  108. {django_spec_cli-0.6.0 → django_spec_cli-0.7.0}/tests/report/test_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@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
34
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
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@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
23
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
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@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
40
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
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@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
57
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
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@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
174
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
175
175
  with:
176
176
  enable-cache: true
177
177
  - name: Install Python ${{ matrix.python-version }}
@@ -43,7 +43,7 @@ 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@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
46
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
47
47
  with:
48
48
  enable-cache: false
49
49
 
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] — 2026-11-14
11
+
12
+ ### Added
13
+ - **A spec whose result is a file writes its bytes.** A spec presenting through the
14
+ kernel's `FilePresenter` was presented as JSON Lines, and the encoder refused its
15
+ `FileResult` with a `TypeError`. Its subcommand now writes the file's bytes to
16
+ `--output-file PATH`, an option only a file spec's subcommand takes, or else to
17
+ stdout's binary buffer when stdout is not a terminal. At a terminal without
18
+ `--output-file`, with `--table` or `--json`, with a stdout that takes no bytes, or with
19
+ a `PATH` that is a directory or whose directory does not exist, the run is a usage
20
+ error raised before the user is looked up or the spec is run, so nothing is written.
21
+ `SPEC_CLI_OUTPUT` is not read for it. The file's name, type and length go to stderr;
22
+ the name is never used as a path. The file is read whole before `PATH` is opened, so
23
+ a refused run leaves a file there as it was, and an `allow_none` spec finding
24
+ nothing writes nothing. A file spec the kernel's `file_output` refuses (a `LIST`
25
+ selector, `many`, `returns_many`, presented affordances) is refused as declared, for
26
+ its spec alone, and `django_spec_cli.W004` reports it. `write_file` is the new public
27
+ helper.
28
+
29
+ ### Changed
30
+ - **The kernel floor is 0.33.0**, up from 0.30.0, for `FileResult`, `FilePresenter` and
31
+ `file_output`. Two things a selector with a `filter_set` shows change with it, both
32
+ the kernel's (0.31.0): `--help` lists a choice filter's labels beside its values, as
33
+ in `one of: ada (Ada Lovelace)`, since the kernel now hands them on; and on Django
34
+ 5.0 or later a named group of choices held in a mapping offers and accepts its
35
+ members, where it offered the group's name and refused a member. A lazy affordance
36
+ reason reaches `ActionUnavailable` as text, as before, and the `QueryDict` a
37
+ `FormValidator` now binds is behind dispatch.
38
+ - **`W003` is described as it behaves**: it reports any project command that fails to
39
+ load, since a command that will not import cannot be told apart from a `SpecCommand`.
40
+ - **The API reference's docstrings are restored.** The docstrings the reference renders
41
+ carry their full text again.
42
+ - **The guide states four things it left out.** The checks carry no tag, so a run
43
+ selecting by `--tag` skips them; finding the commands imports every command module
44
+ of the project's apps on each check run; a table hides a hidden field and JSON Lines
45
+ does not; and a table holds its whole result in memory.
46
+
10
47
  ## [0.6.0] — 2026-11-09
11
48
 
12
49
  ### Fixed
@@ -186,7 +223,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
186
223
  `django-service-specs>=0.10.0`, the first kernel release with every name the
187
224
  command reads at its root.
188
225
 
189
- [Unreleased]: https://github.com/Artui/django-spec-cli/compare/v0.6.0...HEAD
226
+ [Unreleased]: https://github.com/Artui/django-spec-cli/compare/v0.7.0...HEAD
227
+ [0.7.0]: https://github.com/Artui/django-spec-cli/compare/v0.6.0...v0.7.0
190
228
  [0.6.0]: https://github.com/Artui/django-spec-cli/compare/v0.5.0...v0.6.0
191
229
  [0.5.0]: https://github.com/Artui/django-spec-cli/compare/v0.4.1...v0.5.0
192
230
  [0.4.1]: https://github.com/Artui/django-spec-cli/compare/v0.4.0...v0.4.1
@@ -117,6 +117,27 @@ ships `py.typed`, so consumers get the annotations.
117
117
  Never a mypy-style `# type: ignore` in the package - a pre-commit hook rejects
118
118
  it.
119
119
 
120
+ ## Comment budget
121
+
122
+ `#` comments and docstrings both count, in the package and in the tests:
123
+
124
+ - A class: at most three lines (its docstring plus comments in its body outside its methods).
125
+ - A function or method: at most one line (docstring plus comments inside it, or directly
126
+ above it).
127
+ - A module: nothing before the first import (no module docstring, no header comment); a
128
+ package `__init__.py` with nothing to export is empty.
129
+ - Exempt: directives (`# noqa`, `# ty:`, `# vX.Y.Z` pins), migrations, and a docstring
130
+ the code or a test reads through `__doc__`, which is data.
131
+
132
+ A docstring a `:::` page renders (the object, and its public members unless `members:`
133
+ narrows them) is exempt too: it is the API reference. Comments around it still count.
134
+ Condense rather than move: a reason that does not fit is cut, not relocated to prose
135
+ elsewhere. The exception is a walkthrough comment inside a `--8<--` snippet section of a
136
+ `docs/` example: its prose moves onto the page beside the include. A body left empty by a removed docstring takes `...`. Ruff's E501 is on at 100
137
+ columns and `ruff format` never wraps a comment, so a line that does not fit is
138
+ condensed, not wrapped; an unbreakable string such as a URL takes `# noqa: E501`. The workbench's
139
+ `bin/comment-budget.py` lists the violations.
140
+
120
141
  ## Linting and formatting
121
142
 
122
143
  `ruff` is the source of truth for both. Use `...` rather than `pass` for empty
@@ -139,7 +160,7 @@ configured and enforced. isort order is stdlib, third party, first party.
139
160
  | --- | --- | --- |
140
161
  | Python | 3.10 | 3.10 through 3.14 |
141
162
  | Django | 4.2 | 4.2, 5.0, 5.1, 5.2, 6.0, 6.1 |
142
- | django-service-specs | 0.30.0 | the newest release, and the floor in the `floor` job |
163
+ | django-service-specs | 0.33.0 | the newest release, and the floor in the `floor` job |
143
164
 
144
165
  The Python and Django floors are the kernel's, and cannot sit below them.
145
166
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: django-spec-cli
3
- Version: 0.6.0
3
+ Version: 0.7.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.30.0
33
+ Requires-Dist: django-service-specs>=0.33.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.30.0 or
64
+ Python 3.10 or later, Django 4.2 or later, and `django-service-specs` 0.33.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.30.0 or
28
+ Python 3.10 or later, Django 4.2 or later, and `django-service-specs` 0.33.0 or
29
29
  later, which it installs.
30
30
 
31
31
  ## Quickstart
@@ -1,5 +1,3 @@
1
- """A registry of specs as a management command."""
2
-
3
1
  from django_spec_cli.argv.add_spec_arguments import add_spec_arguments
4
2
  from django_spec_cli.argv.describe_parameters import describe_parameters
5
3
  from django_spec_cli.argv.parameter_descriptor import ParameterDescriptor
@@ -9,6 +7,7 @@ from django_spec_cli.render.render_json_lines import render_json_lines
9
7
  from django_spec_cli.render.render_result import render_result
10
8
  from django_spec_cli.render.render_table import render_table
11
9
  from django_spec_cli.render.wants_table import wants_table
10
+ from django_spec_cli.render.write_file import write_file
12
11
  from django_spec_cli.report.unguarded_message import unguarded_message
13
12
  from django_spec_cli.spec_command import SpecCommand
14
13
  from django_spec_cli.types.cli_transport import CLI_TRANSPORT
@@ -26,6 +25,7 @@ __all__ = [
26
25
  "render_result",
27
26
  "render_table",
28
27
  "wants_table",
28
+ "write_file",
29
29
  "add_spec_arguments",
30
30
  "describe_parameters",
31
31
  "parsed_arguments",
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from django.apps import AppConfig
4
+ from django.core.checks import register
5
+
6
+ from django_spec_cli.checks import check_unguarded_specs
7
+
8
+
9
+ class DjangoSpecCliConfig(AppConfig):
10
+ """Registers the system check over every ``SpecCommand``'s specs, and does nothing else."""
11
+
12
+ name = "django_spec_cli"
13
+
14
+ def ready(self) -> None:
15
+ register(check_unguarded_specs)
@@ -1,5 +1,3 @@
1
- """How a spec's parameters become command-line options, and come back as its arguments."""
2
-
3
1
  from django_spec_cli.argv.add_spec_arguments import add_spec_arguments
4
2
  from django_spec_cli.argv.describe_parameters import describe_parameters
5
3
  from django_spec_cli.argv.parameter_descriptor import ParameterDescriptor
@@ -1,5 +1,3 @@
1
- """``add_spec_arguments`` - one argparse option per parameter a command line can send."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  import argparse
@@ -100,8 +98,6 @@ def _add_option(parser: argparse.ArgumentParser, descriptor: ParameterDescriptor
100
98
  "help": _help(descriptor),
101
99
  }
102
100
  if descriptor.type == "boolean":
103
- # No ``metavar``, ``type`` or ``choices``: a flag takes no value, and
104
- # Python 3.12 deprecates passing them to this action.
105
101
  parser.add_argument(option, action=argparse.BooleanOptionalAction, **common)
106
102
  return
107
103
  parser.add_argument(
@@ -118,7 +114,6 @@ def _metavar(descriptor: ParameterDescriptor) -> str:
118
114
  if descriptor.format is not None:
119
115
  return descriptor.format.upper()
120
116
  kind = descriptor.items if descriptor.type == "array" else descriptor.type
121
- # An ``any``, and an array declaring no element, take any value.
122
117
  return "VALUE" if kind in (None, "any") else kind.upper()
123
118
 
124
119
 
@@ -139,31 +134,19 @@ def _help(descriptor: ParameterDescriptor) -> str | None:
139
134
  notes.append("required")
140
135
  if descriptor.default is not UNSET:
141
136
  notes.append(f"default: {_text(descriptor.default)}")
142
- # ``str``: a help text may be a lazy translation, rendered here.
143
137
  parts = [str(descriptor.help)] if descriptor.help else []
144
138
  if notes:
145
139
  parts.append("(" + "; ".join(notes) + ")")
146
- # Argparse %-formats every help string, so a literal ``%`` - in the
147
- # declared help, a choice, a label or a default - is doubled to print as
148
- # written: test_a_percent_sign_in_any_help_text_is_printed_as_written.
140
+ # Argparse %-formats help, so a literal ``%`` is doubled to print as written.
149
141
  return " ".join(parts).replace("%", "%%") if parts else None
150
142
 
151
143
 
152
144
  def _choice(choice: Any, pairs: tuple[tuple[Any, str], ...]) -> str:
153
145
  """A choice as typed, with its label where one is declared."""
154
- # Matched by type as well as value, as the declaration keeps them apart:
155
- # ``1`` and ``True`` are equal to Python and two choices to a caller. Each
156
- # condition is held by its own case of test_a_choice_is_shown_with_its_own_label:
157
- # [equal-across-types] (the type) and [unlabelled] (the value).
158
146
  label = next((lb for v, lb in pairs if type(v) is type(choice) and v == choice), None)
159
147
  return _text(choice) if label is None else f"{_text(choice)} ({label})"
160
148
 
161
149
 
162
150
  def _text(value: Any) -> str:
163
- """A value as it is typed: a string bare, anything else as its JSON.
164
-
165
- An empty string is quoted, since bare it would read as no value at all.
166
- """
167
- # Each condition is held by its own test: test_an_empty_string_default_is_shown_quoted
168
- # (the emptiness) and test_a_choice_that_is_not_a_string_is_listed_as_json (the type).
151
+ """A value as it is typed: a string bare unless empty, anything else as its JSON."""
169
152
  return value if isinstance(value, str) and value else json.dumps(value)
@@ -1,5 +1,3 @@
1
- """``describe_parameters`` - a spec's Parameters, as a command line sees them."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  import json
@@ -24,7 +22,7 @@ def describe_parameters(parameters: Parameters) -> tuple[ParameterDescriptor, ..
24
22
  Never refuses a declaration: a parameter with no flat spelling is
25
23
  described with ``argv=False`` rather than left out, so a command can still
26
24
  say in ``--help`` that a spec takes something argv cannot send.
27
- """
25
+ """ # noqa: E501
28
26
  return tuple(_describe(param) for param in parameters)
29
27
 
30
28
 
@@ -34,11 +32,7 @@ def _describe(param: Parameter) -> ParameterDescriptor:
34
32
  type=param.type,
35
33
  format=param.format,
36
34
  items=param.items if isinstance(param.items, str) else None,
37
- # The shape check's own rule for an absent argument, so the help says
38
- # "(required)" exactly where an omission is refused. Each condition is
39
- # held by its own test: test_required_means_an_omitted_option_is_refused
40
- # (the declaration) and test_a_required_parameter_with_a_default_is_not_required
41
- # (the default, reported or not).
35
+ # The shape check's rule for an omitted argument: "(required)" marks a refused omission.
42
36
  required=param.required and param.default is UNSET,
43
37
  default=_shown_default(param.default),
44
38
  choices=param.choices,
@@ -53,26 +47,14 @@ def _describe(param: Parameter) -> ParameterDescriptor:
53
47
 
54
48
 
55
49
  def _has_flat_spelling(param: Parameter) -> bool:
56
- # Read from the type rather than from ``nested``, which alone is narrower
57
- # than "no honest option": an object with no ``fields`` and an array of
58
- # ``object``, ``array`` or ``file`` elements have no nested Parameters and
59
- # still no string that spells them. Held by
60
- # test_a_parameter_with_no_flat_spelling_has_no_option, one case per type,
61
- # and test_every_parameter_a_command_line_can_send_has_an_option.
50
+ # Read from the type: ``nested`` is narrower than "no honest option".
62
51
  if param.type == "array":
63
52
  return param.items in _FLAT_ELEMENTS
64
53
  return param.type in _FLAT_TYPES
65
54
 
66
55
 
67
56
  def _shown_default(default: Any) -> Any:
68
- """The default, where JSON spells it; otherwise ``UNSET``, so nothing is shown.
69
-
70
- One check rather than a list of exclusions: ``json.dumps`` refuses
71
- ``UNSET``, a callable and any other object it has no spelling for, and
72
- ``allow_nan=False`` refuses NaN and the infinities, which Python's encoder
73
- would otherwise write as tokens no JSON reader takes. Each is a case of
74
- test_a_default_with_no_json_spelling_is_not_reported.
75
- """
57
+ """The default, where JSON spells it; otherwise ``UNSET``, so nothing is shown."""
76
58
  try:
77
59
  json.dumps(default, allow_nan=False)
78
60
  except (TypeError, ValueError):
@@ -1,5 +1,3 @@
1
- """``ParameterDescriptor`` - one parameter, as a command line sees it."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  from dataclasses import dataclass
@@ -1,5 +1,3 @@
1
- """``parsed_arguments`` - the options argparse read, as the raw arguments ``coerce_flat`` takes."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  import argparse
@@ -32,7 +30,7 @@ def parsed_arguments(namespace: argparse.Namespace, parameters: Parameters) -> d
32
30
  A value set there by a caller other than argparse, as ``call_command``
33
31
  does with keyword arguments, is handed on as it was set, except that a
34
32
  ``bool`` for a ``boolean`` is spelled as argparse's would have been.
35
- """
33
+ """ # noqa: E501
36
34
  values = vars(namespace)
37
35
  raw: dict[str, Any] = {}
38
36
  for param in parameters:
@@ -40,10 +38,6 @@ def parsed_arguments(namespace: argparse.Namespace, parameters: Parameters) -> d
40
38
  if key not in values:
41
39
  continue
42
40
  value = values[key]
43
- # Each condition is held by its own case of
44
- # test_a_value_given_by_destination_rather_than_argv_is_handed_on: a
45
- # string for a boolean stays the string, and a ``bool`` for an ``any``
46
- # stays the ``bool``.
47
41
  if param.type == "boolean" and isinstance(value, bool):
48
42
  value = "true" if value else "false"
49
43
  raw[param.name] = value
@@ -1,5 +1,3 @@
1
- """Helpers ``argv/`` and ``SpecCommand`` share: how a parameter's name becomes argv and back."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  DESTINATION_PREFIX = "spec_arg__"
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator, Sequence
4
+ from typing import Any
5
+
6
+ from django.apps import AppConfig
7
+ from django.core.checks import CheckMessage, Warning
8
+ from django.core.exceptions import ImproperlyConfigured
9
+ from django.core.management import get_commands, load_command_class
10
+ from django_service_specs import UnguardedSpec, check_seeds, unguarded_specs
11
+
12
+ from django_spec_cli.report.unguarded_message import unguarded_message
13
+ from django_spec_cli.report.utils import refused_message
14
+ from django_spec_cli.spec_command import SpecCommand
15
+
16
+ _HINT = (
17
+ "Declare a PermissionCheck that refuses someone in the spec's permissions, or add "
18
+ "'django_spec_cli.W001' to SILENCED_SYSTEM_CHECKS if the spec is meant to be open."
19
+ )
20
+ _REFUSED_HINT = (
21
+ "Change the declaration as the message says. Until then a run of the spec is refused, "
22
+ "whether or not django_spec_cli.W004 is silenced."
23
+ )
24
+
25
+
26
+ def check_unguarded_specs(
27
+ app_configs: Sequence[AppConfig] | None, **kwargs: Any
28
+ ) -> list[CheckMessage]:
29
+ """Warn once per spec that authorizes nothing or can't run, in every project ``SpecCommand``."""
30
+ messages: list[CheckMessage] = []
31
+ for name, app_name, loaded in _spec_commands(app_configs):
32
+ if isinstance(loaded, Exception):
33
+ messages.append(
34
+ Warning(
35
+ f"The command {name!r} from {app_name} could not be set up, so its specs "
36
+ f"were not checked: {type(loaded).__name__}: {loaded}",
37
+ hint=(
38
+ f"Run manage.py {name} to see the traceback. Until it loads, the specs "
39
+ "it serves are not checked."
40
+ ),
41
+ id="django_spec_cli.W003",
42
+ )
43
+ )
44
+ continue
45
+ command, specs = loaded
46
+ messages.extend(
47
+ Warning(
48
+ unguarded_message(spec, name),
49
+ hint=_HINT,
50
+ obj=type(command),
51
+ id="django_spec_cli.W001",
52
+ )
53
+ for spec in specs
54
+ )
55
+ messages.extend(
56
+ Warning(
57
+ refused_message(spec_name, name, error),
58
+ hint=_REFUSED_HINT,
59
+ obj=type(command),
60
+ id="django_spec_cli.W004",
61
+ )
62
+ for spec_name, error in _refused(command)
63
+ )
64
+ return messages
65
+
66
+
67
+ def _refused(command: SpecCommand) -> Iterator[tuple[str, ImproperlyConfigured]]:
68
+ """Each spec of ``command`` a run refuses as declared, with its refusal, in registry order."""
69
+ for entry in command.registry:
70
+ try:
71
+ check_seeds(entry.spec, pool_seeds=command.pool_seeds)
72
+ except ImproperlyConfigured as error:
73
+ yield entry.name, error
74
+ continue
75
+ declared = command._refused_by_declaration.get(entry.name)
76
+ if declared is not None:
77
+ yield entry.name, declared
78
+
79
+
80
+ def _spec_commands(
81
+ app_configs: Sequence[AppConfig] | None,
82
+ ) -> Iterator[tuple[str, str, tuple[SpecCommand, tuple[UnguardedSpec, ...]] | Exception]]:
83
+ """Each ``SpecCommand`` the project serves: its unguarded specs, or why it will not load."""
84
+ wanted = None if app_configs is None else {config.name for config in app_configs}
85
+ for name, app_name in get_commands().items():
86
+ if app_name == "django.core" or (wanted is not None and app_name not in wanted):
87
+ continue
88
+ try:
89
+ command = load_command_class(app_name, name)
90
+ if not isinstance(command, SpecCommand):
91
+ continue
92
+ command.create_parser("manage.py", name)
93
+ loaded: tuple[SpecCommand, tuple[UnguardedSpec, ...]] | Exception = (
94
+ command,
95
+ unguarded_specs(command.registry),
96
+ )
97
+ except Exception as error:
98
+ loaded = error
99
+ yield name, app_name, loaded
@@ -1,5 +1,3 @@
1
- """``exit_code_for`` - the exit status a refusal ends a ``SpecCommand`` run with."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  from django_service_specs import (
@@ -1,13 +1,13 @@
1
- """How a command writes a presented result: a table for a terminal, JSON lines for a pipe."""
2
-
3
1
  from django_spec_cli.render.render_json_lines import render_json_lines
4
2
  from django_spec_cli.render.render_result import render_result
5
3
  from django_spec_cli.render.render_table import render_table
6
4
  from django_spec_cli.render.wants_table import wants_table
5
+ from django_spec_cli.render.write_file import write_file
7
6
 
8
7
  __all__ = [
9
8
  "render_json_lines",
10
9
  "render_result",
11
10
  "render_table",
12
11
  "wants_table",
12
+ "write_file",
13
13
  ]
@@ -1,5 +1,3 @@
1
- """``render_json_lines`` - presented rows as JSON Lines, one object per line, streamed."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  from collections.abc import Iterable, Mapping
@@ -1,5 +1,3 @@
1
- """``render_result`` - what ``present`` returned, written as a table or as JSON Lines."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  from collections.abc import Iterable, Mapping
@@ -45,19 +43,10 @@ def render_result(presented: Any, output: Output | None, stream: TextIO, *, tabl
45
43
  """
46
44
  if presented is None:
47
45
  return
48
- # A mapping and a string are both iterable, and each is one value rather
49
- # than a sequence of rows, so both are answered before the iterable check.
50
- # Three conditions in one branch arc, each held by its own test:
51
- # the ``str`` cases of test_a_scalar_is_a_result_column_or_one_line_of_its_json
52
- # (``str``), test_bytes_are_a_scalar_and_never_iterated_as_rows
53
- # (``bytes``), and the ``int`` cases of the first (no iterable, which
54
- # iterating would raise on).
55
46
  if isinstance(presented, Mapping):
56
47
  rows: Iterable[Any] = (presented,)
57
48
  elif isinstance(presented, str | bytes) or not isinstance(presented, Iterable):
58
49
  rows = (presented,)
59
- # A scalar has no declared field to sit under, whatever the spec
60
- # declares, so its table is the one ``result`` column.
61
50
  output = None
62
51
  else:
63
52
  rows = presented
@@ -1,5 +1,3 @@
1
- """``render_table`` - presented rows as an aligned plain-text table."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  import json
@@ -100,8 +98,6 @@ def render_table(rows: Sequence[Mapping[str, Any]], output: Output | None, strea
100
98
  if not columns:
101
99
  return
102
100
  lines = [
103
- # A header is escaped as a cell is: with no declared output it is a
104
- # row's own key, which could carry a line break as well as a value.
105
101
  [_text(column.header) for column in columns],
106
102
  *([_cell(row.get(column.name), column.choices) for column in columns] for row in rows),
107
103
  ]
@@ -111,9 +107,6 @@ def render_table(rows: Sequence[Mapping[str, Any]], output: Output | None, strea
111
107
  _pad(text, width, right=column.right)
112
108
  for text, width, column in zip(line, widths, columns, strict=True)
113
109
  )
114
- # One write per line, ending in a newline: Django's ``OutputWrapper``
115
- # appends one to any message that does not end in it. ``rstrip`` drops
116
- # the padding of an empty or short last cell.
117
110
  stream.write(_GAP.join(cells).rstrip() + "\n")
118
111
 
119
112
 
@@ -124,21 +117,14 @@ def _columns(rows: Sequence[Mapping[str, Any]], output: Output | None) -> tuple[
124
117
 
125
118
 
126
119
  def _audience(field: OutputField) -> FieldAudience | None:
127
- # Coverage sees no branch in a conditional expression, so each arm is
128
- # named: test_an_unmarked_field_without_choices_is_shown_as_presented holds
129
- # the ``None`` (without it ``None.audience`` raises), and
130
- # test_a_hidden_field_is_no_column_and_every_other_audience_is_one the
131
- # marking's own audience.
120
+ # ``None`` for an unmarked field, which has no audience to read.
132
121
  return None if field.marking is None else field.marking.audience
133
122
 
134
123
 
135
124
  def _column(field: OutputField) -> _Column:
136
- # Only a handle keeps its value, not every marked field:
137
- # test_a_handle_keeps_its_value_even_with_choices and
138
- # test_a_label_with_choices_is_shown_by_its_display.
125
+ # Only a handle keeps its value, not every marked field.
139
126
  return _Column(
140
127
  field.name,
141
- # ``str()`` for a lazy translation, read in the active language.
142
128
  field.name if field.label is None else str(field.label),
143
129
  right=field.type in _NUMERIC,
144
130
  choices=None if _audience(field) is FieldAudience.HANDLE else field.choices,
@@ -153,25 +139,14 @@ def _cell(value: Any, choices: tuple[tuple[Any, Any], ...] | None) -> str:
153
139
  if isinstance(value, str):
154
140
  return _text(value)
155
141
  encoded = compact_json(value)
156
- # A value the encoder writes as a JSON string - a Decimal, a date-time, a
157
- # UUID, a lazy translation - is shown without its quotes, as text is.
142
+ # A value the encoder writes as a JSON string is shown without its quotes, as text is.
158
143
  return _text(json.loads(encoded) if encoded.startswith('"') else encoded)
159
144
 
160
145
 
161
146
  def _display(value: Any, choices: tuple[tuple[Any, Any], ...]) -> Any:
162
- """The display of the choice ``value`` names, or ``value`` itself.
163
-
164
- Compared by equality rather than looked up in a dict, so an unhashable
165
- value is compared rather than raising. A lazy display is left lazy: the
166
- encoder in ``_cell`` reads it in the active language. An array of choices is displayed
167
- member by member, as the agent projection speaks it. One ``isinstance``
168
- over two kinds, so each is held by its own case of
169
- test_an_array_of_choices_is_shown_member_by_member.
170
- """
147
+ """The display of the choice ``value`` names, or ``value`` itself."""
171
148
  if isinstance(value, list | tuple):
172
149
  return [_display(member, choices) for member in value]
173
- # Matched by type as well as value, as the declaration keeps them apart:
174
- # ``1`` and ``True`` are equal to Python and two choices to a caller.
175
150
  return next(
176
151
  (display for choice, display in choices if type(choice) is type(value) and choice == value),
177
152
  value,
@@ -1,5 +1,3 @@
1
- """Helpers the render modules share: one encoding of a value as JSON, for a line and for a cell."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  import json
@@ -9,8 +7,5 @@ from django.core.serializers.json import DjangoJSONEncoder
9
7
 
10
8
 
11
9
  def compact_json(value: Any) -> str:
12
- """``value`` as compact JSON, its text written as itself, as ``DjangoJSONEncoder`` encodes it.
13
-
14
- What the encoder has no spelling for raises ``TypeError``.
15
- """
10
+ """``value`` as compact JSON, its text written as itself, as ``DjangoJSONEncoder`` does."""
16
11
  return json.dumps(value, cls=DjangoJSONEncoder, ensure_ascii=False, separators=(",", ":"))
@@ -1,5 +1,3 @@
1
- """``wants_table`` - whether a command's result is written as a table or as JSON Lines."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  from typing import Literal, TextIO
@@ -29,9 +27,5 @@ def wants_table(stream: TextIO, *, requested: Literal["table", "json"] | None) -
29
27
  choice = requested if requested is not None else command_output()
30
28
  if choice == "auto":
31
29
  isatty = getattr(stream, "isatty", None)
32
- # One branch to coverage, so each condition is held by its own test:
33
- # test_a_stream_that_cannot_say_is_not_a_terminal (the first, without
34
- # which ``None()`` raises) and
35
- # test_a_terminal_gets_a_table_and_a_pipe_gets_json_lines (the second).
36
30
  return isatty is not None and bool(isatty())
37
31
  return choice == "table"