Python to Wisp: The Lisp That Stole Python's Indentation
Python to Wisp: The Lisp That Stole Python's Indentation
Every Lisp programmer has heard the complaint: "too many parentheses." Every Python programmer has heard the praise: "the indentation makes it readable." Wisp asks a dangerous question: what if we put them together?
Wisp is an indentation-sensitive syntax for Scheme — specifically GNU Guile Scheme — standardized as SRFI-119. It takes the structure of Lisp and expresses it through indentation, the way Python does. The outer parentheses vanish. What remains is code that looks strangely like Python but thinks like Lisp — with macros, tail-call optimization, exact arithmetic, and homoiconicity.
This article walks through every major topic in the official Python tutorial and shows how Wisp approaches the same concept. Two languages, both indented, separated by philosophy.
1. Whetting Your Appetite
Python sells itself on readability, rapid prototyping, and an enormous ecosystem. It's the default choice for scripts, web backends, data science, and teaching.
Wisp sells itself on a different proposition: the readability of Python's visual structure combined with the raw power of Scheme. Scheme is one of the most theoretically clean programming languages ever designed — tail-call optimization, first-class continuations, hygienic macros — but its parenthesized syntax scares newcomers away. Wisp removes that barrier.
Wisp runs on GNU Guile, a mature Scheme implementation that ships with many GNU/Linux distributions. It gives you access to Guile's full ecosystem: real OS threads (no GIL), arbitrary-precision arithmetic, a sophisticated module system, and the ability to reshape the language through macros.
The trade-off? Python has PyPI. Wisp has the Guile ecosystem and whatever you can call via FFI. This isn't a contest for library count — it's a contest for expressiveness per line.
2. The Interpreter / The REPL
Python:
>>> 2 + 2
4
>>> print("Hello, world!")
Hello, world!
Wisp:
> + 2 2
4
> display "Hello, world!"
Hello, world!
To use Wisp interactively, launch guile and switch languages with ,L wisp. Or run scripts directly: guile --language=wisp -s hello.w.
The syntax is prefix notation — the function comes first, then its arguments — but without the outer parentheses that traditional Scheme requires. The line display "Hello, world!" is equivalent to Scheme's (display "Hello, world!"). Indentation replaces the parens.
3. An Informal Introduction
Numbers
Python:
>>> 17 / 3 # 5.666...
>>> 17 // 3 # 5
>>> 17 % 3 # 2
>>> 5 ** 2 # 25
Wisp:
/ 17 3 ; 17/3 — an exact rational!
quotient 17 3 ; 5
remainder 17 3 ; 2
expt 5 2 ; 25
The first difference is striking. (/ 17 3) in Scheme returns 17/3 — an exact rational number, not a floating-point approximation. Scheme distinguishes exact and inexact numbers at the type level. If you want the float, you ask for it explicitly: (exact->inexact (/ 17 3)) gives 5.666....
Operators take multiple arguments naturally:
+ 1 2 3 4 5 ; 15
* 2 3 4 ; 24
< 1 2 3 4 ; #t (chained comparison)
And Scheme gives you arbitrary-precision integers for free. No int vs long distinction, no overflow:
expt 2 1000 ; a 302-digit number, exactly
Strings
Python:
word = "Python"
word[0] # 'P'
word[0:2] # 'Py'
len(word) # 6
f"Hello, {word}!"
Wisp:
define word "Wisp"
string-ref word 0 ; #\W (a character)
substring word 0 2 ; "Wi"
string-length word ; 4
format #f "Hello, ~a!" word ; "Hello, Wisp!"
Scheme strings are mutable (unlike Python's), though idiomatic Scheme treats them as values. The format function uses ~a as a placeholder — #f means "return a string" and #t means "print directly":
format #t "Hello, ~a!\n" word ; prints: Hello, Wisp!
Lists
Python:
squares = [1, 4, 9, 16, 25]
squares[0] # 1
squares + [36, 49] # [1, 4, 9, 16, 25, 36, 49]
squares.append(36) # mutates!
Wisp:
define squares '(1 4 9 16 25)
list-ref squares 0 ; 1
append squares '(36 49) ; (1 4 9 16 25 36 49)
Scheme lists are linked lists — immutable by convention, efficient at the head. append returns a new list without modifying the original. There's no .append mutation. If you want indexed access, Scheme has vectors:
define sq #(1 4 9 16 25)
vector-ref sq 0 ; 1
The '(1 4 9 16 25) is a quoted list — the quote ' prevents Scheme from trying to call 1 as a function.
4. Control Flow
if / cond
Python:
if x < 0:
print("Negative")
elif x == 0:
print("Zero")
else:
print("Positive")
Wisp:
cond
: < x 0
display "Negative"
: = x 0
display "Zero"
else
display "Positive"
In Wisp, cond handles multi-branch conditionals. Each branch starts with a : (which creates the nested parentheses Scheme expects). The else clause catches everything.
The simple if takes a condition, a then-branch, and an else-branch:
if : < x 0
display "Negative"
display "Non-negative"
And because if is an expression, not a statement, you can use it anywhere:
define label
if : < x 0
. "negative"
. "non-negative"
The . (dot) prevents the string from being treated as a function call — it marks a bare value rather than an application.
for loops
Python:
for word in ["cat", "window", "defenestrate"]:
print(word, len(word))
Wisp:
for-each
lambda : word
display word
display " "
display : string-length word
newline
'("cat" "window" "defenestrate")
Scheme doesn't have a built-in for loop — iteration is done through for-each (for side effects) or map (for transformation). The lambda defines an anonymous function applied to each element.
Or using a named let (Scheme's idiomatic loop):
let loop
: words '("cat" "window" "defenestrate")
when : not : null? words
display : car words
display " "
display : string-length : car words
newline
loop : cdr words
This is recursion that looks like a loop. car gets the first element, cdr gets the rest, and loop calls itself with the remaining list. Guile guarantees tail-call optimization, so this uses constant stack space — unlike Python, which would hit a recursion limit.
range
Python:
list(range(0, 10, 2)) # [0, 2, 4, 6, 8]
Wisp:
use-modules : srfi srfi-1
iota 5 0 2 ; (0 2 4 6 8)
Guile's iota from SRFI-1 generates numeric sequences. The arguments are count, start, and step — slightly different from Python's start, stop, step.
while
Python:
a, b = 0, 1
while a < 10:
print(a)
a, b = b, a + b
Wisp:
let loop : : a 0
b 1
when : < a 10
display a
newline
loop b {a + b}
There's no while keyword here — it's a named let that recurses. The {a + b} uses Wisp's curly-brace infix syntax (from SRFI-105), which lets you write math the familiar way. Without it, you'd write (+ a b).
Pattern Matching
Python 3.10+:
match command:
case "quit":
quit_game()
case "help":
show_help()
case _:
print("Unknown")
Wisp (using Guile's match):
use-modules : ice-9 match
match command
: "quit"
quit-game
: "help"
show-help
: _
display "Unknown"
Guile's match supports destructuring, guards, and nested patterns — similar to Python's structural pattern matching but available since long before Python 3.10.
Functions
Python:
def factorial(n):
"""Return the factorial of n."""
if n == 0:
return 1
return n * factorial(n - 1)
Wisp:
define : factorial n
"Return the factorial of n."
if : = n 0
. 1
* n : factorial {n - 1}
Functions are defined with define. The : factorial n creates (factorial n) — a function named factorial taking one argument. The docstring goes right after the parameter list. The last expression is the return value.
The Wisp version also benefits from tail-call optimization if rewritten in tail-recursive style:
define : factorial n
let loop : : i n
acc 1
if : = i 0
. acc
loop {i - 1} {acc * i}
This handles (factorial 100000) without breaking a sweat. Python would need sys.setrecursionlimit or an iterative rewrite.
Default and Keyword Arguments
Python:
def greet(name, greeting="Hello"):
print(f"{greeting}, {name}!")
Wisp:
use-modules : ice-9 optargs
define* : greet name #:optional (greeting "Hello")
format #t "~a, ~a!\n" greeting name
Guile uses define* (from ice-9 optargs) for optional and keyword arguments:
define* : connect host #:key (port 8080) (timeout 30)
format #t "Connecting to ~a:~a (timeout ~a)\n" host port timeout
connect "example.com" #:port 443
; => Connecting to example.com:443 (timeout 30)
#:optional for positional defaults, #:key for keyword arguments. More explicit than Python's unified syntax, but equally capable.
Lambda Expressions
Python:
double = lambda x: x * 2
sorted(words, key=lambda w: len(w))
Wisp:
define double
lambda : x
* x 2
sort words
lambda : a b
< (string-length a) (string-length b)
In Wisp, lambda is a full block — no single-expression restriction like Python's lambda. The indented body can contain anything.
5. Data Structures
Lists (Linked Lists)
Scheme lists are cons-cell linked lists — the fundamental data structure of all Lisps:
define fruits '("apple" "banana" "cherry")
car fruits ; "apple" (first)
cdr fruits ; ("banana" "cherry") (rest)
cons "mango" fruits ; ("mango" "apple" "banana" "cherry")
length fruits ; 3
list-ref fruits 1 ; "banana"
| Operation | Python | Wisp |
|---|---|---|
| First element | lst[0] |
car lst |
| Rest of list | lst[1:] |
cdr lst |
| Prepend | [x] + lst |
cons x lst |
| Append | lst + [x] |
append lst (list x) |
| Length | len(lst) |
length lst |
| Nth element | lst[n] |
list-ref lst n |
Vectors (Arrays)
For indexed access, Scheme has vectors:
define v #(10 20 30 40 50)
vector-ref v 2 ; 30
vector-length v ; 5
Hash Tables (Dictionaries)
Python:
tel = {"jack": 4098, "sape": 4139}
tel["guido"] = 4127
del tel["sape"]
Wisp:
define tel : make-hash-table
hashq-set! tel 'jack 4098
hashq-set! tel 'sape 4139
hashq-set! tel 'guido 4127
hashq-remove! tel 'sape
hashq-ref tel 'jack ; 4098
Hash tables in Guile use explicit functions rather than syntax. It's more verbose, but Scheme compensates with association lists for small mappings:
define tel
' : (jack . 4098)
(sape . 4139)
assoc 'jack tel ; (jack . 4098)
Association lists are simple, immutable, and idiomatic for small key-value collections.
Sets
Guile doesn't have a built-in set type, but SRFI-113 provides them, or you can use sorted lists with lset- operations from SRFI-1:
use-modules : srfi srfi-1
define a '(1 2 3 4)
define b '(3 4 5 6)
lset-difference equal? a b ; (1 2)
lset-union equal? a b ; (1 2 3 4 5 6)
lset-intersection equal? a b ; (3 4)
List Comprehensions
Python:
[x**2 for x in range(10) if x % 2 == 0]
Wisp:
use-modules : srfi srfi-42
list-ec : : i (index 10)
if : = 0 : remainder i 2
expt i 2
; => (0 4 16 36 64)
SRFI-42 provides eager comprehensions. The : lines create the nested structure that Scheme expects.
Or idiomatically with map and filter:
map
lambda : x
expt x 2
filter even? : iota 10
Looping Techniques
Python:
for i, v in enumerate(["tic", "tac", "toe"]):
print(i, v)
Wisp:
let loop : : i 0
words '("tic" "tac" "toe")
when : not : null? words
format #t "~a ~a\n" i : car words
loop {i + 1} : cdr words
Or using SRFI-42:
do-ec : : i (index 3)
format #t "~a ~a\n" i
list-ref '("tic" "tac" "toe") i
6. Modules
Importing
Python:
import math
from os.path import join
import json as j
Wisp:
use-modules : ice-9 regex
use-modules
: srfi srfi-1
#:select : iota fold
use-modules
: ice-9 popen
#:prefix popen-
Guile's module system uses use-modules. The #:select option imports specific symbols, and #:prefix namespaces them — similar to Python's from X import Y and import X as Y.
Defining Modules
Python:
# mymodule.py
def greet(name):
return f"Hello, {name}!"
Wisp:
define-module : myproject mymodule
. #:export : greet
define : greet name
format #f "Hello, ~a!" name
The #:export clause explicitly lists what the module makes public — like Python's __all__ but mandatory and standard.
The Script Entry Point
Python:
if __name__ == "__main__":
main()
Wisp scripts use a shell header that invokes Guile with the right flags:
#!/usr/bin/env bash
exec guile -L . -x .w --language=wisp -e main -s "$0" "$@"
!#
define : main args
display "Hello from the command line!\n"
The -e main flag tells Guile to call the main function with command-line arguments.
7. Input and Output
String Formatting
Python:
name = "World"
f"Hello, {name}!"
"{:.2f}".format(3.14159)
Wisp:
define name "World"
format #f "Hello, ~a!" name ; "Hello, World!"
format #f "~,2f" 3.14159 ; "3.14"
Guile's format uses tilde-based directives: ~a for display, ~s for write (with quotes), ~d for integer, ~f for float, ~% for newline. It's closer to Common Lisp's format than to Python's f-strings.
File I/O
Python:
with open("data.txt", encoding="utf-8") as f:
content = f.read()
with open("output.txt", "w") as f:
f.write("Hello\n")
Wisp:
use-modules : ice-9 textual-ports
; Read entire file
define content
call-with-input-file "data.txt" get-string-all
; Write to file
call-with-output-file "output.txt"
lambda : port
display "Hello\n" port
Or using with-input-from-file for a more Python-with-like pattern:
with-input-from-file "data.txt"
lambda ()
let loop : : line (read-line)
when : not : eof-object? line
display line
newline
loop : read-line
Scheme's port system is lower-level than Python's file objects but equally capable. Every I/O function accepts an optional port argument — display "hello" port writes to a specific destination.
JSON
Guile has a JSON module:
use-modules : json
define data
json-string->scm "{\"name\": \"Ada\"}"
; => (("name" . "Ada"))
scm->json-string '((name . "Ada"))
; => "{\"name\":\"Ada\"}"
8. Errors and Exceptions
Try / Catch
Python:
try:
x = int(input("Number: "))
except ValueError as e:
print(f"Invalid: {e}")
finally:
print("Done")
Wisp:
catch #t
lambda ()
; try body
define x
string->number : read-line
when : not x
error "Invalid number"
lambda : key . args
; catch handler
format #t "Error: ~a ~a\n" key args
display "Done\n"
Guile uses catch and throw (or the newer with-exception-handler / raise-exception). The handler is a lambda that receives the error key and arguments.
The more modern style:
use-modules : ice-9 exceptions
with-exception-handler
lambda : exn
format #t "Error: ~a\n" : exception-message exn
lambda ()
string->number "not-a-number"
. #:unwind? #t
Raising Exceptions
Python:
raise ValueError("something went wrong")
Wisp:
error "something went wrong"
Or with structured keys:
throw 'value-error "something went wrong"
Custom Error Types
Python creates exception classes. Guile uses symbol keys:
throw 'insufficient-funds "Need more money"
. balance amount
Or with the newer condition system from R7RS:
use-modules : ice-9 exceptions
define-exception-type &insufficient-funds &error
make-insufficient-funds
insufficient-funds?
: balance insufficient-funds-balance
amount insufficient-funds-amount
More ceremony than Python's class hierarchy, but also more structured — each field is explicitly named and accessible.
9. Classes and Objects
This is where the languages diverge most sharply.
Python's Object-Oriented Approach
class Dog:
kind = "canine"
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} says woof!"
rex = Dog("Rex")
rex.speak()
Wisp's Approach: GOOPS
Guile has GOOPS (GNU Object-Oriented Programming System), a powerful CLOS-style object system:
use-modules : oop goops
define-class <dog> ()
kind
. #:init-value "canine"
. #:getter dog-kind
name
. #:init-keyword #:name
. #:getter dog-name
define-method : speak (dog <dog>)
format #f "~a says woof!" : dog-name dog
define rex : make <dog> #:name "Rex"
speak rex ; "Rex says woof!"
GOOPS supports multiple inheritance, multiple dispatch (methods dispatch on all argument types, not just the first), metaclasses, and method combinations. It's more powerful than Python's object system, but also more verbose.
Inheritance
Python:
class Puppy(Dog):
def speak(self):
return f"{self.name} says yip!"
Wisp:
define-class <puppy> (<dog>)
define-method : speak (dog <puppy>)
format #f "~a says yip!" : dog-name dog
The Alternative: Just Use Data
Many Scheme programmers avoid GOOPS entirely and use plain data structures with functions — similar to Clojure's philosophy:
define : make-dog name
' : (kind . "canine")
(name . ,name)
define : dog-speak dog
format #f "~a says woof!" : assoc-ref dog 'name
define rex : make-dog "Rex"
dog-speak rex
This is simpler and often sufficient.
10. The Standard Library
Python's Batteries vs. Guile's Foundations
Python ships with modules for everything. Guile ships with fewer but more fundamental tools:
| Python Module | Guile Equivalent |
|---|---|
os |
POSIX bindings (built-in) |
sys |
(ice-9 command-line) |
re |
(ice-9 regex) |
math |
Built-in exact arithmetic + (ice-9 math)
|
json |
(json) |
threading |
(ice-9 threads) — real OS threads, no GIL! |
unittest |
SRFI-64 |
datetime |
SRFI-19 |
argparse |
(ice-9 getopt-long) |
collections |
SRFIs (1, 69, 113, etc.) |
http |
(web client), (web server)
|
sqlite3 |
Guile-DBI |
Regex
use-modules : ice-9 regex
define m : string-match "[0-9]+" "hello 42 world"
match:substring m ; "42"
Threading — No GIL
This is Guile's secret weapon. Where Python has the GIL limiting true parallelism, Guile has real POSIX threads:
use-modules : ice-9 threads
call-with-new-thread
lambda ()
display "Hello from another thread!\n"
par-map
lambda : x
* x x
iota 10
; => (0 1 4 9 16 25 36 49 64 81)
par-map is parallel map — it distributes work across OS threads. No multiprocessing workaround, no async/await complexity. Just threads.
Web Server in 10 Lines
use-modules : web server
run-server
lambda : request request-body
values
' : (content-type . (text/plain))
. "Hello, World!"
Guile ships with a built-in HTTP server and client. No framework needed for basic use.
11. Advanced Standard Library
Output Formatting
use-modules : ice-9 pretty-print
pretty-print
' : (name . "Ada")
(languages . ("Python" "Wisp" "Scheme"))
Multi-threading (Continued)
use-modules : ice-9 threads
define mutex : make-mutex
define counter 0
define : increment
lock-mutex mutex
set! counter {counter + 1}
unlock-mutex mutex
; Spawn 100 threads
let loop : : i 100
when : > i 0
call-with-new-thread increment
loop {i - 1}
Exact Decimal Arithmetic
Python needs the decimal module. Scheme has exact rationals built-in:
+ 1/10 2/10 ; 3/10 (exact!)
= (+ 1/10 2/10) 3/10 ; #t
* 7/10 105/100 ; 147/200 (exact!)
No Decimal class, no imports. Exact fractions are a primitive type.
12. Virtual Environments and Packages
Python has venv and pip. Guile has a different model.
Installation:
# Guile is often pre-installed on GNU/Linux
sudo apt install guile-3.0
# Wisp
sudo apt install guile-wisp
# or from source
Dependencies:
Guile uses a load-path model rather than per-project virtual environments:
export GUILE_LOAD_PATH="/path/to/library:$GUILE_LOAD_PATH"
guile --language=wisp -s myapp.w
For project-specific dependencies, the community uses GNU Guix (a functional package manager) or manual load-path management. It's less ergonomic than pip install, but aligns with the GNU ecosystem's philosophy.
There's no PyPI equivalent. Libraries are distributed through GNU Guix, OS packages, or directly as source. This is Wisp's weakest point compared to Python.
13. Floating-Point Arithmetic
Python:
>>> 0.1 + 0.2 == 0.3
False
Wisp:
= {0.1 + 0.2} 0.3 ; #f (same problem with floats!)
Same IEEE 754, same surprises. But Scheme has an escape hatch Python doesn't — exact rationals are a first-class type, not a library:
= {1/10 + 2/10} 3/10 ; #t (exact!)
exact->inexact 1/3 ; 0.3333333333333333
inexact->exact 0.1 ; 3602879701896397/36028797018963968
That last line reveals the actual value stored for 0.1 — a ratio of two large integers. Scheme lets you move freely between exact and inexact worlds.
14. Interactive Editing
Python's REPL supports readline history and tab completion.
Guile's REPL offers similar features plus meta-commands:
,help ; show all REPL commands
,describe display ; show documentation
,time (fib 30) ; benchmark an expression
,profile (fib 30) ; profile an expression
,L wisp ; switch to Wisp syntax
,L scheme ; switch back to Scheme
You can switch between Wisp and Scheme syntax in the same session. This is useful for learning — write in Wisp, see what Scheme it corresponds to.
15. The Secret Weapon: Macros
Like all Lisps, Wisp supports macros — compile-time code transformations. But because Wisp code looks like indented pseudocode, Wisp macros are unusually readable for Lisp macros.
Example: A Python-style for-in loop
define-syntax-rule : for-in var lst body ...
for-each
lambda : var
body ...
lst
; Now use it:
for-in word '("cat" "window" "defenestrate")
display word
newline
You just added a for-in construct to the language. It's not a function that takes a callback — it's real syntax that the compiler expands before execution.
Example: Unless
define-syntax-rule : unless condition body ...
when : not condition
body ...
unless : = 1 2
display "Math still works\n"
Example: Time-it
define-syntax-rule : time-it body ...
let : : start (current-time)
body ...
format #t "Elapsed: ~a seconds\n"
- (current-time) start
time-it
let loop : : i 1000000
when : > i 0
loop {i - 1}
Why This Matters
In Python, if you need a new control structure, you write a function with callbacks or a context manager — a workaround. In Wisp, you write a macro that generates the exact code you want, with zero runtime overhead. The result is indistinguishable from a built-in language feature.
This is the deep reason Lisp syntax exists: when code and data have the same structure, programs that write programs become natural.
The Big Picture
| Dimension | Python | Wisp |
|---|---|---|
| Syntax | Indentation + keywords | Indentation + prefix notation |
| Paradigm | Multi-paradigm (imperative-first) | Multi-paradigm (functional-first) |
| Type System | Dynamic | Dynamic |
| Mutability | Mutable by default | Immutable by convention |
| Tail Calls | No optimization (recursion limit) | Guaranteed optimization |
| Arithmetic | Floats by default | Exact rationals by default |
| Threading | GIL limits parallelism | Real OS threads |
| Macros | No | Hygienic macros |
| OOP | Classes + inheritance | GOOPS (multiple dispatch) or plain data |
| Ecosystem | Massive (PyPI) | Small (Guile + GNU Guix) |
| Runtime | CPython | GNU Guile (JIT-compiled Scheme) |
| Best For | Scripts, ML/AI, web, teaching | Systems, DSLs, concurrency, extensible programs |
Closing Thoughts
Wisp is not trying to replace Python. It's trying to answer a question: can Lisp be approachable?
The answer is yes. Wisp code reads like indented pseudocode. You can show it to someone who has never programmed and they'll follow the structure. But underneath that gentle surface lies one of the most powerful programming models ever devised — one where functions are data, data is code, and the language reshapes itself to fit your problem.
The ecosystem gap is real. You won't find a Wisp equivalent of NumPy or Django. But for systems programming, scripting, language design, concurrent servers, or any domain where you want to think differently about code — Wisp offers something Python cannot: a Lisp you can read at a glance.
display "Welcome to Wisp.\n"
display "Where indentation meets imagination.\n"
Get started: install Guile, install Wisp, and type ,L wisp in the Guile REPL. The parentheses are gone. The power remains.
This article was written as a companion to the official Python tutorial. Every section maps to a chapter in that tutorial, showing the same concepts through Wisp's indentation-sensitive Lisp lens. For more on Wisp, visit draketo.de/software/wisp.
