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,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,7 @@
1
+ """The wording of a registry's open specs, shared by a command's ``--explain`` and the system check."""
2
+
3
+ from django_spec_cli.report.unguarded_message import unguarded_message
4
+
5
+ __all__ = [
6
+ "unguarded_message",
7
+ ]
@@ -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