Zuken E3.series · Free Python scripts

Free E3.series scripts, and how to run them safely

Four small Python scripts for Zuken E3.series that you can copy and use today, and a step-by-step guide to putting them on your E3 Add-ons menu. Each one does a single everyday job, writes what it found to the Messages window, and asks before it changes or prints anything.

The scripts

They need nothing except Python and Zuken's own e3series package. Every line in the Messages window that mentions a sheet, wire or device is linked to it: double-click the line to jump straight there.

Before you run any script

A script run from E3 can do anything you can do: edit the project, delete files, or send data somewhere. That is true of these scripts and of any script you find online. Five habits keep you safe.

  1. Copy and paste the code, don't download a file. Pasting means the only thing on your PC is the text you can see on this page. A downloaded file can differ from what a page shows, can arrive with something else attached, and is easy to run without ever opening it.
  2. Read it before you run it. You don't need to follow every line. Check what it imports at the top and skim for the warning signs below. Every script here starts with a plain-English description of what it does and what it changes.
  3. Try it on a copy of a project first, and save before running anything that changes the drawing.
  4. Install packages by their exact name. The package is e3series, published on PyPI by Zuken E3 GmbH. Look-alike names are a known trick for slipping in malicious code.
  5. Ask if you're unsure. A colleague who knows Python, your IT team, or an AI assistant can tell you what a script does in a few seconds. Your company may also have rules about running scripts, so check them first.
Warning signs when reading a script None of these is proof of bad intent, but each deserves a reason in the comments before you run it.
  • subprocess or os.system: runs other programs
  • requests, urllib, socket: talks to the internet
  • os.remove, shutil.rmtree: deletes files
  • exec, eval, base64: hides or builds code at run time
  • Long strings of random-looking characters
  • .Delete() or .Save() on the project with no question asked first

Apart from e3series, the scripts on this page import only modules that come with Python. None of them uses the network or runs other programs. The one os.remove is in Wire List To Sheet, and it deletes the temporary picture of the blank sheet it made a moment earlier, which is exactly the kind of reason the comments should give you.

Step 1: install Python and e3series

  1. Download Python from python.org (version 3.9 or newer). Avoid the Microsoft Store version: Zuken's own notes say it causes problems with E3.
  2. Run the installer and tick Add python.exe to PATH. Leave the optional features at their defaults, which include tcl/tk: the Print Project and Replace Component scripts use it for their menus.
  3. Open a Command Prompt (not as administrator) and install Zuken's package. It brings pywin32 and psutil with it, which is how Python talks to E3.
    py -m pip install e3series
  4. Find the exact path of the Python you just installed. You will need it for the menu in step 3.
    py -c "import sys; print(sys.executable)"
    It prints something like C:\Users\you\AppData\Local\Programs\Python\Python313\python.exe. In the same folder is pythonw.exe, which runs a script without opening a black console window. That is the one to use.

Step 2: save a script

  1. Make a folder for your scripts with a short path, such as C:\E3Scripts.
  2. Click Copy code on Check Setup below, open Notepad and paste.
  3. Choose File > Save as, set Save as type to All files and save it as CheckSetup.py in your folder. Without the change of type, Notepad saves it as CheckSetup.py.txt and it won't run.

These are the steps Zuken gives for recent versions of E3.series. If a name differs in yours, look for the Add-ons tab in the Customize dialog.

  1. In E3.series, open Tools > Customize and go to the Add-ons tab.
  2. Add a new add-on with the small button at the top right, and give it a name, such as Check Setup.
  3. Set Command to the full path of pythonw.exe from step 1.
  4. Set Arguments to the full path of the script, inside quotes so a space in the path doesn't break it:
    "C:\E3Scripts\CheckSetup.py"
  5. Go to the Commands tab, pick the Add-ons category and drag your new command onto a toolbar or menu. Close the dialog.

Repeat steps 2 and 3 for each script you want. Each script is one self-contained file, so you can add only the ones you need.

Step 4: run it and read the Messages window

Open a project and click your new Check Setup button. The Messages window should show your Python version, the e3series version, the E3 version and the open project, ending with a green Setup OK. Double-click the project line and E3 jumps to its first sheet.

Every script writes to the Messages window the same way: blue lines are headings, green means the job is done, and problems show as E3 warnings and errors. Anything that refers to an item in the project can be double-clicked to go to it, which is the quickest way to check what a script found.

If nothing happens

pythonw.exe has no window, so a script that fails before it reaches E3 fails silently. To see the error, run the same script from a Command Prompt while E3 is open:

py "C:\E3Scripts\CheckSetup.py"

The code

Each script follows the same layout: a description and its limits at the top, main() telling the story in order, then small functions grouped into reading E3, plain logic, writing to E3 and messages. Comments explain why something is done, not just what.

Check Setup CheckSetup.py

Reads only. Confirms everything is installed and connected.

CheckSetup.py · 92 lines
"""Check Setup - free demo script for Zuken E3.series.

Run this one first. If it works, Python, the e3series package and your Add-ons menu entry are all
set up correctly, and the other scripts will run the same way.

It writes to the E3 Messages window:
  - which Python ran the script, and its version
  - the e3series package version and the E3.series version
  - the open project and how many sheets it has - double-click that line to jump to the first sheet

It only reads. Nothing in the project is changed.

Needs:   Python 3.9 or newer, and the e3series package  (py -m pip install e3series)
Run:     from the E3 Add-ons menu - see https://james-bowen.co.uk/e3-scripting/free-scripts/

Author:  James Bowen - https://james-bowen.co.uk
Licence: MIT - free to use, copy and change. Provided as is, with no warranty.

Version history
    1.0   13/10/26   J Bowen   Initial release
    1.1   13/10/26   J Bowen   Process ends straight away so E3 is never left waiting
    1.2   13/10/26   J Bowen   Ends with TerminateProcess - os._exit could still leave E3 waiting
"""

import ctypes
import sys
import traceback
from importlib.metadata import version

import e3series
import e3series.tools

SCRIPT_NAME = "CHECK SETUP"
SCRIPT_VERSION = "1.2"

# Messages window colours (red, green, blue)
HEADING_COLOUR = (0, 84, 166)
GOOD_COLOUR = (0, 128, 0)


def main():
    # With no process id, e3series connects to the E3 that launched this script from its menu
    e3 = e3series.Application()

    # pythonw.exe has no console window, so an unexpected error would vanish without a trace.
    # Sending stderr to E3 means any crash report appears in the Messages window in red.
    sys.stderr = e3series.tools.E3seriesOutput(e3, sys.stderr, (200, 0, 0))

    e3.ClearOutputWindow()
    e3.PutInfoEx(0, f"==== {SCRIPT_NAME}  v{SCRIPT_VERSION} ====", 0, *HEADING_COLOUR)

    # The Python install that ran this script - the one pip must install packages into
    e3.PutInfo(0, f"Python      {sys.version.split()[0]}   ({sys.executable})")
    e3.PutInfo(0, f"e3series    {version('e3series')}")
    e3.PutInfo(0, f"E3.series   {e3.GetVersion()}")

    prj = e3.CreateJobObject()
    if prj.GetId() == 0:
        # Not an error - the setup is fine, there is just nothing open to look at
        e3.PutWarning(0, "No project is open - open one to see its details here.")
    else:
        sheet_count, sheet_ids = prj.GetSheetIds()
        # Passing an id as the last argument makes the line clickable: double-click jumps to it
        first_sheet = sheet_ids[0] if sheet_ids else 0
        e3.PutInfo(0, f"Project     {prj.GetName()}   ({sheet_count} sheets)", first_sheet)

    e3.PutInfoEx(0, "Setup OK - you are ready to run the other scripts.", 0, *GOOD_COLOUR)


def end_process():
    """Ends this Python process immediately.

    E3 waits for an add-on to finish before it gives back right-click and drag and drop. A normal
    Python exit unloads every library first, and some (tkinter's Tcl among them) can hang while
    unloading - leaving E3 waiting on a script that looks finished. TerminateProcess skips all of
    that. ctypes ships with Python, so this needs nothing extra.
    """
    if sys.stderr:
        sys.stderr.flush()
    kernel32 = ctypes.windll.kernel32
    kernel32.GetCurrentProcess.restype = ctypes.c_void_p
    kernel32.TerminateProcess.argtypes = [ctypes.c_void_p, ctypes.c_uint]
    kernel32.TerminateProcess(kernel32.GetCurrentProcess(), 0)


if __name__ == "__main__":
    try:
        main()
    except Exception:
        traceback.print_exc()           # reaches the Messages window once main() has redirected stderr
    finally:
        end_process()

Print Project PrintProject.py

Reads only. Select sheets in the project tree first, or nothing for the whole project, then set a quantity for each sheet in the print menu, grouped by structure. Every sheet starts at 1, and 0 skips a sheet.

PrintProject.py · 351 lines
"""Print Project - free demo script for Zuken E3.series.

Prints sheets from the open project on E3's current printer, with a quantity for each sheet.
  - Select sheets in the project tree first to print just those.
  - Select nothing to offer every sheet in the project.

The sheets are listed in the Messages window under their structures - double-click a line to jump
to that sheet - and a print menu opens, grouped the same way, with a quantity box for each sheet.
Every sheet starts at 1. Set a quantity to 0 to leave a sheet out. Nothing is sent to the
printer until you press Print.

Free version limits (the full version does these):
  - Quantities start at 1 every run - nothing is remembered or worked out from sheet types.
  - Prints on whichever printer E3 is currently set to.
  - No choosing sheets by revision, or by what has changed since the last release.

Needs:   Python 3.9 or newer, and the e3series package  (py -m pip install e3series)
         The print menu uses tkinter, which comes with the standard Python installer.
Run:     from the E3 Add-ons menu - see https://james-bowen.co.uk/e3-scripting/free-scripts/

Author:  James Bowen - https://james-bowen.co.uk
Licence: MIT - free to use, copy and change. Provided as is, with no warranty.

Version history
    1.0   13/10/26   J Bowen   Initial release
    1.1   13/10/26   J Bowen   Print menu with a quantity for each sheet
    1.2   13/10/26   J Bowen   Process ends straight away so E3 is never left waiting
    1.3   13/10/26   J Bowen   Sheets grouped under their structure, in the menu and the Messages window
"""

import ctypes
import sys
import traceback
from itertools import groupby

import e3series
import e3series.tools

SCRIPT_NAME = "PRINT PROJECT"
SCRIPT_VERSION = "1.3"

# Messages window colours (red, green, blue)
HEADING_COLOUR = (0, 84, 166)
GOOD_COLOUR = (0, 128, 0)

# Scale passed to PrintOut - 0 fits each sheet to the paper
FIT_TO_PAGE = 0
DEFAULT_QTY = 1
MAX_QTY = 99

# Set in main(). Module level so every function can write to the Messages window.
e3 = None
prj = None


def main():
    global e3, prj
    # With no process id, e3series connects to the E3 that launched this script from its menu
    e3 = e3series.Application()
    # pythonw.exe has no console, so send any crash report to the Messages window in red instead
    sys.stderr = e3series.tools.E3seriesOutput(e3, sys.stderr, (200, 0, 0))
    prj = e3.CreateJobObject()

    e3.ClearOutputWindow()
    heading(f"==== {SCRIPT_NAME}  v{SCRIPT_VERSION} ====")

    if prj.GetId() == 0:
        e3.PutError(0, "No project is open - open a project and run the script again.")
        return

    sheet_ids, source = get_sheets_to_print()
    if not sheet_ids:
        e3.PutWarning(0, "The project has no sheets - nothing to print.")
        return

    sheets = read_sheets(sheet_ids)
    list_sheets(sheets, source)

    printer = e3.GetPrinterName()
    quantities = ask_quantities(sheets, printer, source)
    if quantities is None:
        e3.PutWarning(0, "Cancelled - nothing was printed.")
        return

    jobs = build_jobs(sheets, quantities)
    if not jobs:
        e3.PutWarning(0, "Every quantity was 0 - nothing was printed.")
        return

    print_jobs(jobs, printer)


# ###############################################################################
# ############################ READING E3 #######################################
# ###############################################################################

def get_sheets_to_print():
    """The sheets picked in the project tree, or every sheet if none are picked.

    Returns (sheet ids, a short description of where they came from) so the user is always told
    which of the two happened - a silent fallback to 'everything' would be a nasty surprise.
    """
    _, selected_ids = prj.GetTreeSelectedSheetIds()
    if selected_ids:
        return selected_ids, "selected in the tree"

    _, all_ids = prj.GetSheetIds()
    return all_ids, "whole project"


def read_sheets(sheet_ids):
    """Each sheet as a dict - id, name, and the structure it sits under - in tree order.
    One Sheet object is pointed at each id in turn: creating a new object per sheet works, but is
    much slower in Python."""
    owners = read_sheet_owners()
    sheet = prj.CreateSheetObject()
    sheets = []
    for sheet_id in sheet_ids:
        sheet.SetId(sheet_id)
        path, node_id = owners.get(sheet_id, ("(not in a structure)", 0))
        sheets.append({"id": sheet_id, "name": sheet.GetName(), "structure": path, "node_id": node_id})
    return sheets


def read_sheet_owners():
    """{sheet id: (structure path, structure id)} for every sheet in the tree, where the path reads
    like the tree itself - "+Chassis parts / +Floor"."""
    node = prj.CreateStructureNodeObject()
    names, parents = {}, {}

    # Walk down from the top so every structure is found, however deep
    _, to_visit = prj.GetStructureNodeIds()
    to_visit = list(to_visit)
    while to_visit:
        node_id = to_visit.pop()
        if node_id in names:
            continue
        node.SetId(node_id)
        names[node_id] = node.GetName()
        parents[node_id] = node.GetParentId()
        to_visit.extend(node.GetStructureNodeIds()[1])

    def path(node_id):
        parts = []
        # Stop at the root - it has no parent, and its name is the project, not a structure
        while node_id in names and parents.get(node_id):
            parts.append(names[node_id])
            node_id = parents[node_id]
        return " / ".join(reversed(parts))

    owners = {}
    for node_id in names:
        node.SetId(node_id)
        for sheet_id in node.GetSheetIds()[1]:
            owners[sheet_id] = (path(node_id), node_id)
    return owners


# ###############################################################################
# ############################ PURE LOGIC #######################################
# ###############################################################################

def build_jobs(sheets, quantities):
    """Turns {sheet id: qty} into print jobs of [qty, [sheet ids]].

    E3 sets the number of copies for a whole job, so sheets that need the same quantity share a
    job. Only NEIGHBOURING sheets are joined, so the pages still come out in tree order.
    Sheets with a quantity of 0 are left out.
    """
    wanted = [(sheet["id"], quantities.get(sheet["id"], 0)) for sheet in sheets]
    wanted = [(sheet_id, qty) for sheet_id, qty in wanted if qty > 0]
    return [[qty, [sheet_id for sheet_id, _ in group]]
            for qty, group in groupby(wanted, key=lambda item: item[1])]


def group_by_structure(sheets):
    """[(structure path, structure id, [sheets]), ...] - neighbouring sheets under the same
    structure, so the order still follows the tree."""
    return [(path, node_id, list(group))
            for (path, node_id), group in groupby(sheets, key=lambda s: (s["structure"], s["node_id"]))]


def plural(count, word):
    return word if count == 1 else word + "s"


# ###############################################################################
# ############################ PRINTING #########################################
# ###############################################################################

def print_jobs(jobs, printer):
    heading("---- Printing ----")
    total_pages = 0
    try:
        for number, (qty, sheet_ids) in enumerate(jobs, start=1):
            # Copies is an E3 setting, not a project one, so it is set for every job
            e3.SetPrinterCopies(qty)
            if prj.PrintOut(FIT_TO_PAGE, sheet_ids) != 1:
                e3.PutError(0, f"Job {number} was not accepted - check the printer in File > Print setup.",
                            sheet_ids[0])
                continue
            pages = qty * len(sheet_ids)
            total_pages += pages
            # The line jumps to the first sheet of the job
            e3.PutInfo(0, f"Job {number} of {len(jobs)}:  {len(sheet_ids)} {plural(len(sheet_ids), 'sheet')}"
                          f" x {qty}  =  {pages} {plural(pages, 'page')}", sheet_ids[0])
    finally:
        # Put copies back to 1 so the next print from E3 itself is not a surprise
        e3.SetPrinterCopies(1)

    e3.PutInfoEx(0, f"Sent {total_pages} {plural(total_pages, 'page')} to {printer}.", 0, *GOOD_COLOUR)


# ###############################################################################
# ############################ MESSAGES AND PRINT MENU ##########################
# ###############################################################################

def list_sheets(sheets, source):
    """The sheets under their structures, one clickable line each - double-click a line in the
    Messages window to open that sheet."""
    heading(f"---- {len(sheets)} {plural(len(sheets), 'sheet')} ({source}) ----")
    for path, node_id, group in group_by_structure(sheets):
        e3.PutInfo(0, path, node_id)
        for sheet in group:
            e3.PutInfo(0, f"        {sheet['name']}", sheet["id"])


def ask_quantities(sheets, printer, source):
    """The print menu - the sheets under their structure headings, each with a quantity box.
    Returns {sheet id: qty}, or None if the window is cancelled or closed.

    Built with tkinter, which ships with Python, so there is nothing extra to install. It is
    imported here rather than at the top, so it is only loaded once there is a menu to show.
    """
    import tkinter
    from tkinter import ttk

    root = tkinter.Tk()
    root.title(SCRIPT_NAME)
    root.attributes("-topmost", True)      # otherwise it can open hidden behind E3
    root.resizable(False, True)
    result = {}

    # Only whole numbers 0-99 can be typed, so reading a box never fails. Empty counts as 0.
    def is_valid(text):
        return text == "" or (text.isdigit() and int(text) <= MAX_QTY)
    check = (root.register(is_valid), "%P")

    outer = ttk.Frame(root, padding=12)
    outer.pack(fill="both", expand=True)
    ttk.Label(outer, text=f"{len(sheets)} {plural(len(sheets), 'sheet')} ({source})  -  set 0 to skip a sheet",
              font=("Segoe UI", 10, "bold")).pack(anchor="w")
    ttk.Label(outer, text=f"Printer:  {printer}").pack(anchor="w", pady=(2, 8))

    # A scrolling area, so a project with hundreds of sheets still fits on screen
    area = ttk.Frame(outer)
    area.pack(fill="both", expand=True)
    canvas = tkinter.Canvas(area, highlightthickness=0)
    scrollbar = ttk.Scrollbar(area, orient="vertical", command=canvas.yview)
    rows = ttk.Frame(canvas)
    rows.bind("<Configure>", lambda _: canvas.configure(scrollregion=canvas.bbox("all")))
    canvas.create_window((0, 0), window=rows, anchor="nw")
    canvas.configure(yscrollcommand=scrollbar.set)
    canvas.pack(side="left", fill="both", expand=True)
    scrollbar.pack(side="right", fill="y")
    root.bind("<MouseWheel>", lambda e: canvas.yview_scroll(-1 * (e.delta // 120), "units"))

    qty_vars = {}
    row = 0
    for path, _, group in group_by_structure(sheets):
        # Structure heading, as it reads in the project tree
        ttk.Label(rows, text=path, font=("Segoe UI", 9, "bold"), wraplength=380).grid(
            row=row, column=0, columnspan=2, sticky="w", pady=(8 if row else 0, 2))
        row += 1
        for sheet in group:
            ttk.Label(rows, text=sheet["name"], width=36).grid(row=row, column=0, sticky="w",
                                                                padx=(16, 0), pady=1)
            # The box's value lives in a StringVar, so the total updates however it changes -
            # typing, the arrows, or the All buttons
            qty = tkinter.StringVar(value=str(DEFAULT_QTY))
            qty.trace_add("write", lambda *_: update_total())
            ttk.Spinbox(rows, from_=0, to=MAX_QTY, width=5, textvariable=qty, validate="key",
                        validatecommand=check).grid(row=row, column=1, padx=(8, 0), pady=1)
            qty_vars[sheet["id"]] = qty
            row += 1

    # Size the scrolling area to fit the rows, so the boxes are never hidden behind the scrollbar
    rows.update_idletasks()
    canvas.configure(width=rows.winfo_reqwidth(), height=min(rows.winfo_reqheight(), 460))

    def read_quantities():
        return {sheet_id: int(qty.get() or 0) for sheet_id, qty in qty_vars.items()}

    # A running total, so a stray 50 is obvious before anything is printed
    total = ttk.Label(outer)
    total.pack(anchor="w", pady=(10, 0))

    def update_total():
        pages = sum(read_quantities().values())
        total.configure(text=f"Total:  {pages} {plural(pages, 'page')}")

    def set_all(qty):
        for qty_var in qty_vars.values():
            qty_var.set(str(qty))
        update_total()

    def on_print():
        result.update(read_quantities())
        root.destroy()

    buttons = ttk.Frame(outer)
    buttons.pack(fill="x", pady=(10, 0))
    ttk.Button(buttons, text="All 1", command=lambda: set_all(1)).pack(side="left")
    ttk.Button(buttons, text="All 0", command=lambda: set_all(0)).pack(side="left", padx=(6, 0))
    ttk.Button(buttons, text="Cancel", command=root.destroy).pack(side="right")
    ttk.Button(buttons, text="Print", command=on_print).pack(side="right", padx=(0, 6))
    root.bind("<Escape>", lambda _: root.destroy())

    update_total()
    root.focus_force()
    root.mainloop()
    return result or None


def heading(text):
    e3.PutInfoEx(0, text, 0, *HEADING_COLOUR)


def end_process():
    """Ends this Python process immediately.

    E3 waits for an add-on to finish before it gives back right-click and drag and drop. A normal
    Python exit unloads every library first, and some (tkinter's Tcl among them) can hang while
    unloading - leaving E3 waiting on a script that looks finished. TerminateProcess skips all of
    that. ctypes ships with Python, so this needs nothing extra.
    """
    if sys.stderr:
        sys.stderr.flush()
    kernel32 = ctypes.windll.kernel32
    kernel32.GetCurrentProcess.restype = ctypes.c_void_p
    kernel32.TerminateProcess.argtypes = [ctypes.c_void_p, ctypes.c_uint]
    kernel32.TerminateProcess(kernel32.GetCurrentProcess(), 0)


if __name__ == "__main__":
    try:
        main()
    except Exception:
        traceback.print_exc()           # reaches the Messages window once main() has redirected stderr
    finally:
        end_process()

Wire List To Sheet WireListToSheet.py

Adds one sheet under the selected structure. Select a structure in the project tree first. It renders the blank sheet to a picture to find the empty space, so the table works with any border.

WireListToSheet.py · 514 lines
"""Wire List To Sheet - free demo script for Zuken E3.series.

Lists every wire under one structure and draws the list as a table on a new sheet.

  1. Select a structure (for example +Main Harness) in the project tree.
  2. Run the script. Each wire is listed in the Messages window - double-click a line to jump to it.
  3. Say yes and a new "WIRE_LIST" sheet is added under that structure with the table on it.

A wire counts as "under" the structure if it belongs to a cable or wire group assigned to the
structure (or any structure below it), or is drawn on one of its sheets.

The new sheet uses the same border as the structure's other sheets. Before drawing, the script
renders the blank sheet to a picture and finds the biggest empty space on it, so the table sits
clear of the title block, logo and revision boxes whatever border your company uses.

Free version limits (the full version does these):
  - One structure per run, and the table fills one sheet - wires that do not fit are listed in the
    Messages window but not drawn.
  - Fixed columns, no export to Excel or CSV, and the sheet is not updated - re-running adds another.

Needs:   Python 3.9 or newer, and the e3series package  (py -m pip install e3series)
Run:     from the E3 Add-ons menu - see https://james-bowen.co.uk/e3-scripting/free-scripts/

Author:  James Bowen - https://james-bowen.co.uk
Licence: MIT - free to use, copy and change. Provided as is, with no warranty.

Version history
    1.0   13/10/26   J Bowen   Initial release
    1.1   13/10/26   J Bowen   Sheet named WIRE_LIST (E3 rejects spaces), tries _2.._9 if taken.
                               Process ends straight away so E3 is never left waiting
    1.2   13/10/26   J Bowen   Sheet goes under the selected structure, not the last one below it.
                               Table placed in the biggest empty space on the border.
                               Ends with TerminateProcess - os._exit could still leave E3 waiting
"""

import ctypes
import os
import re
import struct
import sys
import tempfile
import traceback
from collections import Counter

import e3series
import e3series.tools

SCRIPT_NAME = "WIRE LIST TO SHEET"
SCRIPT_VERSION = "1.2"
# E3 rejects sheet names with spaces ("Invalid sheet name"), so underscores it is
SHEET_NAME = "WIRE_LIST"
# Tried in turn if the name is taken - WIRE_LIST, WIRE_LIST_2 ... WIRE_LIST_9
MAX_NAME_TRIES = 9

# Messages window colours (red, green, blue)
HEADING_COLOUR = (0, 84, 166)
GOOD_COLOUR = (0, 128, 0)

# Table layout, in sheet units (normally mm)
MARGIN = 5.0
TEXT_HEIGHT = 2.5
TITLE_HEIGHT = 5.0
ROW_HEIGHT = 5.0
# Finding empty space: the blank sheet is rendered at this many pixels per sheet unit
PIXELS_PER_UNIT = 2
# A pixel this different from the background colour (0-765, summed RGB) counts as ink
INK_THRESHOLD = 60
# (heading, key in the wire dict, where the column starts as a fraction of the table width)
COLUMNS = [
    ("CABLE / GROUP", "cable", 0.00),
    ("WIRE", "wire", 0.14),
    ("COLOUR", "colour", 0.26),
    ("CSA", "csa", 0.38),
    ("LENGTH", "length", 0.46),
    ("FROM", "from", 0.55),
    ("TO", "to", 0.78),
]

# Set in main(). Module level so every function can use them.
e3 = None
prj = None


def main():
    global e3, prj
    # With no process id, e3series connects to the E3 that launched this script from its menu
    e3 = e3series.Application()
    # pythonw.exe has no console, so send any crash report to the Messages window in red instead
    sys.stderr = e3series.tools.E3seriesOutput(e3, sys.stderr, (200, 0, 0))
    prj = e3.CreateJobObject()

    e3.ClearOutputWindow()
    heading(f"==== {SCRIPT_NAME}  v{SCRIPT_VERSION} ====")

    if prj.GetId() == 0:
        e3.PutError(0, "No project is open - open a project and run the script again.")
        return

    node_id = get_selected_structure()
    if node_id == 0:
        return

    node = prj.CreateStructureNodeObject()
    node.SetId(node_id)
    node_name = node.GetName()

    core_ids, sheets_found = set(), []
    collect_wires_and_sheets(node_id, core_ids, sheets_found)

    wires = sorted(read_wires(core_ids), key=sort_key)
    if not wires:
        e3.PutWarning(0, f"No wires found under {node_name}.", node_id)
        return

    list_wires(wires, node_name)

    if not sheets_found:
        # The new sheet borrows its border and tree position from an existing sheet
        e3.PutError(0, f"{node_name} has no sheets, so there is no border to copy for the wire list.",
                    node_id)
        return

    if not ask_yes_no(f"Add a {SHEET_NAME} sheet under {node_name}\n"
                      f"with {len(wires)} {plural(len(wires), 'wire')}?"):
        e3.PutWarning(0, "Cancelled - the project was not changed.")
        return

    sheet_id, sheet_name = create_sheet_under(node_id, sheets_found)
    if sheet_id == 0:
        e3.PutError(0, f"E3 could not create a {SHEET_NAME} sheet - nothing was added. "
                       "See E3's own message above for the reason.")
        return

    if not is_sheet_under(node_id, sheet_id):
        e3.PutWarning(0, f"{sheet_name} could not be put directly under {node_name} - "
                         "double-click to find it, and drag it in the tree if needed.", sheet_id)

    area, found_space = find_empty_area(sheet_id)
    if not found_space:
        e3.PutWarning(0, "Could not check the border for empty space - the table uses the whole "
                         "working area and may overlap the title block.", sheet_id)
    drawn = draw_wire_table(sheet_id, area, node_name, wires)

    e3.PutInfoEx(0, f"Added {sheet_name} with {drawn} {plural(drawn, 'wire')} - "
                    "double-click this line to open it.", sheet_id, *GOOD_COLOUR)
    if drawn < len(wires):
        e3.PutWarning(0, f"{len(wires) - drawn} more wires did not fit on one sheet. "
                         "The full version carries on over as many sheets as it needs.")
    prj.JumpToID(sheet_id)


# ###############################################################################
# ############################ READING E3 #######################################
# ###############################################################################

def get_selected_structure():
    """The one structure picked in the project tree, or 0 after telling the user what to do."""
    _, node_ids = prj.GetTreeSelectedStructureNodeIds()
    if len(node_ids) == 1:
        return node_ids[0]

    if not node_ids:
        e3.PutError(0, "Select a structure in the project tree first, then run the script again.")
    else:
        e3.PutError(0, f"{len(node_ids)} structures are selected - the free version lists one at a time.")
    return 0


def collect_wires_and_sheets(node_id, core_ids, sheets_found):
    """Walks the structure and every structure below it, adding wire ids to core_ids (a set, so a
    wire found twice is only counted once) and (sheet id, structure id) pairs, in tree order, to
    sheets_found."""
    node = prj.CreateStructureNodeObject()
    device = prj.CreateDeviceObject()
    sheet = prj.CreateSheetObject()
    segment = prj.CreateNetSegmentObject()

    def walk(current_id):
        node.SetId(current_id)
        # Read everything needed from this node BEFORE recursing - the recursion moves `node` on
        _, device_ids = node.GetAllDeviceIds()
        _, own_sheet_ids = node.GetSheetIds()
        _, child_ids = node.GetStructureNodeIds()

        # Wires that belong to a cable or wire group assigned to this structure
        for device_id in device_ids:
            device.SetId(device_id)
            if device.IsCable() or device.IsWireGroup():
                # GetAllCoreIds also finds cores nested inside an overbraid; GetCoreIds would not
                core_ids.update(device.GetAllCoreIds()[1])

        # Wires drawn on this structure's sheets
        for sheet_id in own_sheet_ids:
            sheets_found.append((sheet_id, current_id))
            sheet.SetId(sheet_id)
            for segment_id in sheet.GetNetSegmentIds()[1]:
                segment.SetId(segment_id)
                core_ids.update(segment.GetCoreIds()[1])

        for child_id in child_ids:
            walk(child_id)

    walk(node_id)


def read_wires(core_ids):
    """Every wire as a plain dict, so the sorting and drawing below never touch E3 again.

    Four E3 objects are made once and pointed at each id in turn with SetId. Making new objects
    for every wire works, but in Python it is by far the slowest part on a big harness."""
    wire = prj.CreatePinObject()           # a wire (core) is a Pin object in the E3 API
    parent = prj.CreateDeviceObject()
    end_pin = prj.CreatePinObject()
    end_device = prj.CreateDeviceObject()

    def end_text(end):
        """'X1:3' - the device and pin at one end of the wire, or '' if that end is not connected."""
        pin_id = wire.GetEndPinId(end)
        if pin_id == 0:
            return ""
        end_pin.SetId(pin_id)
        end_device.SetId(pin_id)           # a pin id on a Device object gives the device it is on
        return f"{end_device.GetName()}:{end_pin.GetName()}"

    wires = []
    for core_id in core_ids:
        wire.SetId(core_id)
        parent.SetId(core_id)              # a core id on a Device object gives its cable or wire group
        wires.append({
            "id": core_id,
            "cable": parent.GetName(),
            "wire": wire.GetName(),
            "colour": wire.GetColourDescription(),
            "csa": wire.GetCrossSectionDescription() or format_number(wire.GetCrossSection()),
            "length": format_number(wire.GetLength()),
            "from": end_text(1),
            "to": end_text(2),
        })
    return wires


# ###############################################################################
# ############################ PURE LOGIC #######################################
# ###############################################################################

def sort_key(wire):
    """Cable then wire, in natural order - so W2 comes before W10, not after it."""
    return natural_key(wire["cable"]), natural_key(wire["wire"])


def natural_key(text):
    return [int(part) if part.isdigit() else part.upper() for part in re.split(r"(\d+)", text)]


def format_number(value):
    """2.5 -> '2.5', 1.0 -> '1', 0 or unset -> ''."""
    return f"{value:g}" if value and value > 0 else ""


def trim_levels(value, levels):
    """Drops the last `levels` parts of an E3 location or assignment, which are joined with '.':
    trim_levels('+Chassis parts.230V Chassis parts', 1) -> '+Chassis parts'."""
    for _ in range(levels):
        value = value.rsplit(".", 1)[0] if "." in value else ""
    return value


def read_bmp(path):
    """A Windows bitmap as rows of (r, g, b) tuples, top row first. Handles the 24 and 32-bit
    files E3 writes, using only the standard library."""
    with open(path, "rb") as file:
        data = file.read()
    if data[:2] != b"BM":
        raise ValueError("not a bitmap")
    pixel_offset = struct.unpack_from("<I", data, 10)[0]
    width, height, _, bits = struct.unpack_from("<iiHH", data, 18)
    if bits not in (24, 32):
        raise ValueError(f"{bits}-bit bitmaps are not handled")

    step = bits // 8
    row_size = (width * step + 3) // 4 * 4          # every row is padded to a multiple of 4 bytes
    rows = []
    for row in range(abs(height)):
        start = pixel_offset + row * row_size
        # Bytes are stored blue, green, red
        rows.append([(data[i + 2], data[i + 1], data[i]) for i in range(start, start + width * step, step)])
    # A positive height means the file is stored bottom row first
    return rows[::-1] if height > 0 else rows


def ink_grid(rows):
    """True wherever a pixel differs from the background. The background is whatever colour is most
    common, so a white or a coloured sheet background both work."""
    background = Counter(pixel for row in rows[::4] for pixel in row[::4]).most_common(1)[0][0]
    return [[sum(abs(a - b) for a, b in zip(pixel, background)) > INK_THRESHOLD for pixel in row]
            for row in rows]


def largest_empty_rectangle(ink):
    """(left, top, right, bottom) in pixels of the biggest all-False rectangle in a True/False grid.

    The classic 'largest rectangle in a histogram' method: each row keeps, per column, how many empty
    pixels run straight up from it, and a stack finds the widest box that height allows. One pass
    over the grid, so it stays quick on a full A3 sheet.
    """
    best, best_area = (0, 0, 0, 0), 0
    heights = [0] * (len(ink[0]) if ink else 0)
    for row_number, row in enumerate(ink):
        heights = [0 if is_ink else h + 1 for h, is_ink in zip(heights, row)]
        stack = []                                  # columns with rising heights
        for col, h in enumerate(heights + [0]):     # the extra 0 empties the stack at the end
            start = col
            while stack and stack[-1][1] >= h:
                start, tall = stack.pop()
                area = tall * (col - start)
                if area > best_area:
                    best_area = area
                    best = (start, row_number - tall + 1, col, row_number + 1)
            stack.append((start, h))
    return best


# ###############################################################################
# ############################ WRITING E3 #######################################
# ###############################################################################

def create_sheet_under(node_id, sheets_found):
    """Adds the wire list sheet under the selected structure itself.

    A sheet's place in the tree comes from its assignment (=) and location (+). They are copied from
    a sheet already under the structure - its own last sheet if it has one. Otherwise the first
    sheet further down is used and one level is trimmed off for each structure in between:
    '+Chassis parts.230V Chassis parts' becomes '+Chassis parts'.

    Returns (sheet id, name it was given), or (0, "") if E3 refused every name tried.
    """
    own = [sheet_id for sheet_id, owner_id in sheets_found if owner_id == node_id]
    reference_id, owner_id = (own[-1], node_id) if own else sheets_found[0]

    reference = prj.CreateSheetObject()
    reference.SetId(reference_id)
    assignment, location = reference.GetAssignment(), reference.GetLocation()
    up_assignment, up_location = levels_between(owner_id, node_id)
    assignment = trim_levels(assignment, up_assignment)
    location = trim_levels(location, up_location)

    new_sheet = prj.CreateSheetObject()
    for name in sheet_names_to_try():
        # Arguments: module (0 = none), name, border symbol, insert next to this sheet, 0 = after it
        sheet_id = new_sheet.Create(0, name, reference.GetFormat(), reference_id, 0)
        if sheet_id:
            new_sheet.SetId(sheet_id)
            new_sheet.SetAssignment(assignment)
            new_sheet.SetLocation(location)
            return sheet_id, name
    return 0, ""


def levels_between(lower_id, upper_id):
    """How many assignment (=) and location (+) levels sit between a structure and one above it,
    counting the lower one and not the upper one."""
    node = prj.CreateStructureNodeObject()
    up_assignment = up_location = 0
    current_id = lower_id
    while current_id and current_id != upper_id:
        node.SetId(current_id)
        name = node.GetName()
        if name.startswith("="):
            up_assignment += 1
        elif name.startswith("+"):
            up_location += 1
        current_id = node.GetParentId()
    return up_assignment, up_location


def is_sheet_under(node_id, sheet_id):
    """True if the sheet now shows directly under the structure in the tree."""
    node = prj.CreateStructureNodeObject()
    node.SetId(node_id)
    return sheet_id in node.GetSheetIds()[1]


def find_empty_area(sheet_id):
    """The biggest empty rectangle on the new, blank sheet, as (x_min, y_min, x_max, y_max).

    Every company's border is different, so rather than guess where the title block is, E3 renders
    the blank sheet to a bitmap and the script looks for the largest area with no ink on it.
    Returns (area, True), or (working area, False) if the picture could not be made or read.
    """
    sheet = prj.CreateSheetObject()
    sheet.SetId(sheet_id)
    ok, x_min, y_min, x_max, y_max = sheet.GetWorkingArea()
    if not ok:
        _, x_min, y_min, x_max, y_max = sheet.GetDrawingArea()
    whole = (x_min, y_min, x_max, y_max)

    width = max(1, int((x_max - x_min) * PIXELS_PER_UNIT))
    height = max(1, int((y_max - y_min) * PIXELS_PER_UNIT))
    image_path = os.path.join(tempfile.gettempdir(), "e3_wire_list_blank_sheet.bmp")
    try:
        # Arguments: format, (unused), file, area, size in pixels, 24-bit colour, not grey, dpi, compression
        if not sheet.ExportImageArea("bmp", 0, image_path, x_min, y_min, x_max, y_max,
                                     width, height, 24, 0, 96, 96, 0):
            return whole, False
        ink = ink_grid(read_bmp(image_path))
    except (OSError, ValueError):
        return whole, False
    finally:
        if os.path.exists(image_path):
            os.remove(image_path)           # only ever the temporary picture made just above

    left, top, right, bottom = largest_empty_rectangle(ink)
    if right <= left or bottom <= top:
        return whole, False
    # Pixel rows count down from the top of the area; sheet y counts up from the bottom
    return (x_min + left / PIXELS_PER_UNIT, y_max - bottom / PIXELS_PER_UNIT,
            x_min + right / PIXELS_PER_UNIT, y_max - top / PIXELS_PER_UNIT), True


def sheet_names_to_try():
    """WIRE_LIST first, then WIRE_LIST_2, _3... in case an earlier run's sheet is still there."""
    return [SHEET_NAME] + [f"{SHEET_NAME}_{n}" for n in range(2, MAX_NAME_TRIES + 1)]


def draw_wire_table(sheet_id, area, node_name, wires):
    """Draws a title, a header row and one row per wire inside the area, until it is full.
    Returns how many wires were drawn."""
    x_min, y_min, x_max, y_max = area
    graph = prj.CreateGraphObject()
    left, right = x_min + MARGIN, x_max - MARGIN
    width = right - left
    y = y_max - MARGIN - TITLE_HEIGHT

    add_text(graph, sheet_id, f"WIRE LIST - {node_name}   ({len(wires)} wires)", left, y, TITLE_HEIGHT)
    y -= ROW_HEIGHT * 2

    for title, _, start in COLUMNS:
        add_text(graph, sheet_id, title, left + start * width, y, TEXT_HEIGHT)
    graph.CreateLine(sheet_id, left, y - 1.5, right, y - 1.5)
    y -= ROW_HEIGHT

    drawn = 0
    for wire in wires:
        if y < y_min + MARGIN:
            break                           # area full - the free version stops at one sheet
        for _, key, start in COLUMNS:
            add_text(graph, sheet_id, wire[key], left + start * width, y, TEXT_HEIGHT)
        y -= ROW_HEIGHT
        drawn += 1
    return drawn


def add_text(graph, sheet_id, text, x, y, height):
    if text == "":
        return                              # nothing to draw, and E3 rejects empty text
    graph_id = graph.CreateText(sheet_id, text, x, y)
    if graph_id:
        graph.SetId(graph_id)
        graph.SetTextHeight(height)


# ###############################################################################
# ############################ MESSAGES #########################################
# ###############################################################################

def list_wires(wires, node_name):
    """One clickable line per wire - double-click it in the Messages window to jump to the wire."""
    heading(f"---- {len(wires)} {plural(len(wires), 'wire')} under {node_name} ----")
    for wire in wires:
        route = f"{wire['from'] or '?'} -> {wire['to'] or '?'}"
        parts = [f"{wire['cable']}:{wire['wire']}", wire["colour"], wire["csa"], route]
        e3.PutInfo(0, "   ".join(part for part in parts if part), wire["id"])


def heading(text):
    e3.PutInfoEx(0, text, 0, *HEADING_COLOUR)


def plural(count, word):
    return word if count == 1 else word + "s"


def ask_yes_no(question):
    """A Windows Yes/No box using ctypes, which ships with Python - no extra package needed.
    Kept on top, otherwise it can open hidden behind the E3 window."""
    MB_YESNO, MB_ICONQUESTION, MB_TOPMOST, IDYES = 0x04, 0x20, 0x40000, 6
    answer = ctypes.windll.user32.MessageBoxW(None, question, SCRIPT_NAME,
                                              MB_YESNO | MB_ICONQUESTION | MB_TOPMOST)
    return answer == IDYES


def end_process():
    """Ends this Python process immediately.

    E3 waits for an add-on to finish before it gives back right-click and drag and drop. A normal
    Python exit unloads every library first, and some (tkinter's Tcl among them) can hang while
    unloading - leaving E3 waiting on a script that looks finished. TerminateProcess skips all of
    that. ctypes ships with Python, so this needs nothing extra.
    """
    if sys.stderr:
        sys.stderr.flush()
    kernel32 = ctypes.windll.kernel32
    kernel32.GetCurrentProcess.restype = ctypes.c_void_p
    kernel32.TerminateProcess.argtypes = [ctypes.c_void_p, ctypes.c_uint]
    kernel32.TerminateProcess(kernel32.GetCurrentProcess(), 0)


if __name__ == "__main__":
    try:
        main()
    except Exception:
        traceback.print_exc()           # reaches the Messages window once main() has redirected stderr
    finally:
        end_process()

Replace Component ReplaceComponent.py

Changes the project. Select devices on a sheet or in the tree first, and save before running.

ReplaceComponent.py · 224 lines
"""Replace Component - free demo script for Zuken E3.series.

Swaps the component (part) on the selected devices for another one from your E3 database.

  1. Select one or more devices on a sheet or in the device tree.
  2. Run the script. The devices are listed in the Messages window - double-click to jump to one.
  3. Type the new component name and version, check the summary, and say yes.

Each device is reported as changed or not changed, with a clickable line, so you can go straight
to anything that needs a look. Save the project before running, so you can go back if needed.

Free version limits (the full version does these):
  - Selected devices only - no replacing a part everywhere it is used, or from a list of swaps.
  - No check that the new part's pins match the old one, and no before/after bill of materials.

Needs:   Python 3.9 or newer, and the e3series package  (py -m pip install e3series)
         The input box uses tkinter, which comes with the standard Python installer.
Run:     from the E3 Add-ons menu - see https://james-bowen.co.uk/e3-scripting/free-scripts/

Author:  James Bowen - https://james-bowen.co.uk
Licence: MIT - free to use, copy and change. Provided as is, with no warranty.

Version history
    1.0   13/10/26   J Bowen   Initial release
    1.1   13/10/26   J Bowen   Process ends straight away so E3 is never left waiting
    1.2   13/10/26   J Bowen   Ends with TerminateProcess - os._exit could still leave E3 waiting
"""

import ctypes
import sys
import traceback

import e3series
import e3series.tools

SCRIPT_NAME = "REPLACE COMPONENT"
SCRIPT_VERSION = "1.2"

# Messages window colours (red, green, blue)
HEADING_COLOUR = (0, 84, 166)
GOOD_COLOUR = (0, 128, 0)

# Set in main(). Module level so every function can use them.
e3 = None
prj = None


def main():
    global e3, prj
    # With no process id, e3series connects to the E3 that launched this script from its menu
    e3 = e3series.Application()
    # pythonw.exe has no console, so send any crash report to the Messages window in red instead
    sys.stderr = e3series.tools.E3seriesOutput(e3, sys.stderr, (200, 0, 0))
    prj = e3.CreateJobObject()

    e3.ClearOutputWindow()
    heading(f"==== {SCRIPT_NAME}  v{SCRIPT_VERSION} ====")

    if prj.GetId() == 0:
        e3.PutError(0, "No project is open - open a project and run the script again.")
        return

    device_ids = get_selected_devices()
    if not device_ids:
        e3.PutError(0, "Select one or more devices on a sheet or in the tree, then run the script again.")
        return

    device = prj.CreateDeviceObject()
    list_devices(device, device_ids)

    # Offer the first device's part as the starting text - usually only the end needs changing
    device.SetId(device_ids[0])
    new_name, new_version = ask_for_component(device.GetComponentName(), device.GetComponentVersion())
    if not new_name:
        e3.PutWarning(0, "Cancelled - the project was not changed.")
        return

    # Leave out devices that already have the new part, so the summary only counts real changes
    to_change = [device_id for device_id in device_ids
                 if not has_component(device, device_id, new_name, new_version)]
    if not to_change:
        e3.PutWarning(0, f"Every selected device is already {new_name} version {new_version} - nothing to do.")
        return

    count = f"{len(to_change)} {plural(len(to_change), 'device')}"
    if not ask_yes_no(f"Change the component on {count} to\n\n    {new_name}   (version {new_version})\n\n"
                      "Save the project first if you may want to go back."):
        e3.PutWarning(0, "Cancelled - the project was not changed.")
        return

    replace_components(device, to_change, new_name, new_version)


# ###############################################################################
# ############################ READING E3 #######################################
# ###############################################################################

def get_selected_devices():
    """Devices picked on a sheet and in the tree, without duplicates, in the order they came back.

    E3 keeps two separate selections, so both are asked for. Picking a single wire reports its
    whole cable here - E3 hands back the parent of anything picked inside a device.
    """
    _, on_sheets = prj.GetSelectedAllDeviceIds()
    _, in_tree = prj.GetTreeSelectedAllDeviceIds()
    # dict.fromkeys drops repeats but, unlike a set, keeps the order
    return list(dict.fromkeys(list(on_sheets) + list(in_tree)))


def has_component(device, device_id, name, version):
    device.SetId(device_id)
    return (device.GetComponentName().upper() == name.upper()
            and device.GetComponentVersion() == version)


# ###############################################################################
# ############################ WRITING E3 #######################################
# ###############################################################################

def replace_components(device, device_ids, new_name, new_version):
    """Sets the new part on each device and reports every one, changed or not, with a jump link."""
    heading("---- Results ----")
    changed = 0
    for device_id in device_ids:
        device.SetId(device_id)
        old_part = f"{device.GetComponentName()} v{device.GetComponentVersion()}"

        # Returns 1 on success, 0 if E3 could not apply it - most often the part is not in the database
        if device.SetComponentName(new_name, new_version) == 1:
            changed += 1
            e3.PutInfo(0, f"{device.GetName()}   {old_part}  ->  {new_name} v{new_version}", device_id)
        else:
            e3.PutError(0, f"{device.GetName()}   NOT changed - is {new_name} version {new_version} "
                           "in the database, and the right type for this device?", device_id)

    failed = len(device_ids) - changed
    summary = f"Changed {changed} of {len(device_ids)} {plural(len(device_ids), 'device')}."
    if failed:
        e3.PutWarning(0, f"{summary} {failed} could not be changed - see the red lines above.")
    else:
        e3.PutInfoEx(0, summary + " Check connections on the changed devices before saving.",
                     0, *GOOD_COLOUR)


# ###############################################################################
# ############################ MESSAGES AND DIALOGS #############################
# ###############################################################################

def list_devices(device, device_ids):
    """One clickable line per device - double-click it in the Messages window to jump to it."""
    heading(f"---- {len(device_ids)} selected {plural(len(device_ids), 'device')} ----")
    for device_id in device_ids:
        device.SetId(device_id)
        e3.PutInfo(0, f"{device.GetName()}   {device.GetComponentName()} "
                      f"v{device.GetComponentVersion()}", device_id)


def ask_for_component(current_name, current_version):
    """Asks for the new component name, then its version. Returns ('', '') if either is cancelled.

    tkinter ships with Python, so no extra package is needed. It is imported here rather than at
    the top, so it is only loaded when there is something to ask. The hidden root window is kept
    on top so the boxes cannot open behind E3.
    """
    import tkinter
    from tkinter import simpledialog

    root = tkinter.Tk()
    root.withdraw()
    root.attributes("-topmost", True)
    try:
        name = simpledialog.askstring(SCRIPT_NAME, "New component name:",
                                      initialvalue=current_name, parent=root)
        if not name or not name.strip():
            return "", ""
        version = simpledialog.askstring(SCRIPT_NAME, f"Version of {name.strip()}:",
                                         initialvalue=current_version or "1", parent=root)
        if not version or not version.strip():
            return "", ""
        return name.strip(), version.strip()
    finally:
        root.destroy()


def heading(text):
    e3.PutInfoEx(0, text, 0, *HEADING_COLOUR)


def plural(count, word):
    return word if count == 1 else word + "s"


def ask_yes_no(question):
    """A Windows Yes/No box using ctypes, which ships with Python - no extra package needed.
    Kept on top, otherwise it can open hidden behind the E3 window."""
    MB_YESNO, MB_ICONQUESTION, MB_TOPMOST, IDYES = 0x04, 0x20, 0x40000, 6
    answer = ctypes.windll.user32.MessageBoxW(None, question, SCRIPT_NAME,
                                              MB_YESNO | MB_ICONQUESTION | MB_TOPMOST)
    return answer == IDYES


def end_process():
    """Ends this Python process immediately.

    E3 waits for an add-on to finish before it gives back right-click and drag and drop. A normal
    Python exit unloads every library first, and some (tkinter's Tcl among them) can hang while
    unloading - leaving E3 waiting on a script that looks finished. TerminateProcess skips all of
    that. ctypes ships with Python, so this needs nothing extra.
    """
    if sys.stderr:
        sys.stderr.flush()
    kernel32 = ctypes.windll.kernel32
    kernel32.GetCurrentProcess.restype = ctypes.c_void_p
    kernel32.TerminateProcess.argtypes = [ctypes.c_void_p, ctypes.c_uint]
    kernel32.TerminateProcess(kernel32.GetCurrentProcess(), 0)


if __name__ == "__main__":
    try:
        main()
    except Exception:
        traceback.print_exc()           # reaches the Messages window once main() has redirected stderr
    finally:
        end_process()

Need more than the free version?

These scripts are deliberately simple. The versions I write for companies go further: printing only what changed since the last release, wire lists that run over as many sheets as needed and export to Excel, and component swaps that check the pins match and compare the bill of materials before and after. They also come with automated tests, a log of every run and a menu that keeps every user on the latest version.

See what's in daily use on the E3 scripting page, or tell me about a job you do by hand and I'll say how I would script it.