Epsil for Python Users
A working translation guide. Every Epsil example on this page is executed by
the documentation test suite and its // ➔ output verified, so nothing here
can drift from the implementation.
What carries over. The shape of a program: sequential statements,
lexically scoped functions, closures, first-class lambdas, map/filter, a
for x in collection loop, the conditional expression a if c else b,
arbitrary-precision integers, % with Python's sign convention, negative
indices, chained comparisons, and ** for exponentiation.
What to unlearn. Three things, in order of how much trouble they cause:
- Indexing is 1-based.
xs[1]is the first element. - Arithmetic is exact and symbolic by default.
1/3is the rational one third,ln(2)staysln(2). Floats happen only when you ask, withN(…). //is a comment, not floor division, and=assigns only as a whole statement — inside an expression it isEqual, never equality. Both fail quietly — see Traps.
There is no print. A program's value is the value of its last statement.
Variables and Functions
| Python | Epsil |
|---|---|
x = 5 | let x = 5 |
TAU = 6.28 (by convention) | const tau = 6.28 (enforced) |
x: int = 4 | let n: integer = 4 |
def f(x): return x**2 | f(x) = x^2 |
def f(x): with a body | function f(x) { … } — value is the last expression |
lambda x: x*2 | x => 2x |
lambda: 42 | () => 42 |
def f(x: float) -> float: | f(x: real) -> real = x^2 |
return | (no return) — the last expression is the value |
math.floor(x), np.mean(xs) | floor(x), mean(xs) — no modules, no imports |
obj.method(a) | c.area(a) only when area is a protocol function; otherwise f(c, a) or c |> f — xs.Sort() is an error |
Naming convention: library operators are lowercase, as in Python (sin,
map, max, len is length), and also answer to their MathJSON names
(sin, map, max, length). Your names are lowercase too and shadow a
library name by scope, as a Python assignment to sum does. Calling an
unknown function is not an error — the call stays symbolic, with a
did-you-mean warning when a close library name exists (len suggests
length).
fact(n) = 1 if n <= 1 else n * fact(n - 1)
let double = x => 2x
(fact(5), double(21))
// ➔ (120, 42)
Collections
| Python | Epsil |
|---|---|
[1, 2, 3] | [1, 2, 3] |
{1, 2, 3} (set) | {1, 2, 3} |
(1, 2) (tuple) | (1, 2) |
{"a": 1} (dict) | {"a" -> 1}; empty dictionary is {->} |
d["a"] | d["a"], or d.a when the key is an identifier |
xs[0] | xs[1] — 1-based |
xs[-1] | xs[-1] |
xs[1:3] | xs[2..3] — 1-based, inclusive on both ends |
range(1, 6) | 1..5 or Range(1, 5) — inclusive of the end |
len(xs) | length(xs) |
sorted(xs) / sorted(xs, reverse=True) | sort(xs) / sort(xs, (a, b) => a > b) |
sum, min, max, any, all | sum, min, max, any, all |
reversed(xs) | reverse(xs) |
zip(a, b) | zip(a, b) |
enumerate(xs) | zip(1..length(xs), xs) |
xs.index(v) | indexOf(xs, v) |
xs + ys, xs.append(v) | join(xs, ys), append(xs, v) — both return a new collection |
xs[2] = 9 | (no element assignment) — rebuild with map/join |
d.keys(), d.values() | keys(d), values(d) |
dict(zip(ks, vs)) | dictionaryFrom(zip(ks, vs)) |
collections.Counter(xs) | tally(xs) → a (values, counts) pair |
Collections are immutable values. There is no in-place mutation: build a new collection and rebind the name. The values-are-immutable, bindings-are-not model is worth reading once in full — see Values and bindings — because it also explains why a closure sees a later reassignment and why a function cannot modify its caller's variable.
let counts = dictionaryFrom(zip(["apples", "figs"], [3, 1]))
(counts["apples"], keys(counts), counts["pears"])
// ➔ (3, ["apples","figs"], NaN)
A missing numeric dictionary field yields NaN rather than raising
KeyError; a missing nonnumeric field remains missing. isMissing
recognizes either representation, and Coalesce(value, fallback) supplies a
default. See Traps.
Comprehensions
List, set and dictionary comprehensions read as in Python, with two
differences: several for clauses are separated by a comma instead of a
repeated for, and a dictionary key is written with ->.
[n**2 for n in range(1, 11) if n % 2 == 1]
{n % 3 for n in range(1, 11)}
{s: len(s) for s in ["ab", "cde"]}
[(x, y) for x in range(1, 4) for y in range(1, x + 1)]
[n^2 for n in 1..10 if n % 2 == 1] // ➔ [1, 9, 25, 49, 81]
{n % 3 for n in 1..10} // ➔ {1, 2, 0}
{s -> length(s) for s in ["ab", "cde"]} // ➔ {"ab" -> 2, "cde" -> 3}
[(x, y) for x in 1..3, y in 1..x]
There is no bare generator expression: sum(n**2 for n in …) is written with
brackets, sum([n^2 for n in 1..10 if n % 2 == 1]), or as a pipeline. A list
comprehension is lazy like a Python generator, so the brackets cost nothing
until the list is read. The pipeline operator |> with filter/map remains
available; _ is the placeholder for the piped value.
1..10 |> filter(_, n => n % 2 == 1) |> map(n => n^2, _) |> sum
// ➔ 165
Range, map, filter, take, drop, join and a comprehension are
generators, like Python's — they enumerate only when materialized
(indexed, aggregated, or iterated). One difference from Python: an
assignment of a finite generator that reads a variable stores the list
of its elements, so the "late binding in a closure" surprise does not apply
to a variable:
let n = 1
let m = map(k => k * n, 1..3)
n = 10
sum(m)
// ➔ 6
A generator that is not assigned (an argument, an operand) reads variables
at materialization time, and so does an assigned generator with no last
element (filter(1..oo, k => k > n)).
Control Flow
| Python | Epsil |
|---|---|
if c: … elif d: … else: … | if c { … } else if d { … } else { … } |
a if c else b | a if c else b — same syntax; chains nest right, so there is no elif spelling to learn |
and, or, not | &&, ||, ! (the words are reserved but unimplemented) |
for x in xs: | for x in xs { … } |
for i in range(n): | for i in 1..n { … } |
while c: | while c { … } |
break, continue | break, continue |
match … case (3.10+) | match … { pattern => body } |
try/except | (none) — errors are ordinary values |
# comment | // comment or /* … */ |
Loops run for effect: their value is nothing. Accumulate into a variable
declared outside the loop, or use map/filter/reduce/fold when you want
a value.
let total = 0
for k in 1..100 { if k % 3 == 0 || k % 5 == 0 { total = total + k } }
total
// ➔ 2418
Pattern matching
Epsil match is close to Python 3.10's match/case, with three
differences: cases are written pattern => body (no case keyword and no
colon), a bare name always binds (it never compares), and you pin a value
to compare against with == expr.
match n:
case 0: "zero"
case k if k > 0: "positive"
case _: "negative"
classify(n) = match n {
0 => "zero"
k if k > 0 => "positive"
_ => "negative"
}
map(classify, [-2, 0, 5])
// ➔ ["negative", "zero", "positive"]
Because a bare name binds, match x { Pi => … } does not test for π — it
binds a fresh variable named pi. Write match x { == Pi => … }. This is the
same rule as Python's (where a bare case FOO: is a capture pattern), but it
bites more often because Epsil's constants are ordinary names.
Math and Numerics
| Python | Epsil |
|---|---|
7 / 2 → 3.5 | 7 / 2 → the exact rational 7/2; N(7 / 2) → 3.5 |
7 // 2 → 3 | floor(7 / 2) — // starts a comment in Epsil |
7 % 2, -7 % 3 → 2 | 7 % 2, -7 % 3 → 2 — same sign convention |
x ** 2, pow(x, 2) | x^2 or x**2 |
math.sqrt(x) | sqrt(x) — exact: sqrt(9) is 3, sqrt(2) stays √2 |
math.pi, math.e | pi, e |
math.log(x), math.log10(x) | ln(x), log(x); log(x, b) for base b |
abs, round, math.floor, math.ceil | abs, round, floor, ceil (not Ceiling) |
float(expr) | N(expr), or N(expr, digits) for a precision |
10 ** 100 (bigint) | 10^100 — same unbounded integers |
complex(2, 3) | 2 + 3i |
statistics.mean/median | mean, median, variance, standardDeviation |
math.gcd, math.factorial | gcd, lcm, n! |
| (SymPy territory) | simplify, solve, D, integrate, limit, series are built in |
Exactness is the default, and comparison is tolerant, so the classic floating-point gotcha does not appear:
let exact = 1/3 + 1/6
let approx = N(1/3 + 1/6)
(exact, approx, 0.1 + 0.2 == 0.3)
// ➔ (1/2, 0.5, True)
round rounds halves away from zero; Python rounds halves to even. This
is the one numeric answer that differs on values you are likely to type:
(round(0.5), round(2.5), round(-0.5))
// ➔ (1, 3, -1)
(Python gives 0, 2, 0.)
Arithmetic broadcasts over a list elementwise, without anything like NumPy:
([1, 2, 3] + 1, [1, 2, 3] * [4, 5, 6], sum(map(k => k^2, 1..4)))
// ➔ ([2,3,4], [4,10,18], 30)
Strings
| Python | Epsil |
|---|---|
f"x is {x}" | "x is \(x)" — works in any string literal |
"a" + "b" | join("a", "b") — + on strings is a type error |
len(s) | length(s) — a string is a collection of its characters (grapheme clusters, not code points) |
s[0] | s[1] — 1-based; each element is a character |
c in s | c in s — character membership; substring search is a separate operation |
"ab" in s | containsSequence(s, "ab") — in never means substring |
s.split() / s.split(",") | stringSplit(s) / stringSplit(s, ",") |
"".join(parts) / sep.join(parts) | stringJoin(parts) / stringJoin(parts, sep) |
str(x) | String(x) |
"""…""" | """…""" — multi-line strings, same delimiter |
r"raw\string" | #"raw\string"# — extended string literal |
let name = "world"
let parts = stringSplit("a b c")
("hello \(name)", join("a", "b"), length(name), parts[2])
// ➔ ("hello world", "ab", 5, "b")
.upper(), .lower(), .replace() and .strip() are toUpperCase,
toLowerCase, stringReplace(s, target, replacement) and
trim/trimStart/trimEnd; .zfill()/.rjust() are padStart/padEnd,
s * n is stringRepeat(s, n) and float(s)/int(s) are numberFrom(s)
(which answers an error value, never NaN, on text that is not a numeral).
.find()/.index() is rangeOf(s, needle), which answers the span of the
first occurrence (a range) or nothing — feed it straight to slice;
needle in s (substring) is containsSequence(s, needle), and
.startswith()/.endswith() are startsWith/endsWith. Note that Epsil's
c in s is character membership, not substring search.
.casefold() is caseFold(s), and stringCompare(a, b) gives the -1/0/1
code-point ordering that < on two multi-character strings does not (it
compares UTF-16 code units, which sorts the astral characters below
U+E000–U+FFFF).
A string is an indexed collection of character
values, so the generic collection operators apply directly (length,
reverse, filter, sort, contains, indexOf, map — the
element-preserving ones return a string, map returns a list; rejoin with
String(...)). For a specific decomposition use characters,
unicodeScalars, utf8/utf16; stringSplit, stringJoin, join,
stringFrom and String round out the library.
Errors
There are no exceptions. A runtime problem becomes an ordinary
Error(…) value that flows through the computation, so a bad element does
not abort the rest of the work:
map(x => sqrt(x), [16, -4, "banana", 81])
// ➔ [4, 2i, NaN, 9]
Note also sqrt(-4) → 2i rather than a ValueError: the engine works over
the complex numbers. Malformed source is different — it produces
diagnostics with source positions, reported separately from the value.
Familiar
These transfer straight across — no translation needed:
let xs = [10, 20, 30]
(xs[-1], 20 in xs, 1 < 2 < 3, 2**10, -7 % 3)
// ➔ (30, True, True, 1024, 2)
- Negative indices count from the end;
intests membership. - Chained comparisons (
1 < x <= 4) mean the conjunction, as in Python. **is an accepted alias of^, right-associative (2^3^2is512).%is the remainder with Python's sign convention.- Integers are arbitrary precision, with no
int/longdistinction. true/falseare accepted spellings ofTrue/False.- Closures capture lexically, and functions are first-class values.
;separates statements on one line, exactly as in Python.
Traps
Reflexes that produce a wrong answer rather than an error. The parser emits
a warning diagnostic for the first three — visible on stderr from the CLI,
and in the diagnostics array when embedding — but the program still runs and
still returns a plausible-looking value.
| You write | What actually happens | Write instead |
|---|---|---|
7 // 2 | // starts a comment, so the statement is just 7 | floor(7 / 2) |
xs[0] | Silently NaN — indexing is 1-based | xs[1] |
f(a = 1) as a keyword argument | There are no keyword arguments; inside an expression = is Equal, so this passes the boolean a == 1 | pass positionally |
d["missing"] | An absence value, not a KeyError (NaN for a numeric field, otherwise missing) | Coalesce(d["missing"], fallback) or test with isMissing |
xs[1:3] | Python's half-open slice; xs[2..3] is 1-based and inclusive | check both ends |
x^1/2 | (x^1)/2 — ^ binds tighter than / | sqrt(x) or x^(1/2) |
x = 5 inside an expression | Compares, rather than assigning — only a whole statement assigns | := to assign in place, == to be explicit |
print(x) | Inert, nothing is printed | the program's value is its last statement |
round(2.5) | 3 (half away from zero), not Python's 2 | (intentional) |
3!^2 | Diagnostic — the lexer reads !^ as one token | 3! ^ 2 |
a +b | Diagnostic — an infix operator needs spaces on both sides or neither | a + b or a+b |
"\(xs)" with a list xs | Broadcasts into a list of strings | interpolate scalars only |
x && y on fresh symbols | Types those symbols boolean for the engine's lifetime | use distinct names for boolean work |
One more, specific to a symbolic language: a take(xs, 3) (or any lazy
operator) stored inside a tuple stays unevaluated, because a tuple does
not materialize its operands. Aggregate or index where you stand if you need
the work done now.