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,31 @@
|
|
|
1
|
+
"""A registry of specs as a management command."""
|
|
2
|
+
|
|
3
|
+
from django_spec_cli.argv.add_spec_arguments import add_spec_arguments
|
|
4
|
+
from django_spec_cli.argv.describe_parameters import describe_parameters
|
|
5
|
+
from django_spec_cli.argv.parameter_descriptor import ParameterDescriptor
|
|
6
|
+
from django_spec_cli.argv.parsed_arguments import parsed_arguments
|
|
7
|
+
from django_spec_cli.exit_code_for import exit_code_for
|
|
8
|
+
from django_spec_cli.render.render_json_lines import render_json_lines
|
|
9
|
+
from django_spec_cli.render.render_result import render_result
|
|
10
|
+
from django_spec_cli.render.render_table import render_table
|
|
11
|
+
from django_spec_cli.render.wants_table import wants_table
|
|
12
|
+
from django_spec_cli.report.unguarded_message import unguarded_message
|
|
13
|
+
from django_spec_cli.spec_command import SpecCommand
|
|
14
|
+
from django_spec_cli.types.exit_code import ExitCode
|
|
15
|
+
from django_spec_cli.version import __version__
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"__version__",
|
|
19
|
+
"ExitCode",
|
|
20
|
+
"ParameterDescriptor",
|
|
21
|
+
"SpecCommand",
|
|
22
|
+
"exit_code_for",
|
|
23
|
+
"render_json_lines",
|
|
24
|
+
"render_result",
|
|
25
|
+
"render_table",
|
|
26
|
+
"wants_table",
|
|
27
|
+
"add_spec_arguments",
|
|
28
|
+
"describe_parameters",
|
|
29
|
+
"parsed_arguments",
|
|
30
|
+
"unguarded_message",
|
|
31
|
+
]
|
django_spec_cli/apps.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The app a project lists to have its open specs reported by ``manage.py check``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.apps import AppConfig
|
|
6
|
+
from django.core.checks import register
|
|
7
|
+
|
|
8
|
+
from django_spec_cli.checks import check_unguarded_specs
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DjangoSpecCliConfig(AppConfig):
|
|
12
|
+
"""Registers the unguarded-spec system check, and does nothing else.
|
|
13
|
+
|
|
14
|
+
Optional: the package works unlisted, and a project that does not list it
|
|
15
|
+
gets no check and no sign that it is missing. It owns no models, so it
|
|
16
|
+
sets no ``default_auto_field``.
|
|
17
|
+
|
|
18
|
+
The check carries no tag, and in particular not ``Tags.security``: a
|
|
19
|
+
``SpecCommand`` is a mistake-prevention and attribution surface, not an
|
|
20
|
+
authorization boundary, and a security tag would say it is one.
|
|
21
|
+
|
|
22
|
+
The import of the check is at the top of this module, which Django
|
|
23
|
+
imports before the app registry is ready. That holds because nothing the
|
|
24
|
+
package imports reaches a model or ``get_user_model`` at import time
|
|
25
|
+
(test_importing_the_app_before_setup_loads_no_model).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
name = "django_spec_cli"
|
|
29
|
+
|
|
30
|
+
def ready(self) -> None:
|
|
31
|
+
register(check_unguarded_specs)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""How a spec's parameters become command-line options, and come back as its arguments."""
|
|
2
|
+
|
|
3
|
+
from django_spec_cli.argv.add_spec_arguments import add_spec_arguments
|
|
4
|
+
from django_spec_cli.argv.describe_parameters import describe_parameters
|
|
5
|
+
from django_spec_cli.argv.parameter_descriptor import ParameterDescriptor
|
|
6
|
+
from django_spec_cli.argv.parsed_arguments import parsed_arguments
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ParameterDescriptor",
|
|
10
|
+
"add_spec_arguments",
|
|
11
|
+
"describe_parameters",
|
|
12
|
+
"parsed_arguments",
|
|
13
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""``add_spec_arguments`` - one argparse option per parameter a command line can send."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Collection
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
11
|
+
from django_service_specs import UNSET, Parameters
|
|
12
|
+
|
|
13
|
+
from django_spec_cli.argv.describe_parameters import describe_parameters
|
|
14
|
+
from django_spec_cli.argv.parameter_descriptor import ParameterDescriptor
|
|
15
|
+
from django_spec_cli.argv.utils import destination, option_string
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def add_spec_arguments(
|
|
19
|
+
parser: argparse.ArgumentParser, parameters: Parameters, *, reserved: Collection[str] = ()
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Add an option to ``parser`` for each parameter a command line can send.
|
|
22
|
+
|
|
23
|
+
The option is ``--`` and the name with ``_`` as ``-``: ``due_at`` is
|
|
24
|
+
``--due-at``. Argparse only collects strings here, and every decision about
|
|
25
|
+
them is the kernel's, so a command line refuses what an HTTP caller would
|
|
26
|
+
be refused, in the same words:
|
|
27
|
+
|
|
28
|
+
- **No value is converted.** No option sets ``type=``; every value stays
|
|
29
|
+
the string typed, and [`coerce_flat`][django_service_specs.parameters.coerce_flat.coerce_flat]
|
|
30
|
+
reads it as it reads a query string.
|
|
31
|
+
- **No option is required by argparse.** The shape check owns "missing",
|
|
32
|
+
so a required parameter is refused as HTTP refuses it, beside every other
|
|
33
|
+
mistake in the call, and ``--help`` marks it ``(required)``.
|
|
34
|
+
- **No choice is enforced by argparse.** The choices are listed in
|
|
35
|
+
``--help`` and the shape check refuses any other value. Argparse's own
|
|
36
|
+
``choices=`` would answer one mistake with a second message and exit
|
|
37
|
+
code, and only for a parameter whose choices are all strings.
|
|
38
|
+
- **An omitted option is absent from the namespace**, never ``None``: its
|
|
39
|
+
default is ``argparse.SUPPRESS``. That keeps it apart from an empty
|
|
40
|
+
string and from ``--no-flag``.
|
|
41
|
+
- A ``boolean`` is a ``--flag``/``--no-flag`` pair, and an ``array`` is a
|
|
42
|
+
repeated option, ``--ids 1 --ids 2``, each value one element.
|
|
43
|
+
- Each value lands under a namespaced destination rather than the
|
|
44
|
+
parameter's name, so a parameter named ``verbosity`` or ``settings``
|
|
45
|
+
cannot overwrite ``BaseCommand``'s own. Read the namespace back with
|
|
46
|
+
[`parsed_arguments`][django_spec_cli.argv.parsed_arguments.parsed_arguments].
|
|
47
|
+
|
|
48
|
+
A parameter with no honest flat spelling - an object, an array of objects,
|
|
49
|
+
a file - gets no option: see
|
|
50
|
+
[`ParameterDescriptor`][django_spec_cli.argv.parameter_descriptor.ParameterDescriptor].
|
|
51
|
+
|
|
52
|
+
``reserved`` is the option strings the command owns itself, such as
|
|
53
|
+
``--as-user`` and Django's ``--verbosity``; ``--help`` is always reserved,
|
|
54
|
+
since every parser owns its own.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ImproperlyConfigured: naming the parameter and the option, when an
|
|
58
|
+
option would be one in ``reserved``, or one another parameter maps
|
|
59
|
+
to as well - ``a_b`` and ``a-b``, or a boolean ``x``, whose
|
|
60
|
+
``--no-x`` is a parameter ``no_x``'s option. Every option is
|
|
61
|
+
checked before the first is added, so a refused spec leaves the
|
|
62
|
+
parser as it was.
|
|
63
|
+
"""
|
|
64
|
+
descriptors = [d for d in describe_parameters(parameters) if d.argv]
|
|
65
|
+
_check_options(descriptors, frozenset(reserved) | {"--help"})
|
|
66
|
+
for descriptor in descriptors:
|
|
67
|
+
_add_option(parser, descriptor)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _option_strings(descriptor: ParameterDescriptor) -> list[str]:
|
|
71
|
+
"""Every option string a parameter claims: a boolean claims its negative too."""
|
|
72
|
+
option = option_string(descriptor.name)
|
|
73
|
+
if descriptor.type == "boolean":
|
|
74
|
+
return [option, "--no-" + option[2:]]
|
|
75
|
+
return [option]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _check_options(descriptors: list[ParameterDescriptor], reserved: frozenset[str]) -> None:
|
|
79
|
+
claimed: dict[str, str] = {}
|
|
80
|
+
for descriptor in descriptors:
|
|
81
|
+
for option in _option_strings(descriptor):
|
|
82
|
+
if option in reserved:
|
|
83
|
+
raise ImproperlyConfigured(
|
|
84
|
+
f"Parameter {descriptor.name!r}: the option {option!r} is the command's "
|
|
85
|
+
"own; rename the parameter."
|
|
86
|
+
)
|
|
87
|
+
if option in claimed:
|
|
88
|
+
raise ImproperlyConfigured(
|
|
89
|
+
f"Parameters {claimed[option]!r} and {descriptor.name!r} both map to the "
|
|
90
|
+
f"option {option!r}; rename one of them."
|
|
91
|
+
)
|
|
92
|
+
claimed[option] = descriptor.name
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _add_option(parser: argparse.ArgumentParser, descriptor: ParameterDescriptor) -> None:
|
|
96
|
+
option = option_string(descriptor.name)
|
|
97
|
+
common: dict[str, Any] = {
|
|
98
|
+
"dest": destination(descriptor.name),
|
|
99
|
+
"default": argparse.SUPPRESS,
|
|
100
|
+
"help": _help(descriptor),
|
|
101
|
+
}
|
|
102
|
+
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
|
+
parser.add_argument(option, action=argparse.BooleanOptionalAction, **common)
|
|
106
|
+
return
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
option,
|
|
109
|
+
action="append" if descriptor.type == "array" else "store",
|
|
110
|
+
# Set, or argparse prints the namespaced destination in its place.
|
|
111
|
+
metavar=_metavar(descriptor),
|
|
112
|
+
**common,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _metavar(descriptor: ParameterDescriptor) -> str:
|
|
117
|
+
"""What one value is: an array's is its element's, and a format names a string's."""
|
|
118
|
+
if descriptor.format is not None:
|
|
119
|
+
return descriptor.format.upper()
|
|
120
|
+
kind = descriptor.items if descriptor.type == "array" else descriptor.type
|
|
121
|
+
# An ``any``, and an array declaring no element, take any value.
|
|
122
|
+
return "VALUE" if kind in (None, "any") else kind.upper()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _help(descriptor: ParameterDescriptor) -> str | None:
|
|
126
|
+
notes: list[str] = []
|
|
127
|
+
if descriptor.type == "array":
|
|
128
|
+
notes.append("repeat the option for each element")
|
|
129
|
+
if descriptor.choices is not None:
|
|
130
|
+
pairs = descriptor.choice_labels or ()
|
|
131
|
+
notes.append("one of: " + ", ".join(_choice(c, pairs) for c in descriptor.choices))
|
|
132
|
+
for label, bound in (("minimum", descriptor.minimum), ("maximum", descriptor.maximum)):
|
|
133
|
+
if bound is not None:
|
|
134
|
+
notes.append(f"{label} {bound}")
|
|
135
|
+
for label, length in (("at least", descriptor.min_length), ("at most", descriptor.max_length)):
|
|
136
|
+
if length is not None:
|
|
137
|
+
notes.append(f"{label} {length}")
|
|
138
|
+
if descriptor.required:
|
|
139
|
+
notes.append("required")
|
|
140
|
+
if descriptor.default is not UNSET:
|
|
141
|
+
notes.append(f"default: {_text(descriptor.default)}")
|
|
142
|
+
# ``str``: a help text may be a lazy translation, rendered here.
|
|
143
|
+
parts = [str(descriptor.help)] if descriptor.help else []
|
|
144
|
+
if notes:
|
|
145
|
+
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.
|
|
149
|
+
return " ".join(parts).replace("%", "%%") if parts else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _choice(choice: Any, pairs: tuple[tuple[Any, str], ...]) -> str:
|
|
153
|
+
"""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
|
+
label = next((lb for v, lb in pairs if type(v) is type(choice) and v == choice), None)
|
|
159
|
+
return _text(choice) if label is None else f"{_text(choice)} ({label})"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
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).
|
|
169
|
+
return value if isinstance(value, str) and value else json.dumps(value)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""``describe_parameters`` - a spec's Parameters, as a command line sees them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django_service_specs import UNSET, Parameter, Parameters
|
|
9
|
+
|
|
10
|
+
from django_spec_cli.argv.parameter_descriptor import ParameterDescriptor
|
|
11
|
+
|
|
12
|
+
_FLAT_TYPES = ("string", "integer", "number", "boolean", "any")
|
|
13
|
+
"""The types one string on a command line spells: ``coerce_flat`` reads it as one
|
|
14
|
+
of these, and an ``any`` is handed on as the string it is."""
|
|
15
|
+
|
|
16
|
+
_FLAT_ELEMENTS = (None, "string", "integer", "number", "boolean")
|
|
17
|
+
"""An array's elements a repeated option spells: a declared scalar, or no
|
|
18
|
+
declared ``items``, whose elements stay the strings given."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def describe_parameters(parameters: Parameters) -> tuple[ParameterDescriptor, ...]:
|
|
22
|
+
"""One [`ParameterDescriptor`][django_spec_cli.argv.parameter_descriptor.ParameterDescriptor] per parameter, in declaration order.
|
|
23
|
+
|
|
24
|
+
Never refuses a declaration: a parameter with no flat spelling is
|
|
25
|
+
described with ``argv=False`` rather than left out, so a command can still
|
|
26
|
+
say in ``--help`` that a spec takes something argv cannot send.
|
|
27
|
+
"""
|
|
28
|
+
return tuple(_describe(param) for param in parameters)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _describe(param: Parameter) -> ParameterDescriptor:
|
|
32
|
+
return ParameterDescriptor(
|
|
33
|
+
name=param.name,
|
|
34
|
+
type=param.type,
|
|
35
|
+
format=param.format,
|
|
36
|
+
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).
|
|
42
|
+
required=param.required and param.default is UNSET,
|
|
43
|
+
default=_shown_default(param.default),
|
|
44
|
+
choices=param.choices,
|
|
45
|
+
choice_labels=param.choice_labels,
|
|
46
|
+
minimum=param.minimum,
|
|
47
|
+
maximum=param.maximum,
|
|
48
|
+
min_length=param.min_length,
|
|
49
|
+
max_length=param.max_length,
|
|
50
|
+
help=param.help,
|
|
51
|
+
argv=_has_flat_spelling(param),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
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.
|
|
62
|
+
if param.type == "array":
|
|
63
|
+
return param.items in _FLAT_ELEMENTS
|
|
64
|
+
return param.type in _FLAT_TYPES
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
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
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
json.dumps(default, allow_nan=False)
|
|
78
|
+
except (TypeError, ValueError):
|
|
79
|
+
return UNSET
|
|
80
|
+
return default
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""``ParameterDescriptor`` - one parameter, as a command line sees it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django_service_specs import UNSET
|
|
9
|
+
|
|
10
|
+
from django_spec_cli.argv.utils import option_string
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ParameterDescriptor:
|
|
15
|
+
"""A [`Parameter`][django_service_specs.parameters.parameters.Parameter] seen from argv.
|
|
16
|
+
|
|
17
|
+
What a command line can say about a parameter, built by
|
|
18
|
+
[`describe_parameters`][django_spec_cli.argv.describe_parameters.describe_parameters]:
|
|
19
|
+
the option it is sent with, and what ``--help`` can honestly show about
|
|
20
|
+
the value. Each field is the declaration's, read for a person typing it,
|
|
21
|
+
and three are narrower than the ``Parameter`` they come from:
|
|
22
|
+
|
|
23
|
+
- ``required`` is whether **an omitted option is refused**: a parameter
|
|
24
|
+
declared required with a default is not, since the shape check lets the
|
|
25
|
+
Validator supply the default. A spec declared ``partial`` refuses no
|
|
26
|
+
omission at all, which the declaration cannot see; the command that
|
|
27
|
+
knows its spec says so.
|
|
28
|
+
- ``default`` is the declared default **only where JSON can spell it**:
|
|
29
|
+
``None`` included, and never a callable, a sentinel or NaN, whose
|
|
30
|
+
``repr`` in the help would be a concrete falsehood. ``UNSET`` means
|
|
31
|
+
nothing is shown, not that nothing is declared.
|
|
32
|
+
- ``items`` is an array's element type, or ``None``: an element that is a
|
|
33
|
+
row of nested Parameters is no type a command line could show.
|
|
34
|
+
|
|
35
|
+
``argv`` is ``False`` for a parameter that has no honest flat spelling -
|
|
36
|
+
an object, an array of objects, of arrays or of files, and a file - so
|
|
37
|
+
no option is invented for it: a dotted option for a nested field would be
|
|
38
|
+
a syntax only this command spoke. Sent anyway, under its name by a
|
|
39
|
+
caller that fills the arguments itself, it still reaches ``coerce_flat``'s
|
|
40
|
+
refusal; left out, the shape check's ``required``.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
type: str
|
|
45
|
+
format: str | None = None
|
|
46
|
+
items: str | None = None
|
|
47
|
+
required: bool = False
|
|
48
|
+
default: Any = UNSET
|
|
49
|
+
choices: tuple[Any, ...] | None = None
|
|
50
|
+
choice_labels: tuple[tuple[Any, str], ...] | None = None
|
|
51
|
+
minimum: int | float | None = None
|
|
52
|
+
maximum: int | float | None = None
|
|
53
|
+
min_length: int | None = None
|
|
54
|
+
max_length: int | None = None
|
|
55
|
+
help: str | None = None
|
|
56
|
+
argv: bool = True
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def option(self) -> str | None:
|
|
60
|
+
"""The option string, ``--due-at`` for ``due_at``, or ``None`` with no option."""
|
|
61
|
+
return option_string(self.name) if self.argv else None
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""``parsed_arguments`` - the options argparse read, as the raw arguments ``coerce_flat`` takes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django_service_specs import Parameters
|
|
9
|
+
|
|
10
|
+
from django_spec_cli.argv.utils import destination
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parsed_arguments(namespace: argparse.Namespace, parameters: Parameters) -> dict[str, Any]:
|
|
14
|
+
"""The spec's arguments from a namespace filled by [`add_spec_arguments`][django_spec_cli.argv.add_spec_arguments.add_spec_arguments]'s options.
|
|
15
|
+
|
|
16
|
+
The raw dict a command hands to
|
|
17
|
+
[`coerce_flat`][django_service_specs.parameters.coerce_flat.coerce_flat],
|
|
18
|
+
keyed by parameter name, so argv crosses the one coercion and the one
|
|
19
|
+
shape check every transport shares and is refused, or validated, exactly
|
|
20
|
+
as the same values off a JSON wire would be:
|
|
21
|
+
|
|
22
|
+
- **An omitted option is absent**, so the shape check decides what its
|
|
23
|
+
absence means, as it does for a JSON caller who left the key out.
|
|
24
|
+
- A ``boolean`` is the string ``"true"`` or ``"false"``: one of the four
|
|
25
|
+
spellings ``coerce_flat`` reads, rather than a Python ``bool`` only this
|
|
26
|
+
transport would hand on.
|
|
27
|
+
- An ``array`` is the list of strings given, one per repeated option.
|
|
28
|
+
- Everything else is the string given, an empty one included.
|
|
29
|
+
|
|
30
|
+
Only the namespaced destinations are read, so a command's own options in
|
|
31
|
+
the same namespace - ``verbosity``, ``settings`` - never become arguments.
|
|
32
|
+
A value set there by a caller other than argparse, as ``call_command``
|
|
33
|
+
does with keyword arguments, is handed on as it was set, except that a
|
|
34
|
+
``bool`` for a ``boolean`` is spelled as argparse's would have been.
|
|
35
|
+
"""
|
|
36
|
+
values = vars(namespace)
|
|
37
|
+
raw: dict[str, Any] = {}
|
|
38
|
+
for param in parameters:
|
|
39
|
+
key = destination(param.name)
|
|
40
|
+
if key not in values:
|
|
41
|
+
continue
|
|
42
|
+
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
|
+
if param.type == "boolean" and isinstance(value, bool):
|
|
48
|
+
value = "true" if value else "false"
|
|
49
|
+
raw[param.name] = value
|
|
50
|
+
return raw
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Helpers ``argv/`` and ``SpecCommand`` share: how a parameter's name becomes argv and back."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
DESTINATION_PREFIX = "spec_arg__"
|
|
6
|
+
"""Where a spec argument lands in argparse's namespace.
|
|
7
|
+
|
|
8
|
+
A spec parameter may be named ``verbosity``, ``settings`` or ``traceback``, and
|
|
9
|
+
``BaseCommand`` keeps its own options in the same namespace under exactly those
|
|
10
|
+
names. Prefixed, the two never meet, so a spec argument cannot change how the
|
|
11
|
+
command itself runs. ``parsed_arguments`` is the only reader."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def destination(name: str) -> str:
|
|
15
|
+
"""The namespace attribute a parameter's option stores its value under."""
|
|
16
|
+
return DESTINATION_PREFIX + name
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def option_string(name: str) -> str:
|
|
20
|
+
"""The option a parameter is sent with: ``due_at`` is ``--due-at``."""
|
|
21
|
+
return "--" + name.replace("_", "-")
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""The system check reporting every spec a ``SpecCommand`` serves that authorizes nothing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django.apps import AppConfig
|
|
9
|
+
from django.core.checks import CheckMessage, Warning
|
|
10
|
+
from django.core.management import get_commands, load_command_class
|
|
11
|
+
from django_service_specs import UnguardedSpec, unguarded_specs
|
|
12
|
+
|
|
13
|
+
from django_spec_cli.report.unguarded_message import unguarded_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
|
+
|
|
21
|
+
|
|
22
|
+
def check_unguarded_specs(
|
|
23
|
+
app_configs: Sequence[AppConfig] | None, **kwargs: Any
|
|
24
|
+
) -> list[CheckMessage]:
|
|
25
|
+
"""Warn once per spec that authorizes nothing, in every ``SpecCommand`` of the project.
|
|
26
|
+
|
|
27
|
+
Registered by the app's ``ready()``, so a project gets it by listing
|
|
28
|
+
``django_spec_cli`` in ``INSTALLED_APPS`` and not otherwise. There is
|
|
29
|
+
no global registry to read: each ``SpecCommand`` subclass names its own,
|
|
30
|
+
so the check finds them the way ``manage.py`` does, by loading every
|
|
31
|
+
command the installed apps serve and keeping the ``SpecCommand`` ones.
|
|
32
|
+
Loading a command imports its module and constructs it, third-party
|
|
33
|
+
commands included; Django's own are not loaded, since none is a
|
|
34
|
+
``SpecCommand``.
|
|
35
|
+
|
|
36
|
+
- ``django_spec_cli.W001``, one per spec that
|
|
37
|
+
[`unguarded_specs`][django_service_specs.registry.unguarded_specs.unguarded_specs]
|
|
38
|
+
reports, naming the command an operator types and the spec, with the
|
|
39
|
+
command class as ``obj``. A ``Warning``, never an ``Error``: an open
|
|
40
|
+
spec is a decision or an accident the check cannot tell apart, and an
|
|
41
|
+
``Error`` would stop ``migrate`` on a project working as intended.
|
|
42
|
+
``SILENCED_SYSTEM_CHECKS`` silences it by id, so for every spec at once.
|
|
43
|
+
- ``django_spec_cli.W003``, one per command that could not be loaded or
|
|
44
|
+
whose parser could not be built, naming it and the error. Without this
|
|
45
|
+
app nothing loads that command until somebody runs it, so failing the
|
|
46
|
+
check on it would make a broken command nobody runs stop ``migrate``;
|
|
47
|
+
and dropping it silently would leave its specs unchecked with nothing
|
|
48
|
+
saying so.
|
|
49
|
+
|
|
50
|
+
With ``app_configs``, as ``manage.py check <app_label>`` passes, only
|
|
51
|
+
those apps' commands are loaded.
|
|
52
|
+
|
|
53
|
+
The check runs wherever system checks run: ``manage.py check``, and
|
|
54
|
+
every ``manage.py`` command that keeps ``requires_system_checks``.
|
|
55
|
+
``call_command`` skips system checks for every command but ``check``
|
|
56
|
+
itself, so a command invoked through it does not run this.
|
|
57
|
+
"""
|
|
58
|
+
messages: list[CheckMessage] = []
|
|
59
|
+
for name, app_name, loaded in _spec_commands(app_configs):
|
|
60
|
+
if isinstance(loaded, Exception):
|
|
61
|
+
messages.append(
|
|
62
|
+
Warning(
|
|
63
|
+
f"The command {name!r} from {app_name} could not be set up, so its specs "
|
|
64
|
+
f"were not checked: {type(loaded).__name__}: {loaded}",
|
|
65
|
+
hint=(
|
|
66
|
+
f"Run manage.py {name} to see the traceback. Until it loads, the specs "
|
|
67
|
+
"it serves are not checked."
|
|
68
|
+
),
|
|
69
|
+
id="django_spec_cli.W003",
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
continue
|
|
73
|
+
command, specs = loaded
|
|
74
|
+
messages.extend(
|
|
75
|
+
Warning(
|
|
76
|
+
unguarded_message(spec, name),
|
|
77
|
+
hint=_HINT,
|
|
78
|
+
obj=type(command),
|
|
79
|
+
id="django_spec_cli.W001",
|
|
80
|
+
)
|
|
81
|
+
for spec in specs
|
|
82
|
+
)
|
|
83
|
+
return messages
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _spec_commands(
|
|
87
|
+
app_configs: Sequence[AppConfig] | None,
|
|
88
|
+
) -> Iterator[tuple[str, str, tuple[SpecCommand, tuple[UnguardedSpec, ...]] | Exception]]:
|
|
89
|
+
"""Each ``SpecCommand`` the project serves with its unguarded specs, or why it would not load.
|
|
90
|
+
|
|
91
|
+
The one place that reads Django's management internals: ``get_commands``
|
|
92
|
+
maps each command name to the app serving it, and
|
|
93
|
+
``load_command_class`` imports that module and constructs its
|
|
94
|
+
``Command``. Neither is documented API, so
|
|
95
|
+
``tests/test_checks.py``'s ``TestTheDjangoInternalsTheWalkReads`` pins the
|
|
96
|
+
shape this relies on.
|
|
97
|
+
|
|
98
|
+
Reading the registry is inside the same ``try``, so a ``SpecCommand``
|
|
99
|
+
that names none is reported like one that would not construct.
|
|
100
|
+
"""
|
|
101
|
+
wanted = None if app_configs is None else {config.name for config in app_configs}
|
|
102
|
+
for name, app_name in get_commands().items():
|
|
103
|
+
# One arc to coverage, so each operand is named by the test that fails
|
|
104
|
+
# without it: ``django.core`` by test_django_s_own_commands_are_not_loaded,
|
|
105
|
+
# the ``wanted`` clause by test_an_app_not_named_has_its_commands_skipped,
|
|
106
|
+
# and its ``not in`` by test_the_app_named_has_its_commands_checked.
|
|
107
|
+
# Django's own commands are skipped by app rather than by name, since a
|
|
108
|
+
# project command overriding one maps to the project's app
|
|
109
|
+
# (test_a_project_command_under_a_core_name_is_walked); loading them
|
|
110
|
+
# all costs more than the rest of the walk.
|
|
111
|
+
if app_name == "django.core" or (wanted is not None and app_name not in wanted):
|
|
112
|
+
continue
|
|
113
|
+
try:
|
|
114
|
+
command = load_command_class(app_name, name)
|
|
115
|
+
if not isinstance(command, SpecCommand):
|
|
116
|
+
continue
|
|
117
|
+
# Built as ``manage.py`` builds it, so a command whose parser
|
|
118
|
+
# cannot be built (a parameter taking an option the command owns,
|
|
119
|
+
# two specs sharing a subcommand name) is reported here and not
|
|
120
|
+
# first found by the operator running it.
|
|
121
|
+
command.create_parser("manage.py", name)
|
|
122
|
+
loaded: tuple[SpecCommand, tuple[UnguardedSpec, ...]] | Exception = (
|
|
123
|
+
command,
|
|
124
|
+
unguarded_specs(command.registry),
|
|
125
|
+
)
|
|
126
|
+
# Anything a module, a constructor or the parser's construction
|
|
127
|
+
# raises: an ``ImportError`` from a missing dependency, an
|
|
128
|
+
# ``ImproperlyConfigured`` from a registry refusing a spec at import,
|
|
129
|
+
# or the command's own bug. Not ``BaseException``, so an interrupt
|
|
130
|
+
# still stops the check.
|
|
131
|
+
except Exception as error:
|
|
132
|
+
loaded = error
|
|
133
|
+
yield name, app_name, loaded
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""``exit_code_for`` - the exit status a refusal ends a ``SpecCommand`` run with."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django_service_specs import (
|
|
6
|
+
AdditionalInputRequired,
|
|
7
|
+
DispatchError,
|
|
8
|
+
NotPermitted,
|
|
9
|
+
PrincipalUnavailable,
|
|
10
|
+
ServiceConflict,
|
|
11
|
+
ServiceError,
|
|
12
|
+
ServiceNotFound,
|
|
13
|
+
ServiceValidationError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from django_spec_cli.types.exit_code import ExitCode
|
|
17
|
+
|
|
18
|
+
_CODES: tuple[tuple[type[Exception], ExitCode], ...] = (
|
|
19
|
+
(NotPermitted, ExitCode.NOT_PERMITTED),
|
|
20
|
+
(PrincipalUnavailable, ExitCode.PRINCIPAL_UNAVAILABLE),
|
|
21
|
+
(DispatchError, ExitCode.INVALID_ARGUMENTS),
|
|
22
|
+
(ServiceValidationError, ExitCode.INVALID_ARGUMENTS),
|
|
23
|
+
(ServiceNotFound, ExitCode.NOT_FOUND),
|
|
24
|
+
(ServiceConflict, ExitCode.CONFLICT),
|
|
25
|
+
(AdditionalInputRequired, ExitCode.INPUT_REQUIRED),
|
|
26
|
+
(ServiceError, ExitCode.REFUSED),
|
|
27
|
+
)
|
|
28
|
+
"""Each refusal's code, read top to bottom, first match wins.
|
|
29
|
+
|
|
30
|
+
A subclass sits above its base, or the base's row would answer for it. Only
|
|
31
|
+
rows that change the answer are listed: ``InvalidArguments`` and
|
|
32
|
+
``ActionUnavailable`` take their base's code, so the base's row is theirs.
|
|
33
|
+
Coverage sees one expression, not eight rows, so each row is held by its own
|
|
34
|
+
case of test_every_refusal_the_kernel_raises_has_its_code."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def exit_code_for(error: BaseException) -> ExitCode:
|
|
38
|
+
"""The [`ExitCode`][django_spec_cli.types.exit_code.ExitCode] ``error`` ends a run with.
|
|
39
|
+
|
|
40
|
+
Every [`DispatchError`][django_service_specs.types.dispatch_error.DispatchError]
|
|
41
|
+
and [`ServiceError`][django_service_specs.services.service_error.ServiceError]
|
|
42
|
+
has a code, a subclass a later release adds included: it takes its
|
|
43
|
+
nearest listed base's, so a new refusal of the call is ``INVALID_ARGUMENTS``
|
|
44
|
+
as the HTTP transport answers it 400, and a new refusal by the operation
|
|
45
|
+
``REFUSED``. Anything else is ``ERROR``, which a ``SpecCommand`` never
|
|
46
|
+
catches: an exception nobody declared is a bug, and its traceback is how
|
|
47
|
+
it is found.
|
|
48
|
+
"""
|
|
49
|
+
return next((code for kind, code in _CODES if isinstance(error, kind)), ExitCode.ERROR)
|
django_spec_cli/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""How a command writes a presented result: a table for a terminal, JSON lines for a pipe."""
|
|
2
|
+
|
|
3
|
+
from django_spec_cli.render.render_json_lines import render_json_lines
|
|
4
|
+
from django_spec_cli.render.render_result import render_result
|
|
5
|
+
from django_spec_cli.render.render_table import render_table
|
|
6
|
+
from django_spec_cli.render.wants_table import wants_table
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"render_json_lines",
|
|
10
|
+
"render_result",
|
|
11
|
+
"render_table",
|
|
12
|
+
"wants_table",
|
|
13
|
+
]
|