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,42 @@
|
|
|
1
|
+
"""``render_json_lines`` - presented rows as JSON Lines, one object per line, streamed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from typing import Any, TextIO
|
|
7
|
+
|
|
8
|
+
from django_spec_cli.render.utils import compact_json
|
|
9
|
+
|
|
10
|
+
# JSON escapes every C0 control character and leaves these three raw. Each is
|
|
11
|
+
# a line break to some readers - Python's ``str.splitlines`` among them - so a
|
|
12
|
+
# value carrying one would end a line where the JSON Lines format says no line
|
|
13
|
+
# ends. They can only occur inside a JSON string, so replacing them in the
|
|
14
|
+
# encoded text changes no structure and decodes to the same value.
|
|
15
|
+
_LINE_BREAKS = {"
": "\\u2028", "
": "\\u2029", "\x85": "\\u0085"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render_json_lines(rows: Iterable[Mapping[str, Any]], stream: TextIO) -> None:
|
|
19
|
+
"""Write each row to ``stream`` as one line of JSON.
|
|
20
|
+
|
|
21
|
+
**Streamed**: each row is encoded and written before the next is read, so
|
|
22
|
+
a generator over a large result is never held in memory whole. That is
|
|
23
|
+
why the format is JSON Lines rather than one JSON array.
|
|
24
|
+
|
|
25
|
+
Each row is written as it was presented: every key, in the row's own
|
|
26
|
+
order, including a key no ``Output`` declares (the ``affordances``
|
|
27
|
+
``present`` adds) and a field marked hidden, which is how an HTTP response
|
|
28
|
+
carries it too. Text is written as itself (``ensure_ascii=False``), and
|
|
29
|
+
``DjangoJSONEncoder`` encodes what JSON has no type for: a ``Decimal`` as
|
|
30
|
+
its string, a date-time, date or time in ISO 8601, a ``UUID``, a lazy
|
|
31
|
+
translation in the active language. A value the encoder refuses raises
|
|
32
|
+
``TypeError`` before its line is written.
|
|
33
|
+
|
|
34
|
+
One ``write`` per line, each ending in ``"\\n"``: Django's
|
|
35
|
+
``OutputWrapper`` appends a newline to any message that does not end in
|
|
36
|
+
one, so a line written in pieces would come out broken.
|
|
37
|
+
"""
|
|
38
|
+
for row in rows:
|
|
39
|
+
line = compact_json(row)
|
|
40
|
+
for raw, escaped in _LINE_BREAKS.items():
|
|
41
|
+
line = line.replace(raw, escaped)
|
|
42
|
+
stream.write(line + "\n")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""``render_result`` - what ``present`` returned, written as a table or as JSON Lines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from typing import Any, TextIO
|
|
7
|
+
|
|
8
|
+
from django_service_specs import Output
|
|
9
|
+
|
|
10
|
+
from django_spec_cli.render.render_json_lines import render_json_lines
|
|
11
|
+
from django_spec_cli.render.render_table import render_table
|
|
12
|
+
|
|
13
|
+
# The column a value that is no row is shown under in a table.
|
|
14
|
+
_RESULT = "result"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def render_result(presented: Any, output: Output | None, stream: TextIO, *, table: bool) -> None:
|
|
18
|
+
"""Write ``presented`` to ``stream``, as a table when ``table`` and JSON Lines when not.
|
|
19
|
+
|
|
20
|
+
``presented`` is what
|
|
21
|
+
[`present`][django_service_specs.dispatch.present.present] returned for a
|
|
22
|
+
spec, and ``output`` what the spec declares (``spec.output()``), which
|
|
23
|
+
gives a table its columns. Read in this order:
|
|
24
|
+
|
|
25
|
+
- ``None``, what a service that returned nothing presents: **nothing is
|
|
26
|
+
written**, in either format. A header over no rows would read as an
|
|
27
|
+
empty list.
|
|
28
|
+
- **a mapping**, one row: a one-row table, or one line.
|
|
29
|
+
- **a string or bytes**, a scalar, though both are iterable: a string is
|
|
30
|
+
never read as rows of its characters. Bytes are refused by the JSON
|
|
31
|
+
encoder with ``TypeError``, as an HTTP response refuses them.
|
|
32
|
+
- **any other iterable**, rows: a table, or one line each, streamed, so a
|
|
33
|
+
generator is read one row at a time and nothing here turns it into a
|
|
34
|
+
list. A member that is no mapping - a spec with no presenter listing
|
|
35
|
+
plain values - is a row of one ``result`` column in a table, and its
|
|
36
|
+
own JSON on its own line.
|
|
37
|
+
- anything else, **a scalar**: a table of one ``result`` column and one
|
|
38
|
+
row, or one line holding its JSON (``42``, ``"done"``, ``true``).
|
|
39
|
+
|
|
40
|
+
A table reads every row before writing its first line; see
|
|
41
|
+
[`render_table`][django_spec_cli.render.render_table.render_table]
|
|
42
|
+
for its columns, cells and markings, and
|
|
43
|
+
[`render_json_lines`][django_spec_cli.render.render_json_lines.render_json_lines]
|
|
44
|
+
for the encoding. Only ``stream`` is written to.
|
|
45
|
+
"""
|
|
46
|
+
if presented is None:
|
|
47
|
+
return
|
|
48
|
+
# A mapping and a string are both iterable, and each is one value rather
|
|
49
|
+
# than a sequence of rows, so both are answered before the iterable check.
|
|
50
|
+
# Three conditions in one branch arc, each held by its own test:
|
|
51
|
+
# the ``str`` cases of test_a_scalar_is_a_result_column_or_one_line_of_its_json
|
|
52
|
+
# (``str``), test_bytes_are_a_scalar_and_never_iterated_as_rows
|
|
53
|
+
# (``bytes``), and the ``int`` cases of the first (no iterable, which
|
|
54
|
+
# iterating would raise on).
|
|
55
|
+
if isinstance(presented, Mapping):
|
|
56
|
+
rows: Iterable[Any] = (presented,)
|
|
57
|
+
elif isinstance(presented, str | bytes) or not isinstance(presented, Iterable):
|
|
58
|
+
rows = (presented,)
|
|
59
|
+
# A scalar has no declared field to sit under, whatever the spec
|
|
60
|
+
# declares, so its table is the one ``result`` column.
|
|
61
|
+
output = None
|
|
62
|
+
else:
|
|
63
|
+
rows = presented
|
|
64
|
+
if table:
|
|
65
|
+
render_table([_row(row) for row in rows], output, stream)
|
|
66
|
+
else:
|
|
67
|
+
render_json_lines(rows, stream)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _row(row: Any) -> Mapping[str, Any]:
|
|
71
|
+
return row if isinstance(row, Mapping) else {_RESULT: row}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""``render_table`` - presented rows as an aligned plain-text table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import unicodedata
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, TextIO
|
|
10
|
+
|
|
11
|
+
from django_service_specs import FieldAudience, Output, OutputField
|
|
12
|
+
|
|
13
|
+
from django_spec_cli.render.utils import compact_json
|
|
14
|
+
|
|
15
|
+
# Two spaces between columns: one reads as a space inside a value.
|
|
16
|
+
_GAP = " "
|
|
17
|
+
# Unicode categories that move the cursor or the line rather than print: the
|
|
18
|
+
# C0 and C1 controls (newline, tab, the escape that starts a terminal
|
|
19
|
+
# sequence) and the line and paragraph separators. Written escaped, so a row
|
|
20
|
+
# can neither split itself across two lines nor drive the operator's terminal.
|
|
21
|
+
_BREAKING = frozenset({"Cc", "Zl", "Zp"})
|
|
22
|
+
_NUMERIC = frozenset({"integer", "number"})
|
|
23
|
+
# East Asian wide and full-width: two terminal columns each.
|
|
24
|
+
_WIDE = frozenset({"W", "F"})
|
|
25
|
+
# Nonspacing and enclosing marks and format characters: drawn on or between
|
|
26
|
+
# their neighbours, so no column of their own. ``unicodedata.combining`` is
|
|
27
|
+
# not this test - it answers a canonical combining class, which a variation
|
|
28
|
+
# selector and an enclosing mark both have as zero.
|
|
29
|
+
_ZERO_WIDTH = frozenset({"Mn", "Me", "Cf"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class _Column:
|
|
34
|
+
name: str
|
|
35
|
+
header: str
|
|
36
|
+
right: bool = False
|
|
37
|
+
# ``None`` where the value is shown as presented: no choices declared, or a
|
|
38
|
+
# handle, whose value is what the next command takes.
|
|
39
|
+
choices: tuple[tuple[Any, Any], ...] | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def render_table(rows: Sequence[Mapping[str, Any]], output: Output | None, stream: TextIO) -> None:
|
|
43
|
+
"""Write ``rows`` to ``stream`` as a table with a header line.
|
|
44
|
+
|
|
45
|
+
**The columns are the declared ``output``'s, never the rows'**: its fields
|
|
46
|
+
in declaration order, each headed by its ``label``, else its name. So an
|
|
47
|
+
empty result still prints its header, a row with a null never shifts a
|
|
48
|
+
column, and a key a row carries that ``output`` does not declare - the
|
|
49
|
+
``affordances`` that ``present`` adds - is no column. JSON Lines, which
|
|
50
|
+
writes each row whole, is where such a key is read.
|
|
51
|
+
|
|
52
|
+
A cell is:
|
|
53
|
+
|
|
54
|
+
- **empty** for a null, and for a key the row leaves out, as a presenter
|
|
55
|
+
may for a field not ``always_present``. The table cannot tell the two
|
|
56
|
+
apart; JSON Lines can.
|
|
57
|
+
- a choice's **display** rather than its value, read in the active
|
|
58
|
+
language, member by member for an array of choices. A value no choice
|
|
59
|
+
names is shown as itself. A field marked as a handle keeps its value,
|
|
60
|
+
because the operator pastes it into the next command, which takes the
|
|
61
|
+
value.
|
|
62
|
+
- **compact JSON** for a nested object or array, in one cell.
|
|
63
|
+
- otherwise the value in JSON's spelling with a string's quotes left off:
|
|
64
|
+
``true``, ``12.50`` for a ``Decimal``, ISO 8601 for a date-time.
|
|
65
|
+
|
|
66
|
+
A line break, tab or other control character inside a value is written
|
|
67
|
+
escaped (``\\n``, ``\\x1b``), so a row stays on one line and a value
|
|
68
|
+
cannot send the terminal an escape sequence.
|
|
69
|
+
|
|
70
|
+
**Markings.** A field marked hidden is no column. Its marking says it is
|
|
71
|
+
plumbing, and the projection for an agent drops it for that reason; a
|
|
72
|
+
person reading a table has no more use for it. That is a choice of
|
|
73
|
+
readability and not a secret kept: no marking means a field never leaves
|
|
74
|
+
the server, so an HTTP response and JSON Lines both carry a hidden field.
|
|
75
|
+
Label, handle and ordinary content fields are columns. A marking's
|
|
76
|
+
formatter is how an agent audience reads a value, and a command names no
|
|
77
|
+
audience, so it is not applied.
|
|
78
|
+
|
|
79
|
+
``output=None``, a spec that declares no output, is the one case a row is
|
|
80
|
+
read for its columns: the first row's keys, in its order. With no rows
|
|
81
|
+
there are then no columns, and nothing is printed; an ``Output`` with no
|
|
82
|
+
fields prints nothing either.
|
|
83
|
+
|
|
84
|
+
Plain text: columns padded with spaces, separated by two, numbers
|
|
85
|
+
(``integer`` and ``number`` fields) right-aligned and everything else
|
|
86
|
+
left-aligned, no colour and no box drawing, and no line ending in
|
|
87
|
+
whitespace. Widths are measured in terminal columns, per character: an
|
|
88
|
+
East Asian wide or full-width character counts two, most emoji among
|
|
89
|
+
them; a combining or enclosing mark and a format character (a zero-width
|
|
90
|
+
joiner, a variation selector) count none; anything else counts one. That
|
|
91
|
+
is an approximation of what a terminal draws, not a measurement of it:
|
|
92
|
+
an East Asian ambiguous character a terminal draws wide, or an emoji
|
|
93
|
+
sequence it draws as one glyph, leaves its row a column or two off.
|
|
94
|
+
|
|
95
|
+
**Every row is read before the first line is written**, because a
|
|
96
|
+
column's width is its widest cell. A table therefore holds the whole
|
|
97
|
+
result in memory; JSON Lines is the format that streams.
|
|
98
|
+
"""
|
|
99
|
+
columns = _columns(rows, output)
|
|
100
|
+
if not columns:
|
|
101
|
+
return
|
|
102
|
+
lines = [
|
|
103
|
+
# A header is escaped as a cell is: with no declared output it is a
|
|
104
|
+
# row's own key, which could carry a line break as well as a value.
|
|
105
|
+
[_text(column.header) for column in columns],
|
|
106
|
+
*([_cell(row.get(column.name), column.choices) for column in columns] for row in rows),
|
|
107
|
+
]
|
|
108
|
+
widths = [max(_width(line[n]) for line in lines) for n in range(len(columns))]
|
|
109
|
+
for line in lines:
|
|
110
|
+
cells = (
|
|
111
|
+
_pad(text, width, right=column.right)
|
|
112
|
+
for text, width, column in zip(line, widths, columns, strict=True)
|
|
113
|
+
)
|
|
114
|
+
# One write per line, ending in a newline: Django's ``OutputWrapper``
|
|
115
|
+
# appends one to any message that does not end in it. ``rstrip`` drops
|
|
116
|
+
# the padding of an empty or short last cell.
|
|
117
|
+
stream.write(_GAP.join(cells).rstrip() + "\n")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _columns(rows: Sequence[Mapping[str, Any]], output: Output | None) -> tuple[_Column, ...]:
|
|
121
|
+
if output is None:
|
|
122
|
+
return tuple(_Column(str(key), str(key)) for key in rows[0]) if rows else ()
|
|
123
|
+
return tuple(_column(field) for field in output if _audience(field) is not FieldAudience.HIDDEN)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _audience(field: OutputField) -> FieldAudience | None:
|
|
127
|
+
# Coverage sees no branch in a conditional expression, so each arm is
|
|
128
|
+
# named: test_an_unmarked_field_without_choices_is_shown_as_presented holds
|
|
129
|
+
# the ``None`` (without it ``None.audience`` raises), and
|
|
130
|
+
# test_a_hidden_field_is_no_column_and_every_other_audience_is_one the
|
|
131
|
+
# marking's own audience.
|
|
132
|
+
return None if field.marking is None else field.marking.audience
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _column(field: OutputField) -> _Column:
|
|
136
|
+
# Only a handle keeps its value, not every marked field:
|
|
137
|
+
# test_a_handle_keeps_its_value_even_with_choices and
|
|
138
|
+
# test_a_label_with_choices_is_shown_by_its_display.
|
|
139
|
+
return _Column(
|
|
140
|
+
field.name,
|
|
141
|
+
# ``str()`` for a lazy translation, read in the active language.
|
|
142
|
+
field.name if field.label is None else str(field.label),
|
|
143
|
+
right=field.type in _NUMERIC,
|
|
144
|
+
choices=None if _audience(field) is FieldAudience.HANDLE else field.choices,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _cell(value: Any, choices: tuple[tuple[Any, Any], ...] | None) -> str:
|
|
149
|
+
if value is None:
|
|
150
|
+
return ""
|
|
151
|
+
if choices is not None:
|
|
152
|
+
value = _display(value, choices)
|
|
153
|
+
if isinstance(value, str):
|
|
154
|
+
return _text(value)
|
|
155
|
+
encoded = compact_json(value)
|
|
156
|
+
# A value the encoder writes as a JSON string - a Decimal, a date-time, a
|
|
157
|
+
# UUID, a lazy translation - is shown without its quotes, as text is.
|
|
158
|
+
return _text(json.loads(encoded) if encoded.startswith('"') else encoded)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _display(value: Any, choices: tuple[tuple[Any, Any], ...]) -> Any:
|
|
162
|
+
"""The display of the choice ``value`` names, or ``value`` itself.
|
|
163
|
+
|
|
164
|
+
Compared by equality rather than looked up in a dict, so an unhashable
|
|
165
|
+
value is compared rather than raising. A lazy display is left lazy: the
|
|
166
|
+
encoder in ``_cell`` reads it in the active language. An array of choices is displayed
|
|
167
|
+
member by member, as the agent projection speaks it. One ``isinstance``
|
|
168
|
+
over two kinds, so each is held by its own case of
|
|
169
|
+
test_an_array_of_choices_is_shown_member_by_member.
|
|
170
|
+
"""
|
|
171
|
+
if isinstance(value, list | tuple):
|
|
172
|
+
return [_display(member, choices) for member in value]
|
|
173
|
+
# Matched by type as well as value, as the declaration keeps them apart:
|
|
174
|
+
# ``1`` and ``True`` are equal to Python and two choices to a caller.
|
|
175
|
+
return next(
|
|
176
|
+
(display for choice, display in choices if type(choice) is type(value) and choice == value),
|
|
177
|
+
value,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _text(value: str) -> str:
|
|
182
|
+
return "".join(
|
|
183
|
+
char.encode("unicode_escape").decode("ascii")
|
|
184
|
+
if unicodedata.category(char) in _BREAKING
|
|
185
|
+
else char
|
|
186
|
+
for char in value
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _width(text: str) -> int:
|
|
191
|
+
return sum(
|
|
192
|
+
0
|
|
193
|
+
if unicodedata.category(char) in _ZERO_WIDTH
|
|
194
|
+
else 2
|
|
195
|
+
if unicodedata.east_asian_width(char) in _WIDE
|
|
196
|
+
else 1
|
|
197
|
+
for char in text
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _pad(text: str, width: int, *, right: bool) -> str:
|
|
202
|
+
fill = " " * (width - _width(text))
|
|
203
|
+
return fill + text if right else text + fill
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Helpers the render modules share: one encoding of a value as JSON, for a line and for a cell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django.core.serializers.json import DjangoJSONEncoder
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def compact_json(value: Any) -> str:
|
|
12
|
+
"""``value`` as compact JSON, its text written as itself, as ``DjangoJSONEncoder`` encodes it.
|
|
13
|
+
|
|
14
|
+
What the encoder has no spelling for raises ``TypeError``.
|
|
15
|
+
"""
|
|
16
|
+
return json.dumps(value, cls=DjangoJSONEncoder, ensure_ascii=False, separators=(",", ":"))
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""``wants_table`` - whether a command's result is written as a table or as JSON Lines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal, TextIO
|
|
6
|
+
|
|
7
|
+
from django_spec_cli.settings import command_output
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def wants_table(stream: TextIO, *, requested: Literal["table", "json"] | None) -> bool:
|
|
11
|
+
"""Whether to write a table to ``stream`` rather than JSON Lines.
|
|
12
|
+
|
|
13
|
+
Three answers, the first that decides winning:
|
|
14
|
+
|
|
15
|
+
1. ``requested``: what the operator asked for with ``--table`` or
|
|
16
|
+
``--json``. The setting is not read at all when the flag decides, so a
|
|
17
|
+
misconfigured setting never overrides an explicit request.
|
|
18
|
+
2. The project's ``SPEC_CLI_OUTPUT`` setting, when it pins
|
|
19
|
+
``"table"`` or ``"json"``.
|
|
20
|
+
3. ``stream.isatty()``: a table for a person at a terminal, JSON Lines for
|
|
21
|
+
a pipe, a file or a test's buffer. A stream with no ``isatty`` cannot
|
|
22
|
+
say it is a terminal and gets JSON Lines.
|
|
23
|
+
|
|
24
|
+
That third answer means an interactive run and CI print different things
|
|
25
|
+
by default, which is why both the flag and the setting exist. Django's
|
|
26
|
+
``OutputWrapper``, which is what a command's ``self.stdout`` is, answers
|
|
27
|
+
``isatty`` for the stream it wraps.
|
|
28
|
+
"""
|
|
29
|
+
choice = requested if requested is not None else command_output()
|
|
30
|
+
if choice == "auto":
|
|
31
|
+
isatty = getattr(stream, "isatty", None)
|
|
32
|
+
# One branch to coverage, so each condition is held by its own test:
|
|
33
|
+
# test_a_stream_that_cannot_say_is_not_a_terminal (the first, without
|
|
34
|
+
# which ``None()`` raises) and
|
|
35
|
+
# test_a_terminal_gets_a_table_and_a_pipe_gets_json_lines (the second).
|
|
36
|
+
return isatty is not None and bool(isatty())
|
|
37
|
+
return choice == "table"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""``unguarded_message`` - the sentence that reports one unguarded spec."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django_service_specs import UnguardedReason, UnguardedSpec
|
|
6
|
+
|
|
7
|
+
# One clause per reason, indexed rather than branched on, so a member added
|
|
8
|
+
# without its sentence fails test_every_reason_has_its_own_wording instead of
|
|
9
|
+
# printing another member's.
|
|
10
|
+
_WHY = {
|
|
11
|
+
UnguardedReason.UNRESTRICTED: "every permission check it declares is Unrestricted",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def unguarded_message(spec: UnguardedSpec, command_label: str) -> str:
|
|
16
|
+
"""The one-line report of ``spec``, served by the command ``command_label``.
|
|
17
|
+
|
|
18
|
+
``command_label`` is the name an operator types after ``manage.py``. The
|
|
19
|
+
system check and the command's own listing both print this, so the two
|
|
20
|
+
read the same.
|
|
21
|
+
|
|
22
|
+
The sentence says "any active user": dispatch refuses a deactivated
|
|
23
|
+
principal whatever the spec's checks allow, so an open spec is open to
|
|
24
|
+
every account that can still sign in, and to no other.
|
|
25
|
+
"""
|
|
26
|
+
return (
|
|
27
|
+
f"{spec.name!r} in manage.py {command_label} authorizes nothing: "
|
|
28
|
+
f"{_WHY[spec.reason]}, so any active user named by --as-user may run it."
|
|
29
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The Django setting this package reads, read at the call rather than at import."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
9
|
+
|
|
10
|
+
_COMMAND_OUTPUT = "SPEC_CLI_OUTPUT"
|
|
11
|
+
_COMMAND_OUTPUTS = ("auto", "table", "json")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def command_output() -> Literal["auto", "table", "json"]:
|
|
15
|
+
"""``SPEC_CLI_OUTPUT``: the format a ``SpecCommand`` writes its result in.
|
|
16
|
+
|
|
17
|
+
``"auto"`` when the project sets nothing: a table on a terminal and JSON
|
|
18
|
+
Lines on anything else, so a person reads one and a script parses the
|
|
19
|
+
other. ``"table"`` or ``"json"`` pins the format project-wide, for a
|
|
20
|
+
project whose CI should print what an interactive run does. A command's
|
|
21
|
+
own ``--table`` or ``--json`` still wins over either.
|
|
22
|
+
|
|
23
|
+
Read on every call, never at import, as every setting the kernel reads is.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
ImproperlyConfigured: anything but one of the three strings, matched
|
|
27
|
+
exactly, so ``"TABLE"`` or ``"jsonl"`` is refused by name rather
|
|
28
|
+
than quietly read as the default.
|
|
29
|
+
"""
|
|
30
|
+
value: Any = getattr(settings, _COMMAND_OUTPUT, "auto")
|
|
31
|
+
if value not in _COMMAND_OUTPUTS:
|
|
32
|
+
raise ImproperlyConfigured(
|
|
33
|
+
f"{_COMMAND_OUTPUT} must be one of 'auto', 'table' or 'json'; got {value!r}. It is "
|
|
34
|
+
"the format a SpecCommand writes its result in."
|
|
35
|
+
)
|
|
36
|
+
return value
|