Skip to main content

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:

  1. Indexing is 1-based. xs[1] is the first element.
  2. Arithmetic is exact and symbolic by default. 1/3 is the rational one third, ln(2) stays ln(2). Floats happen only when you ask, with N(…).
  3. // is a comment, not floor division, and = assigns only as a whole statement — inside an expression it is Equal, 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​

PythonEpsil
x = 5let x = 5
TAU = 6.28 (by convention)const tau = 6.28 (enforced)
x: int = 4let n: integer = 4
def f(x): return x**2f(x) = x^2
def f(x): with a bodyfunction f(x) { … } — value is the last expression
lambda x: x*2x => 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​

PythonEpsil
[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, allsum, 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​

PythonEpsil
if c: … elif d: … else: …if c { … } else if d { … } else { … }
a if c else ba 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, continuebreak, 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​

PythonEpsil
7 / 2 → 3.57 / 2 → the exact rational 7/2; N(7 / 2) → 3.5
7 // 2 → 3floor(7 / 2) — // starts a comment in Epsil
7 % 2, -7 % 3 → 27 % 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.epi, e
math.log(x), math.log10(x)ln(x), log(x); log(x, b) for base b
abs, round, math.floor, math.ceilabs, 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/medianmean, median, variance, standardDeviation
math.gcd, math.factorialgcd, 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​

PythonEpsil
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 sc in s — character membership; substring search is a separate operation
"ab" in scontainsSequence(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; in tests membership.
  • Chained comparisons (1 < x <= 4) mean the conjunction, as in Python.
  • ** is an accepted alias of ^, right-associative (2^3^2 is 512).
  • % is the remainder with Python's sign convention.
  • Integers are arbitrary precision, with no int/long distinction.
  • true/false are accepted spellings of True/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 writeWhat actually happensWrite instead
7 // 2// starts a comment, so the statement is just 7floor(7 / 2)
xs[0]Silently NaN — indexing is 1-basedxs[1]
f(a = 1) as a keyword argumentThere are no keyword arguments; inside an expression = is Equal, so this passes the boolean a == 1pass 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 inclusivecheck both ends
x^1/2(x^1)/2 — ^ binds tighter than /sqrt(x) or x^(1/2)
x = 5 inside an expressionCompares, rather than assigning — only a whole statement assigns:= to assign in place, == to be explicit
print(x)Inert, nothing is printedthe program's value is its last statement
round(2.5)3 (half away from zero), not Python's 2(intentional)
3!^2Diagnostic — the lexer reads !^ as one token3! ^ 2
a +bDiagnostic — an infix operator needs spaces on both sides or neithera + b or a+b
"\(xs)" with a list xsBroadcasts into a list of stringsinterpolate scalars only
x && y on fresh symbolsTypes those symbols boolean for the engine's lifetimeuse 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.

Next​