django-spec-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,344 @@
1
+ """``SpecCommand`` - a registry of specs as one management command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import inspect
7
+ from collections.abc import Mapping
8
+ from typing import Any, TextIO, cast
9
+
10
+ from django.core.exceptions import ImproperlyConfigured
11
+ from django.core.management.base import BaseCommand, CommandError, CommandParser
12
+ from django.utils.translation import gettext
13
+ from django_service_specs import (
14
+ DEFAULT_POOL_SEEDS,
15
+ ActionUnavailable,
16
+ AdditionalInputRequired,
17
+ DispatchError,
18
+ InvalidArguments,
19
+ Parameters,
20
+ PoolSeeds,
21
+ PrincipalUnavailable,
22
+ RegisteredSpec,
23
+ SelectorSpec,
24
+ ServiceError,
25
+ ServiceSpec,
26
+ ServiceValidationError,
27
+ SpecRegistry,
28
+ coerce_flat,
29
+ dispatch,
30
+ kernel_spec,
31
+ present,
32
+ refusal_messages,
33
+ relax_partial,
34
+ resolve_principal,
35
+ server_owned_keys,
36
+ unguarded_specs,
37
+ )
38
+
39
+ from django_spec_cli.argv.add_spec_arguments import add_spec_arguments
40
+ from django_spec_cli.argv.describe_parameters import describe_parameters
41
+ from django_spec_cli.argv.parsed_arguments import parsed_arguments
42
+ from django_spec_cli.argv.utils import option_string
43
+ from django_spec_cli.exit_code_for import exit_code_for
44
+ from django_spec_cli.render.render_result import render_result
45
+ from django_spec_cli.render.wants_table import wants_table
46
+ from django_spec_cli.report.unguarded_message import unguarded_message
47
+ from django_spec_cli.types.exit_code import ExitCode
48
+
49
+ _OWN_OPTIONS = frozenset({"--as-user", "--json", "--table"})
50
+ """The options every subcommand owns, which no spec parameter may map to."""
51
+
52
+
53
+ class SpecCommand(BaseCommand):
54
+ """A management command serving every spec in a registry, one subcommand each.
55
+
56
+ Declared by subclassing, in the project's own ``management/commands/``
57
+ module, which names the registry::
58
+
59
+ class Command(SpecCommand):
60
+ registry = notes_registry
61
+
62
+ The registry is the subclass's to name, so one project can expose two
63
+ registries under two command names. It is read when the parser is built,
64
+ and a command naming none is refused then, as ``ImproperlyConfigured``.
65
+ ``pool_seeds`` names the seeds the specs' callables are resolved with, as
66
+ ``SpecView.pool_seeds`` does, for a registry whose callables read one.
67
+
68
+ It is run as ``manage.py <command> <spec> --as-user USER [--json|--table]
69
+ [--option value ...]``, or ``manage.py <command> --explain``:
70
+
71
+ - **One subcommand per registry entry**, named after the entry with each
72
+ ``_`` shown as ``-``. Its options are the spec's parameters, as
73
+ [`add_spec_arguments`][django_spec_cli.argv.add_spec_arguments.add_spec_arguments]
74
+ spells them, less any key the server fills
75
+ ([`server_owned_keys`][django_service_specs.specs.server_owned_keys.server_owned_keys]).
76
+ No option is matched by a prefix: an abbreviation is refused rather
77
+ than read as whichever option it begins.
78
+ - **``--as-user`` is required.** Its value goes through
79
+ [`resolve_as_user`][django_spec_cli.spec_command.SpecCommand.resolve_as_user],
80
+ and the user it returns is the principal ``dispatch`` runs the spec
81
+ for, with the permission checks enforced: the command passes no
82
+ ``Grant``, and opens no transaction of its own.
83
+ - The result goes through the spec's presenter and is written as a table
84
+ or as JSON Lines: ``--table`` and ``--json`` choose, then the
85
+ ``SPEC_CLI_OUTPUT`` setting, then whether stdout is a
86
+ terminal ([`wants_table`][django_spec_cli.render.wants_table.wants_table]).
87
+ - A refusal ends the run with a ``CommandError`` carrying its message and
88
+ an [`ExitCode`][django_spec_cli.types.exit_code.ExitCode].
89
+ - ``--explain`` runs no spec: it lists the ones that authorize nothing,
90
+ as the system check does.
91
+
92
+ The command is a mistake-prevention and attribution surface, **never an
93
+ authorization boundary**: whoever can run ``manage.py`` can run any code
94
+ the project can, and names any user they like with ``--as-user``. What
95
+ it guarantees is that a run is attributed to a user and checked against
96
+ the permissions that user holds.
97
+
98
+ Sync only: it dispatches with ``dispatch``, never ``adispatch``.
99
+ """
100
+
101
+ registry: SpecRegistry
102
+ pool_seeds: PoolSeeds = DEFAULT_POOL_SEEDS
103
+ help = "Run one operation of a registry of specs, as the user --as-user names."
104
+
105
+ _command_label: str
106
+ _parser: CommandParser
107
+ _entries: dict[str, RegisteredSpec]
108
+
109
+ def create_parser(self, prog_name: str, subcommand: str, **kwargs: Any) -> CommandParser:
110
+ """Django's parser, with this command's options added and no option read by its prefix."""
111
+ self._command_label = subcommand
112
+ parser = super().create_parser(prog_name, subcommand, **kwargs)
113
+ # Django's own options are on this parser, and a prefix of one is no
114
+ # more an option here than on a subcommand.
115
+ parser.allow_abbrev = False
116
+ self._parser = parser
117
+ return parser
118
+
119
+ def add_arguments(self, parser: CommandParser) -> None:
120
+ """``--explain``, then one subcommand per registry entry, each with its spec's options.
121
+
122
+ Every subcommand is built here, on every run: one spec's options are
123
+ a handful of ``add_argument`` calls, and building them eagerly is
124
+ what lets ``--help`` list every spec.
125
+
126
+ Raises:
127
+ ImproperlyConfigured: the class names no ``SpecRegistry``; two
128
+ entries are one subcommand, ``a_b`` and ``a-b``; or a spec
129
+ parameter maps to an option the command owns - ``--as-user``,
130
+ ``--json``, ``--table``, or one of Django's own, such as
131
+ ``--settings``, which ``manage.py`` reads from anywhere on
132
+ the line.
133
+ """
134
+ registry = getattr(self, "registry", None)
135
+ if not isinstance(registry, SpecRegistry):
136
+ cls = type(self)
137
+ raise ImproperlyConfigured(
138
+ f"{cls.__module__}.{cls.__qualname__} names no registry: declare "
139
+ f"registry = SpecRegistry(...) on the class (got {registry!r})."
140
+ )
141
+ parser.add_argument(
142
+ "--explain",
143
+ action="store_true",
144
+ help="List the specs this command serves that authorize nothing, and run none.",
145
+ )
146
+ # Every option string the command owns: its subcommands' own and
147
+ # Django's. ``manage.py`` reads ``--settings`` and ``--pythonpath``
148
+ # from anywhere on the line, so a spec option of either name would
149
+ # configure Django instead. Read from argparse's map of option
150
+ # strings, which carries no public reader, so Django's list is never
151
+ # copied here to drift.
152
+ reserved = _OWN_OPTIONS | frozenset(parser._option_string_actions)
153
+ subparsers = parser.add_subparsers(dest="spec_name", metavar="spec", title="specs")
154
+ self._entries = {}
155
+ for entry in registry:
156
+ name = entry.name.replace("_", "-")
157
+ if name in self._entries:
158
+ raise ImproperlyConfigured(
159
+ f"The specs {self._entries[name].name!r} and {entry.name!r} are both the "
160
+ f"subcommand {name!r}; register one of them under another name."
161
+ )
162
+ self._entries[name] = entry
163
+ spec = kernel_spec(entry.spec)
164
+ parameters = _sendable(spec)
165
+ summary = _summary(spec, entry.name)
166
+ subparser = subparsers.add_parser(
167
+ name,
168
+ help=summary.replace("%", "%%"),
169
+ description=summary,
170
+ epilog=_unsendable(parameters),
171
+ # Without it, ``--as-user 7`` beside a parameter ``as_user_id``
172
+ # is one typo from ``--as-user-i 7``, which argparse would read
173
+ # as ``--as-user-id``.
174
+ allow_abbrev=False,
175
+ )
176
+ subparser.add_argument(
177
+ "--as-user",
178
+ dest="as_user",
179
+ required=True,
180
+ metavar="USER",
181
+ help="The user the operation runs as, and is checked and recorded for.",
182
+ )
183
+ formats = subparser.add_mutually_exclusive_group()
184
+ formats.add_argument(
185
+ "--json",
186
+ dest="output_format",
187
+ action="store_const",
188
+ const="json",
189
+ help="Write the result as JSON Lines, one row per line.",
190
+ )
191
+ formats.add_argument(
192
+ "--table",
193
+ dest="output_format",
194
+ action="store_const",
195
+ const="table",
196
+ help="Write the result as a table.",
197
+ )
198
+ add_spec_arguments(subparser, parameters, reserved=reserved)
199
+
200
+ def resolve_as_user(self, value: str) -> Any:
201
+ """The user ``--as-user VALUE`` names, or ``PrincipalUnavailable``.
202
+
203
+ By primary key, through
204
+ [`resolve_principal`][django_service_specs.authorization.resolve_principal.resolve_principal],
205
+ which refuses a missing row, a malformed key and a deactivated
206
+ account. A project whose runbooks name people by username or email
207
+ overrides this method with that lookup.
208
+
209
+ The contract is: return an authenticated user, or raise
210
+ [`PrincipalUnavailable`][django_service_specs.authorization.principal_unavailable.PrincipalUnavailable],
211
+ which ends the run with ``ExitCode.PRINCIPAL_UNAVAILABLE``. Anything
212
+ else an override raises is a bug in the lookup, and propagates. An
213
+ override need not refuse a deactivated user itself: ``dispatch``
214
+ refuses one whatever the lookup returns. A lookup answering ``None``
215
+ or an anonymous user is refused the same way, by the command. There is
216
+ no fallback to an anonymous user and no user of the command's own.
217
+ """
218
+ return resolve_principal(value)
219
+
220
+ def handle(self, *args: Any, **options: Any) -> None:
221
+ name = options["spec_name"]
222
+ if options["explain"]:
223
+ if name is not None:
224
+ self._parser.error("--explain runs no spec; leave out the spec name.")
225
+ self._explain()
226
+ return
227
+ if name is None:
228
+ self._parser.error(
229
+ f"manage.py {self._command_label} serves no spec: its registry is empty."
230
+ if not self._entries
231
+ else "name a spec to run, or pass --explain. --help lists the specs."
232
+ )
233
+ entry = self._entries[name]
234
+ spec = kernel_spec(entry.spec)
235
+ parameters = spec.parameters()
236
+ raw = parsed_arguments(argparse.Namespace(**options), parameters)
237
+ # Read before the run, not after it: a setting that names no format
238
+ # raises, and raising once a write has committed reports a failure for
239
+ # an operation that happened, which a script retries.
240
+ # Django's ``OutputWrapper`` is an ``io.TextIOBase`` rather than a
241
+ # ``typing.TextIO``, and answers the ``write`` and ``isatty`` both
242
+ # functions read for the stream it wraps.
243
+ stdout = cast("TextIO", self.stdout)
244
+ table = wants_table(stdout, requested=options["output_format"])
245
+ try:
246
+ principal = self.resolve_as_user(options["as_user"])
247
+ # A lookup is the project's, and ``filter(...).first()`` is the
248
+ # obvious one: a typo then answers ``None``, which an open spec
249
+ # would run for. Each condition is held by its own test:
250
+ # test_a_lookup_answering_none_is_refused and
251
+ # test_a_lookup_answering_an_anonymous_user_is_refused.
252
+ if principal is None or not getattr(principal, "is_authenticated", False):
253
+ raise PrincipalUnavailable(
254
+ f"The user {options['as_user']!r} resolved to nobody who can sign in."
255
+ )
256
+ result = dispatch(
257
+ entry.spec,
258
+ principal=principal,
259
+ arguments=coerce_flat(parameters, raw),
260
+ pool_seeds=self.pool_seeds,
261
+ )
262
+ except (DispatchError, ServiceError) as error:
263
+ raise CommandError(_message(error), returncode=int(exit_code_for(error))) from error
264
+ if result.kind == "not_found":
265
+ raise CommandError(gettext("Not found."), returncode=int(ExitCode.NOT_FOUND))
266
+ render_result(present(entry.spec, result), spec.output(), stdout, table=table)
267
+
268
+ def _explain(self) -> None:
269
+ label = self._command_label
270
+ if not self._entries:
271
+ self.stdout.write(f"manage.py {label} serves no spec: its registry is empty.")
272
+ return
273
+ found = unguarded_specs(self.registry)
274
+ if not found:
275
+ self.stdout.write(
276
+ f"Every spec manage.py {label} serves declares a permission check other than "
277
+ "Unrestricted."
278
+ )
279
+ for unguarded in found:
280
+ self.stdout.write(unguarded_message(unguarded, label))
281
+
282
+
283
+ def _sendable(spec: ServiceSpec | SelectorSpec) -> Parameters:
284
+ """What a caller may send, as the shape check reads it: required only where dispatch demands it.
285
+
286
+ Relaxed for ``partial`` by the shape check's own reading, so ``--help``
287
+ marks ``(required)`` exactly where an omission is refused
288
+ (test_a_partial_spec_requires_only_what_names_its_row), and without the
289
+ keys the server fills, whose value a caller's would never replace
290
+ (test_a_key_the_server_fills_is_no_option).
291
+ """
292
+ owned = server_owned_keys(spec)
293
+ relaxed = relax_partial(spec, spec.parameters())
294
+ return Parameters.of(*(param for param in relaxed if param.name not in owned))
295
+
296
+
297
+ def _summary(spec: ServiceSpec | SelectorSpec, name: str) -> str:
298
+ """The first paragraph of what the spec's callable says about itself, and whether it reads or writes.
299
+
300
+ Only a function's or a method's own docstring: a ``partial`` or a
301
+ callable object answers with its class's, which describes the class and
302
+ not the operation. With none, the registered name stands in. Both halves
303
+ of the guard are held by test_only_a_function_s_or_a_method_s_own_docstring_is_read:
304
+ its bound method (``ismethod``) and its ``partial`` (the guard itself).
305
+ """
306
+ call = spec.selector if isinstance(spec, SelectorSpec) else spec.service
307
+ kind = "read" if isinstance(spec, SelectorSpec) else "write"
308
+ doc = call.__doc__ if inspect.isfunction(call) or inspect.ismethod(call) else None
309
+ first = " ".join(inspect.cleandoc(doc).split("\n\n")[0].split()) if doc else name
310
+ return f"{first} ({kind})"
311
+
312
+
313
+ def _unsendable(parameters: Parameters) -> str | None:
314
+ """The epilog naming the parameters no option can send, or ``None`` when there are none."""
315
+ names = [d.name for d in describe_parameters(parameters) if not d.argv]
316
+ return f"Takes arguments a command line cannot send: {', '.join(names)}." if names else None
317
+
318
+
319
+ def _message(error: DispatchError | ServiceError) -> str:
320
+ """A refusal as the text a person reads: its message, and each refused field on a line."""
321
+ if isinstance(error, InvalidArguments):
322
+ return _with_fields(error.message, error.detail)
323
+ # Two conditions, each held by a case of test_a_service_s_refusal_is_its_exit_code:
324
+ # [other] for the type (a plain ServiceError has no ``detail``) and
325
+ # [validation] for the detail (a string is one message, not a tree).
326
+ if isinstance(error, ServiceValidationError) and isinstance(error.detail, Mapping | list):
327
+ return _with_fields(error.message, error.detail)
328
+ if isinstance(error, ActionUnavailable):
329
+ return f"{error.message} (code: {error.code})"
330
+ # Two conditions, each held by a case of test_a_service_s_refusal_is_its_exit_code:
331
+ # [other] for the type (a plain ServiceError has no ``schema``) and
332
+ # [input_bare] for the schema (with none there is nothing to name).
333
+ if isinstance(error, AdditionalInputRequired) and error.schema:
334
+ options = ", ".join(option_string(key) for key in error.schema)
335
+ return f"{error.message} Run it again with: {options}"
336
+ return str(error.message)
337
+
338
+
339
+ def _with_fields(header: Any, detail: Any) -> str:
340
+ """``header``, then each message in ``detail`` on its own line, after its path."""
341
+ rows = [
342
+ f"{'.'.join(path)}: {text}" if path else text for path, text in refusal_messages(detail)
343
+ ]
344
+ return "\n".join([str(header), *rows])
@@ -0,0 +1,7 @@
1
+ """Value types: the exit status a command's run ends with."""
2
+
3
+ from django_spec_cli.types.exit_code import ExitCode
4
+
5
+ __all__ = [
6
+ "ExitCode",
7
+ ]
@@ -0,0 +1,49 @@
1
+ """``ExitCode`` - what a ``SpecCommand``'s exit status says about the run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import IntEnum
6
+
7
+
8
+ class ExitCode(IntEnum):
9
+ """The exit status of a [`SpecCommand`][django_spec_cli.spec_command.SpecCommand] run, a public contract.
10
+
11
+ A script branches on these, so a member's number never changes and a
12
+ number is never reused. Each says who refused and what the caller can do
13
+ about it, read the way the HTTP transport reads the same refusals:
14
+
15
+ | Code | Member | The run | Raised for |
16
+ | --- | --- | --- | --- |
17
+ | 0 | `SUCCESS` | ran, and its result was written | |
18
+ | 1 | `ERROR` | failed in a way nobody declared | anything else, with a traceback |
19
+ | 2 | `USAGE` | never started: the command line was malformed | argparse's own refusals |
20
+ | 3 | `INVALID_ARGUMENTS` | was refused its arguments | `InvalidArguments`, `ServiceValidationError`, any other `DispatchError` |
21
+ | 4 | `NOT_PERMITTED` | was refused to the principal | `NotPermitted` |
22
+ | 5 | `PRINCIPAL_UNAVAILABLE` | had nobody to run as | `PrincipalUnavailable` |
23
+ | 6 | `NOT_FOUND` | found no row to act on | a `not_found` result, `ServiceNotFound` |
24
+ | 7 | `CONFLICT` | was refused by the row's current state | `ServiceConflict`, `ActionUnavailable` |
25
+ | 8 | `INPUT_REQUIRED` | needs a value it was not given | `AdditionalInputRequired` |
26
+ | 9 | `REFUSED` | was refused by the operation's own rule | any other `ServiceError` |
27
+
28
+ ``INPUT_REQUIRED`` has its own code because a command line cannot ask:
29
+ the operator re-runs the command with the option the message names, and
30
+ a script that knows it may answer does so. ``ActionUnavailable`` is a
31
+ conflict, as its base is; its ``code`` is in the message.
32
+
33
+ ``USAGE`` is the command line's: ``manage.py`` exits 2 on a malformed
34
+ line. Through ``call_command`` Django raises every parser error as a
35
+ ``CommandError`` with ``returncode`` 1 instead, and the command's own
36
+ usage errors take the same path, so a caller there reads a usage error
37
+ by its message.
38
+ """
39
+
40
+ SUCCESS = 0
41
+ ERROR = 1
42
+ USAGE = 2
43
+ INVALID_ARGUMENTS = 3
44
+ NOT_PERMITTED = 4
45
+ PRINCIPAL_UNAVAILABLE = 5
46
+ NOT_FOUND = 6
47
+ CONFLICT = 7
48
+ INPUT_REQUIRED = 8
49
+ REFUSED = 9
@@ -0,0 +1,7 @@
1
+ """The single source of truth for this package's version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__: str = "0.1.0"
6
+
7
+ __all__ = ["__version__"]
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-spec-cli
3
+ Version: 0.1.0
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
+ Project-URL: Homepage, https://github.com/Artui/django-spec-cli
6
+ Project-URL: Repository, https://github.com/Artui/django-spec-cli
7
+ Project-URL: Issues, https://github.com/Artui/django-spec-cli/issues
8
+ Author-email: Artur Veres <artur8118@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,dispatch,django,management-command,services,specs
12
+ Classifier: Development Status :: 2 - Pre-Alpha
13
+ Classifier: Environment :: Web Environment
14
+ Classifier: Framework :: Django
15
+ Classifier: Framework :: Django :: 4.2
16
+ Classifier: Framework :: Django :: 5.0
17
+ Classifier: Framework :: Django :: 5.1
18
+ Classifier: Framework :: Django :: 5.2
19
+ Classifier: Framework :: Django :: 6.0
20
+ Classifier: Framework :: Django :: 6.1
21
+ Classifier: Intended Audience :: Developers
22
+ Classifier: License :: OSI Approved :: MIT License
23
+ Classifier: Operating System :: OS Independent
24
+ Classifier: Programming Language :: Python
25
+ Classifier: Programming Language :: Python :: 3
26
+ Classifier: Programming Language :: Python :: 3.10
27
+ Classifier: Programming Language :: Python :: 3.11
28
+ Classifier: Programming Language :: Python :: 3.12
29
+ Classifier: Programming Language :: Python :: 3.13
30
+ Classifier: Programming Language :: Python :: 3.14
31
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
32
+ Requires-Python: >=3.10
33
+ Requires-Dist: django-service-specs>=0.10.0
34
+ Requires-Dist: django>=4.2
35
+ Description-Content-Type: text/markdown
36
+
37
+ # django-spec-cli
38
+
39
+ [![CI](https://github.com/Artui/django-spec-cli/workflows/tests/badge.svg)](https://github.com/Artui/django-spec-cli/actions/workflows/tests.yml)
40
+ [![PyPI](https://img.shields.io/pypi/v/django-spec-cli.svg)](https://pypi.org/project/django-spec-cli/)
41
+ [![Python versions](https://img.shields.io/pypi/pyversions/django-spec-cli.svg)](https://pypi.org/project/django-spec-cli/)
42
+ [![Django versions](https://img.shields.io/pypi/djversions/django-spec-cli.svg)](https://pypi.org/project/django-spec-cli/)
43
+ [![Docs](https://img.shields.io/badge/docs-artui.github.io-blue.svg)](https://artui.github.io/django-spec-cli/)
44
+ [![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Artui/django-spec-cli/gh-pages/coverage.json)](https://github.com/Artui/django-spec-cli/actions/workflows/tests.yml)
45
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
46
+ [![License](https://img.shields.io/pypi/l/django-spec-cli.svg)](LICENSE)
47
+
48
+ Run a [`django-service-specs`](https://github.com/Artui/django-service-specs)
49
+ registry from `manage.py`: one subcommand per spec, an option for each of its
50
+ parameters, a principal named on the command line, and an exit code for every
51
+ refusal.
52
+
53
+ It is the kernel's management-command transport, moved out of the kernel with
54
+ its history so the kernel carries no transport. The command line, the exit
55
+ codes and the `SpecCommand` subclass contract are the ones the kernel shipped;
56
+ the import path, the system check ids and the setting key are this package's.
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ pip install django-spec-cli
62
+ ```
63
+
64
+ Python 3.10 or later, Django 4.2 or later, and `django-service-specs` 0.10.0 or
65
+ later, which it installs.
66
+
67
+ ## Quickstart
68
+
69
+ Subclass `SpecCommand` in one of your apps' `management/commands/` modules and
70
+ name a registry; the module's name is the command's:
71
+
72
+ ```python
73
+ # yourapp/management/commands/notes.py
74
+ from django_spec_cli import SpecCommand
75
+
76
+ from yourapp.specs import notes # a django_service_specs.SpecRegistry
77
+
78
+
79
+ class Command(SpecCommand):
80
+ registry = notes
81
+ ```
82
+
83
+ ```console
84
+ $ python manage.py notes rename-note --as-user 1 --pk 1 --title Final --json
85
+ {"id":1,"title":"Final"}
86
+ ```
87
+
88
+ The run goes through the kernel's `dispatch`, so the permission checks are
89
+ enforced for the user `--as-user` names. `--explain` lists the specs that
90
+ authorize nothing.
91
+
92
+ ## The system check
93
+
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.
98
+
99
+ Until the kernel removes its own copy of the command, kernel 0.10.x still
100
+ registers `django_service_specs.W001` and `W003` over its own `SpecCommand`. A
101
+ project listing both `"django_service_specs"` and `"django_spec_cli"` sees both
102
+ ids: each walk reports the commands subclassing its own class, and a command
103
+ that cannot be loaded is reported by each. A project that moves its imports
104
+ here and lists only `"django_service_specs"` gets no check for the moved
105
+ commands, so list `"django_spec_cli"`.
106
+
107
+ ## Documentation
108
+
109
+ <https://artui.github.io/django-spec-cli/>
110
+
111
+ ## License
112
+
113
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,28 @@
1
+ django_spec_cli/__init__.py,sha256=U4p6xCjWczZuNmMn-uzhbWLAe9vir0HqS2JHQ4HpJLI,1176
2
+ django_spec_cli/apps.py,sha256=EbrYxNJNne6Pb3BacNdI72t5kUeMBGf4TE3714klLpU,1164
3
+ django_spec_cli/checks.py,sha256=ijY2pD-IwTE8aukUJq12BGhW7hSxlUSpJdPqsHnsHXA,6444
4
+ django_spec_cli/exit_code_for.py,sha256=0dGnxKNTb208Z1MX6wrBAFGI6mzDTTmu8XxnmFub_wE,2076
5
+ django_spec_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ django_spec_cli/settings.py,sha256=EUigKdFmZ3jqS2VxAWH8wj2viyfbP6ozOVjk3rt4QaM,1444
7
+ django_spec_cli/spec_command.py,sha256=RJmYdIZosrO5rMbSsU17eQvVv_3fZB7Htqw2sXM9WtY,16467
8
+ django_spec_cli/version.py,sha256=IGt2-6r71nTeUfPEK_x1lNeMEBrz0uYGAfUvfX0lTVs,152
9
+ django_spec_cli/argv/__init__.py,sha256=ziDCgFVROkRj1FQ44BDJEDm1BeqZKNmpmBg8WTTiiDA,496
10
+ django_spec_cli/argv/add_spec_arguments.py,sha256=0QMg6fhEvCSXvV-lVDC0pUw3Eu9wfdEhVmr79H1QkF0,8145
11
+ django_spec_cli/argv/describe_parameters.py,sha256=vP3pZcb43aZSxfXCGd_bZadJgl9DFRQLhjQi9ln3zMA,3424
12
+ django_spec_cli/argv/parameter_descriptor.py,sha256=MwMruNhDzU0WQPmcyqV7x0R_QNyBmmwP2Tcn5DkTmTk,2624
13
+ django_spec_cli/argv/parsed_arguments.py,sha256=JotsSaHQLoKyuQp98n7DgPfQXK08Xqu_oc6wzV_Y0wg,2299
14
+ django_spec_cli/argv/utils.py,sha256=4Cnyvx-bdHaTH1c2p1vTwJqkVbgqqNn9Vp3poTqHSS0,836
15
+ django_spec_cli/render/__init__.py,sha256=z5GsGciCYcd-qMzNXV9V0GtCo-KV6HOXlFzo82MDcoI,449
16
+ django_spec_cli/render/render_json_lines.py,sha256=Gue2rASL8Xn1fqtxB_LuUE9qcTyGsUUJhl_hFOp45qM,2031
17
+ django_spec_cli/render/render_result.py,sha256=KEF3Um9yEl4WzhTSThK_hGy9yFNxw9OLfKV1lHH4fic,3291
18
+ django_spec_cli/render/render_table.py,sha256=lUlT729OQI5BSbYzO8KWa19T7WAFNk0JgXlXOxQytR0,9130
19
+ django_spec_cli/render/utils.py,sha256=OgbsBsZ-0_KhHvV50h8r2DPyqRIqaKcuDHRCluzI43o,537
20
+ django_spec_cli/render/wants_table.py,sha256=FWTtgG1lJXTUxXxG31LIzV6YY3CkQYhHSKyWkt9RDxs,1723
21
+ django_spec_cli/report/__init__.py,sha256=Jt58bnjKYTuqmcZ_aF47mtMEV_AVhyi1Wa0tcVcjUWM,216
22
+ django_spec_cli/report/unguarded_message.py,sha256=-cWPjS2cnHMt0Rb1qq7kRU6k-p99sUovXFzGU7h2nQc,1177
23
+ django_spec_cli/types/__init__.py,sha256=Eey5XTKebuSbCkfQ6EiP3qN3iEP6_kYl1Ctqcti1Em4,147
24
+ django_spec_cli/types/exit_code.py,sha256=QgDRQkhMzdesIaJg04ee214VduxPnGCThTXMgQ6PLzI,2296
25
+ django_spec_cli-0.1.0.dist-info/METADATA,sha256=yfTtvsTD3VDj7pdRLFdhkRV5gEsp1JBCUvP5dBayYRQ,4912
26
+ django_spec_cli-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
27
+ django_spec_cli-0.1.0.dist-info/licenses/LICENSE,sha256=0rCDnUPa87Fm67SxYMrdqEdLV6UiMENH6NCPLZ_KT5M,1068
28
+ django_spec_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Artur Veres
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.