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,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
+ ]
@@ -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)
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
+ ]