tests / fedora:latest (push) Successful in 1m15s
tests / ubuntu-latest · py3.11 (push) Successful in 1m32s
tests / ubuntu-latest · py3.13 (push) Successful in 1m1s
tests / archlinux:latest (push) Successful in 53s
tests / debian:12 (push) Successful in 51s
tests / ubuntu:24.04 (push) Successful in 1m8s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 10s
With neither mode derivable (nothing selected, no marker rectangles, no config) the dialog now opens anyway instead of failing with an error figure: both radios disabled with their reasons, OK/Save disabled, Load config… live - a saved config can bootstrap the run. README: note that macOS/NixOS field tests last ran with plugin v1.3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HjSRLWQ8ywBBYyakvr3YTx
535 lines
26 KiB
Python
535 lines
26 KiB
Python
"""Top-level orchestration for the KiCad-launched action.
|
||
|
||
Flow: connect -> load the config named "default" (or the board-specific
|
||
one) -> derive BOTH modes' terminals (classic: selection / marker
|
||
rectangles / config refs; PDN: per-rectangle marker scan, or the
|
||
config's terminal set) -> gather fills -> dialog with a Classic/PDN
|
||
mode selector (classic: the two-contact form; PDN: editable per-role
|
||
terminal tables) -> extract vias -> solve -> figures + report. The
|
||
dialog's "Load config…" button loops back to the derivation with the
|
||
picked file, so a run can be set up from any saved config - and the
|
||
dialog opens even when NEITHER mode is derivable (nothing selected, no
|
||
marker rectangles, no config): load-only, both radios disabled with
|
||
their reasons, so a saved config can bootstrap the run.
|
||
|
||
Every failure is reported twice: on stdout (lands in the KiCad status-bar
|
||
warning list) and as a matplotlib error figure, so it cannot be missed.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
import traceback
|
||
from pathlib import Path
|
||
|
||
from . import config, pipeline, progress, report
|
||
from .errors import ConfigError, SelectionError, UserFacingError
|
||
from .geometry import Terminal
|
||
|
||
|
||
def _fail(message: str, outdir) -> None:
|
||
print(f"ERROR: {message}")
|
||
try:
|
||
if outdir is None:
|
||
# A failure before the run has an output directory (a broken
|
||
# plugin environment throws on import) would otherwise save
|
||
# no PNG - and with no GUI toolkit, plots falls back to
|
||
# opening the saved PNGs, so the figure would never be shown
|
||
# either. Exactly the case the docstring promises to cover.
|
||
import tempfile
|
||
from pathlib import Path
|
||
outdir = Path(tempfile.gettempdir()) / "fill-resistance-error"
|
||
from . import plots
|
||
fig = plots.fig_error(message)
|
||
plots.save_and_show([(fig, "error")], outdir)
|
||
except Exception: # reporting must not mask the fault
|
||
traceback.print_exc()
|
||
sys.exit(1)
|
||
|
||
|
||
# config globals a config file may override; "Load config…" re-derives
|
||
# from a fresh baseline so one file's physics/marker layers never leak
|
||
# into the next
|
||
_CFG_GLOBALS = ("RHO_CU_OHM_M", "COPPER_THICKNESS_UM", "VIA_PLATING_UM",
|
||
"ELECTRODE_POS_LAYER", "ELECTRODE_NEG_LAYER",
|
||
"ELECTRODE_PDN_LAYER")
|
||
|
||
|
||
def main() -> None:
|
||
outdir = None
|
||
try:
|
||
try:
|
||
from kipy.errors import ApiError
|
||
|
||
from . import board_io, configfile, dialog
|
||
except ImportError as e:
|
||
if "cannot open shared object file" not in str(e):
|
||
raise
|
||
# pip's Linux wheels link against FHS system libraries;
|
||
# on NixOS those paths don't exist and PySide6/pynng die
|
||
# exactly like this. Nothing inside the venv can fix it.
|
||
raise UserFacingError(
|
||
f"A compiled dependency cannot load its system "
|
||
f"libraries: {e}\nThe plugin venv is built from pip "
|
||
f"wheels, which expect standard (FHS) library paths. "
|
||
f"On NixOS, run KiCad inside an FHS environment "
|
||
f"(buildFHSEnv wrapper, or steam-run for a quick "
|
||
f"test) - see docs/NIXOS.md in the plugin repo."
|
||
)
|
||
try:
|
||
kicad, board = board_io.connect()
|
||
stackup = board_io.get_stackup_info(board)
|
||
except ApiError as e:
|
||
raise UserFacingError(
|
||
f"KiCad API error: {e}\nIf KiCad is showing a dialog, close "
|
||
f"it and run again."
|
||
)
|
||
cfg_path = configfile.find_config(
|
||
board_io.board_dir(board),
|
||
getattr(board, "name", "") or "")
|
||
base_globals = {name: getattr(config, name)
|
||
for name in _CFG_GLOBALS}
|
||
while True:
|
||
for name, value in base_globals.items():
|
||
setattr(config, name, value)
|
||
try:
|
||
cfg = configfile.load_config(cfg_path) if cfg_path else None
|
||
# a config with a terminals section is the PDN source;
|
||
# cfg.mode is only the STARTING mode - nothing is
|
||
# pinned, the dialog switches modes and nets freely
|
||
pdn_cfg = cfg is not None and bool(cfg.terminals)
|
||
if cfg is not None:
|
||
print(f"using config {cfg_path.name} ({cfg.mode} mode)")
|
||
# before any geometry: the marker layers steer
|
||
# get_electrodes, the physics steers build_problem
|
||
configfile.apply_physics(cfg)
|
||
|
||
# BOTH terminal derivations always run; a failure only
|
||
# disables that mode's radio (with the reason shown).
|
||
# Even with neither mode possible the dialog still
|
||
# opens - load-only - so "Load config…" works without
|
||
# first selecting pads or drawing marker rectangles
|
||
classic_reason = pdn_reason = None
|
||
es1: list = []
|
||
es2: list = []
|
||
net_hint = None
|
||
terminals: list = [] # resolved config terminals
|
||
marker_terms = None # live-scan MarkerTerminal list
|
||
new_terms: list = [] # rects not in the config yet
|
||
merge_note = ""
|
||
pdn_groups: list = [] # electrode groups, either way
|
||
pdn_hints: list = [] # per-terminal Component text
|
||
term_nets: list = [] # per-terminal overlapped nets
|
||
has_selection = bool(list(board.get_selection()))
|
||
try:
|
||
if cfg is not None and cfg.pos_parts is not None:
|
||
es1, es2 = board_io.resolve_classic_parts(
|
||
board, stackup, cfg.pos_parts,
|
||
cfg.neg_parts, cfg.net)
|
||
net_hint = cfg.net
|
||
else:
|
||
es1, es2, net_hint = board_io.get_electrodes(
|
||
board, stackup)
|
||
except SelectionError as e:
|
||
classic_reason = str(e)
|
||
try:
|
||
if pdn_cfg:
|
||
# config refs resolve against run.net; a broken
|
||
# ref disables PDN mode instead of killing the
|
||
# launch (classic may still work)
|
||
if not cfg.net:
|
||
raise ConfigError(
|
||
f"{cfg_path.name}: run.net is required "
|
||
f"to resolve the config terminals")
|
||
terminals = board_io.resolve_terminal_specs(
|
||
board, stackup, cfg.terminals, cfg.net)
|
||
# rectangles drawn since the save become NEW
|
||
# terminals - the file freezes nothing. A scan
|
||
# problem only forfeits the new ones, never
|
||
# the config set
|
||
try:
|
||
new_terms = board_io.new_marker_terminals(
|
||
cfg.terminals,
|
||
board_io.scan_marker_terminals(
|
||
board, require_both=False))
|
||
except (SelectionError, ConfigError) as e:
|
||
merge_note = (f"rectangle scan failed ({e})"
|
||
f" - new rectangles not "
|
||
f"offered")
|
||
print(f"note: {merge_note}")
|
||
pdn_groups = (
|
||
[t.electrodes for t in terminals]
|
||
+ [mt.electrodes for mt in new_terms])
|
||
else:
|
||
marker_terms = board_io.scan_marker_terminals(
|
||
board)
|
||
pdn_groups = [mt.electrodes
|
||
for mt in marker_terms]
|
||
pdn_hints = board_io.component_hints(board,
|
||
pdn_groups)
|
||
except (SelectionError, ConfigError) as e:
|
||
pdn_reason = str(e)
|
||
|
||
if board_io.any_zone_unfilled(board) or config.ALWAYS_REFILL:
|
||
board_io.refill(board)
|
||
fills = board_io.gather_net_fills(board)
|
||
tracks = board_io.gather_net_tracks(board)
|
||
copper = board_io.merge_copper(
|
||
fills, board_io.tracks_as_polygons(tracks))
|
||
|
||
classic_nets: list = []
|
||
pdn_nets: list = []
|
||
if classic_reason is None:
|
||
classic_nets = board_io.nets_overlapping(
|
||
copper, es1, es2)
|
||
if not classic_nets:
|
||
classic_reason = (
|
||
"No copper (zone fill or trace) overlaps "
|
||
"both contacts. Check that both sit over "
|
||
"copper of the same net and that the fills "
|
||
"are up to date (press B in the board "
|
||
"editor).")
|
||
if pdn_reason is None:
|
||
# per-terminal net sets drive BOTH the candidate
|
||
# list (a net qualifies with >= 1 supply and >= 1
|
||
# load terminal on it) and the dialog's row filter
|
||
# (only terminals on the selected net are shown
|
||
# and solved) - config and live sources alike
|
||
term_nets = board_io.group_nets(copper, pdn_groups)
|
||
roles = (([t.role for t in terminals]
|
||
+ [mt.role for mt in new_terms]) if pdn_cfg
|
||
else [mt.role for mt in marker_terms])
|
||
sup_nets: set = set()
|
||
load_nets: set = set()
|
||
for role, tn in zip(roles, term_nets):
|
||
(sup_nets if role == "supply"
|
||
else load_nets).update(tn)
|
||
pdn_nets = sorted(sup_nets & load_nets)
|
||
if not pdn_nets:
|
||
pdn_reason = (
|
||
"no net's copper overlaps at least one "
|
||
"supply and one load "
|
||
+ (f"terminal of {cfg_path.name}"
|
||
if pdn_cfg else "rectangle"))
|
||
elif pdn_cfg and cfg.net not in pdn_nets:
|
||
print(f"note: run.net '{cfg.net}' has no "
|
||
f"workable supply+load copper; PDN "
|
||
f"candidates: {', '.join(pdn_nets)}")
|
||
if classic_reason is not None and pdn_reason is not None:
|
||
# nothing runnable right now: the dialog opens with
|
||
# both radios and OK/Save disabled, offering only
|
||
# Load config… (and Cancel) - a loaded file then
|
||
# re-derives everything through the loop below
|
||
print(f"note: classic mode unavailable: "
|
||
f"{classic_reason}")
|
||
print(f"note: PDN mode unavailable: {pdn_reason}")
|
||
buildups = board_io.gather_mask_buildups(board)
|
||
except ApiError as e:
|
||
raise UserFacingError(
|
||
f"KiCad API error: {e}\nIf KiCad is showing a dialog, "
|
||
f"close it and run again."
|
||
)
|
||
|
||
def group_label(parts):
|
||
names = [p.label for p in parts[:3]]
|
||
more = f" +{len(parts) - 3}" if len(parts) > 3 else ""
|
||
return f"{len(parts)}× " + ", ".join(names) + more
|
||
|
||
def group_contact(parts):
|
||
contacts = {p.contact for p in parts}
|
||
return contacts.pop() if len(contacts) == 1 else "auto"
|
||
|
||
def rect_desc(e):
|
||
r = e.rect
|
||
return (f"rect ({r.x0 / 1e6:.1f}, {r.y0 / 1e6:.1f}).."
|
||
f"({r.x1 / 1e6:.1f}, {r.y1 / 1e6:.1f}) mm")
|
||
|
||
def marker_desc(mt):
|
||
if len(mt.electrodes) == 1:
|
||
return rect_desc(mt.electrodes[0])
|
||
# same-named rectangles grouped into one terminal (the
|
||
# Bonded checkbox shows/controls the lug behavior)
|
||
return (f"{len(mt.electrodes)}× "
|
||
f"{rect_desc(mt.electrodes[0])} …")
|
||
|
||
def live_row(mt, hint, tn):
|
||
return dialog.PdnTerminalRow(
|
||
name=mt.name, role=mt.role,
|
||
resolved=marker_desc(mt), component=hint,
|
||
bonded=mt.bonded, nets=tn)
|
||
|
||
defaults = configfile.dialog_defaults(cfg)
|
||
pdn_setup = None
|
||
if pdn_cfg and pdn_reason is None:
|
||
n_cfg = len(terminals)
|
||
rows = [dialog.PdnTerminalRow(
|
||
name=t.label, role=t.role,
|
||
resolved=group_label(t.electrodes),
|
||
component=hint,
|
||
i_draw_a=(t.i_draw_a if t.role == "load"
|
||
else None),
|
||
r_out_ohm=(t.r_out_ohm if t.role == "supply"
|
||
else None),
|
||
v_oc=t.v_oc, bonded=t.bonded,
|
||
contact=spec.contact or "auto",
|
||
active=spec.active, comment=spec.comment,
|
||
nets=tn, from_config=True)
|
||
for t, spec, hint, tn in zip(
|
||
terminals, cfg.terminals, pdn_hints[:n_cfg],
|
||
term_nets[:n_cfg])]
|
||
# newly drawn rectangles append as live rows: a save
|
||
# writes them into the config alongside the file's set
|
||
rows += [live_row(mt, hint, tn)
|
||
for mt, hint, tn in zip(new_terms,
|
||
pdn_hints[n_cfg:],
|
||
term_nets[n_cfg:])]
|
||
notes = []
|
||
if new_terms:
|
||
notes.append(f"{len(new_terms)} new rectangle(s) "
|
||
f"not in {cfg_path.name} yet - "
|
||
f"Save config… adds them")
|
||
if merge_note:
|
||
notes.append(merge_note)
|
||
pdn_setup = dialog.PdnSetup(
|
||
rows=rows, source=cfg_path.name, from_config=True,
|
||
note="; ".join(notes))
|
||
elif pdn_reason is None:
|
||
note = ""
|
||
if has_selection:
|
||
note = ("board selection ignored in PDN mode - "
|
||
"terminals are the marker rectangles")
|
||
print(f"note: {note}")
|
||
pdn_setup = dialog.PdnSetup(
|
||
rows=[live_row(mt, hint, tn)
|
||
for mt, hint, tn in zip(marker_terms,
|
||
pdn_hints,
|
||
term_nets)],
|
||
source=(f"marker rectangles on "
|
||
f"{config.ELECTRODE_POS_LAYER}/"
|
||
f"{config.ELECTRODE_NEG_LAYER}"),
|
||
note=note)
|
||
|
||
# cfg.mode is only the starting radio - never a pin
|
||
start_pdn = ((cfg is not None and cfg.mode == "pdn"
|
||
and pdn_reason is None)
|
||
or classic_reason is not None)
|
||
if start_pdn:
|
||
default_net = (defaults.net if defaults.net in pdn_nets
|
||
else (pdn_nets[0] if pdn_nets else ""))
|
||
else:
|
||
default_net = (defaults.net if defaults.net in classic_nets
|
||
else net_hint if net_hint in classic_nets
|
||
else classic_nets[0])
|
||
|
||
def rect_infos(terms):
|
||
# unlabeled terminals are always single rectangles, so
|
||
# freezing the first rect's coordinates is exact
|
||
return [(mt.labeled,
|
||
(mt.electrodes[0].rect.x0 / 1e6,
|
||
mt.electrodes[0].rect.y0 / 1e6,
|
||
mt.electrodes[0].rect.x1 / 1e6,
|
||
mt.electrodes[0].rect.y1 / 1e6))
|
||
for mt in terms]
|
||
|
||
def save_cb(sel, target, cfg=cfg, cfg_path=cfg_path,
|
||
pdn_cfg=pdn_cfg, marker_terms=marker_terms,
|
||
new_terms=new_terms):
|
||
if sel.mode == "classic":
|
||
configfile.save_classic_config(target, sel)
|
||
elif pdn_cfg:
|
||
# the config rows update positionally; newly drawn
|
||
# rectangles append as fresh terminal entries
|
||
n = len(cfg.raw["terminals"])
|
||
tj = configfile.updated_terminals_json(
|
||
cfg.raw["terminals"], sel.pdn_rows[:n])
|
||
if sel.pdn_rows[n:]:
|
||
tj += configfile.rect_terminals_json(
|
||
sel.pdn_rows[n:], rect_infos(new_terms))
|
||
print(f"note: {len(sel.pdn_rows[n:])} new "
|
||
f"terminal(s) added to the config")
|
||
configfile.save_pdn_config(target, sel, tj)
|
||
else:
|
||
# EVERY row is saved - off-net ones arrive from the
|
||
# dialog as active: false (nothing drawn on the
|
||
# board is lost by a save); labeled (possibly
|
||
# grouped) rectangles save as rect:NAME
|
||
configfile.save_pdn_config(
|
||
target, sel,
|
||
configfile.rect_terminals_json(
|
||
sel.pdn_rows, rect_infos(marker_terms)))
|
||
print("note: the saved config now provides the "
|
||
"terminal set on later launches - labeled "
|
||
"rectangles stay live (rect:NAME), unlabeled "
|
||
"ones were frozen as coordinates; remove the "
|
||
"terminals section (or the file) to return "
|
||
"to the live rectangle scan")
|
||
print(f"config saved to {target}")
|
||
# only "default" (or its legacy plain spelling) and the
|
||
# board-stem name load on launch; other names need the
|
||
# Load config… button - say so before it surprises
|
||
auto = {config.CONFIG_FILENAME,
|
||
configfile.named_config_filename("default")}
|
||
stem = Path(getattr(board, "name", "") or "").stem
|
||
if stem:
|
||
auto.add(f"{stem}.{config.CONFIG_FILENAME}")
|
||
if target.name not in auto:
|
||
print("note: this name does not load automatically "
|
||
"- pull it in with Load config…")
|
||
return target.name
|
||
|
||
selection = dialog.ask(
|
||
candidates={n: list(copper[n].keys())
|
||
for n in classic_nets},
|
||
layer_order=stackup.names,
|
||
default_net=default_net,
|
||
e1_label=(group_label(es1) if es1 else ""),
|
||
e2_label=(group_label(es2) if es2 else ""),
|
||
contact1=((defaults.contact1 or group_contact(es1))
|
||
if es1 else "auto"),
|
||
contact2=((defaults.contact2 or group_contact(es2))
|
||
if es2 else "auto"),
|
||
buildup_layers=sorted(buildups.keys()),
|
||
defaults=defaults, pdn=pdn_setup,
|
||
pdn_candidates={n: list(copper[n].keys())
|
||
for n in pdn_nets},
|
||
classic_reason=classic_reason, pdn_reason=pdn_reason,
|
||
save_callback=save_cb,
|
||
save_target=(cfg_path if cfg_path is not None else
|
||
board_io.board_dir(board)
|
||
/ config.CONFIG_FILENAME),
|
||
load_dir=board_io.board_dir(board),
|
||
start_mode=("pdn" if start_pdn else "classic"),
|
||
)
|
||
if isinstance(selection, dialog.LoadRequest):
|
||
# re-derive everything from the picked file; its validity
|
||
# was already checked by the dialog before it closed
|
||
cfg_path = selection.path
|
||
continue
|
||
break
|
||
if selection is None:
|
||
print("cancelled")
|
||
return
|
||
# the solve owns the thread from here; without this the plugin
|
||
# looks like it did nothing until the figures appear
|
||
progress.start()
|
||
|
||
run_pdn = selection.mode == "pdn"
|
||
if run_pdn:
|
||
def live_terminal(mt, row):
|
||
if row.contact not in ("", "all", "auto"):
|
||
# dialog Layer pick: this terminal's rectangles
|
||
# contact only that copper layer
|
||
for e in mt.electrodes:
|
||
e.contact = row.contact
|
||
return Terminal(
|
||
role=row.role, electrodes=mt.electrodes,
|
||
label=row.name,
|
||
i_draw_a=(row.i_draw_a
|
||
if row.i_draw_a is not None else 0.0),
|
||
r_out_ohm=(row.r_out_ohm
|
||
if row.r_out_ohm is not None else 0.0),
|
||
v_oc=row.v_oc, bonded=row.bonded,
|
||
component=row.component, comment=row.comment)
|
||
if pdn_cfg:
|
||
cfg_rows = selection.pdn_rows[:len(terminals)]
|
||
new_rows = selection.pdn_rows[len(terminals):]
|
||
# a changed Layer scope is geometry: push it onto the
|
||
# specs and re-resolve (part-level contacts inside the
|
||
# file still win, exactly as the schema promises)
|
||
changed = False
|
||
for spec, row in zip(cfg.terminals, cfg_rows):
|
||
if (row.contact or "auto") != (spec.contact or "auto"):
|
||
spec.contact = row.contact
|
||
changed = True
|
||
if changed:
|
||
try:
|
||
terminals = board_io.resolve_terminal_specs(
|
||
board, stackup, cfg.terminals, cfg.net)
|
||
except ApiError as e:
|
||
raise UserFacingError(f"KiCad API error: {e}")
|
||
# dialog value edits win for the run: write them back
|
||
# onto the resolved terminals (positional, same order),
|
||
# then drop the unchecked ones - they stay in the file
|
||
# but take no part in the solve; newly drawn
|
||
# rectangles run as live terminals
|
||
for t, row in zip(terminals, cfg_rows):
|
||
if t.role == "load":
|
||
t.i_draw_a = row.i_draw_a
|
||
else:
|
||
t.r_out_ohm = row.r_out_ohm
|
||
t.v_oc = row.v_oc
|
||
t.bonded = row.bonded
|
||
t.component = row.component
|
||
t.comment = row.comment
|
||
terminals = (
|
||
[t for t, row in zip(terminals, cfg_rows)
|
||
if row.active]
|
||
+ [live_terminal(mt, row)
|
||
for mt, row in zip(new_terms, new_rows)
|
||
if row.active])
|
||
else:
|
||
terminals = [live_terminal(mt, row)
|
||
for mt, row in zip(marker_terms,
|
||
selection.pdn_rows)
|
||
if row.active]
|
||
else:
|
||
if selection.contact1 != "auto":
|
||
for e in es1:
|
||
e.contact = selection.contact1
|
||
if selection.contact2 != "auto":
|
||
for e in es2:
|
||
e.contact = selection.contact2
|
||
if selection.cell_um is not None:
|
||
config.CELL_UM_OVERRIDE = selection.cell_um
|
||
config.ADAPTIVE_CELLS = selection.adaptive
|
||
|
||
try:
|
||
problem = board_io.build_problem(
|
||
board, selection.net, selection.layers,
|
||
([] if run_pdn else es1), ([] if run_pdn else es2),
|
||
stackup, fills,
|
||
buildups=(buildups if selection.include_buildup else None),
|
||
extra_cu_um=selection.extra_cu_um,
|
||
tracks=(tracks if selection.include_tracks else None),
|
||
vias_capped=selection.vias_capped,
|
||
cap_max_drill_mm=selection.cap_max_drill_mm,
|
||
terminals=(terminals if run_pdn else None))
|
||
outdir = report.make_output_dir(board_io.board_dir(board))
|
||
except ApiError as e:
|
||
raise UserFacingError(f"KiCad API error: {e}")
|
||
|
||
report.write_geometry_dump(outdir, problem)
|
||
overlay_cb = None
|
||
if selection.push_overlays:
|
||
def overlay_cb(stack, result):
|
||
board_io.push_result_overlays(board, stack, result)
|
||
trim_cb = None
|
||
trim_pct = trim_abs = None
|
||
if selection.trim_enabled:
|
||
def trim_cb(tr):
|
||
board_io.push_trim_polygons(board, tr)
|
||
if selection.trim_mode == "abs":
|
||
trim_abs = selection.trim_value
|
||
else:
|
||
trim_pct = selection.trim_value
|
||
pipeline.run(problem, outdir, show=True,
|
||
i_test=(None if run_pdn else selection.current_a),
|
||
freq_hz=selection.freq_hz,
|
||
contact_model=(None if run_pdn
|
||
else selection.contact_model),
|
||
overlay=overlay_cb,
|
||
trim_pct=trim_pct, trim_abs=trim_abs,
|
||
trim_push=trim_cb,
|
||
v_nominal=(selection.v_nominal if run_pdn else None))
|
||
except progress.Cancelled:
|
||
print("cancelled") # user's own doing: no error figure
|
||
except UserFacingError as e:
|
||
_fail(str(e), outdir)
|
||
except Exception:
|
||
_fail(traceback.format_exc(), outdir)
|
||
finally:
|
||
progress.done() # also on the error paths
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|