Back to Blog
Python OnrampPythonAutoLISPAutoCADSolar EngineeringOOPBeginner

Python Functions, Classes, and Dataclasses for AutoLISP Programmers

How Python's def, class, and @dataclass map to AutoLISP defun and list-based data — translated for solar engineers who think in LISP and need to read modern Python code for AutoCAD work.

Leaf Engineering
Leaf Automation
April 9, 2026
~25 min read

Python Functions, Classes, and Dataclasses for AutoLISP Programmers

You ran your first Python script. You can read import csv and with open(...) as f: and follow what's happening. Then you opened a real Python file from a solar tutorial — maybe a stringing script — and saw class Panel:, @dataclass, def __init__(self, ...), self.x = x. The LISP analogies stop working. This is where most LISP-to-Python crossings get stuck.

By the end of this post you'll be able to read a Python class, write a dataclass, and stop being intimidated by self. Every concept gets a LISP equivalent first, then the places where the analogy breaks. Plan for 25 minutes.

Functions: defun → def

In LISP, you define a function with defun. In Python, you use def. The structure is nearly identical.

(defun calc-string-length (panel-count panel-width spacing)
  (+ (* panel-count panel-width)
     (* (- panel-count 1) spacing)))
def calc_string_length(panel_count, panel_width, spacing):
    return panel_count * panel_width + (panel_count - 1) * spacing

The correspondence is exact:

  • defundef
  • Parameters in parentheses → parameters in parentheses (same idea, no quotes around the name)
  • LISP returns the value of the last expression automatically → Python requires an explicit return keyword
  • Parentheses group the arithmetic in LISP → indentation and operator precedence do it in Python

The indentation rule is the one that surprises LISP programmers most. Python uses the level of indentation to define what belongs inside a function — no closing parenthesis, no end. Mess up the indentation and you get an IndentationError. VS Code handles this automatically with Tab, so in practice it's not a problem.

Python adds two features you'll see in real code that LISP doesn't have cleanly:

Default values. You can make a parameter optional by giving it a default:

def calc_string_length(panel_count, panel_width=1.0, spacing=0.05):
    return panel_count * panel_width + (panel_count - 1) * spacing

Callers can omit panel_width and spacing and get the defaults. In LISP you'd handle this with (if (null panel-width) (setq panel-width 1.0) ...). The Python version is cleaner.

Type hints. Modern Python code annotates parameter types. The hints are optional — Python won't enforce them at runtime — but you'll see them everywhere:

def calc_string_length(panel_count: int, panel_width: float = 1.0, spacing: float = 0.05) -> float:
    return panel_count * panel_width + (panel_count - 1) * spacing

The -> float says the function returns a float. Read these as documentation, not enforcement.

Lists: LISP lists → Python lists

LISP is built around lists. Python has lists too, but they work differently.

(setq panels '("P001" "P002" "P003"))
(car panels)        ; "P001"
(cdr panels)        ; ("P002" "P003")
(length panels)     ; 3
panels = ["P001", "P002", "P003"]
panels[0]           # "P001"
panels[1:]          # ["P002", "P003"]
len(panels)         # 3

The differences:

  • Square brackets instead of parens
  • Index by position with [n] — zero-indexed, so the first element is [0], not (car)
  • panels[1:] is Python's slice syntax. Read it as "from index 1 to the end." panels[1:3] would give you indices 1 and 2. There is no direct LISP equivalent that's as concise — slicing is one of Python's genuinely better ideas
  • len(panels) instead of (length panels)
  • Lists can be modified in place: panels.append("P004") adds to the end. LISP lists are typically built up with cons, not mutated

One thing to keep in mind: Python lists can hold mixed types ([1, "hello", 3.14]), but in production code they shouldn't. If you have a list of panel IDs, every element should be a string. Mixed-type lists will work until they don't, and the error you get is confusing.

Dictionaries: the data structure LISP doesn't have cleanly

LISP programmers have probably faked dictionaries with association lists — (list (cons 'id "P001") (cons 'x 10.5)) — and retrieved values with (cdr (assoc 'x panel)). It works, but it's not first-class. Python has dictionaries as a built-in type and uses them constantly.

panel = {"id": "P001", "x": 10.5, "y": 20.3, "type": "REC400"}
panel["id"]            # "P001"
panel["x"] = 11.0      # update existing key
panel["mppt"] = 1      # add a new key
"id" in panel          # True

This matters because every CSV row, every JSON object, every API response is a dictionary. The csv.DictReader from your first script was handing you one of these for each row. You will see this data structure in every Python file you read.

The LISP assoc equivalent is panel["key"]. The difference: Python raises a KeyError immediately if the key doesn't exist, whereas (assoc 'missing panel) silently returns nil. Python's behavior is usually better — it fails loud and fast rather than propagating a nil downstream.

If you want a safe lookup that returns a default instead of an error, use .get():

panel.get("mppt", 1)   # returns panel["mppt"] if it exists, else 1

The OOP wall: classes

This is where most LISP programmers stall. A class is a template for making objects. Each object made from the class has its own copy of the data and shares the same methods (functions that operate on the data).

The reason this maps awkwardly from LISP is that LISP separates data from the functions that operate on it. A panel is a list of property pairs. The functions that work with panels are free-standing defun calls that take the panel list as their first argument:

(defun make-panel (id x y type)
  (list (cons 'id id) (cons 'x x) (cons 'y y) (cons 'type type)))

(defun panel-x (panel)
  (cdr (assoc 'x panel)))

(setq p1 (make-panel "P001" 10.5 20.3 "REC400"))
(panel-x p1)        ; 10.5

In Python, you bundle the data and the functions that operate on it into one thing called a class:

class Panel:
    def __init__(self, panel_id, x, y, panel_type):
        self.id = panel_id
        self.x = x
        self.y = y
        self.type = panel_type

p1 = Panel("P001", 10.5, 20.3, "REC400")
p1.x        # 10.5

Walk through each piece:

  • class Panel: — declare a new type called Panel. Everything indented underneath it belongs to the class.
  • def __init__(self, ...) — the constructor. Python calls this automatically when you write Panel(...). The double underscores (called "dunder" in Python circles) mean Python uses this method name internally — you don't call __init__ directly, you just write Panel(...) and Python handles it.
  • self — the object being constructed. It is the Python equivalent of passing panel as the first argument to every LISP function that operates on a panel. Every method on a class gets self as its first parameter, and Python passes it automatically — you never supply it when calling the method.
  • self.id = panel_id — store an attribute on the object. The equivalent of (cons 'id id) in your LISP property list. After construction, p1.id retrieves it, just like (cdr (assoc 'id p1)) in LISP, but without the cdr/assoc ceremony.

The mental shift from LISP: the data lives inside the object. Functions that operate on it are written inside the class definition and travel with it. Any module that imports your Panel class gets both the data structure and the operations together.

@dataclass: the shortcut you actually want

The __init__ boilerplate in the example above gets repetitive quickly. For every attribute you add, you write self.x = x twice — once in the parameter list, once in the assignment. Python has a decorator called @dataclass that writes that boilerplate for you.

from dataclasses import dataclass

@dataclass
class Panel:
    id: str
    x: float
    y: float
    type: str

p1 = Panel("P001", 10.5, 20.3, "REC400")
p1.x        # 10.5

That is the same class. Four field declarations instead of eight lines of constructor code. Python reads the type hints, auto-generates __init__, and also generates a __repr__ — which means print(p1) shows Panel(id='P001', x=10.5, y=20.3, type='REC400') instead of <Panel object at 0x7f3c...>.

@dataclass is everywhere in modern Python code (Python 3.7+, released 2018). For anything you write yourself, start with it. The manual __init__ version is worth being able to read — library code from before 2018 and some frameworks use it — but you rarely need to write it.

The @ syntax is a decorator. Read it as "apply this transformation to the class that follows." The details of how decorators work aren't necessary to use them — the important part is recognizing that @dataclass before a class declaration means Python is handling the setup code for you.

A method on a class

Adding a method to a class means adding a def inside the class body:

@dataclass
class Panel:
    id: str
    x: float
    y: float
    type: str

    def distance_to(self, other):
        return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5

p1 = Panel("P001", 10.5, 20.3, "REC400")
p2 = Panel("P002", 12.0, 20.3, "REC400")
p1.distance_to(p2)      # 1.5

self is the panel you're calling the method on (p1). other is the panel you're passing in (p2). The dot notation — p1.distance_to(p2) — reads as "call distance_to on p1, passing p2."

In LISP this would have been a free-standing function:

(defun panel-distance (p1 p2)
  (sqrt (+ (expt (- (cdr (assoc 'x p1)) (cdr (assoc 'x p2))) 2)
           (expt (- (cdr (assoc 'y p1)) (cdr (assoc 'y p2))) 2))))

(panel-distance p1 p2)    ; 1.5

The computation is the same. The Python version puts the function inside the class so it travels with the data. When you pass a Panel to another function, the distance_to method comes along.

Reading a typical solar Python script

Here's a real-world snippet you'd find in any solar Python codebase. This is the kind of thing that looks like a wall of syntax until you know what each part does:

from dataclasses import dataclass, field
from typing import List

@dataclass
class String:
    string_id: int
    panels: List[Panel] = field(default_factory=list)
    mppt_input: int = 1

    def total_modules(self):
        return len(self.panels)

    def add_panel(self, panel: Panel):
        self.panels.append(panel)

Line by line:

  • from typing import List — type hints for collections. In Python 3.8 and earlier, you write List[Panel] to say "a list of Panel objects." In Python 3.9+, you can write list[Panel] directly. You'll see both in the wild.
  • panels: List[Panel] = field(default_factory=list) — the field(default_factory=list) part tells Python "give each String instance its own empty list." If you wrote panels: List[Panel] = [] instead, every String would share the same list — a subtle but destructive bug. field(default_factory=...) is the correct way to set a mutable default in a dataclass.
  • mppt_input: int = 1 — a default value. Any String created without specifying mppt_input gets 1.
  • self.panels.append(panel) — modifies the list belonging to this specific String instance, not some shared list.

If you can read that snippet, you can read 80% of the solar Python code on GitHub. The remaining 20% is third-party libraries — numpy, pdfplumber, shapely — which you pick up one at a time as you need them.

Where to go next

  1. Reading SolarEdge PDFs in Python: A Beginner's Guide to pdfplumber — your first third-party library. Parses a real SolarEdge Designer report and uses the data structures from this post. (~30 minutes)
  2. Stringing Solar Panels for AutoCAD with Python — a 200-line working solver that uses @dataclass throughout. Now that you know what Panel, SolverConfig, and StringResult mean as dataclasses, the code reads cleanly. (~45 minutes)
  3. Parsing SolarEdge Designer PDFs for AutoCAD with Python and pdfplumber — production-grade PDF extraction with all four pitfalls handled. The code here is more complex, but the class and dataclass patterns are the same ones from this post. (~45 minutes)

If you'd rather skip the code entirely — Branch handles the PDF import, string routing, and AutoCAD drafting without you writing a line of Python. See how it works at /product.

Start Free Trial — 14 days free