checks / unit (pull_request) Successful in 6m17s
The Configuration tab can only render types and ranges. `validate.py`'s most
useful refusals are CROSS-FIELD - two inverters naming one `setpoint_entity`, a
`maintenance_charge_w` above `max_w`, a flat `setpoint_entity` left set beside a
declared roster - and the Supervisor's UI has no way to express a rule spanning
two keys. Today the operator meets those as exit 78 in a log, after saving and
restarting. This puts them on the screen beside the field they name, before
anything is written.
WHAT IT ADDS
app/configui.py the field model, built from `GET /addons/self/info`'s own
`schema` + `translations`. No option key, no label, no
rule of its own. UX-01's `Group · ` label prefix becomes a
real section heading, in whichever language resolved.
app/selfconfig.py the Supervisor conversation and the write gate.
app/config_page.py the page. Its longest function decides which HTML input a
type deserves; it contains no rule.
app/web.py five routes. Still the only file a reviewer has to read to
answer "does anything here reach the inverter".
THE FOUR THINGS THAT COULD HURT SOMEBODY, AND HOW EACH IS HELD
1. A blob the SHIPPED validator refuses is never written - not warned about,
not written. An invalid blob plus a restart is exit 78, and the panel is
gone with the add-on that serves it.
2. A blob that moved underneath is refused, not clobbered. Read hands out a
sha256 of the canonical blob, save hands it back, and the stored blob is
re-read IMMEDIATELY BEFORE the write. This is SAFETY-28's discipline
applied to configuration - "a blob reaches the Supervisor only if it was
derived from the blob stored right now" - with the honest weakening stated
in the decision record: a second writer is DETECTED, not made
inexpressible.
3. Every message is the validator's own, byte for byte. test_selfconfig.py
part 6 inserts ONE new rule into a COPY of validate.py, hashes every file
under app/ to show validate.py is the only one that differs, and serves the
real /config/validate route out of that copy in a subprocess. The new
sentence arrives, filed under the key it names.
4. Nothing on the surface commands an inverter or clears a latch. Saving does
not restart: applying costs a control interruption - the wind-down commands
0 W on every inverter - so that is a separate, confirmed button that says
so first.
MEASURED, NOT CITED (tools/supervisor_selfapi_probe.py, run against
Supervisor 2026.08.0's own code; it contacts nothing)
- the three self-paths are permitted, and `/addons/self/info` is admitted by
the `default` role too while `options` and `restart` rest on the bypass
ALONE. The probe's first draft claimed the role admitted none of the three
and went red.
- `/addons/self/options/validate` is NOT reachable from inside an add-on, so
the only validator this panel can call is ours - which is the one with the
cross-field rules.
- a POST that omits a key does not preserve it: the key reverts to
config.yaml's default. Hence whole-blob read-modify-write.
- the Configuration tab CAN be removed, and doing so drops 0-of-71 options
through and takes the add-on down. The card said it could not be; it stays
by decision, which is a better reason.
DOES NOT TOUCH config.yaml, main.py or any control path. UX-01 is a dependency,
not something this supersedes: its translations are where every label,
description and section heading on this page comes from, and the Configuration
tab is still the recovery surface. DECISIONS/UX-02-two-config-surfaces.md
records which surface is authoritative (neither: the stored blob is) and what
the second surface costs.
Also corrects a stale "70 options" in three prose spots to the 71 the probe
counts - config.yaml grew a key in SAFETY-42 and the sentences did not follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
---
⚠️ REBASED ONTO release/1.0 @ 3368231, AND HISTORY DELIBERATELY FLATTENED.
The original branch carried `653874d` - an ORPHANED first attempt at PKG-07
whose commit message justified the version bump with a claim that was FALSE
(that `slew_w: 0` used to mean "no limit"; the schema was `int(50,5000)` from
the repo's first commit, so 0 was never legal). That commit's CONTENT was
superseded when release/1.0 was merged in, and the merge resolved correctly -
one 0.8.0 section, no fabricated text. But the retracted claim would have
entered release/1.0's history as a commit message with no retraction attached
to it, where PKG-07's own merged commit is the thing that retracts it.
So the branch is replayed as a single commit on top of the corrected PKG-07
rather than merged with the bad one trailing behind. The tree is byte-identical
to the reviewed branch tip; only the ancestry changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
335 lines
18 KiB
Python
335 lines
18 KiB
Python
"""UX-02: can this add-on read and write its OWN options, and what comes back?
|
|
|
|
⚠️ WHY THIS SCRIPT EXISTS. The UX-02 card asserts that `GET /addons/self/info`,
|
|
`POST /addons/self/options` and `POST /addons/self/restart` are permitted to an
|
|
add-on with `hassio_api: true` and no special `hassio_role`, and it says - in
|
|
its own words - "confirm this again in the PR by calling it, rather than citing
|
|
this card". A permission read off a regex in somebody else's repository is a
|
|
hypothesis; the whole configuration surface rests on it, so it is RUN here,
|
|
against the compiled patterns out of the release image this site runs.
|
|
|
|
It also answers the question the panel's design depends on next: WHAT the info
|
|
response carries. app/configui.py builds the entire form from `schema` and
|
|
`translations` and owns no copy of either, which is only sound if those two
|
|
really are in the response and really are the loader's own output. Both are
|
|
constructed here, out of the image, and this repository's own model is built
|
|
from them.
|
|
|
|
HOW TO RUN IT (the version is the one `ha info` reports on the target install -
|
|
2026.08.0 is what 192.168.2.158 ran when UX-02 landed, and it is the same image
|
|
tools/supervisor_shape_probe.py pins):
|
|
|
|
docker run --rm \
|
|
-v "$PWD:/w:ro" -v "$PWD/app:/app:ro" -v "$PWD/tools:/probe:ro" \
|
|
--entrypoint python3 \
|
|
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
|
/probe/supervisor_selfapi_probe.py
|
|
|
|
⚠️ IT TOUCHES NOTHING, AND IT TOUCHES THE LIVE SITE LEAST OF ALL. It reads
|
|
config.yaml and translations/, imports Supervisor's own modules and runs them in
|
|
a throwaway container. It opens no socket, contacts no Supervisor, POSTs no
|
|
options and restarts nothing. The add-on is live in a real house; the only
|
|
honest way to measure this is against a copy of the software, not against it.
|
|
|
|
⚠️ WHAT IT CANNOT MEASURE, SAID OUT LOUD. It does not execute an HTTP request
|
|
against a running Supervisor, because doing that would mean pointing it at the
|
|
live one. So §A grades the ACCESS DECISION (the compiled patterns, run against
|
|
the exact paths) and §B grades the RESPONSE KEYS (the constants `info_data`
|
|
builds its dict from), rather than a captured response body. What is NOT
|
|
verified here is the wiring between the two - that the handler which builds that
|
|
dict is the one mounted at that path. That is read, not run, and this is the
|
|
sentence saying so.
|
|
"""
|
|
import copy
|
|
import json
|
|
import sys
|
|
|
|
sys.path.insert(0, "/usr/src/supervisor")
|
|
sys.path.insert(0, "/") # app/ is mounted at /app
|
|
|
|
import voluptuous as vol # noqa: E402
|
|
from pathlib import Path # noqa: E402
|
|
from supervisor.utils.yaml import read_yaml_file # noqa: E402
|
|
from supervisor.apps.validate import SCHEMA_APP_CONFIG # noqa: E402
|
|
from supervisor.apps.options import AppOptions, UiOptions # noqa: E402
|
|
from supervisor.apps.app import _OPTIONS_MERGER # noqa: E402
|
|
from supervisor.store.data import _read_app_translations # noqa: E402
|
|
from supervisor.const import (ATTR_OPTIONS, ATTR_SCHEMA, # noqa: E402
|
|
ATTR_TRANSLATIONS)
|
|
from supervisor.api.middleware.security import _V1_PATTERNS # noqa: E402
|
|
|
|
from app import configui # noqa: E402
|
|
|
|
FAILED = []
|
|
|
|
|
|
def show(title, ok, detail=""):
|
|
print(f" {'PASS' if ok else 'FAIL'} {title}")
|
|
if detail:
|
|
print(" " + str(detail)[:400])
|
|
if not ok:
|
|
FAILED.append(title)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("== A. THE PERMISSION, RUN RATHER THAN READ ==")
|
|
# ⚠️ THE PATTERNS ARE SUPERVISOR'S OWN COMPILED OBJECTS, imported, not retyped.
|
|
# `token_validation()` tests `api_bypass` against `request.path` for an
|
|
# installed app BEFORE it consults `access_hassio_api` or any role, so a match
|
|
# here is the whole permission question for these paths.
|
|
for path, want in (("/addons/self/info", True),
|
|
("/addons/self/options", True),
|
|
("/addons/self/restart", True),
|
|
("/addons/self/options/config", True),
|
|
# The two the pattern explicitly excludes, asserted so that
|
|
# a future widening of it does not go unnoticed here.
|
|
("/addons/self/security", False),
|
|
("/addons/self/update", False)):
|
|
got = bool(_V1_PATTERNS.api_bypass.match(path))
|
|
show(f"{path:32s} bypass={got}", got == want)
|
|
|
|
# ⚠️ THE ONE THE CARD DID NOT PREDICT, AND IT IS A NEGATIVE RESULT WORTH HAVING.
|
|
# Supervisor has its OWN options validator at `/addons/{app}/options/validate`.
|
|
# It is two path segments after `self`, so `[^/]+` cannot match it, and it is
|
|
# not one of the listed literals either. An add-on cannot call it. That does
|
|
# not hurt this card - the Supervisor validator knows only types and ranges, and
|
|
# the whole reason this panel exists is the cross-field rules it structurally
|
|
# cannot express - but "there is exactly one validator reachable from inside the
|
|
# add-on, and it is ours" is a fact the design should rest on knowingly.
|
|
_val = bool(_V1_PATTERNS.api_bypass.match("/addons/self/options/validate"))
|
|
show("/addons/self/options/validate is NOT reachable from an add-on - so the "
|
|
"ONLY validator this panel can call is app/validate.py, which is the one "
|
|
"with the cross-field rules", not _val, f"bypass={_val}")
|
|
|
|
# ⚠️ AND WHICH OF THE TWO MECHANISMS IS CARRYING EACH PATH - MEASURED, BECAUSE
|
|
# THE FIRST DRAFT OF THIS SECTION GOT IT WRONG. It asserted that the `default`
|
|
# role admits none of the three, and that FAILED: the default role's pattern is
|
|
# `^(?:|/.+/info)$`, which matches `/addons/self/info` perfectly well. So the
|
|
# split is:
|
|
#
|
|
# /addons/self/info - admitted by BOTH the bypass and the default role
|
|
# /addons/self/options - the bypass ONLY
|
|
# /addons/self/restart - the bypass ONLY
|
|
#
|
|
# i.e. the two WRITING paths rest entirely on the bypass list, and if it were
|
|
# narrowed this panel could still read its own options and could no longer save
|
|
# them. That is a more useful thing to know than the tidier claim that was
|
|
# asserted first, and it is only known because the claim was run.
|
|
_default = _V1_PATTERNS.role_access["default"]
|
|
show("`/addons/self/info` is admitted by the DEFAULT role as well as by the "
|
|
"bypass (`/.+/info`) - so reading has two independent licences",
|
|
bool(_default.match("/addons/self/info")))
|
|
show("...while `/addons/self/options` and `/addons/self/restart` are admitted "
|
|
"by the BYPASS ONLY. The two writing paths rest on that list alone",
|
|
not _default.match("/addons/self/options")
|
|
and not _default.match("/addons/self/restart"))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n== B. WHAT `GET /addons/self/info` IS BUILT FROM ==")
|
|
# `AppsAPI.info_data()` builds its dict with these constants as keys. Their
|
|
# VALUES are what the panel reads off the JSON, so they are asserted here
|
|
# rather than typed into app/selfconfig.py as a guess.
|
|
show("the response keys the panel reads are named `options`, `schema` and "
|
|
"`translations`",
|
|
(ATTR_OPTIONS, ATTR_SCHEMA, ATTR_TRANSLATIONS)
|
|
== ("options", "schema", "translations"),
|
|
f"{ATTR_OPTIONS} / {ATTR_SCHEMA} / {ATTR_TRANSLATIONS}")
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n== C. `schema` - what App.schema_ui actually is, for THIS config.yaml ==")
|
|
raw = read_yaml_file(Path("/w/config.yaml"))
|
|
cfg = SCHEMA_APP_CONFIG(raw)
|
|
schema, defaults = cfg["schema"], cfg["options"]
|
|
ui = UiOptions(None)(schema)
|
|
show(f"UiOptions renders every key config.yaml declares ({len(ui)})",
|
|
len(ui) == len(schema), f"schema keys {len(schema)}, ui nodes {len(ui)}")
|
|
|
|
# ⚠️ THE NAME IS THE MEASUREMENT. A NUMERIC range comes back as `lengthMin` /
|
|
# `lengthMax` - which reads like a string length and is not - and app/configui.py
|
|
# copies those two names straight through into the form's `min`/`max`. Guessing
|
|
# `valueMin` would have produced a form with no bounds at all and no error
|
|
# anywhere.
|
|
_maxw = [n for n in ui if n["name"] == "max_w"][0]
|
|
show("a numeric range arrives as lengthMin/lengthMax (NOT valueMin/valueMax), "
|
|
"which is why app/configui.py reads those two names",
|
|
_maxw.get("lengthMin") == 100 and _maxw.get("lengthMax") == 5000,
|
|
json.dumps(_maxw))
|
|
_sel = [n for n in ui if n["name"] == "meter_source"][0]
|
|
show("an enum arrives as type `select` with its `options` listed",
|
|
_sel.get("type") == "select" and "homewizard_local" in _sel.get("options", []),
|
|
json.dumps(_sel))
|
|
_inv = [n for n in ui if n["name"] == "inverters"][0]
|
|
show("the roster arrives as type `schema` + `multiple`, carrying its per-entry "
|
|
"fields - which is what the panel's roster editor draws cards from",
|
|
_inv.get("type") == "schema" and _inv.get("multiple") is True
|
|
and [s["name"] for s in _inv["schema"]][:3] == ["name", "setpoint_entity",
|
|
"max_w"],
|
|
json.dumps([s["name"] for s in _inv["schema"]]))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n== D. `options` - and what a POST to /addons/self/options REPLACES ==")
|
|
# ⚠️ THE HAZARD THE COMPARE-AND-SWAP EXISTS FOR, DEMONSTRATED. `App.options` is
|
|
# `deepmerge(config.yaml defaults, the STORED user options)` and the setter is
|
|
# `persist[options] = deepcopy(value)` - a REPLACEMENT. So a key left out of a
|
|
# POST does not keep its previous value; it falls back to the config.yaml
|
|
# DEFAULT. Both halves are run below on real objects.
|
|
_persist_before = {"max_w": 4200, "gain": 0.42}
|
|
_effective_before = _OPTIONS_MERGER.merge(copy.deepcopy(defaults),
|
|
copy.deepcopy(_persist_before))
|
|
show("stored user options merge OVER the config.yaml defaults, so every key is "
|
|
"present in what an add-on reads back",
|
|
_effective_before["max_w"] == 4200
|
|
and set(_effective_before) == set(defaults),
|
|
f"max_w={_effective_before['max_w']} gain={_effective_before['gain']}")
|
|
|
|
_persist_after = {"gain": 0.42} # a POST that simply omits max_w
|
|
_effective_after = _OPTIONS_MERGER.merge(copy.deepcopy(defaults),
|
|
copy.deepcopy(_persist_after))
|
|
show("...and a POST that OMITS a key does not preserve it - the key reverts to "
|
|
"config.yaml's default. That is why this panel posts the WHOLE blob it "
|
|
"read, and why it refuses to post one built on a stale read",
|
|
_effective_after["max_w"] == defaults["max_w"] != 4200,
|
|
f"omitted max_w -> {_effective_after['max_w']} "
|
|
f"(default {defaults['max_w']}, was 4200)")
|
|
|
|
# The blob the panel would actually POST: the full merged options, unchanged.
|
|
# If Supervisor refused that shape the read-modify-write would be unusable.
|
|
_v = vol.Schema(vol.All(dict, AppOptions(None, schema, cfg["name"],
|
|
"local_goodwe_controller")))
|
|
try:
|
|
_validated = _v(copy.deepcopy(_effective_before))
|
|
show("the WHOLE merged blob - what this panel sends back - is accepted by "
|
|
"Supervisor's own options validator", True)
|
|
except vol.Invalid as err:
|
|
_validated = None
|
|
show("the WHOLE merged blob is accepted", False, str(err))
|
|
|
|
# ⚠️ AND WHETHER IT COMES BACK BYTE-IDENTICAL, because the panel tells the
|
|
# operator which settings are "stored but not running" by comparing the stored
|
|
# blob with `/data/options.json`. Supervisor writes that file through
|
|
# `schema.validate(self.options)`, so any COERCION in that step would show up
|
|
# as a spurious difference on every page load.
|
|
if _validated is not None:
|
|
_drift = sorted(k for k in set(_validated) | set(_effective_before)
|
|
if _validated.get(k) != _effective_before.get(k))
|
|
show("...and validation does not COERCE it, so `stored vs running` compares "
|
|
"like with like and shows a difference only when there is one. "
|
|
f"Keys changed by validation: {_drift}", not _drift)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n== E. `translations` - UX-01's labels, reaching a SECOND surface ==")
|
|
translations = _read_app_translations(Path("/w"))
|
|
show("Supervisor's own loader accepts both files, so `info['translations']` "
|
|
"carries them", set(translations) == {"en", "nl"}, sorted(translations))
|
|
|
|
for lang, groups_expected in (("en", 7), ("nl", 7)):
|
|
model = configui.field_model(ui, translations, lang)
|
|
names = [g["name"] for g in model["groups"]]
|
|
show(f"[{lang}] this repository's own field_model() turns those into "
|
|
f"{len(names)} real section headings out of UX-01's `Group · ` prefix "
|
|
f"- which the Supervisor's flat list cannot render at all",
|
|
len(names) == groups_expected and not model["ungrouped"],
|
|
f"{names} ungrouped={model['ungrouped']}")
|
|
_raw = [f["key"] for g in model["groups"] for f in g["fields"]
|
|
if f["label_lang"] == "raw"]
|
|
show(f"[{lang}] ...and not one field falls back to its raw key name. "
|
|
f"Raw: {_raw}", not _raw)
|
|
_nohelp = [f["key"] for g in model["groups"] for f in g["fields"]
|
|
if not f["help"]]
|
|
show(f"[{lang}] ...and not one is left without the sentence that says "
|
|
f"whether to touch it. Missing: {_nohelp}", not _nohelp)
|
|
|
|
# ⚠️ THE ONE PLACE THIS PANEL IS STRICTLY BETTER THAN THE CONFIGURATION TAB ON
|
|
# THE SAME DATA, and shape_probe §F is the measurement that makes it a claim
|
|
# rather than a boast: on frontend 20250925.1 the roster's per-entry keys go to
|
|
# `ha-selector-object`, which labels them `fields[name].label || name`, and the
|
|
# converter sets no label - so they render RAW there, however well UX-01
|
|
# translated them. Here they are read out of the same `fields:` block and shown.
|
|
_model_en = configui.field_model(ui, translations, "en")
|
|
_roster = [f for g in _model_en["groups"] for f in g["fields"]
|
|
if f["key"] == "inverters"][0]
|
|
_sub = [(s["key"], s["label"], s["label_lang"]) for s in _roster["fields"]]
|
|
show("the roster's PER-ENTRY keys resolve to their UX-01 labels here, which is "
|
|
"exactly what the Supervisor's Configuration tab renders raw on frontend "
|
|
"20250925.1 (shape_probe §F)",
|
|
all(lang == "en" for _, _, lang in _sub) and len(_sub) == 7,
|
|
json.dumps([(k, l) for k, l, _ in _sub]))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n== F. the anchor list is the SCHEMA's keys, and collisions are real ==")
|
|
# app/configui.py files each validator sentence under every option key it
|
|
# NAMES, with word boundaries. Three of this schema's keys are suffixes of one
|
|
# another, so a naive `in` test would file every per-unit refusal under the site
|
|
# key as well. Run against the real key list rather than a hand-made one.
|
|
_keys = sorted(defaults)
|
|
_collide = [k for k in _keys if any(o != k and o.endswith("_" + k)
|
|
for o in _keys)]
|
|
show(f"config.yaml really does contain keys that are suffixes of other keys, "
|
|
f"so the boundary matching is load-bearing: {_collide}", bool(_collide))
|
|
_by, _un = configui.problems_by_field(
|
|
["unit_sign_disagree_w is 3000 W but unit-0's max_w is 2000 W"], _keys)
|
|
show("...and a sentence naming `unit_sign_disagree_w` is NOT also filed under "
|
|
"`sign_disagree_w`",
|
|
sorted(_by) == ["max_w", "unit_sign_disagree_w"] and not _un,
|
|
json.dumps(sorted(_by)))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n== G. COULD THE SUPERVISOR'S CONFIGURATION TAB BE REMOVED? ==")
|
|
# ⚠️ THE CARD SAYS IT CANNOT BE. THAT TURNS OUT TO BE THE RIGHT DECISION FOR
|
|
# THE WRONG REASON, AND THE DIFFERENCE MATTERS - a constraint you believe is
|
|
# imposed is one nobody re-examines, while a choice is something a decision
|
|
# record has to justify. So it was RUN.
|
|
#
|
|
# The tab renders `App.schema_ui`, which is `UiOptions(...)(data["schema"])`.
|
|
# Delete `schema:` from config.yaml and there is nothing to render. Supervisor
|
|
# ACCEPTS that manifest - the key is optional and defaults to `{}` - so the tab
|
|
# really would go away.
|
|
#
|
|
# What goes away with it is the add-on. `AppOptions` DROPS every option that
|
|
# the schema does not declare (one "Option 'x' does not exist in the schema"
|
|
# warning per key, and nothing else), so `App.options` validates down to the
|
|
# EMPTY DICT, `write_options()` writes `{}` to /data/options.json, and
|
|
# `load_options()` hands the controller nothing. The startup gate then refuses
|
|
# it for having no actuator - exit 78 - and the ingress panel this card builds
|
|
# is not running either, because the add-on serving it is not.
|
|
#
|
|
# So: removable, at the cost of every setting on the site and the add-on with
|
|
# them. That is the measured reason DECISIONS/UX-02-two-config-surfaces.md
|
|
# keeps the tab, and it is a much better reason than "the platform will not
|
|
# let us".
|
|
_noschema = {k: v for k, v in raw.items() if k != "schema"}
|
|
try:
|
|
_cfg2 = SCHEMA_APP_CONFIG(_noschema)
|
|
_accepted, _detail = True, repr(_cfg2.get("schema"))
|
|
except vol.Invalid as err:
|
|
_cfg2, _accepted, _detail = None, False, str(err)
|
|
show("a manifest with NO `schema:` block is ACCEPTED by Supervisor, and its "
|
|
"`schema_ui` is empty - so the Configuration tab is removable after all",
|
|
_accepted and _cfg2.get("schema") == {}
|
|
and UiOptions(None)(_cfg2["schema"]) == [], _detail)
|
|
if _accepted:
|
|
_v2 = vol.Schema(vol.All(dict, AppOptions(None, _cfg2["schema"],
|
|
_cfg2["name"], "local_x")))
|
|
try:
|
|
_out2 = _v2(copy.deepcopy(_cfg2["options"]))
|
|
_kept = len(_out2)
|
|
except vol.Invalid as err:
|
|
_kept = f"refused: {err}"
|
|
show("⚠️ ...and EVERY OPTION IS DROPPED WITH IT. Options not declared in "
|
|
"the schema are discarded with one log warning each, so the add-on "
|
|
"would start on an empty configuration, be refused by its own startup "
|
|
"gate for having no actuator, and take the ingress panel down with "
|
|
"it. That is why the tab stays",
|
|
_kept == 0, f"options surviving an empty schema: {_kept} "
|
|
f"of {len(defaults)}")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print()
|
|
if FAILED:
|
|
print(f"== {len(FAILED)} CASE(S) FAILED ==")
|
|
for title in FAILED:
|
|
print(" - " + title)
|
|
sys.exit(1)
|
|
print("== all cases passed ==")
|