blob: 3876000d8726ba46c19f65d9b4d6f936b0241bb2 [file] [edit]
#!/usr/bin/env python3
"""hw/bsp/family.json: what each board's default cmake configure selects, observed from
the configure's own outputs, never scraped from cmake text.
{"<family>": {"<board>": {"cmake": {"defines": {...}, "family_mcus": [...],
"mcu": "OPT_MCU_*", "options": {...},
"portable": [...], "roles": [...]}}}}
A row is written by tools/build.py after a default configure of the board (no -D or
--cflag, the gcc toolchain, the family's deps at their pins, into a dir that did not
exist before): family_support.cmake leaves family.json.part in the build dir and the
configure exports compile_commands.json; `observe` joins them and `merge` writes the row.
`cmake: null` marks a board of a Make-only family, and only the boards NULL_BOARDS lists.
`check` needs no toolchain: inventory both ways against hw/bsp/*/boards/*, field shapes,
and the canonical serialization byte for byte. `fix` is what pre-commit runs: it drops
rows of boards that are gone, configures once into a fresh private dir every board that
has no row and every board whose cmake the commit changes, rewrites the file canonical,
and exits 1 when it changed anything, so the commit is retried with the file staged, the
way codespell's fixes are. `roles` is which of the groups under examples/ the configure built a target of, the half of
driver coverage `portable` cannot answer: a board compiling an hcd its family always compiles
covers nothing if it configures no host or dual example. `refresh` is the release sweep: every
cmake board re-observed the same way, exit 1 when any board could not be. Between releases an
edit to what every row is observed through (src/common/tusb_mcu.h, hw/bsp/family_support.cmake,
and for `roles` an example's own CMakeLists.txt, skip.txt or only.txt) is not detected.
"""
import argparse
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CATALOG = ROOT / 'hw' / 'bsp' / 'family.json'
PART = 'family.json.part'
FIELDS = ('defines', 'family_mcus', 'mcu', 'options', 'portable', 'roles')
# Make-only boards, the one hand-typed fact in the file
NULL_BOARDS = {'pic32mz': ('olimex_emz64', 'olimex_hmz144')}
DIRECTIVE = re.compile(r'^\s*#\s*(if|elif|ifdef|ifndef)\b(.*)$')
IDENT = re.compile(r'\b([A-Za-z_]\w*)\b')
USBIP = re.compile(r'^#define (TUP_USBIP_\w+)', re.M)
DEP_FLAGS = {'-MD', '-MMD', '-MP', '-c'}
DEP_FLAGS_ARG = {'-MF', '-MT', '-MQ', '-o'}
class Failure(Exception):
"""One line naming why no row can be written; the build's own result is unaffected."""
def canonical_text(data):
return json.dumps(data, sort_keys=True, indent=2, ensure_ascii=False) + '\n'
def load(path=CATALOG):
"""The catalog, {} when the file does not exist; malformed JSON is a Failure, never
something to replace, and so is a family that is not an object of board rows: past
this point every caller may take both levels for mappings."""
try:
text = path.read_text(encoding='utf-8')
except FileNotFoundError:
return {}
try:
data = json.loads(text)
except ValueError as e:
raise Failure(f'{path.relative_to(ROOT)} is not valid JSON ({e}); fix it by hand')
if not isinstance(data, dict):
raise Failure(f'{path.relative_to(ROOT)} is not a JSON object')
for family, rows in data.items():
if not isinstance(rows, dict):
kind = 'null' if rows is None else f'a {type(rows).__name__}'
raise Failure(f'{family} is {kind}, not an object of board rows; fix it by hand')
return data
def tested_identifiers(text):
"""The macro names src/common/tusb_mcu.h branches on: identifiers in its #if, #elif,
#ifdef and #ifndef lines, continuation lines joined. CFG_TUSB_MCU is the row's own
`mcu`, the OPT_MCU_* and TUP_* names are its outputs, `defined` is syntax."""
names = set()
joined = re.sub(r'\\\n', ' ', text)
for line in joined.splitlines():
m = DIRECTIVE.match(line)
if not m:
continue
for n in IDENT.findall(m.group(2)):
if n != 'defined' and n != 'CFG_TUSB_MCU' and not n.startswith(('OPT_MCU_', 'TUP_')) \
and not n[0].isdigit():
names.add(n)
return names
def compile_tokens(entry):
"""One compile command's tokens, response files expanded."""
tokens = list(entry['arguments']) if 'arguments' in entry else shlex.split(entry['command'])
out = []
for t in tokens:
if t.startswith('@') and len(t) > 1:
try:
out.extend(shlex.split(Path(entry['directory'], t[1:]).read_text(encoding='utf-8')))
except OSError:
out.append(t)
else:
out.append(t)
return out
def preprocess_argv(entry):
"""The entry's command with everything that names its source, object or dependency
output removed, so the same compiler and context can preprocess another file."""
tokens = compile_tokens(entry)
src = str(Path(entry['directory'], entry['file']).resolve())
out, skip = [], False
for t in tokens:
if skip:
skip = False
continue
if t in DEP_FLAGS_ARG:
skip = True
continue
if t in DEP_FLAGS or any(t.startswith(f) and len(t) > len(f) for f in ('-MF', '-MT', '-MQ', '-o')):
continue
if t == entry['file'] or str(Path(entry['directory'], t).resolve()) == src:
continue
out.append(t)
return out
def defines_of(tokens):
"""{name: value} for every -D on a command, '1' for a bare -DNAME."""
out = {}
for i, t in enumerate(tokens):
if t == '-D' and i + 1 < len(tokens):
d = tokens[i + 1]
elif t.startswith('-D') and len(t) > 2:
d = t[2:]
else:
continue
name, eq, value = d.partition('=')
out[name] = value if eq else '1'
return out
def example_groups(root=ROOT):
"""The groups examples/CMakeLists.txt adds as subdirectories, read from the tree rather
than from its EXAMPLES_LIST: a directory under examples/ with a CMakeLists.txt of its
own. One vocabulary for `roles`, shared by the observer and the validator."""
ex = Path(root) / 'examples'
return {d.name for d in ex.iterdir() if d.is_dir() and (d / 'CMakeLists.txt').is_file()} if ex.is_dir() else set()
def observe(build_dir, root=None):
"""(family, board, row, entries) from one configured build dir, or a Failure. Entries
are the compile commands of TinyUSB translation units under src/; the row's `mcu`
must agree across them and `defines` is their union, one value per key. `roles` is read
the same way, from the example sources the configure compiled: an example compiles its
own sources, so a translation unit under examples/<group>/<example>/ is that target,
configured. It says a target of that group exists, not which examples they were, and an
example whose CMakeLists.txt returns before adding a target contributes nothing, which
is the point: passing the family filter is not proof of a target."""
build_dir, root = Path(build_dir), Path(root or ROOT)
try:
part = json.loads((build_dir / PART).read_text(encoding='utf-8'))
except OSError:
raise Failure(f'no {PART} in {build_dir}: the configure did not reach the end of family_support.cmake')
except ValueError as e:
raise Failure(f'{PART} in {build_dir} is not valid JSON ({e})')
try:
db = json.loads((build_dir / 'compile_commands.json').read_text(encoding='utf-8'))
except OSError:
raise Failure(f'no compile_commands.json in {build_dir}')
except ValueError as e:
raise Failure(f'compile_commands.json in {build_dir} is not valid JSON ({e})')
src = (root / 'src').resolve()
examples = (root / 'examples').resolve()
groups = example_groups(root)
entries, roles = [], set()
for e in db:
f = Path(e['directory'], e['file']).resolve()
if src in f.parents:
entries.append((f.relative_to(src).as_posix(), e))
elif examples in f.parents:
parts = f.relative_to(examples).parts
if len(parts) > 2 and parts[0] in groups and (examples / parts[0] / parts[1]).is_dir():
roles.add(parts[0])
if not entries:
raise Failure(f'no TinyUSB translation unit in {build_dir}/compile_commands.json')
tested = tested_identifiers((root / 'src' / 'common' / 'tusb_mcu.h').read_text(encoding='utf-8'))
mcu, defines, portable = None, {}, set()
for rel, e in entries:
d = defines_of(compile_tokens(e))
m = d.get('CFG_TUSB_MCU')
if m is None:
raise Failure(f'{rel} compiles without CFG_TUSB_MCU')
if mcu is None:
mcu = m
elif m != mcu:
raise Failure(f'CFG_TUSB_MCU differs between translation units: {mcu} and {m} ({rel})')
# the union: rp2040 adds CFG_TUH_MAX3421 to its host targets only, where
# family_support.cmake puts it on every target; one key with two values is
# a variant, not a configuration
for k, v in d.items():
if k in tested:
if defines.get(k, v) != v:
raise Failure(f'{k} differs between translation units: {defines[k]} and {v} ({rel})')
defines[k] = v
if rel.startswith('portable/'):
portable.add(rel[len('portable/'):])
row = {
'defines': defines, 'family_mcus': sorted(set(part['family_mcus'])), 'mcu': mcu,
'options': dict(part.get('options', {})), 'portable': sorted(portable),
'roles': sorted(roles),
}
return part['family'], part['board'], row, [e for _, e in entries]
def usbips(argv, run=subprocess.run):
"""The TUP_USBIP_* a preprocessor run over src/tusb_option.h defines, None when the
compiler is absent or the run fails, with the reason."""
try:
r = run(argv, capture_output=True, text=True, cwd=ROOT)
except OSError as e:
return None, str(e)
if r.returncode != 0:
first = next((l for l in r.stderr.splitlines() if l.strip()), f'exit {r.returncode}')
return None, first
return frozenset(USBIP.findall(r.stdout)), ''
SYNTHETIC_CONFIG = ['-UCFG_TUSB_CONFIG_FILE', '-DCFG_TUSB_CONFIG_FILE=<stdint.h>']
OPTION_H = str(ROOT / 'src' / 'tusb_option.h')
def real_probe_argv(entry):
"""The board's own compiler, with its context, over tusb_option.h and a synthetic
config, so both probes read the same configuration."""
return preprocess_argv(entry) + ['-E', '-dM', *SYNTHETIC_CONFIG, '-x', 'c', OPTION_H]
def host_probe_argv(row, cc='cc'):
"""What a consumer without the toolchain can reproduce from the row alone."""
return [cc, '-E', '-dM', f'-DCFG_TUSB_MCU={row["mcu"]}',
*[f'-D{k}={v}' for k, v in sorted(row['defines'].items())],
*SYNTHETIC_CONFIG, '-I', str(ROOT / 'src'), '-x', 'c', OPTION_H]
def validate(row, entries, run=subprocess.run):
"""The row is valid when the board's real compiler probe succeeds; when the host probe
succeeds too, both must select the same USB IPs, or the row's defines are not what
tusb_mcu.h keys on (an SDK header decides, as for LPC54). A note when the host probe
is unavailable: consumers then know the IPs only through the real compiler."""
real, why = usbips(real_probe_argv(entries[0]), run)
if real is None:
raise Failure(f'real compiler probe failed: {why}')
host, why = usbips(host_probe_argv(row), run)
if host is None:
return f'host probe unavailable ({why})'
if host != real:
raise Failure(f'host probe selects {sorted(host ^ real)} differently from the real compiler: '
f'the defines do not carry what tusb_mcu.h keys on')
return ''
def merge(family, board, row, path=CATALOG):
"""Write the row into the catalog if it differs. Returns 'updated' or 'unchanged'."""
def put(data):
data.setdefault(family, {})[board] = {'cmake': row}
return rewrite(put, path)
def rewrite(edit, path=CATALOG):
"""Read, apply `edit` to the data in place, compare, replace atomically, under a lock
so two writers (boards configuring at once, a repair beside a build) cannot lose
each other's change. Returns 'updated' or 'unchanged'."""
lock = ROOT / 'cmake-build' / '.family.json.lock'
lock.parent.mkdir(exist_ok=True)
with open(lock, 'w') as lf:
_lock(lf)
data = load(path)
edit(data)
text = canonical_text(data)
try:
before = path.read_text(encoding='utf-8')
except OSError:
before = None
if text == before:
return 'unchanged'
tmp = path.with_suffix('.json.tmp')
tmp.write_text(text, encoding='utf-8')
os.replace(tmp, path)
return 'updated'
def _lock(f):
try:
import fcntl
fcntl.flock(f, fcntl.LOCK_EX)
except ImportError:
import msvcrt
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
def join(build_dirs):
"""(family, board, row, entries) over the dirs of one configure (Espressif has one
per example): the same board everywhere, the row fields agreeing, `portable` and
`roles` the union — one Espressif dir holds one example, so its roles are one
group of the board's."""
family = board = row = None
portable, roles, entries = set(), set(), []
for d in build_dirs:
f, b, r, es = observe(d)
if row is None:
family, board, row = f, b, r
elif (f, b) != (family, board):
raise Failure(f'{d} configured {b}, not {board}')
else:
for k in ('mcu', 'defines', 'family_mcus', 'options'):
if r[k] != row[k]:
raise Failure(f'{k} differs between example trees: {row[k]} and {r[k]} ({d})')
portable.update(r['portable'])
roles.update(r['roles'])
entries.extend(es)
if row is None:
raise Failure('no configured build dir')
row['portable'] = sorted(portable)
row['roles'] = sorted(roles)
return family, board, row, entries
def update(build_dirs, run=subprocess.run, path=CATALOG):
"""The on-the-fly write after a canonical configure: join, validate, merge. Returns
the stderr line."""
board = None
try:
family, board, row, entries = join(build_dirs)
note = validate(row, entries, run)
what = merge(family, board, row, path)
except Failure as e:
return f'family.json: {board or "?"} not updated: {e}'
return f'family.json: {what} {board}' + (f' ({note})' if note else '')
FIX = 'python3 tools/family_json.py fix'
def inventory(root=ROOT):
"""family -> its board dirs, the boards the catalog must have rows for."""
bsp = root / 'hw' / 'bsp'
return {f.name: sorted(b.name for b in (f / 'boards').iterdir() if b.is_dir())
for f in sorted(bsp.iterdir()) if (f / 'boards').is_dir()}
def check(path=CATALOG, root=ROOT):
"""Every violation, each one line; a family with no row at all is one line."""
out = []
try:
data = load(path)
text = path.read_text(encoding='utf-8')
except (Failure, OSError) as e:
return [f'{path.relative_to(root)}: {e}']
if text != canonical_text(data):
out.append(f'{path.relative_to(root)} is not in canonical form')
tree = inventory(root)
option_h = (root / 'src' / 'tusb_option.h').read_text(encoding='utf-8')
mcus = set(re.findall(r'^#define (OPT_MCU_\w+)', option_h, re.M))
for family, boards in tree.items():
rows = data.get(family, {})
missing = [b for b in boards if b not in rows]
if missing == boards:
out.append(f'{family}: no row for any of its {len(boards)} boards')
else:
out.extend(f'{family}/{b}: no row' for b in missing)
for board, entry in rows.items():
if board not in boards:
out.append(f'{family}/{board}: row for a board dir that does not exist; remove the row')
continue
null = board in NULL_BOARDS.get(family, ())
row = entry.get('cmake') if isinstance(entry, dict) else None
if set(entry) != {'cmake'} if isinstance(entry, dict) else True:
out.append(f'{family}/{board}: an entry is {{"cmake": ...}} and nothing else')
continue
if row is None:
if not null:
out.append(f'{family}/{board}: cmake is null but the board is not Make-only')
continue
if null:
out.append(f'{family}/{board}: is Make-only (NULL_BOARDS) but carries a cmake row')
continue
out.extend(f'{family}/{board}: {v}' for v in row_violations(row, mcus, root))
for family in data:
if family not in tree:
out.append(f'{family}: rows for a family dir that does not exist; remove them')
return out
def changed_cmake(root=ROOT, run=subprocess.run):
"""The hw/bsp cmake files the tree changes against HEAD, which is what a commit is about
to carry; nothing when git cannot say (no git, no repository, no HEAD). --no-renames as
ci_select does: rename detection reports only a rename's destination, so the board or
family a file moved out of would keep its row from before the move."""
try:
r = run(['git', '-C', str(root), 'diff', '--no-renames', '--name-only', '-z', 'HEAD', '--', 'hw/bsp'],
capture_output=True, text=True)
except OSError:
return []
return [p for p in r.stdout.split('\0') if p] if r.returncode == 0 else []
def stale_rows(changed, tree):
"""(family, board) whose row was observed before one of the `changed` paths edited the
cmake it was configured from: a file under the board's own dir is that board, one in
the family dir is every board of the family. A family's cmake is its .cmake files and
its CMakeLists.txt: hw/bsp/espressif/components/tinyusb_src/CMakeLists.txt is where
every Espressif row's portable sources and defines come from. hw/bsp/family_support.cmake,
src/common/tusb_mcu.h and the example tree (an example's CMakeLists.txt, skip.txt or
only.txt decides `roles` for any board) are left out on purpose: each feeds every row, and
re-observing all of them is a sweep, not a hook; that sweep is `refresh`, run when a
release is cut."""
out = set()
for p in changed:
parts = p.split('/')
if not (p.endswith('.cmake') or p.endswith('/CMakeLists.txt')) or len(parts) < 4 \
or parts[:2] != ['hw', 'bsp'] or parts[2] not in tree:
continue
family = parts[2]
if parts[3] == 'boards':
if len(parts) > 4 and parts[4] in tree[family]:
out.add((family, parts[4]))
else:
out.update((family, b) for b in tree[family])
return out
OBSERVED = re.compile(r'^family\.json: (updated|unchanged) (\S+)')
def reobserve(boards, root=ROOT, run=subprocess.run):
"""Configure each (family, board) once with tools/build.py into a private dir of this
process, removed after, so the row is observed fresh. Prints build.py's line per board;
returns the boards it did not observe, with why: a nonzero exit, a "not updated"
reason, or no line at all (an Espressif example that failed to configure)."""
failed = []
for family, board in boards:
name = f'fj-{board}-{os.getpid()}'
build_dir = root / 'cmake-build' / f'cmake-build-{name}'
r = run([sys.executable, 'tools/build.py', '-b', board, '--configure-only', '--build-name', name],
cwd=root, capture_output=True, text=True)
shutil.rmtree(build_dir, ignore_errors=True)
line = next((l for l in r.stderr.splitlines() if l.startswith('family.json: ')), None)
print(line or f'family.json: {board} not updated: tools/build.py exit {r.returncode}')
m = OBSERVED.match(line or '')
if r.returncode != 0 or not m or m.group(2) != board:
failed.append((family, board, line or f'tools/build.py exit {r.returncode}'))
return failed
def prune(path, root):
"""Drop rows of boards or families that are gone, set NULL_BOARDS' rows to null,
rewrite the file canonical (one locked edit); the inventory and the data after."""
tree = inventory(root)
def edit(data):
for family in list(data):
if family not in tree:
del data[family]
continue
for board in list(data[family]):
if board not in tree[family]:
del data[family][board]
for family, boards in NULL_BOARDS.items():
for board in boards:
if board in tree.get(family, ()):
data.setdefault(family, {})[board] = {'cmake': None}
rewrite(edit, path)
return tree, load(path)
def fix(path=CATALOG, root=ROOT, run=subprocess.run, changed_paths=None):
"""Repair what observation can: prune, then re-observe each board without a row and
each board whose cmake `changed_paths` names - the row it carries was observed from
the configuration before that edit, defaulting to what git reports uncommitted.
Returns (whether the file changed, what check() still reports). A board this machine
cannot configure keeps the row it has, with tools/build.py's reason printed, and a
board that has none stays reported."""
before = path.read_text(encoding='utf-8') if path.is_file() else None
try:
tree, data = prune(path, root)
except Failure as e:
return False, [str(e)]
stale = stale_rows(changed_cmake(root) if changed_paths is None else changed_paths, tree)
todo = [(family, board) for family, boards in tree.items() for board in boards
if board not in NULL_BOARDS.get(family, ())
and (not isinstance(data.get(family, {}).get(board), dict)
or data[family][board].get('cmake') is None or (family, board) in stale)]
failed = reobserve(todo, root, run)
changed = (path.read_text(encoding='utf-8') if path.is_file() else None) != before
problems = check(path, root)
try:
after = load(path)
except Failure:
after = {}
# A row this run decided was stale, whose board would not be observed, is not what the
# tree configures any more: keeping it quietly would make the catalog the last
# successful observation rather than the truth. A board merely un-observable here,
# whose row nothing invalidated, keeps its row without a word, as before.
for family, board, why in failed:
entry = after.get(family, {}).get(board)
if (family, board) in stale and isinstance(entry, dict) and entry.get('cmake') is not None:
problems.append(f'{family}/{board}: row kept from before the change that made it stale: {why}')
return changed, problems
def refresh(path=CATALOG, root=ROOT, run=subprocess.run):
"""The release sweep: prune, then re-observe every board that is not Make-only, rows
or no rows. Returns (boards asked, boards not observed with why, what check() still
reports); a board not observed keeps the row it had."""
try:
tree, _ = prune(path, root)
except Failure as e:
return [], [], [str(e)]
todo = [(family, board) for family, boards in tree.items() for board in boards
if board not in NULL_BOARDS.get(family, ())]
return todo, reobserve(todo, root, run), check(path, root)
def row_violations(row, mcus, root):
out = []
if not isinstance(row, dict) or tuple(sorted(row)) != FIELDS:
return [f'fields must be exactly {", ".join(FIELDS)}']
if not isinstance(row['mcu'], str) or row['mcu'] not in mcus:
out.append(f'mcu {row["mcu"]!r} is not an OPT_MCU_* defined in src/tusb_option.h')
for name in ('family_mcus', 'portable', 'roles'):
v = row[name]
if not isinstance(v, list) or not all(isinstance(x, str) for x in v) or v != sorted(set(v)):
out.append(f'{name} must be a sorted list of unique strings')
for name in ('defines', 'options'):
v = row[name]
if not isinstance(v, dict) or not all(isinstance(k, str) and isinstance(x, str) for k, x in v.items()):
out.append(f'{name} must map names to string values')
if isinstance(row['portable'], list):
for p in row['portable']:
# a plain relative path: joining an absolute one would discard the base
if not isinstance(p, str) or p.startswith('/') or '..' in p.split('/') \
or not (root / 'src' / 'portable' / p).is_file():
out.append(f'portable entry {p!r} is not a relative path to a file under src/portable/')
if isinstance(row['roles'], list) and all(isinstance(x, str) for x in row['roles']):
unknown = sorted(set(row['roles']) - example_groups(root))
if unknown:
out.append(f'roles: {", ".join(unknown)} is not a group under examples/')
if isinstance(row['family_mcus'], list) and isinstance(row['options'], dict):
if ('MAX3421' in row['family_mcus']) != (row['options'].get('MAX3421_HOST') == '1'):
out.append('MAX3421 belongs in family_mcus exactly when options.MAX3421_HOST is "1"')
return out
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = p.add_subparsers(dest='cmd', required=True)
sub.add_parser('check', help='every violation, exit 1 if any; no toolchain needed')
sub.add_parser('fix', help='repair the catalog in place (pre-commit); exit 1 when it changed or cannot')
sub.add_parser('refresh', help='re-observe every cmake board (release); exit 1 when one could not be')
sub.add_parser('format', help='rewrite the catalog in canonical form')
o = sub.add_parser('observe', help="print a configured build dir's row without writing it")
o.add_argument('build_dir', nargs='+')
a = p.parse_args(argv)
if a.cmd == 'check':
problems = check()
for line in problems:
print(line)
if problems:
print(f'repair: {FIX}')
return 1 if problems else 0
if a.cmd == 'fix':
changed, problems = fix(CATALOG, ROOT, subprocess.run)
for line in problems:
print(line)
if changed:
print(f'{CATALOG.relative_to(ROOT)} updated: stage it and commit again')
return 1 if changed or problems else 0
if a.cmd == 'refresh':
asked, failed, problems = refresh(CATALOG, ROOT, subprocess.run)
for line in problems:
print(line)
for family, board, why in failed:
print(f'{family}/{board}: not observed: {why}')
print(f'{len(asked) - len(failed)} of {len(asked)} boards re-observed')
return 1 if failed or problems else 0
if a.cmd == 'format':
try:
CATALOG.write_text(canonical_text(load()), encoding='utf-8')
except Failure as e:
print(f'family.json: not formatted: {e}', file=sys.stderr)
return 1
return 0
try:
family, board, row, entries = join(a.build_dir)
note = validate(row, entries)
print(json.dumps({family: {board: {'cmake': row}}}, sort_keys=True, indent=2))
if note:
print(note, file=sys.stderr)
except Failure as e:
print(f'family.json: not observed: {e}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())