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.
- django_spec_cli/__init__.py +31 -0
- django_spec_cli/apps.py +31 -0
- django_spec_cli/argv/__init__.py +13 -0
- django_spec_cli/argv/add_spec_arguments.py +169 -0
- django_spec_cli/argv/describe_parameters.py +80 -0
- django_spec_cli/argv/parameter_descriptor.py +61 -0
- django_spec_cli/argv/parsed_arguments.py +50 -0
- django_spec_cli/argv/utils.py +21 -0
- django_spec_cli/checks.py +133 -0
- django_spec_cli/exit_code_for.py +49 -0
- django_spec_cli/py.typed +0 -0
- django_spec_cli/render/__init__.py +13 -0
- django_spec_cli/render/render_json_lines.py +42 -0
- django_spec_cli/render/render_result.py +71 -0
- django_spec_cli/render/render_table.py +203 -0
- django_spec_cli/render/utils.py +16 -0
- django_spec_cli/render/wants_table.py +37 -0
- django_spec_cli/report/__init__.py +7 -0
- django_spec_cli/report/unguarded_message.py +29 -0
- django_spec_cli/settings.py +36 -0
- django_spec_cli/spec_command.py +344 -0
- django_spec_cli/types/__init__.py +7 -0
- django_spec_cli/types/exit_code.py +49 -0
- django_spec_cli/version.py +7 -0
- django_spec_cli-0.1.0.dist-info/METADATA +113 -0
- django_spec_cli-0.1.0.dist-info/RECORD +28 -0
- django_spec_cli-0.1.0.dist-info/WHEEL +4 -0
- django_spec_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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,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
|
+
[](https://github.com/Artui/django-spec-cli/actions/workflows/tests.yml)
|
|
40
|
+
[](https://pypi.org/project/django-spec-cli/)
|
|
41
|
+
[](https://pypi.org/project/django-spec-cli/)
|
|
42
|
+
[](https://pypi.org/project/django-spec-cli/)
|
|
43
|
+
[](https://artui.github.io/django-spec-cli/)
|
|
44
|
+
[](https://github.com/Artui/django-spec-cli/actions/workflows/tests.yml)
|
|
45
|
+
[](https://github.com/astral-sh/ruff)
|
|
46
|
+
[](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,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.
|