Skip to content

API reference

Everything below is exported from django_spec_cli directly, and from the subpackage it is listed under. Import from the package root in application code; the subpackage paths are where each name is defined. Kernel names the command reads - SpecRegistry, dispatch, the refusals - are documented on the kernel's site.

Settings

One Django setting, read at the call rather than at import, so override_settings reaches a command built before it.

  • SPEC_CLI_OUTPUT: the format a SpecCommand writes its result in. "auto" when unset, a table on a terminal and JSON Lines on anything else; "table" or "json" pins one project-wide, and a command's own --table or --json still wins. Anything else, a different case included, is refused as ImproperlyConfigured where it is read. See wants_table.

System checks

Registered by DjangoSpecCliConfig, so a project gets them by listing "django_spec_cli" in INSTALLED_APPS.

  • django_spec_cli.W001: a spec a SpecCommand serves authorizes nothing, every permission check it declares being Unrestricted. Once per open spec, worded by unguarded_message.
  • django_spec_cli.W003: a command could not be loaded to be checked.

The command

SpecCommand

Bases: BaseCommand

A management command serving every spec in a registry, one subcommand each.

Declared by subclassing, in the project's own management/commands/ module, which names the registry::

class Command(SpecCommand):
    registry = notes_registry

The registry is the subclass's to name, so one project can expose two registries under two command names. It is read when the parser is built, and a command naming none is refused then, as ImproperlyConfigured. pool_seeds names the seeds the specs' callables are resolved with, as pool_seeds on django-spec-html's SpecView does, for a registry whose callables read one.

It is run as manage.py <command> <spec> --as-user USER [--json|--table] [--option value ...], or manage.py <command> --explain:

  • One subcommand per registry entry, named after the entry with each _ shown as -. Its options are the spec's parameters, as add_spec_arguments spells them, less any key the server fills (server_owned_keys). No option is matched by a prefix: an abbreviation is refused rather than read as whichever option it begins.
  • --as-user is required. Its value goes through resolve_as_user, and the user it returns is the principal dispatch runs the spec for, with the permission checks enforced: the command passes no Grant, and opens no transaction of its own.
  • The result goes through the spec's presenter and is written as a table or as JSON Lines: --table and --json choose, then the SPEC_CLI_OUTPUT setting, then whether stdout is a terminal (wants_table).
  • A refusal ends the run with a CommandError carrying its message and an ExitCode.
  • --explain runs no spec: it lists the ones that authorize nothing, as the system check does.

The command is a mistake-prevention and attribution surface, never an authorization boundary: whoever can run manage.py can run any code the project can, and names any user they like with --as-user. What it guarantees is that a run is attributed to a user and checked against the permissions that user holds.

Sync only: it dispatches with dispatch, never adispatch.

create_parser

create_parser(prog_name: str, subcommand: str, **kwargs: Any) -> CommandParser

Django's parser, with this command's options added and no option read by its prefix.

add_arguments

add_arguments(parser: CommandParser) -> None

--explain, then one subcommand per registry entry, each with its spec's options.

Every subcommand is built here, on every run: one spec's options are a handful of add_argument calls, and building them eagerly is what lets --help list every spec.

Raises:

Type Description
ImproperlyConfigured

the class names no SpecRegistry; two entries are one subcommand, a_b and a-b; or a spec parameter maps to an option the command owns - --as-user, --json, --table, or one of Django's own, such as --settings, which manage.py reads from anywhere on the line.

resolve_as_user

resolve_as_user(value: str) -> Any

The user --as-user VALUE names, or PrincipalUnavailable.

By primary key, through resolve_principal, which refuses a missing row, a malformed key and a deactivated account. A project whose runbooks name people by username or email overrides this method with that lookup.

The contract is: return an authenticated user, or raise PrincipalUnavailable, which ends the run with ExitCode.PRINCIPAL_UNAVAILABLE. Anything else an override raises is a bug in the lookup, and propagates. An override need not refuse a deactivated user itself: dispatch refuses one whatever the lookup returns. A lookup answering None or an anonymous user is refused the same way, by the command. There is no fallback to an anonymous user and no user of the command's own.

exit_code_for

exit_code_for(error: BaseException) -> ExitCode

The ExitCode error ends a run with.

Every DispatchError and ServiceError has a code, a subclass a later release adds included: it takes its nearest listed base's, so a new refusal of the call is INVALID_ARGUMENTS as the HTTP transport answers it 400, and a new refusal by the operation REFUSED. Anything else is ERROR, which a SpecCommand never catches: an exception nobody declared is a bug, and its traceback is how it is found.

Rendering a result

render_result

render_result(
    presented: Any, output: Output | None, stream: TextIO, *, table: bool
) -> None

Write presented to stream, as a table when table and JSON Lines when not.

presented is what present returned for a spec, and output what the spec declares (spec.output()), which gives a table its columns. Read in this order:

  • None, what a service that returned nothing presents: nothing is written, in either format. A header over no rows would read as an empty list.
  • a mapping, one row: a one-row table, or one line.
  • a string or bytes, a scalar, though both are iterable: a string is never read as rows of its characters. Bytes are refused by the JSON encoder with TypeError, as an HTTP response refuses them.
  • any other iterable, rows: a table, or one line each, streamed, so a generator is read one row at a time and nothing here turns it into a list. A member that is no mapping - a spec with no presenter listing plain values - is a row of one result column in a table, and its own JSON on its own line.
  • anything else, a scalar: a table of one result column and one row, or one line holding its JSON (42, "done", true).

A table reads every row before writing its first line; see render_table for its columns, cells and markings, and render_json_lines for the encoding. Only stream is written to.

render_table

render_table(
    rows: Sequence[Mapping[str, Any]], output: Output | None, stream: TextIO
) -> None

Write rows to stream as a table with a header line.

The columns are the declared output's, never the rows': its fields in declaration order, each headed by its label, else its name. So an empty result still prints its header, a row with a null never shifts a column, and a key a row carries that output does not declare - the affordances that present adds - is no column. JSON Lines, which writes each row whole, is where such a key is read.

A cell is:

  • empty for a null, and for a key the row leaves out, as a presenter may for a field not always_present. The table cannot tell the two apart; JSON Lines can.
  • a choice's display rather than its value, read in the active language, member by member for an array of choices. A value no choice names is shown as itself. A field marked as a handle keeps its value, because the operator pastes it into the next command, which takes the value.
  • compact JSON for a nested object or array, in one cell.
  • otherwise the value in JSON's spelling with a string's quotes left off: true, 12.50 for a Decimal, ISO 8601 for a date-time.

A line break, tab or other control character inside a value is written escaped (\n, \x1b), so a row stays on one line and a value cannot send the terminal an escape sequence.

Markings. A field marked hidden is no column. Its marking says it is plumbing, and the projection for an agent drops it for that reason; a person reading a table has no more use for it. That is a choice of readability and not a secret kept: no marking means a field never leaves the server, so an HTTP response and JSON Lines both carry a hidden field. Label, handle and ordinary content fields are columns. A marking's formatter is how an agent audience reads a value, and a command names no audience, so it is not applied.

output=None, a spec that declares no output, is the one case a row is read for its columns: the first row's keys, in its order. With no rows there are then no columns, and nothing is printed; an Output with no fields prints nothing either.

Plain text: columns padded with spaces, separated by two, numbers (integer and number fields) right-aligned and everything else left-aligned, no colour and no box drawing, and no line ending in whitespace. Widths are measured in terminal columns, per character: an East Asian wide or full-width character counts two, most emoji among them; a combining or enclosing mark and a format character (a zero-width joiner, a variation selector) count none; anything else counts one. That is an approximation of what a terminal draws, not a measurement of it: an East Asian ambiguous character a terminal draws wide, or an emoji sequence it draws as one glyph, leaves its row a column or two off.

Every row is read before the first line is written, because a column's width is its widest cell. A table therefore holds the whole result in memory; JSON Lines is the format that streams.

render_json_lines

render_json_lines(rows: Iterable[Mapping[str, Any]], stream: TextIO) -> None

Write each row to stream as one line of JSON.

Streamed: each row is encoded and written before the next is read, so a generator over a large result is never held in memory whole. That is why the format is JSON Lines rather than one JSON array.

Each row is written as it was presented: every key, in the row's own order, including a key no Output declares (the affordances present adds) and a field marked hidden, which is how an HTTP response carries it too. Text is written as itself (ensure_ascii=False), and DjangoJSONEncoder encodes what JSON has no type for: a Decimal as its string, a date-time, date or time in ISO 8601, a UUID, a lazy translation in the active language. A value the encoder refuses raises TypeError before its line is written.

One write per line, each ending in "\n": Django's OutputWrapper appends a newline to any message that does not end in one, so a line written in pieces would come out broken.

wants_table

wants_table(stream: TextIO, *, requested: Literal['table', 'json'] | None) -> bool

Whether to write a table to stream rather than JSON Lines.

Three answers, the first that decides winning:

  1. requested: what the operator asked for with --table or --json. The setting is not read at all when the flag decides, so a misconfigured setting never overrides an explicit request.
  2. The project's SPEC_CLI_OUTPUT setting, when it pins "table" or "json".
  3. stream.isatty(): a table for a person at a terminal, JSON Lines for a pipe, a file or a test's buffer. A stream with no isatty cannot say it is a terminal and gets JSON Lines.

That third answer means an interactive run and CI print different things by default, which is why both the flag and the setting exist. Django's OutputWrapper, which is what a command's self.stdout is, answers isatty for the stream it wraps.

Parameters as options

describe_parameters

describe_parameters(parameters: Parameters) -> tuple[ParameterDescriptor, ...]

One ParameterDescriptor per parameter, in declaration order.

Never refuses a declaration: a parameter with no flat spelling is described with argv=False rather than left out, so a command can still say in --help that a spec takes something argv cannot send.

ParameterDescriptor dataclass

A Parameter seen from argv.

What a command line can say about a parameter, built by describe_parameters: the option it is sent with, and what --help can honestly show about the value. Each field is the declaration's, read for a person typing it, and three are narrower than the Parameter they come from:

  • required is whether an omitted option is refused: a parameter declared required with a default is not, since the shape check lets the Validator supply the default. A spec declared partial refuses no omission at all, which the declaration cannot see; the command that knows its spec says so.
  • default is the declared default only where JSON can spell it: None included, and never a callable, a sentinel or NaN, whose repr in the help would be a concrete falsehood. UNSET means nothing is shown, not that nothing is declared.
  • items is an array's element type, or None: an element that is a row of nested Parameters is no type a command line could show.

argv is False for a parameter that has no honest flat spelling - an object, an array of objects, of arrays or of files, and a file - so no option is invented for it: a dotted option for a nested field would be a syntax only this command spoke. Sent anyway, under its name by a caller that fills the arguments itself, it still reaches coerce_flat's refusal; left out, the shape check's required.

option property

option: str | None

The option string, --due-at for due_at, or None with no option.

add_spec_arguments

add_spec_arguments(
    parser: ArgumentParser, parameters: Parameters, *, reserved: Collection[str] = ()
) -> None

Add an option to parser for each parameter a command line can send.

The option is -- and the name with _ as -: due_at is --due-at. Argparse only collects strings here, and every decision about them is the kernel's, so a command line refuses what an HTTP caller would be refused, in the same words:

  • No value is converted. No option sets type=; every value stays the string typed, and coerce_flat reads it as it reads a query string.
  • No option is required by argparse. The shape check owns "missing", so a required parameter is refused as HTTP refuses it, beside every other mistake in the call, and --help marks it (required).
  • No choice is enforced by argparse. The choices are listed in --help and the shape check refuses any other value. Argparse's own choices= would answer one mistake with a second message and exit code, and only for a parameter whose choices are all strings.
  • An omitted option is absent from the namespace, never None: its default is argparse.SUPPRESS. That keeps it apart from an empty string and from --no-flag.
  • A boolean is a --flag/--no-flag pair, and an array is a repeated option, --ids 1 --ids 2, each value one element.
  • Each value lands under a namespaced destination rather than the parameter's name, so a parameter named verbosity or settings cannot overwrite BaseCommand's own. Read the namespace back with parsed_arguments.

A parameter with no honest flat spelling - an object, an array of objects, a file - gets no option: see ParameterDescriptor.

reserved is the option strings the command owns itself, such as --as-user and Django's --verbosity; --help is always reserved, since every parser owns its own.

Raises:

Type Description
ImproperlyConfigured

naming the parameter and the option, when an option would be one in reserved, or one another parameter maps to as well - a_b and a-b, or a boolean x, whose --no-x is a parameter no_x's option. Every option is checked before the first is added, so a refused spec leaves the parser as it was.

parsed_arguments

parsed_arguments(namespace: Namespace, parameters: Parameters) -> dict[str, Any]

The spec's arguments from a namespace filled by add_spec_arguments's options.

The raw dict a command hands to coerce_flat, keyed by parameter name, so argv crosses the one coercion and the one shape check every transport shares and is refused, or validated, exactly as the same values off a JSON wire would be:

  • An omitted option is absent, so the shape check decides what its absence means, as it does for a JSON caller who left the key out.
  • A boolean is the string "true" or "false": one of the four spellings coerce_flat reads, rather than a Python bool only this transport would hand on.
  • An array is the list of strings given, one per repeated option.
  • Everything else is the string given, an empty one included.

Only the namespaced destinations are read, so a command's own options in the same namespace - verbosity, settings - never become arguments. A value set there by a caller other than argparse, as call_command does with keyword arguments, is handed on as it was set, except that a bool for a boolean is spelled as argparse's would have been.

Reporting open specs

unguarded_message

unguarded_message(spec: UnguardedSpec, command_label: str) -> str

The one-line report of spec, served by the command command_label.

command_label is the name an operator types after manage.py. The system check and the command's own listing both print this, so the two read the same.

The sentence says "any active user": dispatch refuses a deactivated principal whatever the spec's checks allow, so an open spec is open to every account that can still sign in, and to no other.

Types

ExitCode

Bases: IntEnum

The exit status of a SpecCommand run, a public contract.

A script branches on these, so a member's number never changes and a number is never reused. Each says who refused and what the caller can do about it, read the way the HTTP transport reads the same refusals:

Code Member The run Raised for
0 SUCCESS ran, and its result was written
1 ERROR failed in a way nobody declared anything else, with a traceback
2 USAGE never started: the command line was malformed argparse's own refusals
3 INVALID_ARGUMENTS was refused its arguments InvalidArguments, ServiceValidationError, any other DispatchError
4 NOT_PERMITTED was refused to the principal NotPermitted
5 PRINCIPAL_UNAVAILABLE had nobody to run as PrincipalUnavailable
6 NOT_FOUND found no row to act on a not_found result, ServiceNotFound
7 CONFLICT was refused by the row's current state ServiceConflict, ActionUnavailable
8 INPUT_REQUIRED needs a value it was not given AdditionalInputRequired
9 REFUSED was refused by the operation's own rule any other ServiceError

INPUT_REQUIRED has its own code because a command line cannot ask: the operator re-runs the command with the option the message names, and a script that knows it may answer does so. ActionUnavailable is a conflict, as its base is; its code is in the message.

USAGE is the command line's: manage.py exits 2 on a malformed line. Through call_command Django raises every parser error as a CommandError with returncode 1 instead, and the command's own usage errors take the same path, so a caller there reads a usage error by its message.