vnnlib-test-solver 2.0.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 @@
1
+ __version__ = "2.0.0"
@@ -0,0 +1,163 @@
1
+ """The section 5.3.1 command-line assignment format.
2
+
3
+ The one place in this package where the output is compared byte for byte
4
+ rather than merely parsed, so every rule below is separately testable and none
5
+ of them is cosmetic::
6
+
7
+ A float32 [2,2]
8
+ 0.5
9
+ 0.3
10
+ 0.4
11
+ 0.2
12
+ B int32 [1]
13
+ -1
14
+
15
+ Variables appear in the order the query declares them, across every network.
16
+ The dimension form has no spaces inside the brackets. Values are row-major,
17
+ one per line. The type is the specification's string form and it is the type
18
+ the query *declares*: the standard's own example prints ``Real`` for four
19
+ variables declared ``float32``, which is either an inconsistency in the
20
+ example or a distinction between declared and analysis types that nothing
21
+ else in the standard defines. The client confirmed the declared-type reading,
22
+ and the one other implementation of this interface prints the declared type
23
+ as well.
24
+
25
+ Structure comes from the query and values come from configuration. A
26
+ configuration that also described the variables could disagree with the query
27
+ it was pointed at, and produce output that is well formed, plausible and
28
+ wrong. That is the failure this split exists to make impossible.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from collections.abc import Mapping, Sequence
34
+ from typing import Union
35
+
36
+ from .errors import ConfigError
37
+ from .querymodel import Declaration, QueryModel
38
+
39
+ Value = Union[int, float]
40
+
41
+ OPEN_BRACKET = "["
42
+ CLOSE_BRACKET = "]"
43
+ DIMENSION_SEPARATOR = ","
44
+ """Section 5.3.1's dimension form is [2,2]: no spaces anywhere inside it.
45
+
46
+ Named rather than written inline because the separator and the brackets are
47
+ the whole of a rule that a byte comparison exists to enforce, and a space
48
+ added to any of them is invisible in a diff of the code and glaring in a diff
49
+ of the output.
50
+ """
51
+
52
+
53
+ def render(model: QueryModel, assignments: Mapping[str, Sequence[Value]]) -> str:
54
+ """Return the assignment block for ``model``, terminated by a newline.
55
+
56
+ Every declared variable appears, in declaration order. The mapping supplies
57
+ only values, keyed by variable name; nothing about which variables exist or
58
+ what shape they have is read from it.
59
+
60
+ Raises:
61
+ ConfigError: the supplied values do not fit the query - a variable is
62
+ missing, a variable is named that the query does not declare, or a
63
+ value list is not the length the declared shape requires.
64
+ """
65
+ _reject_mismatch(model, assignments)
66
+ lines = []
67
+ for declaration in model.declarations:
68
+ lines.append(header(declaration))
69
+ lines.extend(_format(value) for value in assignments[declaration.name])
70
+ return "".join(f"{line}\n" for line in lines)
71
+
72
+
73
+ def header(declaration: Declaration) -> str:
74
+ """Render one variable's header line: name, declared type, dimensions."""
75
+ return f"{declaration.name} {declaration.element_type} {dimensions(declaration)}"
76
+
77
+
78
+ def dimensions(declaration: Declaration) -> str:
79
+ """Render a shape as section 5.3.1 writes it: [2,2], no internal spaces."""
80
+ inner = DIMENSION_SEPARATOR.join(str(d) for d in declaration.shape)
81
+ return f"{OPEN_BRACKET}{inner}{CLOSE_BRACKET}"
82
+
83
+
84
+ def size(declaration: Declaration) -> int:
85
+ """The number of values a declaration's shape requires, row-major.
86
+
87
+ A shape with no dimensions holds one value, which is what an empty product
88
+ gives, so this needs no special case.
89
+ """
90
+ total = 1
91
+ for dimension in declaration.shape:
92
+ total *= dimension
93
+ return total
94
+
95
+
96
+ def _reject_mismatch(
97
+ model: QueryModel, assignments: Mapping[str, Sequence[Value]]
98
+ ) -> None:
99
+ """Refuse values that do not fit the query, before a byte is emitted.
100
+
101
+ All three mismatches are configuration errors rather than query errors: the
102
+ query parsed and means what it says, and it is the values pointed at it
103
+ that do not fit.
104
+
105
+ **A partial assignment is refused rather than printed.** A rule that
106
+ supplies values is claiming to give a satisfying assignment, and one that
107
+ omits a declared variable is not one. Printing it would produce a block
108
+ that is well formed, shorter than the standard requires, and impossible for
109
+ a consumer to tell apart from a complete one.
110
+ """
111
+ declared = {declaration.name: declaration for declaration in model.declarations}
112
+
113
+ # Sorted, because these are set differences and the message must not depend
114
+ # on iteration order. Declaration order would read better but cannot be
115
+ # recovered for a name the query never declared.
116
+ unknown = sorted(set(assignments) - set(declared))
117
+ if unknown:
118
+ raise ConfigError(
119
+ f"the rule assigns a value to {unknown[0]!r}, which {model.path} does not "
120
+ f"declare; a rule supplies values, and the query decides which variables "
121
+ f"exist"
122
+ )
123
+
124
+ missing = sorted(set(declared) - set(assignments))
125
+ if missing:
126
+ raise ConfigError(
127
+ f"the rule assigns no value to {missing[0]!r}, which {model.path} declares; "
128
+ f"an assignment covers every declared variable or a rule supplies none at "
129
+ f"all, because a partial assignment is not a satisfying assignment"
130
+ )
131
+
132
+ for name, declaration in declared.items():
133
+ expected = size(declaration)
134
+ supplied = len(assignments[name])
135
+ if supplied != expected:
136
+ raise ConfigError(
137
+ f"the rule assigns {supplied} value(s) to {name!r}, which "
138
+ f"{model.path} declares as {dimensions(declaration)} and so requires "
139
+ f"{expected}; values are row-major, one per position"
140
+ )
141
+
142
+
143
+ def _format(value: Value) -> str:
144
+ """Render one value as the shortest string that round-trips to it.
145
+
146
+ ``str`` of a TOML integer gives no decimal point and ``str`` of a TOML
147
+ float keeps one, so ``-1`` and ``1.0`` both survive as the standard's
148
+ example writes them, and the integer/decimal distinction the configuration
149
+ chose is preserved. The standard specifies no numeric format, so nothing
150
+ here reformats beyond that.
151
+
152
+ **This is not the same as reproducing what the author typed**, and the
153
+ difference is worth stating in the one package whose whole claim is control
154
+ over its own bytes. Python's float repr is the shortest string that
155
+ round-trips, so a value written in scientific notation is normalised:
156
+ ``1e10`` renders as ``10000000000.0`` and ``1.5e-8`` as ``1.5e-08``. That
157
+ property is what a baseline actually needs - it is identical on every
158
+ platform and every run, where "as typed" would depend on the spelling
159
+ someone happened to choose. ``inf``, ``-inf`` and ``nan`` pass through as
160
+ those literals. A test that needs an exact byte sequence rather than a
161
+ numerically equal one uses ``raw_stdout``.
162
+ """
163
+ return str(value)
@@ -0,0 +1,504 @@
1
+ """Console entry point: command dispatch per section 5.1, global options per 5.2.
2
+
3
+ Results go to stdout; the configuration note, warnings and every error go to
4
+ stderr, which section 5.1 requires. Neither the name, the version nor any
5
+ capability is held in code: all of them are answers, and answers come from
6
+ configuration.
7
+
8
+ Everything bound for stdout goes through ``emit``, which writes bytes. This is
9
+ the only module that writes to stdout at all, so that rule is enforceable by
10
+ reading one file.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import os
17
+ import sys
18
+ from collections.abc import Sequence
19
+ from typing import Any, NoReturn, Optional
20
+
21
+ from .assignments import render as render_assignment
22
+ from .config import (
23
+ ENV_VAR,
24
+ WORKING_DIRECTORY_NAME,
25
+ Config,
26
+ load_config,
27
+ resolve_config_source,
28
+ )
29
+ from .errors import ConfigError, QueryError, UsageError
30
+ from .injection import Injection, resolve
31
+ from .querymodel import QueryModel, read_query
32
+ from .rules import Rule, select
33
+ from .spec import SAT
34
+ from .supports import (
35
+ CAPABILITIES,
36
+ MULTIPLE_INPUT_OUTPUT,
37
+ MULTIPLE_INPUT_OUTPUT_ALIAS,
38
+ render,
39
+ response_shape,
40
+ )
41
+ from .validation import soundness_warnings
42
+ from .verify import (
43
+ VERIFY_COMMAND,
44
+ VERIFY_OPTIONS,
45
+ VerifyRequest,
46
+ add_arguments,
47
+ answer,
48
+ check_element_types,
49
+ check_network_mapping,
50
+ check_soundness,
51
+ request_from,
52
+ )
53
+ from .verify import warnings as verify_warnings
54
+
55
+ PROGRAM = "vnnlibTestSolver"
56
+ DEBUG_ENV_VAR = "VNNLIB_TEST_SOLVER_DEBUG"
57
+
58
+ SUPPORTS_COMMAND = "supports"
59
+ CAPABILITIES_COMMAND = "capabilities"
60
+
61
+ GLOBAL_OPTIONS = ("--config", "--name", "--version")
62
+
63
+ EXIT_OK = 0
64
+ # The standard defines no exit codes. A usage mistake (bad flags, no command,
65
+ # unknown capability) and a configuration mistake (bad TOML, an unsound claim)
66
+ # are different conditions and carry different codes.
67
+ EXIT_USAGE_ERROR = 1
68
+ EXIT_CONFIG_ERROR = 2
69
+
70
+
71
+ class _UsageErrorParser(argparse.ArgumentParser):
72
+ """Exits with EXIT_USAGE_ERROR rather than argparse's own hard-coded 2.
73
+
74
+ A mutually-exclusive-group conflict (``--name --version``, or two capability
75
+ flags at once) is caught inside argparse itself and never reaches main() -
76
+ the base ``error()`` always calls ``self.exit(2, ...)``. Overriding it here,
77
+ on the top-level parser, is what lets every usage mistake share one code:
78
+ ``add_subparsers()`` defaults new sub-parsers to the parent's own class, so
79
+ ``supports`` and its hidden alias inherit this without being told to.
80
+ """
81
+
82
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
83
+ # argparse resolves any unambiguous prefix to its full option, which
84
+ # would answer --onnx-e as --onnx-element-types. Section 5.4 defines
85
+ # eleven exact spellings and this package publishes exactly those, so an
86
+ # abbreviation is an option that does not exist, not a shorthand.
87
+ kwargs.setdefault("allow_abbrev", False)
88
+ super().__init__(*args, **kwargs)
89
+
90
+ def error(self, message: str) -> NoReturn:
91
+ self.print_usage(sys.stderr)
92
+ self.exit(EXIT_USAGE_ERROR, f"{self.prog}: error: {message}\n")
93
+
94
+
95
+ def emit(text: str) -> None:
96
+ """Write text to stdout as bytes, with no line-ending translation.
97
+
98
+ ``print`` and ``sys.stdout`` are text-mode streams, and text mode on Windows
99
+ turns every newline into a carriage return and a newline. Nothing here asks
100
+ for that, and section 5.3.1 output is compared byte for byte: bytes that
101
+ depend on the machine that produced them are not a baseline. A consumer
102
+ splitting the result line on newlines would also get a trailing carriage
103
+ return inside the token, so ``sat`` would not equal ``sat``.
104
+
105
+ Writing through the underlying binary buffer is what makes the bytes the
106
+ same everywhere. The text layer is flushed first so that nothing it holds
107
+ can appear after bytes written past it.
108
+
109
+ stderr is deliberately left in text mode. It carries diagnostic prose rather
110
+ than a format the standard specifies, and argparse writes some of it through
111
+ a stream this code does not own.
112
+ """
113
+ sys.stdout.flush()
114
+ sys.stdout.buffer.write(text.encode("utf-8"))
115
+ sys.stdout.buffer.flush()
116
+
117
+
118
+ def emit_line(text: str) -> None:
119
+ """Emit one line, terminated by a single LF on every platform."""
120
+ emit(f"{text}\n")
121
+
122
+
123
+ def inject_stderr(injection: Injection) -> None:
124
+ """Write the configured noise, before the answer rather than after it.
125
+
126
+ Ordering is a decision here rather than something implied. Writing first is
127
+ what a solver logging its progress would do, it is the order the consuming
128
+ interface's own stub solvers use, and it is the arrangement that actually
129
+ proves the two streams do not interfere: noise written before an answer has
130
+ to survive the answer being written after it, on a different stream, without
131
+ either being corrupted.
132
+
133
+ For ``verify`` this happens before the configured delay is waited out, so a
134
+ caller reading both streams sees the noise while the solver is still
135
+ pretending to think, rather than in one burst at the end.
136
+ """
137
+ if injection.stderr is not None:
138
+ print(injection.stderr, file=sys.stderr)
139
+
140
+
141
+ def deliver(text: str, injection: Injection) -> int:
142
+ """Write the answer, honour any crash, and return the exit code.
143
+
144
+ ``raw_stdout`` **replaces** the answer rather than adding to it. That is
145
+ what makes a result line that is not one of the four, a truncated capability
146
+ response, and no output at all reachable from configuration, which is the
147
+ whole point of the control - and it is one rule with no special cases rather
148
+ than a list of what it does and does not override.
149
+
150
+ A crash happens **after** the bytes are written and flushed, so that both a
151
+ crash carrying output and a crash carrying none are configurable: the second
152
+ is an empty ``raw_stdout`` beside it. The reverse order would make the first
153
+ unreachable.
154
+ """
155
+ # Bound to a local rather than tested through a property, so that the
156
+ # None case narrows for the type checker in the same place a reader sees
157
+ # it narrow.
158
+ replacement = injection.raw_stdout
159
+ emit(text if replacement is None else replacement)
160
+ if injection.crash:
161
+ _crash()
162
+ return injection.exit_code if injection.exit_code is not None else EXIT_OK
163
+
164
+
165
+ def _crash() -> NoReturn:
166
+ """Terminate abnormally rather than exiting.
167
+
168
+ ``os.abort`` raises ``SIGABRT`` on a Unix-like system, which is what the
169
+ consuming process runner distinguishes from a normal exit: it reports the
170
+ child as not having exited normally and carries no exit status. On Windows
171
+ there are no real signals and the process ends with the operating system's
172
+ own fast-fail status instead. Both were measured on this machine rather than
173
+ read about, and the test that covers this asserts the right one per platform
174
+ rather than merely a non-zero code.
175
+
176
+ Both streams are flushed first. ``os.abort`` bypasses interpreter shutdown
177
+ entirely, so anything still buffered is simply lost, and a crash that also
178
+ silently dropped the output configured beside it would be testing something
179
+ other than what the configuration says.
180
+ """
181
+ sys.stdout.flush()
182
+ sys.stderr.flush()
183
+ os.abort()
184
+
185
+
186
+ def build_parser() -> argparse.ArgumentParser:
187
+ parser = _UsageErrorParser(
188
+ prog=PROGRAM,
189
+ description=(
190
+ "A configurable test solver implementing the VNN-LIB command-line interface."
191
+ ),
192
+ )
193
+ parser.add_argument(
194
+ "--config",
195
+ metavar="PATH",
196
+ help=(
197
+ "configuration file to answer from; otherwise "
198
+ f"${ENV_VAR}, then ./{WORKING_DIRECTORY_NAME}. There is no default: "
199
+ "with none of the three, the run is refused"
200
+ ),
201
+ )
202
+ globals_group = parser.add_mutually_exclusive_group()
203
+ globals_group.add_argument(
204
+ "--name", action="store_true", help="print the solver's full name"
205
+ )
206
+ globals_group.add_argument(
207
+ "--version", action="store_true", help="print the solver version"
208
+ )
209
+
210
+ commands = parser.add_subparsers(dest="command", metavar="<command>")
211
+ # Every sub-parser is built through add_parser, which copies the parent's own
212
+ # class: each one inherits the usage exit code and the refusal of abbreviated
213
+ # options without being told to.
214
+ add_arguments(
215
+ commands.add_parser(
216
+ VERIFY_COMMAND,
217
+ help="answer a query about a network",
218
+ description="Answer one VNN-LIB query, per standard section 5.3.",
219
+ )
220
+ )
221
+ _add_capability_flags(
222
+ commands.add_parser(
223
+ SUPPORTS_COMMAND,
224
+ help="report whether the solver supports a capability",
225
+ description="Report one capability, per standard section 5.4.",
226
+ )
227
+ )
228
+ # Section 5.1 names this command 'capabilities' while section 5.4 defines it as
229
+ # 'supports'. Both are accepted so that neither reading of the standard breaks a
230
+ # consumer; the alias is given no help text, so only one spelling is documented.
231
+ _add_capability_flags(commands.add_parser(CAPABILITIES_COMMAND))
232
+ return parser
233
+
234
+
235
+ def _add_capability_flags(command: argparse.ArgumentParser) -> None:
236
+ """Declare the eleven capability flags, of which exactly one may be given."""
237
+ group = command.add_mutually_exclusive_group()
238
+ for capability in CAPABILITIES:
239
+ group.add_argument(
240
+ capability,
241
+ dest="capability",
242
+ action="store_const",
243
+ const=capability,
244
+ help=response_shape(capability),
245
+ )
246
+ group.add_argument(
247
+ MULTIPLE_INPUT_OUTPUT_ALIAS,
248
+ dest="capability",
249
+ action="store_const",
250
+ const=MULTIPLE_INPUT_OUTPUT,
251
+ help=argparse.SUPPRESS,
252
+ )
253
+
254
+
255
+ def main(argv: Optional[Sequence[str]] = None) -> int:
256
+ parser = build_parser()
257
+ args, unrecognised = parser.parse_known_args(argv)
258
+
259
+ if unrecognised:
260
+ return _report_unrecognised(parser, args, unrecognised)
261
+
262
+ if args.command is not None and (args.name or args.version):
263
+ print(
264
+ f"{PROGRAM}: --name and --version are global options and cannot be "
265
+ f"combined with the {args.command} command",
266
+ file=sys.stderr,
267
+ )
268
+ return EXIT_USAGE_ERROR
269
+
270
+ try:
271
+ source = resolve_config_source(args.config)
272
+ config = load_config(source)
273
+ except ConfigError as exc:
274
+ print(f"{PROGRAM}: {exc}", file=sys.stderr)
275
+ return EXIT_CONFIG_ERROR
276
+
277
+ if os.environ.get(DEBUG_ENV_VAR):
278
+ print(
279
+ f"{PROGRAM}: configuration read from {source.origin}: {source.path}",
280
+ file=sys.stderr,
281
+ )
282
+
283
+ # A soundness claim the configuration contradicts is a property of the file, not
284
+ # of the command asked for, so it is reported whichever command runs. It is a
285
+ # warning: the run still answers, and stdout is untouched.
286
+ for warning in soundness_warnings(config.capabilities, config.soundness):
287
+ print(f"{PROGRAM}: warning: {warning}", file=sys.stderr)
288
+
289
+ # Resolved once, here, so that every path which delivers an answer takes
290
+ # the same controls from the same place. A run that is refused never gets
291
+ # this far, which is deliberate: a solver that cannot report its own errors
292
+ # because a configuration told it to report something else is not a baseline.
293
+ injection = resolve(config.injection)
294
+
295
+ if args.name:
296
+ inject_stderr(injection)
297
+ return deliver(f"{config.solver.name}\n", injection)
298
+
299
+ if args.version:
300
+ inject_stderr(injection)
301
+ return deliver(f"{config.solver.version}\n", injection)
302
+
303
+ if args.command == VERIFY_COMMAND:
304
+ return _run_verify(args, config)
305
+
306
+ if args.command is not None:
307
+ return _run_supports(args, config, injection)
308
+
309
+ parser.print_usage(sys.stderr)
310
+ print(f"{PROGRAM}: no global option or command given", file=sys.stderr)
311
+ return EXIT_USAGE_ERROR
312
+
313
+
314
+ def _run_verify(args: argparse.Namespace, config: Config) -> int:
315
+ try:
316
+ request = request_from(args)
317
+ except UsageError as exc:
318
+ print(f"{PROGRAM}: {exc}", file=sys.stderr)
319
+ return EXIT_USAGE_ERROR
320
+
321
+ for warning in verify_warnings(request):
322
+ print(f"{PROGRAM}: warning: {warning}", file=sys.stderr)
323
+
324
+ try:
325
+ rule = select(config, request.query)
326
+ except ConfigError as exc:
327
+ print(f"{PROGRAM}: {exc}", file=sys.stderr)
328
+ return EXIT_CONFIG_ERROR
329
+
330
+ # Everything that can refuse the run happens here, before the result is
331
+ # printed and before any delay is waited out, so that a run which cannot be
332
+ # answered fails with nothing on stdout rather than with a result line
333
+ # already committed to it. Section 5.1 puts errors on stderr; a half-written
334
+ # answer is not something a consumer can be expected to unpick.
335
+ try:
336
+ model = _query_model_for(request, rule)
337
+ if model is not None:
338
+ # Section 5.3.2's three conditions, in the order the clause lists
339
+ # them. The order is fixed rather than incidental: a run that gets
340
+ # two of them wrong must report the same one every time, or a test
341
+ # asserting which condition it triggered depends on nothing.
342
+ check_network_mapping(request, model)
343
+ check_element_types(model, rule.model_element_types)
344
+ check_soundness(model, config.soundness.get("sound-for"))
345
+ assignment = _assignment_for(model, rule)
346
+ except (QueryError, UsageError) as exc:
347
+ print(f"{PROGRAM}: {exc}", file=sys.stderr)
348
+ return EXIT_USAGE_ERROR
349
+ except ConfigError as exc:
350
+ print(f"{PROGRAM}: {exc}", file=sys.stderr)
351
+ return EXIT_CONFIG_ERROR
352
+
353
+ # Nothing above this line can be reached by a run that will be refused, so
354
+ # this is where deliberate misbehaviour starts: the noise goes out before
355
+ # the solver spends its configured thinking time, not after.
356
+ injection = resolve(config.injection, rule)
357
+ inject_stderr(injection)
358
+
359
+ # Section 5.3.1: the result is the first line of stdout, and the assignment
360
+ # block follows it when there is one.
361
+ #
362
+ # The result is asked for after the block is built, not before, because a
363
+ # rule whose delay outlasts the caller's timeout answers timed-out however
364
+ # it was configured, and an assignment printed under that would contradict
365
+ # the line above it. Building it either way keeps a configuration that does
366
+ # not fit its query failing identically whether or not the clock ran out.
367
+ #
368
+ # The delay is waited out even when the output is about to be replaced. The
369
+ # delay is how long the solver claims to think and the replacement is what
370
+ # it prints, so they are independent, and keeping them so is what lets a
371
+ # caller test a deadline being reached and unparseable output arriving in
372
+ # the same run.
373
+ result = answer(request, rule)
374
+ text = f"{result}\n"
375
+ if assignment and result == SAT:
376
+ text += assignment
377
+ return deliver(text, injection)
378
+
379
+
380
+ def _query_model_for(request: VerifyRequest, rule: Rule) -> Optional[QueryModel]:
381
+ """Read the query, but only when the run gives a reason to open it.
382
+
383
+ Which result a rule gives is decided by the query file's name, so a bare
384
+ invocation is answerable without the file existing at all. That is not an
385
+ oversight: it is what lets a caller exercise result handling, rule
386
+ selection and deadline behaviour without maintaining a corpus of real
387
+ queries and real models, and it is published as a property of this binary.
388
+
389
+ Three things ask for the file to be opened, and they are one policy rather
390
+ than three rules: the run has handed the solver something whose other half
391
+ is in the query. A ``--network`` argument gives it a mapping to check
392
+ against the declarations, which is section 5.3.2's first error condition.
393
+ Assignment values give it half an assignment, whose other half - names,
394
+ types, dimensions and order - is in the query. Model element types give it
395
+ a claim about what the models expose, which means nothing until it is set
396
+ beside what the query declares, and which would otherwise be a
397
+ configuration key that silently did nothing.
398
+
399
+ Section 5.3.2's third condition, unsound analysis without consent, is
400
+ deliberately **not** on that list even though it needs the query too. It is
401
+ driven by a configuration table rather than by anything the run supplies,
402
+ so honouring it unconditionally would make a real query file mandatory for
403
+ every invocation of every configuration that mentions soundness. Each
404
+ entry above is opted into by the rule or the command line that asks for it,
405
+ which is why none of them changes what an existing configuration does.
406
+
407
+ *Assumption*, recorded as an open question: applied to the letter, section
408
+ 5.3.2 would have every invocation read the query, since the declarations
409
+ are the only source of the required set. That reading would remove the
410
+ affordance above and make a real query file mandatory for every test anyone
411
+ writes against this binary. Both directions of the error stay triggerable
412
+ on demand under the narrower reading, which is the reason a test solver
413
+ carries the condition at all.
414
+ """
415
+ wants_assignment = bool(rule.assignments) and rule.result == SAT
416
+ if not request.networks and not wants_assignment and not rule.model_element_types:
417
+ return None
418
+ return read_query(request.query)
419
+
420
+
421
+ def _assignment_for(model: Optional[QueryModel], rule: Rule) -> str:
422
+ """Return the assignment block to print after the result, or nothing.
423
+
424
+ Section 5.3.1 attaches the assignment to ``sat`` and to no other result, so
425
+ a rule carrying values for a result that is not ``sat`` prints none of them.
426
+ They are not an error: a configuration is free to describe a satisfying
427
+ assignment for a query it also has a rule answering ``unsat`` for, and
428
+ refusing that would be policing something the standard leaves alone.
429
+ """
430
+ if model is None or not rule.assignments or rule.result != SAT:
431
+ return ""
432
+ return render_assignment(model, rule.assignments)
433
+
434
+
435
+ def _run_supports(args: argparse.Namespace, config: Config, injection: Injection) -> int:
436
+ if args.capability is None:
437
+ print(
438
+ f"{PROGRAM}: {args.command} needs exactly one capability; the standard "
439
+ f"defines {', '.join(CAPABILITIES)}",
440
+ file=sys.stderr,
441
+ )
442
+ return EXIT_USAGE_ERROR
443
+
444
+ try:
445
+ # Named 'response' rather than 'answer' so it cannot be mistaken for the
446
+ # verify command's answer(), which is imported into this module.
447
+ response = render(args.capability, config)
448
+ except ConfigError as exc:
449
+ print(f"{PROGRAM}: {exc}", file=sys.stderr)
450
+ return EXIT_CONFIG_ERROR
451
+
452
+ inject_stderr(injection)
453
+ # render() already terminates each line, so the text is delivered whole
454
+ # rather than a line at a time.
455
+ return deliver(response, injection)
456
+
457
+
458
+ def _report_unrecognised(
459
+ parser: argparse.ArgumentParser,
460
+ args: argparse.Namespace,
461
+ unrecognised: Sequence[str],
462
+ ) -> int:
463
+ """Report the first unrecognised token, in the terms of wherever it appeared.
464
+
465
+ Each command draws its arguments from a different closed set, so the message
466
+ names the set the token was measured against: capabilities after supports,
467
+ section 5.3's options after verify. argparse would otherwise report every one
468
+ of them as an unrecognised argument, which says nothing about the set at all.
469
+ """
470
+ offender = unrecognised[0]
471
+
472
+ if args.command is None:
473
+ parser.print_usage(sys.stderr)
474
+ message = f"unrecognised option {offender!r}"
475
+ elif offender in GLOBAL_OPTIONS:
476
+ message = (
477
+ f"{offender} is a global option and must be given before the "
478
+ f"{args.command} command"
479
+ )
480
+ elif args.command == VERIFY_COMMAND:
481
+ message = _unrecognised_for_verify(offender)
482
+ else:
483
+ message = (
484
+ f"unknown capability {offender!r}; the standard defines "
485
+ f"{', '.join(CAPABILITIES)}"
486
+ )
487
+
488
+ print(f"{PROGRAM}: {message}", file=sys.stderr)
489
+ return EXIT_USAGE_ERROR
490
+
491
+
492
+ def _unrecognised_for_verify(offender: str) -> str:
493
+ """Distinguish an option verify does not define from a second query file.
494
+
495
+ Section 5.3 gives verify one positional argument, so a token that does not
496
+ look like an option is a second file rather than a misspelling, and saying so
497
+ is more use than listing the options it is not.
498
+ """
499
+ if offender.startswith("-"):
500
+ return (
501
+ f"unknown option {offender!r} for the {VERIFY_COMMAND} command; the "
502
+ f"standard defines {', '.join(VERIFY_OPTIONS)}"
503
+ )
504
+ return f"{VERIFY_COMMAND} takes exactly one query file and {offender!r} is a second"