Reference

Python API reference

Every public function in the pyacad library, with its exact signature, what it returns, how it fails and whether it needs AutoCAD.

The pyacad library is what your script talks to instead of the raw AutoCAD .NET API. It is a flat set of small modules, each covering one surface of the drawing or of pyACAD itself: the active document, the selection, entities, layers, blocks, xrefs, layouts, plotting, drawing health, AutoLISP, units, and then the dialogs, toasts, the output window, settings and Excel files. Almost everything is a plain function that takes and returns plain Python values.

This page is the complete public surface. If a name is not on this page, it is either private (it starts with an underscore) or it does not exist. Every signature below was taken from the source, and every pyacad.module.name in an example is checked against the library each time the site is built.

Importing pyacad#

The package itself exports one name:

python
import pyacad

pyacad.__version__      # '0.1.0'
pyacad.doc              # AttributeError until something imports pyacad.doc

Everything useful lives in a submodule, and the house style is to import the submodules you need by name:

python
from pyacad import doc, ents, layers, forms, toast

That reads well at the call site (layers.freeze('A-*'), toast.success('Done')) and it matters here for a second reason: several functions share a name with a Python builtin (ents.set, ents.iter, xrefs.list), so from pyacad.ents import * would quietly replace set and iter in your script. Never star-import a pyacad module.

Underscore modules are off limits

pyacad._api, _atomic, _charts, _datafiles, _history, _log, _markdown, _repl, _util and _xlsx are internal. They change without notice and have no compatibility promise; _api loads the AutoCAD assemblies as a side effect of being imported. Everything they do that a script needs is exposed by a public module. The private modules at the foot of this page says what each one is, so you can recognise them in a traceback.

The modules at a glance#

The last column says whether importing the module needs a live AutoCAD session with pyACAD loaded. The drawing modules import anywhere because they reach the AutoCAD API only inside each function, through pyacad._api; calling one of their API functions outside AutoCAD raises ImportError with a plain message. The four framework modules marked yes load the pyACAD runtime assembly at import time, so they import only inside AutoCAD (or AutoCAD Core Console under pyacad run).

ModuleWhat it is forImport needs AutoCAD
doc The active drawing: document, database, editor, transactions, locks, saving, system variables.No
sel The pickfirst set, interactive picks, and filtered select-all.No
ents Entities by handle: iterate a space, snapshot, change, delete, measure.No
layers List, create, rename, delete, merge, freeze, isolate, snapshot and restore layers; layer standards.No
blocks Definitions, references, attributes, counts, inserting, tables, spreadsheet round trips.No
xrefs List, reload, unload, detach, bind and repath external references.No
layouts Paper space tabs: order, current, create, copy, rename, delete, page setups.No
plot Plot a layout to PDF, list plot devices and paper sizes, name batch output files.No
audit Purge, AUDIT, zero-length and duplicate geometry, proxies, missing fonts.No
lisp Call exposed AutoLISP functions, queue an expression, read (pyacad ...) arguments.No
units INSUNITS names, conversions through metres, length formatting.No
forms Modal dialogs: alert, confirm, prompts, pickers, cancellable progress, XAML windows.Yes
toast Small status notes in the corner of the AutoCAD window.Yes
banner One result on a full-width bar across the bottom of the window.Yes
output The output window: HTML, markdown, tables, charts, images, progress, object links.No
script The running command: paths, arguments, toggle state, session variables, per-tool settings and data, a logger.Yes
settings Per-tool JSON settings files under %APPDATA%\pyACAD\settings.No
xl Read and write .xlsx workbooks with no Excel and no extra packages.No

Three badges appear on every function: Pure means plain Python with no AutoCAD and no pyACAD runtime, so it runs and unit-tests anywhere, including a dev-time CPython; Needs AutoCAD means it reads or changes the drawing, or talks to the pyACAD runtime (a window, a toast, the running command), so it only works inside AutoCAD; Blocks means it opens a modal window or a command-line prompt and your script waits until the user answers.

Conventions that cut across every module#

The modules were written to one set of rules. Read these once and most of the individual entries below become predictable.

Objects are handles, not AutoCAD objects#

Every entity, block reference and table the library hands you is identified by its handle: the hex string AutoCAD shows in LIST, such as '2F3A'. Handles survive saving and reopening, so you can store them, write them to Excel and match them up later. Functions that read return plain dicts, lists and strings, never an AutoCAD object that would go stale once its transaction closes. The only functions that hand you raw .NET objects are doc.active(), doc.database(), doc.editor(), doc.transaction() and doc.lock(), which exist for the moment you need the API directly.

A handle argument may be upper or lower case and may carry a 0x prefix (ents.normalize_handle cleans it). An argument that takes "one or many" handles or names also accepts a single string.

Points are tuples, angles are radians#

Points come back as (x, y, z) tuples of floats. Points you pass in may be (x, y) or (x, y, z); a 2D point gets z = 0.0. Angles, such as a block reference's rotation in ents.get or the rotation you give blocks.insert, are in radians, as AutoCAD stores them. The one exception is blocks.export_rows, which writes degrees for people to read in Excel.

One call, one transaction#

Each function opens its own transaction (through doc.transaction()), commits it when it finishes and aborts it if anything raises. A failed call therefore leaves the drawing exactly as it was. The cost is that calling ents.set a thousand times opens a thousand transactions; where a tool naturally works on many objects there is a batch function that does them all in one (ents.lengths, ents.delete, blocks.snapshots, blocks.apply_changes, audit.erase, layers.merge).

Command context and document locks#

A ribbon click, a keyboard chord and a typed command name all run your script as an AutoCAD command, so the document is already locked and the whole run is one undo step. A (pyacad ...) call from AutoLISP runs it in document context, inside whatever command or expression called it. Code that runs later, outside any command, is not: a hook, a dock pane's button handler, a modeless window's callback. Wrap anything that changes the drawing there in with doc.lock():. Reading usually works without a lock, but taking one costs nothing.

Two kinds of wildcard#

Name patterns in layers (and the block name in blocks.references) use pyacad's own matcher: * is any run of characters, ? is one character, matching ignores case and covers the whole name, and you may pass a list of patterns. Nothing else is special, so # and @ are literal. sel.get(layer=...) is different: it goes straight into an AutoCAD selection filter, so it uses AutoCAD's own wildcard syntax (the WCMATCH rules, where #, @, ~ and commas mean something).

Naming a space#

Functions that take a space argument read it the same way (ents.resolve_space): None, '' or 'model' (any case) is model space, 'BLOCK:Door' is the definition of block Door, and anything else is a paper space layout's name. Watch the default: in ents, space=None means model space only; in blocks.references and blocks.counts, space=None means model space and every layout.

Progress and cancel callbacks#

Long-running functions take two optional callables, progress and cancelled. Every doc.STEP items (200), starting with the first, the function calls progress(index, total) and then cancelled(); when cancelled() returns true it stops. Most stop where they are and return what they have so far (and commit it, for the ones that write); layers.merge instead raises RuntimeError and rolls everything back. The cookbook shows how to wire these to a progress window.

How things fail#

ExceptionMeans
ValueErrorBad input, or a name or handle that does not exist: 'No layer is named X.', '2F3A is not a block reference.'. The message is written for a person, so it is fine to show it in a toast.
RuntimeErrorThe drawing is in the wrong state: no drawing open, never saved, read-only, a plot already running, a cancelled merge.
ImportErrorAn AutoCAD function was called outside AutoCAD.
AutoCAD's own exceptionSomething the library does not check first, most often writing to an entity on a locked layer (eOnLockedLayer). It surfaces as Autodesk.AutoCAD.Runtime.Exception, and the transaction is still rolled back.

doc: the active drawing#

Every other drawing module reaches the drawing through this one, and doc.transaction() is the single way into the database. The module imports anywhere; its AutoCAD functions import the API when called. Everything here works in Core Console too (none of it needs the AutoCAD UI assembly). The last four functions are pure helpers the other modules share, and you are welcome to use them.

doc.active#

doc.active()

Needs AutoCAD The active Document (AutoCAD's DocumentManager.MdiActiveDocument). Raises RuntimeError('No drawing is open.') when there is none, which makes it the natural first line of a tool that must have a drawing.

python
from pyacad import doc

document = doc.active()
print(document.Name)

doc.database#

doc.database()

Needs AutoCAD The active drawing's Database. Raises RuntimeError like doc.active().

python
db = doc.database()
print(db.Filename)

doc.editor#

doc.editor()

Needs AutoCAD The active drawing's Editor, for prompts and messages the library does not wrap.

python
doc.editor().WriteMessage('\nHello from pyACAD\n')

doc.path#

doc.path()

Needs AutoCAD The drawing's full path as a string, or the literal 'untitled' when it has never been saved. Compare against 'untitled', not against None.

python
if doc.path() == 'untitled':
    toast.warning('Save the drawing first')

doc.is_saved#

doc.is_saved()

Needs AutoCAD True when the drawing has a file and no unsaved changes (the DBMOD system variable is 0). Always False for an untitled drawing.

python
if not doc.is_saved():
    forms.alert('Save your changes before running a batch plot.')

doc.is_read_only#

doc.is_read_only()

Needs AutoCAD True when the drawing was opened read-only.

python
if doc.is_read_only():
    toast.error('This drawing is read-only')

doc.transaction#

doc.transaction()

Needs AutoCAD A context manager over one database transaction. with hands you the AutoCAD Transaction; it commits when the block ends normally and aborts when the block raises (the exception still propagates), then disposes it either way. Use it when you need the raw API inside one transaction; the rest of the library calls it for you.

python
from pyacad import doc

db = doc.database()          # loads the AutoCAD assemblies on first use
from Autodesk.AutoCAD.DatabaseServices import OpenMode

with doc.transaction() as tr:
    table = tr.GetObject(db.LayerTableId, OpenMode.ForRead)
    print(table.Has('A-WALL'))
Raw API types

The Autodesk.AutoCAD namespaces import once the assemblies are loaded, and the first pyacad call that touches the drawing loads them. That is why the example calls doc.database() before the from Autodesk... line.

doc.lock#

doc.lock()

Needs AutoCAD A context manager that locks the active document and unlocks it on exit. Commands are already locked; you need this in code that runs outside a command: hooks, dock pane handlers, modeless window callbacks. It does not start a transaction.

python
def on_click(sender, args):          # a dock pane button
    with doc.lock():
        layers.freeze('Z-*')

doc.save#

doc.save()

Needs AutoCAD Saves the drawing to its own path in the current DWG format, taking a document lock itself, and returns the path. Raises RuntimeError when the drawing has never been saved (there is no path to save to) or is read-only.

python
saved_to = doc.save()
toast.success('Saved', saved_to)

doc.units#

doc.units()

Needs AutoCAD The drawing's insertion units (INSUNITS) as AutoCAD's enum name, such as 'Millimeters', 'Inches' or 'Undefined'. These names are exactly what the units module takes.

python
print(doc.units())     # 'Millimeters'

doc.open_documents#

doc.open_documents()

Needs AutoCAD A list with one entry per open drawing: the full path for saved drawings, the document name (such as 'Drawing1.dwg') for untitled ones.

python
for name in doc.open_documents():
    print(name)

doc.send_command#

doc.send_command(text)

Needs AutoCAD Queues text plus a newline on the active drawing's command line. It does not run now: it runs after your script returns, so you get nothing back and the next line of your script runs before it. Prefix command names with _. so they work in any language and are not redefined.

python
doc.send_command('_.ZOOM _E')

doc.sysvar#

doc.sysvar(name)

Needs AutoCAD A system variable's value, as AutoCAD returns it (an int, float, string or point object). Wrap it in int() or str() when you compare it, as the library does.

python
if int(doc.sysvar('PSLTSCALE')) != 1:
    toast.warning('PSLTSCALE is not 1')

doc.set_sysvar#

doc.set_sysvar(name, value)

Needs AutoCAD Sets a system variable. Booleans go in as 1 or 0. Integers are tried as 16-bit first (most integer variables are) and then as 32-bit, floats go in as doubles, anything else as it is. AutoCAD raises its own exception for an unknown or read-only variable.

python
doc.set_sysvar('LTSCALE', 1.0)
doc.set_sysvar('PSLTSCALE', True)
doc.set_sysvar('CLAYER', 'A-WALL')

doc.as_point#

doc.as_point(value)

Pure (x, y) or (x, y, z) as a tuple of three floats; a 2D point gets z = 0.0. Raises ValueError for anything else.

python
doc.as_point((10, 20))        # (10.0, 20.0, 0.0)

doc.as_names#

doc.as_names(value)

Pure One name or an iterable of names as a list of strings; None gives []. This is why "one or many" arguments accept a bare string.

python
doc.as_names('A-WALL')            # ['A-WALL']
doc.as_names(('A-WALL', 'A-DOOR'))

doc.step#

doc.step(index, total, progress=None, cancelled=None)

Pure The progress and cancel hook every batch function uses. On every STEPth index (0, 200, 400, ...) it calls progress(index, total) when given, then returns True when cancelled is given and returns true. On other indices it returns False without calling anything. Use it in your own loops to behave like the library.

python
for index, handle in enumerate(handles):
    if doc.step(index, len(handles), report, user_cancelled):
        break

doc.STEP#

doc.STEP = 200

Pure How many items pass between progress reports and cancel checks in every batch function.

sel: selection#

The pickfirst set (what is selected when your tool starts), interactive picks on the command line, and filtered select-all. Everything is in handles. build_filter and value_kind are pure; the rest import the API when called. The pick_* prompts need an interactive editor, so under Core Console they come back as None or [].

sel.current#

sel.current()

Needs AutoCAD Handles of the objects that were selected when the command started (the pickfirst set), or []. pyACAD's commands accept a pickfirst set, so selecting first and then clicking a button works.

python
from pyacad import sel, toast

picked = sel.current()
if not picked:
    toast.info('Select something first')

sel.set_current#

sel.set_current(handles)

Needs AutoCAD Makes these handles the pickfirst set, so they show as selected (grips) when your script ends. One handle or a list. Raises ValueError for a handle that does not exist.

python
sel.set_current(sel.get(type='CIRCLE'))

sel.clear#

sel.clear()

Needs AutoCAD Empties the pickfirst set.

python
sel.clear()

sel.pick_one#

sel.pick_one(prompt='Select an object: ')

Needs AutoCAD Blocks Prompts on the command line for one object and returns its handle, or None when the user presses Esc or picks nothing.

python
handle = sel.pick_one('Select a block: ')
if handle and ents.type_of(handle) == 'INSERT':
    print(blocks.attributes(handle))

sel.pick_many#

sel.pick_many(prompt='Select objects: ')

Needs AutoCAD Blocks Prompts for a selection (window, crossing, any method) and returns a list of handles, [] on cancel.

python
handles = sel.pick_many('Select lines to measure: ')

sel.pick_point#

sel.pick_point(prompt='Pick a point: ')

Needs AutoCAD Blocks Prompts for a point (object snaps work) and returns (x, y, z) in world coordinates, or None on cancel.

python
point = sel.pick_point('Insertion point: ')
if point is not None:
    blocks.insert('NORTH', point)

sel.get#

sel.get(type=None, layer=None, space=None, **more)

Needs AutoCAD Handles of every entity in the drawing that passes the filter, using AutoCAD's select-all with a selection filter (see sel.build_filter for the arguments). With no arguments it returns every entity in model space and all layouts. Entities inside block definitions are not included, because select-all never sees them.

python
walls = sel.get(type='LWPOLYLINE', layer='A-WALL*')
texts = sel.get(type=['TEXT', 'MTEXT'], space='model')
red = sel.get(c62=1)                   # colour index 1 set on the entity

sel.build_filter#

sel.build_filter(type=None, layer=None, space=None, **more)

Pure The (code, value) pairs sel.get turns into a selection filter. type is one DXF name or a list (any case, joined with commas as AutoCAD expects); layer is a layer name with AutoCAD wildcards; space is a layout name, with 'model' (any case) meaning model space. Any other DXF group code goes in as a keyword c<code>=value, such as c62=1 for colour 1 or c40=2.5 for a radius; the value is converted to what that code takes (see sel.value_kind) and extra pairs are sorted by code. Raises ValueError for a keyword that is not shaped like c62.

python
sel.build_filter(type=['line', 'arc'], layer='M-*', c62=3)
# [(0, 'LINE,ARC'), (8, 'M-*'), (62, 3)]

sel.value_kind#

sel.value_kind(code)

Pure What a DXF group code takes in a filter: 'int16', 'int32', 'double', 'point' or 'text' (anything not in a numeric or point range).

python
sel.value_kind(62)    # 'int16'
sel.value_kind(40)    # 'double'
sel.value_kind(8)     # 'text'

ents: entities by handle#

Iterate a space, take a snapshot of an entity as a plain dict, change a few common properties, delete, and measure. ents.get returns a dict, so a script never holds an AutoCAD object past its transaction. resolve_space, normalize_handle, check_props and length_by_layer are pure. Two functions here are named like builtins, iter and set: always call them as ents.iter and ents.set.

ents.iter#

ents.iter(space=None)

Needs AutoCAD A generator over the handle of every entity in model space (the default), a layout (its name) or a block definition ('BLOCK:Name'). The handles are read in one transaction before the first one is yielded, so changing the drawing while you loop is safe. Raises ValueError for an unknown layout or block.

python
from pyacad import ents

for handle in ents.iter():
    print(handle, ents.type_of(handle))

for handle in ents.iter('BLOCK:Door'):
    pass        # the entities that make up the Door definition

ents.get#

ents.get(handle)

Needs AutoCAD A snapshot of one entity as a plain dict. Every entity carries handle, type (the DXF name), layer, color (the colour index: 256 is ByLayer, 0 ByBlock), linetype, lineweight (an int, see ents.LINEWEIGHTS), visible and space ('Model', a layout name, or 'BLOCK:Name'). Some types add geometry:

TypeExtra keys
LINEstart, end
CIRCLEcenter, radius
ARCcenter, radius, start_angle, end_angle (radians)
LWPOLYLINEpoints (a list of (x, y)), closed
TEXTtext, height, position
MTEXTtext (the raw contents, formatting codes included), height, position
INSERTname (a dynamic block's real name, not *U12), position, rotation (radians), scale (x, y, z), attributes {tag: text}

Raises ValueError when no object has that handle.

python
snap = ents.get('2F3A')
if snap['type'] == 'LINE':
    print(snap['start'], snap['end'])

ents.set#

ents.set(handle, **props)

Needs AutoCAD Changes properties of one entity. Any entity takes layer, color (an int 0 to 256), linetype, lineweight (one of ents.LINEWEIGHTS) and visible; TEXT and MTEXT also take text and height; INSERT takes attributes (a dict of tag to text, matched to the tags exactly as the drawing spells them). Everything is validated by ents.check_props before anything changes, and the layer and linetype must exist ('ByLayer' and 'ByBlock' are always allowed), else ValueError. Returns None. An entity on a locked layer raises AutoCAD's eOnLockedLayer.

python
ents.set('2F3A', layer='A-WALL', color=256)
ents.set('2F40', text='LEVEL 2', height=3.5)
ents.set('2F41', attributes={'ROOM_NO': '2.14'})

ents.delete#

ents.delete(handles, progress=None, cancelled=None)

Needs AutoCAD Erases the entities (one handle or a list) in one transaction and returns how many were erased. A handle that does not exist raises ValueError and nothing is erased; an entity on a locked layer raises AutoCAD's exception and nothing is erased. Stopping through cancelled keeps what was erased so far. For a forgiving delete that skips locked layers and missing handles, use audit.erase.

python
erased = ents.delete(sel.get(type='POINT'))

ents.count#

ents.count(space=None)

Needs AutoCAD How many entities a space holds (model space by default; a layout name or 'BLOCK:Name' otherwise).

python
print(ents.count(), 'in model space')

ents.bounds#

ents.bounds(handle)

Needs AutoCAD The entity's extents as ((minx, miny, minz), (maxx, maxy, maxz)). AutoCAD raises its own exception for an entity with no extents (an empty text, a ray).

python
low, high = ents.bounds(handle)
width = high[0] - low[0]

ents.type_of#

ents.type_of(handle)

Needs AutoCAD The entity's DXF type name, such as 'LINE', 'LWPOLYLINE' or 'INSERT', without opening a transaction. Raises ValueError for an unknown handle.

python
if ents.type_of(handle) == 'INSERT':
    print(blocks.attributes(handle))

ents.length_of#

ents.length_of(handle)

Needs AutoCAD The length along a curve in drawing units: lines, arcs, circles (the circumference), polylines of every kind, splines and ellipses. Raises ValueError for anything without a length.

python
print('%.1f' % ents.length_of(handle))

ents.lengths#

ents.lengths(handles, types=LENGTH_TYPES, progress=None, cancelled=None)

Needs AutoCAD Measures many curves in one transaction. Returns a list of {'handle', 'type', 'layer', 'length'} for each handle whose DXF type is in types (by default ents.LENGTH_TYPES: lines, arcs, both kinds of polyline and splines). Other entities and handles that no longer exist are left out silently; a curve AutoCAD cannot measure gets length None. Feed the result to ents.length_by_layer for totals.

python
rows = ents.lengths(sel.get(layer='M-DUCT*'))
total = sum(r['length'] for r in rows if r['length'] is not None)

ents.length_by_layer#

ents.length_by_layer(snapshots)

Pure Totals per layer from dicts that carry at least 'layer' and 'length' (what ents.lengths returns): a list of {'layer', 'count', 'length'} sorted by layer name ignoring case. A dict whose length is None is left out of both the count and the total.

python
ents.length_by_layer([{'layer': 'A', 'length': 2.0}, {'layer': 'A', 'length': 3.0}])
# [{'layer': 'A', 'count': 2, 'length': 5.0}]

ents.resolve_space#

ents.resolve_space(text)

Pure How a space argument is read: ('model', 'Model') for None, '' or 'model' in any case; ('block', name) for 'BLOCK:name'; ('layout', text) for anything else. Raises ValueError for 'BLOCK:' with no name.

python
ents.resolve_space('block:Door')    # ('block', 'Door')
ents.resolve_space('A1 Plan')       # ('layout', 'A1 Plan')

ents.normalize_handle#

ents.normalize_handle(handle)

Pure A handle as uppercase hex text, with surrounding spaces and a 0x prefix removed. Raises ValueError when the text is not hex. Use it before comparing handles that came from a spreadsheet or a file.

python
ents.normalize_handle(' 0x2f3a ')    # '2F3A'

ents.check_props#

ents.check_props(type_name, props)

Pure The validation ents.set runs: raises ValueError when a key is not allowed for that DXF type, when color is not an int from 0 to 256, when lineweight is not in ents.LINEWEIGHTS, when height is not above zero, or when attributes is not a dict. Returns None otherwise. Handy for checking user input before a batch.

python
ents.check_props('LINE', {'color': 1})       # fine
ents.check_props('LINE', {'text': 'x'})      # ValueError: LINE cannot set 'text'

ents.LINEWEIGHTS#

ents.LINEWEIGHTS = ( -3, -2, -1, 0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211 )

Pure The lineweights ents.set accepts, in hundredths of a millimetre: -3 is Default, -2 ByBlock, -1 ByLayer, and the rest are AutoCAD's standard weights from 0 to 2.11 mm. The same numbers come back in ents.get and layers.get.

ents.LENGTH_TYPES#

ents.LENGTH_TYPES = ('LINE', 'ARC', 'LWPOLYLINE', 'POLYLINE', 'SPLINE')

Pure The DXF types ents.lengths measures by default. POLYLINE covers both the old 2D polyline and the 3D polyline.

layers: layers and layer standards#

Read, create, rename, delete and merge layers; freeze, thaw, turn off and on, lock and unlock by pattern; isolate; and save and restore layer states. Pattern arguments take * and ?, ignore case, and may be a list of patterns. The freeze-style functions return how many layers actually changed and regenerate the drawing. The second half of the module is pure: matching, a filter box, and the engine behind a layer-standard mapping (a CSV of old name to new name).

layers.names#

layers.names()

Needs AutoCAD Every layer name in the drawing, in layer table order (xref layers included, as Xref|Layer).

python
from pyacad import layers

for name in layers.names():
    print(name)

layers.current#

layers.current()

Needs AutoCAD The current layer's name (CLAYER).

python
print(layers.current())

layers.set_current#

layers.set_current(name)

Needs AutoCAD Makes the named layer current, thawing it and turning it on first so AutoCAD accepts it. Raises ValueError when there is no such layer.

python
layers.set_current('A-ANNO')

layers.exists#

layers.exists(name)

Needs AutoCAD True when the drawing has a layer of that name (AutoCAD compares layer names ignoring case).

python
if not layers.exists('A-ANNO'):
    layers.create('A-ANNO', color=2)

layers.get#

layers.get(name)

Needs AutoCAD One layer as a dict: name, color (the colour index), linetype (its name), lineweight (an int, see ents.LINEWEIGHTS), is_off, is_frozen, is_locked, is_plottable. Raises ValueError for an unknown layer.

python
state = layers.get('A-WALL')
if state['is_frozen']:
    layers.thaw('A-WALL')

layers.create#

layers.create(name, color=7, linetype='Continuous')

Needs AutoCAD Adds a layer and returns its name. color is a colour index from 1 to 255 (not 0 or 256, which mean ByBlock and ByLayer); linetype must already be loaded in the drawing. Raises ValueError when the layer exists, the name is not a valid symbol name, the colour is out of range or the linetype is missing.

python
layers.create('M-DUCT-SUPPLY', color=4, linetype='Continuous')

layers.rename#

layers.rename(old, new)

Needs AutoCAD Renames a layer. Raises ValueError for layer 0 and Defpoints, an unknown old name, an invalid new name, or a new name another layer already has. A change of case only is allowed.

python
layers.rename('WALLS', 'A-WALL')

layers.delete#

layers.delete(name)

Needs AutoCAD Erases a layer. Raises ValueError when it is layer 0 or Defpoints, the current layer, an xref layer, or still in use (AutoCAD's purge check says something references it). To empty a layer into another one first, use layers.merge.

python
try:
    layers.delete('OLD-TEMP')
except ValueError as error:
    toast.warning('Not deleted', str(error))

layers.merge#

layers.merge(sources, target, progress=None, cancelled=None)

Needs AutoCAD Moves every entity on the source layers (one name or a list) to target, then deletes the sources, all in one transaction, and returns how many objects moved. It covers model space, every layout and every block definition in this drawing (not xrefs), including the attributes on block references, and it moves entities on locked layers too. If a source is the current layer, the target becomes current. A source equal to the target is ignored. Raises ValueError for an unknown layer or a source that cannot be deleted afterwards; when cancelled fires it raises RuntimeError('Merge cancelled; nothing was changed.') and rolls everything back.

python
moved = layers.merge(['WALL', 'WALLS', 'Wall-old'], 'A-WALL')
toast.success('Merged 3 layers', '%d objects moved' % moved)

layers.freeze, thaw, turn_off, turn_on, lock, unlock#

layers.freeze(pattern)
layers.thaw(pattern)
layers.turn_off(pattern)
layers.turn_on(pattern)
layers.lock(pattern)
layers.unlock(pattern)

Needs AutoCAD Change one flag on every layer whose name matches pattern (a pattern or a list) and return how many layers actually changed; layers already in that state do not count. freeze never freezes the current layer (AutoCAD would refuse) and skips it silently. turn_off does turn off the current layer, as AutoCAD allows. All but lock and unlock regenerate the drawing afterwards. These change the layer itself, so the effect shows in model space and every layout; they do not touch viewport overrides.

python
frozen = layers.freeze(['*-ANNO*', 'Z-*'])
layers.lock('X-*')
layers.turn_on('*')

layers.isolate#

layers.isolate(names)

Needs AutoCAD Thaws and turns on the named layers (exact names, one or a list) and freezes every other layer. If the current layer is not among them, the first named layer becomes current. Returns how many layers were frozen. Raises ValueError for an empty list or an unknown name. Take a layers.snapshot() first if the user will want the old state back.

python
before = layers.snapshot()
layers.isolate(['A-WALL', 'A-DOOR'])

layers.snapshot#

layers.snapshot()

Needs AutoCAD {name: layers.get(name)} for every layer, read in one transaction. The result is plain data, so it can be stored with script.store_data or written to JSON.

python
script.store_data('before', layers.snapshot(), per_document=True)

layers.restore#

layers.restore(snapshot)

Needs AutoCAD Puts back is_off, is_frozen and is_locked from a snapshot() (colour and linetype are not restored). Layers that are no longer in the drawing are skipped, the current layer is never frozen, and the return value is how many layers changed. Regenerates afterwards.

python
saved = script.load_data('before', per_document=True)
if saved:
    layers.restore(saved)

layers.matches#

layers.matches(name, pattern)

Pure True when the whole of name fits pattern: * is any run of characters, ? exactly one, case is ignored, and every other character is literal.

python
layers.matches('A-WALL-FULL', 'a-wall*')   # True
layers.matches('A-WALL', 'A-WAL?')         # True

layers.matches_any#

layers.matches_any(name, patterns)

Pure True when name fits any of patterns (one string or a list).

python
layers.matches_any('Z-TEMP', ['X-*', 'Z-*'])   # True

layers.filter_names#

layers.filter_names(names, text)

Pure The names that fit what someone typed in a filter box, in their original order, as a new list. Blank text keeps everything. Each comma-separated term is a wildcard pattern when it holds * or ?, otherwise a case-insensitive substring; a name stays when any term fits.

python
layers.filter_names(['A-WALL', 'A-DOOR', 'M-DUCT'], 'wall, M-*')
# ['A-WALL', 'M-DUCT']

layers.parse_mapping#

layers.parse_mapping(csv_text)

Pure Reads a layer-standard CSV into (old, new, color, linetype) tuples. Columns are old,new[,color][,linetype], and anything after the fourth (a notes column) is ignored. old may use wildcards. color is 1 to 255 or one of red, yellow, green, cyan, blue, magenta, white (black also reads as 7); colour and linetype are None when blank. Cells split on commas, semicolons or tabs, whichever the first row uses most, and may be quoted. Blank lines, a byte order mark and a header row (old,new, from,to, layer,standard and the like) are skipped. Raises ValueError naming the line for a row without an old or new name or with a bad colour.

python
rows = layers.parse_mapping('old,new,color\nWALL*,A-WALL,1\nDOOR,A-DOOR,red\n')
# [('WALL*', 'A-WALL', 1, None), ('DOOR', 'A-DOOR', 1, None)]

layers.plan_mapping#

layers.plan_mapping(existing_names, mapping)

Pure What applying a parsed standard to a drawing would do, as a list of steps, without touching anything. Each existing layer, in order, meets the first row whose old pattern fits it and becomes ('rename', old, new) when no layer is called new yet, or ('merge', old, target) when one is (counting what earlier steps renamed or merged away). Layer 0, Defpoints, xref layers and invalid new names become ('skip', name, reason); a layer already called new gives nothing. Every new name still missing at the end becomes ('create', new, color, linetype) once. Run the steps yourself with layers.rename, layers.merge and layers.create.

python
plan = layers.plan_mapping(layers.names(), rows)
for step in plan:
    print(layers.describe_action(step))

layers.describe_action#

layers.describe_action(action)

Pure One plan_mapping step in words: 'Rename A to B', 'Merge A into B', 'Create B (color 3, DASHED)', 'Skip A: reason'.

python
layers.describe_action(('merge', 'WALLS', 'A-WALL'))   # 'Merge WALLS into A-WALL'

layers.snapshot_choices#

layers.snapshot_choices(slots)

Pure Picker entries for saved snapshot slots. slots maps a slot name to {'saved': 'YYYY-MM-DD HH:MM', 'layers': snapshot()}; the result is (label, name) pairs, newest first then by name, with labels such as 'Before issue (saved 2026-10-12 14:05, 42 layers)'. Slots with no time, or that are not dicts, come last. The pairs go straight into forms.select_from_list.

python
slots = script.load_data('slots', {}, per_document=True)
name = forms.select_from_list(layers.snapshot_choices(slots), title='Restore layers')

blocks: definitions, references and attributes#

Block definitions and references, attributes, counts, inserting blocks and AutoCAD tables, and the pure engine behind the spreadsheet round trip (export to Excel, edit, write back). Dynamic block references always report their definition's real name, never the anonymous *U copy. Name arguments take * and ? as in layers.matches. The pure half (attribute_tags, export_rows, diff_rows, schedule_rows, replace_in_attributes, tag_audit, column_widths) works on the plain dicts blocks.snapshots returns.

blocks.definitions#

blocks.definitions()

Needs AutoCAD Names of the drawing's own blocks, sorted ignoring case: layouts, anonymous blocks, xrefs and blocks that belong to xrefs are left out.

python
from pyacad import blocks

names = blocks.definitions()

blocks.references#

blocks.references(name=None, space=None)

Needs AutoCAD Handles of block references. name is None for all of them, or a pattern (or list of patterns) matched against the block name. space is None for model space and every layout, or one space as in ents.iter. Xref attachments are references too, so they are included when their name matches.

python
doors = blocks.references('DOOR*')
in_model = blocks.references(space='model')

blocks.attributes#

blocks.attributes(handle)

Needs AutoCAD {tag: text} of one block reference's attributes. Raises ValueError when the handle does not exist or is not a block reference.

python
values = blocks.attributes(handle)
print(values.get('ROOM_NO'))

blocks.set_attributes#

blocks.set_attributes(handle, values)

Needs AutoCAD Writes {tag: text} into one block reference and returns how many values actually changed. Tags are matched exactly as the drawing spells them (usually upper case); tags the reference does not have are ignored. Raises ValueError when values is not a dict or the handle is not a block reference.

python
changed = blocks.set_attributes(handle, {'ROOM_NO': '2.14', 'ROOM_NAME': 'Plant'})

blocks.counts#

blocks.counts(space=None)

Needs AutoCAD {block name: number of references} in a space, or in model space and every layout when space is None. Xref attachments are counted under the xref name.

python
for name, number in sorted(blocks.counts().items()):
    print(name, number)

blocks.insert#

blocks.insert(name, point, rotation=0.0, scale=1.0, layer=None, attributes=None)

Needs AutoCAD Inserts a reference to a block already defined in the drawing into the current space (model space, or the paper space layout that is current) and returns its handle. rotation is in radians; scale is one number or (x, y, z); layer must exist. Every non-constant attribute is added, with the value from attributes when its tag is there and the definition's default otherwise. Raises ValueError for an unknown block or layer.

python
import math
handle = blocks.insert('TAG-ROOM', (1200, 3400), rotation=math.radians(90),
                       layer='A-ANNO', attributes={'ROOM_NO': '2.14'})

blocks.definition_info#

blocks.definition_info(name)

Needs AutoCAD One block definition as a dict: name, has_attributes, is_dynamic, origin (the base point) and comments (its description). Raises ValueError for an unknown block.

python
info = blocks.definition_info('TAG-ROOM')
if not info['has_attributes']:
    toast.warning('TAG-ROOM has no attributes')

blocks.snapshots#

blocks.snapshots(handles=None, progress=None, cancelled=None)

Needs AutoCAD Snapshot dicts of block references, read in one transaction: every reference in model space and the layouts when handles is None, else those of the given handles that are block references (anything else, and handles that no longer exist, are left out). Each dict is what ents.get gives for an INSERT, plus tags: the attribute tags in the order the drawing holds them. Xref attachments are never included. This is the input to every pure function below.

python
picked = sel.current()
refs = blocks.snapshots(picked if picked else None)

blocks.apply_changes#

blocks.apply_changes(changes, progress=None, cancelled=None)

Needs AutoCAD Writes changes into many block references in one transaction. changes maps a handle to {'attributes': {tag: text}, 'layer': name}, either part optional, exactly as blocks.diff_rows produces. Returns {'updated': references changed, 'values': attribute values changed, 'failed': {handle: reason}}. A reference that fails (gone, not a block reference, unknown or locked layer) is left as it was and reported; the rest still go in. Raises ValueError up front when changes is not shaped right.

python
result = blocks.apply_changes({'2F3A': {'attributes': {'ROOM_NO': '2.15'}}})
if result['failed']:
    output.print_table(sorted(result['failed'].items()), ['Handle', 'Reason'])

blocks.insert_table#

blocks.insert_table(point, headers, rows, title=None)

Needs AutoCAD Inserts an AutoCAD table with its top left corner at point, in the current space and the drawing's current table style: an optional title row, a header row, then one row per item. Every cell is written as text (whole floats lose their .0), short rows are padded, and columns are sized to their longest text using the style's text height. Returns the table's handle. Raises ValueError for no headers or a row longer than the headers.

python
headers, rows = blocks.schedule_rows(blocks.snapshots())
point = sel.pick_point('Table corner: ')
if point:
    blocks.insert_table(point, headers, rows, title='Block schedule')

blocks.attribute_tags#

blocks.attribute_tags(refs)

Pure Every attribute tag across the references, once each, in the order first met (a reference's tags order when it has one, else its tags sorted). Tags that differ only in case count once, spelled as first met.

python
blocks.attribute_tags([{'tags': ['NO', 'NAME'], 'attributes': {'NO': '1', 'NAME': 'A'}},
                       {'attributes': {'AREA': '12'}}])
# ['NO', 'NAME', 'AREA']

blocks.export_rows#

blocks.export_rows(refs)

Pure (headers, rows) for a spreadsheet of block references: the blocks.EXPORT_COLUMNS, then one column per attribute tag. One row per reference, sorted by block name then handle. Rotation is in degrees (0 up to 360); Scale is one number when uniform, else 'x, y, z' text; a tag the reference lacks is an empty string.

python
headers, rows = blocks.export_rows(blocks.snapshots())
xl.write(path, rows, headers=headers, sheet='Blocks')

blocks.diff_rows#

blocks.diff_rows(current_refs, sheet_rows)

Pure Compares a sheet written from export_rows (header row first, as xl.read returns it) with the references in the drawing now. Columns are found by header name, so a reordered sheet still reads. Only attribute values and Layer go back: a tag cell is a change when its text differs from the reference's value (an emptied cell clears the value), a Layer cell when it is filled in and differs. Returns (changes, skipped): changes is ready for blocks.apply_changes, skipped lists the sheet's handles that are no longer block references. Raises ValueError for an empty sheet or one with no Handle column.

python
changes, skipped = blocks.diff_rows(blocks.snapshots(), xl.read(path))
result = blocks.apply_changes(changes)

blocks.schedule_rows#

blocks.schedule_rows(refs, by_layer=False)

Pure (headers, rows) counting references by block name, or by name and layer: headers ['Block', 'Count'] or ['Block', 'Layer', 'Count'], rows sorted ignoring case, counts as ints.

python
headers, rows = blocks.schedule_rows(refs, by_layer=True)
output.print_table(rows, headers)

blocks.replace_in_attributes#

blocks.replace_in_attributes(refs, find, replace, case_sensitive=True, tag=None)

Pure {handle: {tag: new text}} for every attribute value that changes when find becomes replace (plain text, never a pattern). Only changed values are listed; tag limits it to one tag (any case). Raises ValueError when find is empty. Turn it into apply_changes input with a dict comprehension.

python
found = blocks.replace_in_attributes(refs, 'LVL', 'LEVEL', case_sensitive=False)
blocks.apply_changes(dict((h, {'attributes': v}) for h, v in found.items()))

blocks.tag_audit#

blocks.tag_audit(refs, tag, ignore_case=True)

Pure Audits one attribute tag across references. Returns {'duplicates': {value: [handles]}, 'blanks': [handles], 'missing': [handles], 'tagged': count}: values two or more references share, references whose value is empty or spaces, references without that attribute, and how many have a value. Values are compared trimmed and, unless ignore_case is False, ignoring case. Raises ValueError for an empty tag.

python
report = blocks.tag_audit(blocks.snapshots(blocks.references('TAG-ROOM')), 'ROOM_NO')
for value, handles in report['duplicates'].items():
    print(value, len(handles))

blocks.column_widths#

blocks.column_widths(headers, rows, text_height, margin=None, title=None, title_height=None)

Pure The column widths insert_table uses: each column's longest text (at least 3 characters) times text_height times 0.9, plus a margin each side (a third of the text height unless given). With a title, the columns share any extra width the title needs. Raises ValueError when text_height is not above zero.

python
blocks.column_widths(['Block', 'Count'], [['DOOR', 12]], 2.5)

blocks.EXPORT_COLUMNS#

blocks.EXPORT_COLUMNS = ( 'Handle', 'Block', 'Layer', 'Space', 'X', 'Y', 'Z', 'Rotation', 'Scale', )

Pure The fixed columns at the front of every export_rows sheet, before the attribute tag columns.

xrefs: external references#

List, reload, unload, detach, bind and repath xrefs. Reload, unload, detach and bind must run outside any open transaction, as AutoCAD requires, so call them from your script's top level (a command) or inside doc.lock(), never inside doc.transaction(). Names are xref names as they appear in the External References palette, matched as AutoCAD matches block names. The path helpers and the planning and reporting helpers in the second half are pure and use Windows path rules on every platform. xrefs.list shares its name with the builtin; call it as xrefs.list().

xrefs.list#

xrefs.list()

Needs AutoCAD One dict per xref, nested ones included:

KeyValue
nameThe xref name.
pathThe saved path, exactly as stored (relative or absolute).
found_atWhere AutoCAD actually loaded it from; '' when it is not loaded.
status'loaded', 'unloaded', 'not-found', 'unresolved' or 'unreferenced'.
is_overlayTrue for an overlay, False for an attachment.
nestedTrue for an xref inside another xref.
python
from pyacad import xrefs

broken = [x for x in xrefs.list() if x['status'] == 'not-found']

xrefs.reload#

xrefs.reload(names=None)

Needs AutoCAD Reloads the named xrefs (one name or a list), or every top-level xref when names is None, and returns how many it asked AutoCAD to reload. AutoCAD does not complain when a file still cannot be found, so check the status afterwards (xrefs.check_loaded does exactly that). Raises ValueError for an unknown name or a name that is a plain block.

python
xrefs.reload()
xrefs.reload('Site')

xrefs.unload#

xrefs.unload(names)

Needs AutoCAD Unloads the named xrefs and returns how many. Unloaded xrefs stay attached and keep their path.

python
xrefs.unload(['Site', 'Survey'])

xrefs.detach#

xrefs.detach(name)

Needs AutoCAD Detaches one xref. Returns None. Pass a single name: given a list it detaches only the first.

python
xrefs.detach('Old Survey')

xrefs.bind#

xrefs.bind(names, insert=False)

Needs AutoCAD Binds the named xrefs into the drawing and returns how many. With insert=False (Bind) the xref's layers and styles come in with a prefix, so A-WALL from xref Site becomes Site$0$A-WALL; with insert=True (Insert) they merge into the drawing's own names.

python
xrefs.bind('Site', insert=True)

xrefs.repath#

xrefs.repath(name, new_path, reload=True)

Needs AutoCAD Points one xref at new_path and reloads it. With reload=False only the saved path changes, which keeps an unloaded xref unloaded until its next reload. Raises ValueError for an unknown name.

python
xrefs.repath('Site', r'..\Xref\site-v2.dwg')

xrefs.find_replace#

xrefs.find_replace(find, replace)

Needs AutoCAD Replaces find with replace (plain text, ignoring case) in every top-level xref path, reloads the ones that changed and returns how many. Nested xrefs are left alone: they follow the xref that holds them. Raises ValueError when find is empty.

python
count = xrefs.find_replace(r'\\oldserver\projects', r'\\newserver\projects')

xrefs.to_relative#

xrefs.to_relative()

Needs AutoCAD Makes every top-level xref path relative to the drawing's folder and returns how many changed. Paths on another drive or share cannot be relative and are left alone. Raises RuntimeError when the drawing has never been saved.

python
xrefs.to_relative()

xrefs.to_absolute#

xrefs.to_absolute()

Needs AutoCAD Makes every top-level xref path absolute, resolved against the drawing's folder, and returns how many changed. Like to_relative it raises RuntimeError for a drawing that has never been saved.

python
xrefs.to_absolute()

xrefs.status_name#

xrefs.status_name(xref_status)

Pure The xrefs.list status for an AutoCAD XrefStatus name: 'Resolved' is 'loaded', 'FileNotFound' is 'not-found', and so on; anything unknown comes back lower-cased.

python
xrefs.status_name('FileNotFound')    # 'not-found'

xrefs.status_label#

xrefs.status_label(status)

Pure A status as people read it: 'not-found' becomes 'not found', and an empty status 'unknown'.

python
xrefs.status_label('not-found')    # 'not found'

xrefs.apply_replace#

xrefs.apply_replace(path, find, replace)

Pure path with every occurrence of find replaced, ignoring case. Raises ValueError when find is empty.

python
xrefs.apply_replace(r'P:\Old\site.dwg', 'p:\\old', r'Q:\New')   # 'Q:\\New\\site.dwg'

xrefs.make_relative#

xrefs.make_relative(path, host_dir)

Pure path relative to host_dir, written the way AutoCAD writes relative xref paths (.\x.dwg, ..\a\x.dwg). Relative paths, paths on another drive or share, and an empty host_dir come back unchanged.

python
xrefs.make_relative(r'C:\Job\Xref\site.dwg', r'C:\Job\Sheets')   # '..\\Xref\\site.dwg'

xrefs.make_absolute#

xrefs.make_absolute(path, host_dir)

Pure A relative path resolved against host_dir and normalised; an absolute path comes back normalised; a relative path with no host_dir comes back unchanged.

python
xrefs.make_absolute(r'..\Xref\site.dwg', r'C:\Job\Sheets')   # 'C:\\Job\\Xref\\site.dwg'

xrefs.describe#

xrefs.describe(xref_rows)

Pure One picker label per xrefs.list() row, in order, such as 'Site [loaded, overlay] ..\Xref\site.dwg'. A row with no path shows (no path).

python
rows = xrefs.list()
picked = forms.select_from_list(list(zip(xrefs.describe(rows), [r['name'] for r in rows])),
                                title='Reload xrefs', multiselect=True)

xrefs.plan_repath#

xrefs.plan_repath(xref_rows, mode, find=None, replace=None, folder=None, exists=None, host_dir=None)

Pure What a repath would do, worked out before anything changes: one (old_path, new_path, note) per row. mode is 'relative' or 'absolute' (both need host_dir, the drawing's folder), 'replace' (find becomes replace, ignoring case) or 'folder' (each xref points at folder\<its file name>, but only where exists(path) is true; exists defaults to os.path.isfile, so this mode reads the disk unless you pass your own). note is '' for a row that changes and says why not otherwise ('already relative', 'no match', 'not found in the folder', ...), with new_path equal to old_path. Nested xrefs never change. Raises ValueError for an unknown mode or a missing argument the mode needs.

python
rows = xrefs.list()
plan = xrefs.plan_repath(rows, 'folder', folder=r'\\server\job\xref')

xrefs.format_plan#

xrefs.format_plan(xref_rows, plan, limit=None)

Pure A plan_repath result as preview text: each change as its name with from and to lines, then every row left alone with its note. limit caps each list, the rest becoming ...and N more.

python
if forms.confirm(xrefs.format_plan(rows, plan, limit=20)):
    for row, (old, new, note) in zip(rows, plan):
        if not note:
            xrefs.repath(row['name'], new)

xrefs.headline#

xrefs.headline(results, verb='Updated')

Pure One line for a toast from (name, error) pairs, where a falsy error is a success: 'Reloaded 3 xrefs', or 'Reloaded 2 of 3 xrefs' when some failed; 'No xrefs to update' for none.

python
xrefs.headline([('Site', None), ('Survey', 'not found')], 'Reloaded')
# 'Reloaded 1 of 2 xrefs'

xrefs.summarize#

xrefs.summarize(results, verb='Updated', limit=None)

Pure The headline as a sentence, then every failure as name: error under Not done:; limit caps the failure list. Text for forms.alert.

python
forms.alert(xrefs.summarize(results, 'Reloaded', limit=15))

xrefs.check_loaded#

xrefs.check_loaded(results, xref_rows)

Pure results with each success whose xref did not end up loaded turned into a failure such as 'not found at ..\b.dwg'. Pass the xrefs.list() rows read after the action. Names no longer in the rows (detached, bound) and earlier errors are kept as they were.

python
results = []
for name in names:
    try:
        xrefs.reload(name)
        results.append((name, None))
    except ValueError as error:
        results.append((name, str(error)))
results = xrefs.check_loaded(results, xrefs.list())
toast.info(xrefs.headline(results, 'Reloaded'))

layouts: paper space tabs#

Paper space layouts in tab order, the current one, create, copy, rename, delete, reorder, and the drawing's named page setups. Changes go through AutoCAD's LayoutManager, which acts on the active drawing. is_model, reorder and unique_name are pure.

layouts.names#

layouts.names()

Needs AutoCAD Paper space layout names in tab order. The Model tab is not included.

python
from pyacad import layouts

for name in layouts.names():
    print(name)

layouts.current#

layouts.current()

Needs AutoCAD The current layout's name, 'Model' on the model tab.

python
if layouts.is_model(layouts.current()):
    toast.info('You are in model space')

layouts.set_current#

layouts.set_current(name)

Needs AutoCAD Switches to the named layout ('Model', any case, for the model tab). Raises ValueError for an unknown layout.

python
layouts.set_current('A1 - Plan')

layouts.exists#

layouts.exists(name)

Needs AutoCAD True when the drawing has a layout of that name.

python
layouts.exists('A1 - Plan')

layouts.create#

layouts.create(name)

Needs AutoCAD Adds an empty paper space layout and returns its name. Raises ValueError when it already exists.

python
layouts.create('A1 - Sections')

layouts.delete#

layouts.delete(name)

Needs AutoCAD Deletes a paper space layout. Raises ValueError for the model tab or an unknown layout.

python
layouts.delete('Layout1')

layouts.rename#

layouts.rename(old, new)

Needs AutoCAD Renames a paper space layout; a change of case only is allowed. Raises ValueError when either name is the model tab, the old one does not exist or the new one is taken.

python
layouts.rename('Layout1', 'A1 - Plan')

layouts.copy#

layouts.copy(source, new)

Needs AutoCAD Copies a paper space layout, with its content and plot settings, to a new layout and returns the new name. Raises ValueError when either is the model tab, the new name is blank or taken, or the source does not exist. Use layouts.unique_name to pick a free name.

python
new = layouts.copy('A1 - Plan', layouts.unique_name(layouts.names(), 'A1 - Plan'))

layouts.set_order#

layouts.set_order(ordered)

Needs AutoCAD Puts the paper space layouts in this tab order. ordered must name every layout exactly once (any case), as layouts.reorder returns it; otherwise ValueError lists the layouts.

python
layouts.set_order(sorted(layouts.names(), key=str.lower))

layouts.page_setups#

layouts.page_setups(paper_only=False)

Needs AutoCAD Names of the drawing's named page setups, sorted ignoring case. With paper_only=True the ones made for model space, which a layout cannot take, are left out.

python
setups = layouts.page_setups(paper_only=True)

layouts.apply_page_setup#

layouts.apply_page_setup(setup, layout_names)

Needs AutoCAD Makes a named paper space page setup the plot settings of each named layout and returns how many. All or nothing: an unknown setup, a model space setup, the model tab or an unknown layout raises ValueError before any layout changes.

python
layouts.apply_page_setup('A1 PDF', layouts.names())

layouts.is_model#

layouts.is_model(name)

Pure True for the model tab's name in any case, surrounding spaces ignored.

python
layouts.is_model(' MODEL ')    # True

layouts.reorder#

layouts.reorder(names, name, new_index)

Pure names with name moved to new_index (0 is first), as a new list. The index is clamped to the list; name matches in any case. Raises ValueError when it is not in names.

python
order = layouts.reorder(layouts.names(), 'Cover', 0)
layouts.set_order(order)

layouts.unique_name#

layouts.unique_name(existing, wanted)

Pure wanted, or wanted with ' (2)', ' (3)' ... when a layout in existing (any case) or the model tab already has that name. A counter wanted already carries is replaced, not stacked. Raises ValueError for a blank name.

python
layouts.unique_name(['A1', 'A1 (2)'], 'A1 (2)')    # 'A1 (3)'

plot: PDF output#

Plot a layout to a PDF (or any file a plot device writes), list plot devices and their paper sizes, and name the files of a batch. to_pdf plots in the foreground: it sets BACKGROUNDPLOT to 0 for the duration, puts it back afterwards, and returns once the file is written. Everything here also runs in Core Console. output_name, batch_names and scale_type are pure.

plot.to_pdf#

plot.to_pdf(layout, path, page_setup=None, device='DWG To PDF.pc3', paper=None, scale='fit')

Needs AutoCAD Plots one layout to path and returns path. The settings start from page_setup (a named page setup) when given, else from the layout's own plot settings; the device is set to device; paper is a canonical paper name the device offers (see plot.papers), else the layout's current paper is kept when the device has it. The plot is always the whole layout, centred, unrotated, at scale ('fit' or a ratio such as '1:50', see plot.scale_type). Raises RuntimeError when another plot is running and ValueError for an unknown layout, page setup, paper or scale. It does not create the folder.

python
plot.to_pdf('A1 - Plan', r'C:\Out\A1 - Plan.pdf', paper='ISO_full_bleed_A1_(841.00_x_594.00_MM)')

plot.devices#

plot.devices()

Needs AutoCAD Names of every plot device AutoCAD knows: its .pc3 files and the Windows printers.

python
if 'DWG To PDF.pc3' not in plot.devices():
    toast.error('DWG To PDF.pc3 is missing')

plot.papers#

plot.papers(device)

Needs AutoCAD The canonical paper size names a device offers, the names to_pdf's paper takes (not the friendly names in the Plot dialog).

python
a1 = [p for p in plot.papers('DWG To PDF.pc3') if '_A1_' in p]

plot.output_name#

plot.output_name(pattern, drawing, layout)

Pure pattern with {drawing} (the drawing's file name without folder or extension) and {layout} filled in. Characters Windows forbids in file names become _.

python
plot.output_name('{drawing} - {layout}.pdf', r'C:\Job\GA-100.dwg', 'A1/Plan')
# 'GA-100 - A1_Plan.pdf'

plot.batch_names#

plot.batch_names(drawing_name, layout_names, folder, pattern=None)

Pure (layout, path) for each layout, in order: output_name of pattern (plot.DEFAULT_PATTERN when None) inside folder, with .pdf added when missing. Two layouts never get the same path in any case: a repeat gains ' (2)', ' (3)' ... before the extension, never a name another layout already plots to.

python
for layout, target in plot.batch_names(doc.path(), layouts.names(), r'C:\Out'):
    plot.to_pdf(layout, target)

plot.scale_type#

plot.scale_type(scale)

Pure The AutoCAD StdScaleType member name for 'fit' or a ratio: '1:1', '1:2', '1:4', '1:5', '1:8', '1:10', '1:16', '1:20', '1:30', '1:40', '1:50', '1:100', '2:1', '4:1', '8:1', '10:1', '100:1', '1000:1'. Spaces and case are ignored; anything else raises ValueError.

python
plot.scale_type('1:50')    # 'StdScale1To50'
plot.scale_type('Fit')     # 'ScaleToFit'

plot.DEFAULT_PATTERN#

plot.DEFAULT_PATTERN = '{drawing} - {layout}.pdf'

Pure The file name pattern batch_names uses when you give none.

audit: drawing health#

Purge, AUDIT, zero-length and duplicate geometry, colours to ByLayer, proxy objects, and text styles whose font files cannot be found. Scans cover model space and every layout; proxies and proxy_details also look inside block definitions. The pure half plans and words the results, and is what the shipped Drawing Cleanup and Font and Proxy Report tools are built on. audit.purge has a parameter called all; pass it by keyword.

audit.purge#

audit.purge(all=True)

Needs AutoCAD Erases unreferenced blocks, layers, linetypes, text styles, dimension styles, registered applications, multiline styles, table styles and multileader styles, and returns how many it erased. With all=True it repeats until a pass finds nothing, which catches blocks nested in blocks; all=False does one pass. One transaction.

python
from pyacad import audit

removed = audit.purge()

audit.audit#

audit.audit(fix=True)

Needs AutoCAD Queues the AUDIT command, answering Yes to fixing errors when fix is true. Like doc.send_command it runs after your script returns, so nothing comes back; AUDIT reports on the command line.

python
audit.audit(fix=True)

audit.zero_length#

audit.zero_length(progress=None, cancelled=None)

Needs AutoCAD Handles of LINE and LWPOLYLINE entities with no length, in model space and every layout.

python
erased, skipped = audit.erase(audit.zero_length())

audit.duplicates#

audit.duplicates(tolerance=1e-06, progress=None, cancelled=None)

Needs AutoCAD Groups of handles (two or more each) of entities in the same space with the same type, layer and geometry, as audit.geometry_key defines it, with numbers compared to tolerance. Only the types geometry_key knows are checked: lines, circles, arcs, lightweight polylines, text, mtext and block references.

python
groups = audit.duplicates()
audit.erase(audit.duplicate_extras(groups))   # keeps the first of each group

audit.erase#

audit.erase(handles, progress=None, cancelled=None)

Needs AutoCAD Erases entities by handle in one transaction, leaving any on a locked layer alone, and returns (erased, skipped). Handles that no longer resolve are passed over without counting. This is the forgiving counterpart of ents.delete.

python
erased, skipped = audit.erase(handles)
toast.success('%d erased' % erased, '%d on locked layers left alone' % skipped)

audit.colors_to_bylayer#

audit.colors_to_bylayer(progress=None, cancelled=None)

Needs AutoCAD Sets the colour of every entity in model space and every layout, and of the attributes on block references, to ByLayer, and returns (changed, skipped), where skipped is on locked layers. Viewports and the contents of block definitions are left alone.

python
changed, skipped = audit.colors_to_bylayer()

audit.proxies#

audit.proxies()

Needs AutoCAD Handles of proxy entities (DXF name starting ACAD_PROXY) in every space and block definition of the drawing.

python
if audit.proxies():
    toast.warning('This drawing has proxy entities')

audit.proxy_details#

audit.proxy_details()

Needs AutoCAD Every proxy as a dict: handle, kind ('entity' or 'object'), class (the original class name, '' when the proxy does not say), application (what made it) and where (the space or 'BLOCK:Name' for an entity, 'Named objects' for an object). Objects are found in the named object dictionary and the dictionaries nested in it.

python
for cls, app, count in audit.proxy_summary(audit.proxy_details()):
    print(cls, app, count)

audit.text_styles_missing_fonts#

audit.text_styles_missing_fonts()

Needs AutoCAD Names of the drawing's own text styles whose font file AutoCAD cannot find (the support paths, the drawing's folder, and the Windows fonts for TrueType). Xref styles are skipped.

python
missing = audit.text_styles_missing_fonts()

audit.text_style_fonts#

audit.text_style_fonts()

Needs AutoCAD Every font file the drawing's own text styles name, as (style_name, font_file, role) rows: role 'font' for a style's font, 'big font' for its Asian big font, 'shape' for a shape file loaded for complex linetypes. Feed it to audit.missing_fonts.

python
missing = audit.missing_fonts(audit.text_style_fonts(), audit.font_finder())

audit.font_finder#

audit.font_finder()

Needs AutoCAD A callable that takes a font file name and returns True when AutoCAD can find it: the available argument of audit.missing_fonts.

python
found = audit.font_finder()
print(found('romans.shx'))

audit.set_text_style_font#

audit.set_text_style_font(style_name, font_file)

Needs AutoCAD Points a text style at another font file, such as simplex.shx or arial.ttf, clearing any TrueType typeface so the file is what counts. Text already drawn shows the new font after a REGEN. Raises ValueError for an empty file name, an unknown style, or a style that holds a shape file.

python
for kind, default, styles in audit.remap_plan(missing):
    for style in styles:
        audit.set_text_style_font(style, default)
doc.send_command('_.REGEN')

audit.geometry_key#

audit.geometry_key(snapshot, tolerance=1e-06)

Pure A hashable key for an ents.get snapshot: type, layer (ignoring case) and geometry, with every number snapped to tolerance. A line and its reverse share a key; a block reference's attribute values are part of its key. Returns None for types without a rule. Raises ValueError when tolerance is not above zero.

python
a = {'type': 'LINE', 'layer': 'A', 'start': (0, 0, 0), 'end': (1, 0, 0)}
b = {'type': 'LINE', 'layer': 'a', 'start': (1, 0, 0), 'end': (0, 0, 0)}
audit.geometry_key(a) == audit.geometry_key(b)    # True

audit.duplicate_extras#

audit.duplicate_extras(groups)

Pure The handles to delete from duplicates groups: every one but the first of each group, so one copy always survives.

python
audit.duplicate_extras([['1A', '1B', '1C'], ['2A', '2B']])   # ['1B', '1C', '2B']

audit.step_label#

audit.step_label(key)

Pure The checklist label of a cleanup step key from audit.STEPS. Raises ValueError for an unknown key.

python
audit.step_label('zero_length')   # 'Delete zero-length lines and polylines'

audit.cleanup_plan#

audit.cleanup_plan(choices, defaults=None)

Pure The cleanup steps to run, as keys in STEPS order. choices is a list of keys (exactly those run), a dict of key to true or false (overriding the defaults one step at a time), or None (the defaults run). defaults is a list of keys; None means the steps STEPS ticks by default. Unknown and repeated keys are dropped; case and spaces are ignored.

python
audit.cleanup_plan({'bylayer': True, 'zoom': False})
# ['purge', 'audit', 'zero_length', 'duplicates', 'bylayer']

audit.deleting_steps#

audit.deleting_steps(plan)

Pure The steps in a plan that erase something (purge, zero-length, duplicates), in run order: the ones to confirm with the user first.

python
audit.deleting_steps(['zoom', 'purge'])    # ['purge']

audit.format_report#

audit.format_report(step_counts)

Pure The Drawing Cleanup report, one line per step, from a list of (key, result). A result is a count, None for a step queued to run later (audit, zoom), or a dict with any of count, skipped, groups, error, cancelled, not_run. Returns 'No steps ran.' for an empty list.

python
text = audit.format_report([('purge', 14), ('audit', None),
                            ('duplicates', {'count': 3, 'groups': 2})])
# Purge: 14 unused items removed
# Audit: queued; it runs when this tool finishes and reports on the command line
# Duplicates: 3 extra copies deleted (2 groups)

audit.summary#

audit.summary(step_counts)

Pure One line for the toast after a cleanup, such as '14 purged, 3 duplicates deleted', from the same (key, result) list; 'Nothing needed cleaning' when nothing changed.

python
toast.success(audit.summary(steps), audit.format_report(steps))

audit.font_kind#

audit.font_kind(font_file)

Pure 'ttf' for a TrueType or OpenType file (.ttf, .ttc, .otf), else 'shx'.

python
audit.font_kind('Arial.TTF')    # 'ttf'

audit.missing_fonts#

audit.missing_fonts(styles, available)

Pure The rows of styles whose font file (column 2) is not available, in order; extra columns ride along, and rows with no font file are skipped. available is either a collection of font file names (compared on the bare file name ignoring case, a name with no extension meaning .shx) or a callable such as audit.font_finder(), asked once per distinct file.

python
audit.missing_fonts([('Notes', 'romans.shx'), ('Title', 'corp.ttf')], ['ROMANS'])
# [('Title', 'corp.ttf')]

audit.remap_plan#

audit.remap_plan(missing_rows)

Pure How to remap missing fonts: [(kind, default, style_names)], SHX first ('simplex.shx') then TrueType ('arial.ttf'). Only a style's own font is remapped; big fonts and shape files are left out.

python
audit.remap_plan([('Title', 'corp.ttf', 'font')])   # [('ttf', 'arial.ttf', ['Title'])]

audit.proxy_summary#

audit.proxy_summary(details)

Pure [(class, application, count)] from proxy_details rows, most common first, then by class name. A proxy with no class counts as '(unknown class)'.

python
rows = audit.proxy_summary(audit.proxy_details())
output.print_table(rows, ['Class', 'Application', 'Count'])

audit.format_font_report#

audit.format_font_report(details, missing, limit=15)

Pure The Font and Proxy Report text: proxies grouped by class, then the missing font rows. Each list stops after limit lines with ...and N more.

python
forms.alert(audit.format_font_report(details, missing), 'Font and proxy report')

audit.font_report_rows#

audit.font_report_rows(details, missing)

Pure Spreadsheet rows under audit.FONT_REPORT_HEADERS: one per proxy, then one per missing font row.

python
xl.write(path, audit.font_report_rows(details, missing), headers=audit.FONT_REPORT_HEADERS)

audit.STEPS#

audit.STEPS = ( ('purge', 'Purge everything unused (repeats until nothing is left)', True), ('audit', 'Audit the drawing and fix errors', True), ('zero_length', 'Delete zero-length lines and polylines', True), ('duplicates', 'Delete duplicate entities (same type, layer and geometry)', True), ('bylayer', 'Set every entity colour to ByLayer', False), ('zoom', 'Zoom extents', True), )

Pure The Drawing Cleanup steps as (key, checklist label, ticked by default), in the order they run: purge, audit, zero_length, duplicates, bylayer (off by default), zoom.

audit.FONT_REPORT_HEADERS#

audit.FONT_REPORT_HEADERS = ['Issue', 'Name', 'Detail', 'Where', 'Handle']

Pure The column headers for font_report_rows.

lisp: AutoLISP from Python#

Call a LISP function, queue an expression, and read the arguments a (pyacad ...) call passed in. call goes through AutoCAD's acedInvoke, which reaches only functions LISP has exposed: commands defined as C:NAME and functions registered with (vl-acad-defun 'name). Arbitrary expressions cannot be evaluated synchronously from .NET, so evaluate queues one on the command line instead. Python functions cannot be registered as LISP functions; a script is reachable from LISP as (pyacad "PYNAME" args...), which hands it the arguments and returns its __result__ (see the Commands page). encode and decode are pure.

lisp.call#

lisp.call(function, *args)

Needs AutoCAD Calls an exposed LISP function by name with Python arguments (encoded by lisp.encode) and returns its result as Python values: a single value bare, several as a list, nil as None, T as True, points as tuples, entity names as handles. Returns None when the function returns nothing. AutoCAD raises its own exception when the function is not exposed.

python
# LISP side, loaded at startup:
#   (defun pa-scale (sheet) (if (= sheet "A1") 50 100))
#   (vl-acad-defun 'pa-scale)
factor = lisp.call('pa-scale', 'A1')     # 50

lisp.evaluate#

lisp.evaluate(expression)

Needs AutoCAD Queues a LISP expression on the command line (wrapping it in parentheses when you left them off). It runs after your script returns, so this always returns None.

python
lisp.evaluate('(setvar "OSMODE" 4133)')

lisp.args#

lisp.args()

Needs AutoCAD The arguments of the (pyacad "PYNAME" args...) call, or of the PYACAD command line, that ran this script, as a list; [] when it got none. The same as script.get_args() and the __args__ global. From LISP, strings arrive as str, integers as int, reals as float, nil as None and T as True; from the PYACAD command line every argument is a string.

python
args = lisp.args()
pattern = args[0] if args else '*'

lisp.encode#

lisp.encode(value)

Pure A Python value as (type code, value) pairs: None and False are nil, True is T, strings are text, ints are 32-bit integers (reals when they do not fit), floats are reals, lists and tuples are lists, and a dict is a list of (key value) pairs. Anything else becomes its str().

python
lisp.encode(['A', 2])
# [(5016, None), (5005, 'A'), (5010, 2), (5017, None)]

lisp.decode#

lisp.decode(pairs)

Pure (type code, value) pairs back into Python values: a list with one item per top-level LISP value. Lists nest, a dotted pair closes like a list, nil is None, T is True.

python
lisp.decode([(5016, None), (5005, 'A'), (5010, 2), (5017, None)])   # [['A', 2]]

lisp type codes#

lisp.NIL = 5019
lisp.T = 5021
lisp.TEXT = 5005
lisp.INT16 = 5003
lisp.INT32 = 5010
lisp.DOUBLE = 5001
lisp.POINT2D = 5002
lisp.POINT3D = 5009
lisp.LIST_BEGIN = 5016
lisp.LIST_END = 5017
lisp.DOTTED_PAIR = 5018
lisp.VOID = 5014
lisp.ENAME = 5006

Pure AutoCAD's LISP data type codes (LispDataType), as plain ints, for reading and writing the pairs above.

units: drawing units#

The INSUNITS names, conversions through metres, and length formatting, with feet and inches for Inches and Feet drawings. Everything but current is pure. Unit names are AutoCAD's UnitsValue names ('Millimeters', 'Feet', ...) in any case, exactly what doc.units() returns.

units.current#

units.current()

Needs AutoCAD The active drawing's insertion units name; the same as doc.units().

python
from pyacad import units

name = units.current()     # 'Millimeters'

units.name_of#

units.name_of(value)

Pure The unit name for an INSUNITS number. Raises ValueError for a number that is not one.

python
units.name_of(4)    # 'Millimeters'

units.to_metres#

units.to_metres(value, unit_name)

Pure value in the named unit, as metres. Raises ValueError for an unknown name or for 'Undefined', which has no length.

python
units.to_metres(2500, 'Millimeters')    # 2.5

units.from_metres#

units.from_metres(value, unit_name)

Pure value in metres, as the named unit. Fails like to_metres.

python
units.from_metres(1.0, 'Feet')    # 3.2808...

units.format_length#

units.format_length(value, unit_name, precision=2)

Pure A length for display: feet and inches such as 2'-6.50" for Inches and Feet drawings, the number and short label such as '12.35 mm' for the others, and the bare number for Undefined. precision is decimal places.

python
units.format_length(30.5, 'Inches')           # 2'-6.50"
units.format_length(12345.6, 'Millimeters')   # '12345.60 mm'
units.format_length(12.5, 'Feet', 0)          # 12'-6"

units.UNITS#

units.UNITS = { 0: ('Undefined', None, ''), 1: ('Inches', 0.0254, 'in'), 2: ('Feet', 0.3048, 'ft'), 3: ('Miles', 1609.344, 'mi'), 4: ('Millimeters', 0.001, 'mm'), 5: ('Centimeters', 0.01, 'cm'), 6: ('Meters', 1.0, 'm'), 7: ('Kilometers', 1000.0, 'km'), 8: ('MicroInches', 2.54e-08, 'uin'), 9: ('Mils', 2.54e-05, 'mil'), 10: ('Yards', 0.9144, 'yd'), 11: ('Angstroms', 1e-10, 'A'), 12: ('Nanometers', 1e-09, 'nm'), 13: ('Microns', 1e-06, 'um'), 14: ('Decimeters', 0.1, 'dm'), 15: ('Dekameters', 10.0, 'dam'), 16: ('Hectometers', 100.0, 'hm'), 17: ('Gigameters', 1000000000.0, 'Gm'), 18: ('Astronomical', 149597870700.0, 'au'), 19: ('LightYears', 9460730472580800.0, 'ly'), 20: ('Parsecs', 3.085677581491367e+16, 'pc'), 21: ('USSurveyFeet', 1200.0 / 3937.0, 'ft'), 22: ('USSurveyInch', 100.0 / 3937.0, 'in'), 23: ('USSurveyYard', 3600.0 / 3937.0, 'yd'), 24: ('USSurveyMile', 6336000.0 / 3937.0, 'mi'), }

Pure INSUNITS number to (name, metres per unit, short label) for all 25 values AutoCAD defines, from 0 (Undefined, no metres) to 24 (USSurveyMile).

forms: dialogs#

Modal dialogs in pyACAD's own style (rounded corners, dark title bar in dark mode, the Windows accent colour): an alert, a yes or no question, text, number and file prompts, a searchable list picker, a one-click option picker, a folder picker, a cancellable progress window, and custom windows loaded from XAML. None of them touch the drawing. Every one blocks your script until the user answers, and cancelling always returns None, never raises. Under pyacad run, where nobody can answer, alert becomes a line in the transcript and the others raise InvalidOperationException (check script.is_runner() first).

forms.alert#

forms.alert(message, title='pyACAD', copy=None)

Needs AutoCAD Blocks Shows a message with an OK button. copy adds a Copy button that puts that text on the clipboard without closing the dialog. Returns None.

python
from pyacad import forms

forms.alert('3 xrefs could not be found.', 'Xref check', copy=report_text)

forms.confirm#

forms.confirm(message, title='pyACAD')

Needs AutoCAD Blocks A Yes and No question. Returns True on Yes, False on No or when the window is closed.

python
if forms.confirm('Delete 14 duplicate entities?'):
    audit.erase(extras)

forms.ask_string#

forms.ask_string(prompt, default='', title='pyACAD')

Needs AutoCAD Blocks A single-line text prompt. Returns what was typed (possibly an empty string), or None on cancel.

python
prefix = forms.ask_string('Freeze layers starting with:', default='Z-')
if prefix:
    layers.freeze(prefix + '*')

forms.ask_number#

forms.ask_number(prompt, default=None, min_value=None, max_value=None, title='pyACAD')

Needs AutoCAD Blocks A number prompt that validates as the user types (and against min_value and max_value when given). Returns a float, or None on cancel.

python
height = forms.ask_number('Text height', default=2.5, min_value=0.1)

forms.ask_options#

forms.ask_options(prompt, options, title='pyACAD')

Needs AutoCAD Blocks One click between a few options, shown as buttons: clicking one is the answer, with no OK. Returns the option you passed (the object itself, not its text), or None on Esc. With prompt set to None or '' the window drops its title bar and question and a click anywhere outside it cancels, for a quick switch.

python
scope = forms.ask_options('Export which blocks?', ['Selected', 'Whole drawing'])

forms.select_from_list#

forms.select_from_list(items, title='Select', multiselect=False, prompt=None, checked=None)

Needs AutoCAD Blocks A searchable list picker. items are strings, or (label, value) pairs where the label is shown and the value returned. With multiselect the user ticks several and you get a list of values; otherwise you get one value. checked lists values to open already ticked (or highlighted, single select). Returns None on cancel.

python
names = forms.select_from_list(layers.names(), title='Freeze layers', multiselect=True)
if names:
    layers.freeze(names)

forms.save_file#

forms.save_file(filter='CSV files (*.csv)|*.csv|All files (*.*)|*.*', default_name='', title='Save file')

Needs AutoCAD Blocks The Windows Save dialog (which asks before overwriting). filter is a Windows filter string. Returns the chosen path, or None on cancel.

python
path = forms.save_file('Excel workbook (*.xlsx)|*.xlsx', 'Blocks.xlsx', 'Export blocks')

forms.open_file#

forms.open_file(filter='All files (*.*)|*.*', title='Open file')

Needs AutoCAD Blocks The Windows Open dialog. Returns the chosen path, or None on cancel.

python
path = forms.open_file('CSV files (*.csv)|*.csv', 'Choose a layer standard')

forms.pick_folder#

forms.pick_folder(title='Select folder', initial=None)

Needs AutoCAD Blocks A folder picker, starting at initial when given. Returns the path, or None on cancel.

python
folder = forms.pick_folder('Save PDFs to')

forms.progress#

forms.progress(title, label='')

Needs AutoCAD A context manager that shows a progress window with a Cancel button and closes it when the block ends, however it ends. with hands you an object with two methods:

  • update(fraction, label=None) moves the bar (0.0 to 1.0) and, when given, changes the label. It returns False once the user has clicked Cancel. Leave label out after a cancel so the window's own "Cancelling..." stays.
  • check() lets the window process clicks and raises forms.Cancelled once the user has clicked Cancel. A loop that never calls either method cannot be cancelled, because your script holds the UI thread.
python
handles = list(ents.iter())
with forms.progress('Checking entities') as bar:
    for index, handle in enumerate(handles):
        bar.check()
        bar.update(float(index) / len(handles), handle)

forms.Cancelled#

forms.Cancelled(Exception)

Needs AutoCAD The exception check() raises after a Cancel click. It is an ordinary Python exception, but it lives in forms, which imports only inside AutoCAD. Catch it to stop quietly.

python
try:
    with forms.progress('Exporting') as bar:
        for index, item in enumerate(items):
            bar.check()
            work(item)
except forms.Cancelled:
    toast.info('Cancelled')

forms.WPFWindow#

forms.WPFWindow(xaml_file)
WPFWindow.find(name)
WPFWindow.show_dialog()
WPFWindow.close(result=None)

Needs AutoCAD Blocks A WPF window loaded from a XAML file. A relative xaml_file is looked up in the running bundle's folder; a missing file raises IOError. The loaded Window is .window. win['Name'] returns the element with that x:Name (or raises KeyError), find(name) returns it or None. show_dialog() applies pyACAD's window chrome, shows the window modally over AutoCAD and returns its DialogResult (it raises under pyacad run). close(result) sets DialogResult when result is given, then closes.

python
win = forms.WPFWindow('rename.xaml')
win['Ok'].Click += lambda sender, args: win.close(True)
if win.show_dialog():
    layers.rename(old, win['NewName'].Text)

toast: status notes#

A toast is a small card in the corner of the AutoCAD window: green for success, red for an error, blue for information, amber for a warning. It never takes focus and dismisses itself (after 4 seconds, 5 for a warning, 7 for an error), so a tool can say what it did without opening the output window. This is where one-line statuses go, never print(), which opens an output window for one line. A toast never raises: if one cannot be shown, the failure goes to the log. Under pyacad run it becomes a transcript line.

toast.show#

toast.show(level, message, detail=None)

Needs AutoCAD Shows a toast. level is 'success', 'error', 'info' or 'warning' (anything else shows as success); detail is an optional second, smaller line.

python
from pyacad import toast

toast.show('warning', 'Layer A-ANNO is locked', 'Unlock it and run this again')

toast.success, error, info, warning#

toast.success(message, detail=None)
toast.error(message, detail=None)
toast.info(message, detail=None)
toast.warning(message, detail=None)

Needs AutoCAD toast.show with the level filled in.

python
toast.success('Exported 42 blocks', path)
toast.error('Could not write the workbook', 'Is it open in Excel?')

Where a toast is a note in the corner, a banner is a readout: a bar across the full width of the bottom of the AutoCAD window, filled in the level colour, with the message in large type. Use it for the one value a tool exists to produce, such as a measurement, and for an instruction the tool is waiting on. There is only ever one banner; a new one replaces the last. It never takes focus, a click dismisses it, and by default it goes after 8 seconds (10 for a warning, 12 for an error).

banner.show(level, message, detail=None, seconds=None)

Needs AutoCAD Shows the banner. level is as for toasts. seconds is how long it stays: None for the level's own time, 0 to stay until banner.clear(), another banner, a click or Reload.

python
from pyacad import banner

banner.show('success', 'Total duct run: 148.20 m', '37 segments on M-DUCT*', seconds=20)
banner.success(message, detail=None)
banner.error(message, detail=None)
banner.info(message, detail=None)
banner.warning(message, detail=None)

Needs AutoCAD banner.show with the level filled in and the level's own duration.

python
banner.info('Area: 42.6 m2')
banner.prompt(message, detail=None)

Needs AutoCAD An instruction the tool is waiting on: an info banner that stays until replaced or cleared. Pair it with banner.clear() once the user has answered.

python
banner.prompt('Pick the insertion points', 'Press Enter or Esc to finish')
try:
    point = sel.pick_point('Point: ')
finally:
    banner.clear()
banner.clear()

Needs AutoCAD Removes the banner, if one is showing.

python
banner.clear()

output: the output window#

The output window is where a report goes: HTML, markdown, tables, charts, images and links that select objects in the drawing. Each command run gets its own window, titled after the tool, which opens the first time you write to it (a script that writes nothing gets none). When the WebView2 runtime is missing it falls back to plain text, and under pyacad run everything goes to the transcript; your script never needs to care. table_html and format_table are pure, so the module imports anywhere.

output.print_md#

output.print_md(markdown)

Needs AutoCAD Renders markdown: #, ## and ### headings, **bold**, *italic*, `code`, fenced code blocks, - lists, [text](url) links and paragraphs. Everything is escaped first, so HTML in the text shows as text.

python
from pyacad import output

output.print_md('## Xref check\n\n- **3** loaded\n- **1** not found')

output.print_html#

output.print_html(html)

Needs AutoCAD Renders an HTML fragment as it is. Escape anything that came from the drawing yourself.

python
output.print_html('<p>Largest: <b>%d</b> objects</p>' % biggest)

output.print_table#

output.print_table(rows, headers)

Needs AutoCAD A styled table. Note the order: rows first, then headers. Rows are lists or tuples of any values (each is shown with str()); short rows are padded. A column whose every filled cell is a number is right-aligned.

python
output.print_table([['A-WALL', 120], ['A-DOOR', 14]], ['Layer', 'Objects'])

output.print_code#

output.print_code(text)

Needs AutoCAD A monospace block, escaped, scrolling sideways rather than wrapping.

python
output.print_code(xrefs.format_plan(rows, plan))

output.print_image#

output.print_image(path, caption=None)

Needs AutoCAD Embeds a .png, .jpg, .jpeg, .gif or .svg file, with an optional caption. Raises ValueError for any other extension.

python
output.print_image(r'C:\Out\preview.png', 'Sheet A1 preview')

output.chart_bar#

output.chart_bar(labels, values, title=None)

Needs AutoCAD A bar chart card, drawn as SVG that follows the window's light or dark theme. labels and values pair up by position.

python
counts = blocks.counts()
names = sorted(counts, key=counts.get, reverse=True)[:10]
output.chart_bar(names, [counts[n] for n in names], 'Top 10 blocks')

output.chart_line#

output.chart_line(labels, series, title=None)

Needs AutoCAD A line chart. series is {name: [values]}, every list sharing the labels axis.

python
output.chart_line(['L1', 'L2', 'L3'], {'Supply': [40, 52, 47], 'Return': [38, 50, 44]},
                  'Duct length by level (m)')

output.chart_pie#

output.chart_pie(labels, values, title=None)

Needs AutoCAD A pie chart card.

python
output.chart_pie(['Lines', 'Polylines', 'Arcs'], [120, 48, 9], 'Entity mix')

output.chart_doughnut#

output.chart_doughnut(labels, values, title=None)

Needs AutoCAD The pie chart with a hole in the middle.

python
output.chart_doughnut(['Loaded', 'Not found'], [5, 2], 'Xrefs')
output.element_link(item, label)

Needs AutoCAD HTML for a link that, when clicked, selects the object in the drawing (it becomes the pickfirst set, with grips). item is a handle string; the label is escaped by the window. Use the result inside print_html or a table cell. With no output window (plain text fallback) you get the label back.

python
for handle in audit.zero_length():
    output.print_html('<div>%s</div>' % output.element_link(handle, 'Zero-length ' + handle))

output.progress#

output.progress(fraction, label='')

Needs AutoCAD Shows or moves a progress bar inside the output window; fraction runs from 0 to 1, and 1.0 completes it. For a progress window the user can cancel, use forms.progress.

python
output.progress(0.5, 'Half way')

output.set_title#

output.set_title(text)

Needs AutoCAD Retitles the output window (the default title is pyACAD - plus the tool's title).

python
output.set_title('Xref report: ' + doc.path())

output.save#

output.save(path)

Needs AutoCAD Writes everything printed so far to path as a standalone HTML file.

python
output.save(r'C:\Out\layer-report.html')

output.table_html#

output.table_html(rows, headers)

Pure The HTML print_table renders, as a string: escaped cells, numeric columns marked for right alignment.

python
html = output.table_html([['A', 1]], ['Layer', 'Count'])

output.format_table#

output.format_table(rows, headers)

Pure Rows as an aligned plain-text table with a dashed rule under the headers, for a log file, the clipboard or an alert.

python
text = output.format_table([['A-WALL', 120], ['A-DOOR', 14]], ['Layer', 'Objects'])
script.clipboard_copy(text)

script: the running command#

What the running command is (its script, folder, title, arguments), the pyACAD session (toggle state, shared variables, theme, Reload), per-tool settings and data files, a logger, and a few Windows conveniences. It needs only the pyACAD runtime, never the AutoCAD API, so every function works in a hook or a dock pane as well as a button.

"The running command" is the last one that ran

The runtime records the command's identity when a button or command starts and keeps answering with it until the next one starts. A hook, startup.py, a smartbutton's ribbon-build run and a dock pane's script.py never set it, so inside them get_command_path, get_config, store_data and friends answer for whichever button the user clicked last. In those places use pyacad.settings with a tool key you choose. Code that runs after a button's script.py returns (a modeless window's handlers) should capture the identity first with script.bind().

script.get_host#

script.get_host()

Needs AutoCAD The runtime's host object, the same object scripts receive as the __pyacad__ global. Everything else in this module reads from it.

python
from pyacad import script

host = script.get_host()

script.get_script_path#

script.get_script_path()

Needs AutoCAD Full path of the running command's script.py.

python
print(script.get_script_path())

script.get_command_path#

script.get_command_path()

Needs AutoCAD The running command's bundle folder, where its icons and any files you ship beside script.py live.

python
import os
template = os.path.join(script.get_command_path(), 'template.xlsx')

script.get_bundle_file#

script.get_bundle_file(name)

Needs AutoCAD Full path of a file inside the running command's bundle folder, or None when it does not exist.

python
csv_path = script.get_bundle_file('standard.csv')
if csv_path is None:
    toast.error('standard.csv is missing from the bundle')

script.get_title#

script.get_title()

Needs AutoCAD The running command's title, as shown on the ribbon.

python
forms.alert('Nothing to do.', script.get_title())

script.get_document_path#

script.get_document_path()

Needs AutoCAD Full path of the active drawing, 'untitled' when it was never saved, None when no drawing is open. Unlike doc.path() it never raises, so it is the cheap way to ask "is there a drawing at all?".

python
if script.get_document_path() is None:
    toast.warning('Open a drawing first')

script.get_args#

script.get_args()

Needs AutoCAD The running script's __args__ as a list: what the PYACAD command line or a (pyacad "PYNAME" args...) call passed; [] for a ribbon click. It reads the global from the calling script, so it also works from a helper module the script imported.

python
args = script.get_args()

script.get_host_version#

script.get_host_version()

Needs AutoCAD pyACAD's product version string, such as '0.1.0'. The build stamps the runtime from pyacad.__version__, so the two agree.

python
script.get_logger().info('pyACAD ' + script.get_host_version())

script.is_dark_theme#

script.is_dark_theme()

Needs AutoCAD True when the AutoCAD UI is dark. Use it to theme your own XAML windows.

python
background = '#1E1E1E' if script.is_dark_theme() else '#FFFFFF'

script.is_runner#

script.is_runner()

Needs AutoCAD True when the script runs under pyacad run (Core Console, no ribbon, no windows). Skip prompts when it is; the __runner__ global says the same.

python
folder = r'C:\Out' if script.is_runner() else forms.pick_folder('Save PDFs to')

script.reload_pyacad#

script.reload_pyacad()

Needs AutoCAD Rescans the extension folders and rebuilds the pyACAD ribbon, as the Reload button does. No restart needed.

python
script.reload_pyacad()

script.get_toggle_state#

script.get_toggle_state()

Needs AutoCAD True when this *.toggle bundle is on. The state lives for the AutoCAD session only and starts off.

python
on = not script.get_toggle_state()

script.set_toggle_state#

script.set_toggle_state(on)

Needs AutoCAD Sets this toggle's state and swaps its ribbon icon to icon.on or icon.off. It only works from the toggle's own script: the state is keyed on the running command, which a hook or startup.py never sets. A toggle that must survive a restart should save its value with pyacad.settings (the cookbook shows how).

python
script.set_toggle_state(on)

script.get_envvar#

script.get_envvar(name, default=None)

Needs AutoCAD A session variable shared by every pyACAD script in this AutoCAD session, or default when it is unset. Names ignore case. Nothing is written to disk.

python
last_folder = script.get_envvar('pdf_folder', r'C:\Out')

script.set_envvar#

script.set_envvar(name, value)

Needs AutoCAD Stores a small value for other scripts this session; None removes it.

python
script.set_envvar('pdf_folder', folder)

script.get_config#

script.get_config(defaults, _own=None)

Needs AutoCAD This tool's saved settings merged over defaults (see settings.load): keys missing from the file take the default, keys not in defaults are dropped. The file is %APPDATA%\pyACAD\settings\cfg_<bundle key>.json, so a tool's config.py (Shift+Click) and its script.py share it. Leave _own alone; it is how script.bind() pins the key.

python
cfg = script.get_config({'prefix': 'Z-', 'regen': True})

script.save_config#

script.save_config(values, _own=None)

Needs AutoCAD Saves this tool's settings, replacing the file, so only the keys you pass survive. Written atomically.

python
cfg['prefix'] = answer
script.save_config(cfg)

script.reset_config#

script.reset_config(_own=None)

Needs AutoCAD Deletes this tool's settings file, if there is one, so the defaults apply again.

python
if forms.confirm('Forget your saved settings?'):
    script.reset_config()

script.store_data#

script.store_data(key, value, per_document=False, _own=None)

Needs AutoCAD Saves a JSON-serialisable value under key in this tool's data file, %APPDATA%\pyACAD\data\cfg_<bundle key>.json. With per_document=True the file is per drawing (its name carries the drawing's name and a hash of its full path); that raises RuntimeError when no drawing is open, and every untitled drawing shares one file. Other keys in the file are kept, and a file that will not parse is kept beside it as .corrupt before being replaced.

python
script.store_data('snapshot', layers.snapshot(), per_document=True)

script.load_data#

script.load_data(key, default=None, per_document=False, _own=None)

Needs AutoCAD A value saved with store_data, or default when it is not there or the file is missing or corrupt.

python
saved = script.load_data('snapshot', per_document=True)

script.data_exists#

script.data_exists(key, per_document=False, _own=None)

Needs AutoCAD True when key is in this tool's data file.

python
if not script.data_exists('snapshot', per_document=True):
    toast.info('No snapshot saved for this drawing yet')

script.get_data_file#

script.get_data_file(suffix='json')

Needs AutoCAD The path of this tool's shared data file with the given extension, for when you want to write the file yourself (a CSV log, say). The folder may not exist yet.

python
log_path = script.get_data_file('csv')

script.bind#

script.bind()

Needs AutoCAD Pins this command's identity for code that runs after script.py has returned, and returns a BoundScript. Call it at the top of the script, while the identity is still right.

python
me = script.bind()

def on_closed(sender, args):        # fires long after script.py returned
    me.save_config({'width': window.Width})

script.BoundScript#

script.BoundScript(own)
BoundScript.get_config(defaults)
BoundScript.save_config(values)
BoundScript.reset_config()
BoundScript.store_data(key, value, per_document=False)
BoundScript.load_data(key, default=None, per_document=False)
BoundScript.data_exists(key, per_document=False)

Needs AutoCAD What script.bind() returns: the settings and data functions above, fixed to the tool that was running when it was made. You never construct one yourself.

python
me = script.bind()
cfg = me.get_config({'folder': ''})

script.get_logger#

script.get_logger()

Needs AutoCAD A logger for this script with debug, info, success, warning and error methods (each takes one message) and set_level(level), where level is 'debug', 'info' (the default), 'warning' or 'error'. Lines go to the pyACAD log labelled with the tool's title (or hook <name> in a hook); warnings and errors also appear in the output window, but only when one is already open, so logging never pops up a window. One logger per label, cached.

python
log = script.get_logger()
log.info('Starting export of %d blocks' % len(refs))
log.warning('3 references were on locked layers')

script.clipboard_copy#

script.clipboard_copy(text)

Needs AutoCAD Puts text on the Windows clipboard.

python
script.clipboard_copy('\n'.join(layers.names()))

script.clipboard_text#

script.clipboard_text()

Needs AutoCAD The clipboard's text, or '' when it holds none or another program has it locked. Never raises.

python
handles = script.clipboard_text().split()

script.open_url#

script.open_url(url)

Needs AutoCAD Opens a URL in the default browser.

python
script.open_url('https://help.autodesk.com/view/OARX/2026/ENU/')

script.show_in_explorer#

script.show_in_explorer(path)

Needs AutoCAD Opens Explorer with the file selected, or at the folder.

python
script.show_in_explorer(path)

settings: per-tool settings files#

One JSON file per tool key under %APPDATA%\pyACAD\settings. Pure standard library with no AutoCAD and no runtime, so it works in any engine, in hooks and panes, and outside AutoCAD. script.get_config is built on it with the running tool's key; use this module directly when you want a key you choose, which is the only reliable choice in a hook, a dock pane or startup.py, and the way two tools share settings.

settings.load#

settings.load(tool, defaults)

Pure The tool's saved values merged over defaults (settings.merge). A missing, unreadable or non-object file gives a fresh copy of the defaults; it never raises.

python
from pyacad import settings

cfg = settings.load('save_log', {'on': False, 'folder': ''})

settings.save#

settings.save(tool, values)

Pure Writes values as the tool's settings file (sorted, indented JSON), creating the folder when needed. The write is atomic: a crash leaves the old file intact. values must be JSON-serialisable.

python
settings.save('save_log', {'on': True, 'folder': r'C:\Logs'})

settings.merge#

settings.merge(defaults, stored)

Pure defaults overlaid with stored: only keys in defaults survive, so stale keys from an older version of a tool never leak back.

python
settings.merge({'a': 1, 'b': 2}, {'b': 3, 'old': 9})    # {'a': 1, 'b': 3}

settings.path_for#

settings.path_for(tool)

Pure The settings file path for a tool key: %APPDATA%\pyACAD\settings\<tool>.json.

python
print(settings.path_for('save_log'))

xl: Excel workbooks#

Reads and writes .xlsx files in pure Python, with no Excel and no third-party package, so it runs the same under IronPython and CPython, inside or outside AutoCAD. It writes a minimal valid workbook (inline strings, a bold header row, approximate column widths) and reads both its own files and workbooks Excel saved, shared strings included. Formulas, styles and charts are not its business.

xl.write#

xl.write(path, rows, headers=None, sheet='Sheet1')

Pure Writes rows (lists of str, int, float, bool or None) to the sheet sheet of the workbook at path, with headers as a bold first row when given. Creates the folder when needed. If the file already holds a workbook, the named sheet is replaced in place (or added at the end) and the other sheets are kept, but only as values: they lose their formatting, a bold header row included, and their ints come back as floats. The file is written to a temporary file first and moved into place, so a failure leaves the old workbook intact. A file open in Excel raises IOError or OSError.

python
from pyacad import xl

xl.write(r'C:\Out\blocks.xlsx', [['DOOR', 12], ['WINDOW', 30]], headers=['Block', 'Count'])

xl.read#

xl.read(path, sheet=None)

Pure The rows of the named sheet, or the first sheet when sheet is None. Numbers come back as float, booleans as True or False, everything else as str. A row is as long as its last used column, with gaps as ''; blank rows come back as [], so row positions match the sheet. Raises ValueError for a missing sheet.

python
rows = xl.read(path)
headers, body = rows[0], rows[1:]

xl.sheets#

xl.sheets(path)

Pure The workbook's sheet names, in order.

python
if 'Blocks' in xl.sheets(path):
    rows = xl.read(path, sheet='Blocks')

The private modules#

These turn up in tracebacks. Do not import them.

ModuleWhat it is
_apiLoads the AutoCAD .NET assemblies and exposes their namespaces to the other modules. Importing it outside AutoCAD raises the ImportError the drawing functions pass on.
_atomicWrites a text file through a temporary file and a rename, so a crash never leaves a half-written settings or data file.
_chartsDraws the bar, line, pie and doughnut SVGs for output.chart_*, coloured from the output window's theme variables.
_datafilesThe file naming and JSON storage behind script.store_data.
_historyThe pyACAD console's command history (the up and down arrows).
_logThe level-filtered logger script.get_logger returns.
_markdownThe small markdown converter behind output.print_md, and HTML escaping.
_replRuns a line typed in the pyACAD console and echoes a trailing expression's value.
_utilTree flattening helpers.
_xlsxThe OpenXML building and parsing behind xl.