Arithmetic calculator which uses a Pratt parser
Project description
Table of Contents
- Introduction
- Motivation
- Requirements
- Quick Start
- Installation
- pip
- pipx (recommended)
- Contributing
- Usage
- REPL
- Loading a file
- Evaluating an expression on the fly
- Combining switches
- Arithmetic
- Trigonometric Functions
- A Note on the Implementation of Trig Functions
- Semicolons
- Variables
- Comments
- Quoted Expressions
- Strings
- Conditionals
- Ideas
- A Note on Libraries Used
- The Pratt Parsing Algorithm
- More Thoughts
Introduction
An arithmetic expression calculator in Python, demoing the Pratt parsing algorithm.
This takes inspiration from this excellent 2010 blog post, as well as a few other sources:
- Simple but Powerful Pratt Parsing
- Pratt Parsers: Expression Parsing Made Easy
- Compiling Expressions (Chapter 17 of Crafting Interpreters)
I also have some notes at the end of this document which go into some detail over how the Pratt parsing machinery works.
Motivation
When reading through my copy of An Introduction to Functional Programming Through Lambda Calculus (Dover, 2011), I was inspired by how simply and directly lambda calculus can be used as a means to implement a higher level language. I initially began writing an interpreter for such a language. I then later became frustrated with my attempt, and tried a second time, this time with the intention of scaling back the scope of the project. In researching parsing methods, I came across the Pratt parsing algorithm. I started on a proof-of-concept project to nail my understanding of it, and liked the result so much that I decided to expand on it, which is what you're looking at right now. 😁
Requirements
Requires Python 3.13 or greater.
Quick Start
# Install globally.
pipx install pratt-calc
# Launches an interactive REPL.
pratt-calc
# Painlessly uninstall the application when finished.
pipx uninstall pratt-calc
Installation
Here's a more detailed reference on how to install the application.
pip
Set up and activate a virtual environment, then:
pip install pratt-calc
pipx (recommended)
Using pipx enables you to globally install the application without
worrying about virtual environments.
pipx install pratt-calc
In some cases it may be necessary to specify the Python version manually:
PIPX_DEFAULT_PYTHON=python3.13 pipx install pratt-calc
Or, if you have uv installed:
uvx pipx install pratt-calc
Contributing
Install uv, then run:
git clone https://github.com/BrandonIrizarry/pratt-calc
cd pratt-calc
uv sync --locked
Usage
Pratt Calc supports three modes of usage:
- Evaluating expressions from inside a REPL;
- evaluating the contents of a source file, and
- evaluating an expression given at the command line.
Also see
pratt-calc --help
REPL
To launch the REPL:
pratt-calc
Use the exit command (or Ctrl+D) to quit the REPL.
Loading a file
To execute the contents of a file:
pratt-calc FILENAME
Evaluating an expression on the fly
To evaluate a one-off expression:
pratt-calc -e EXPRESSION
Single quotes surrounding the expression are recommended, to prevent
the shell from expanding * and so on. Example:
pratt-calc -e '3-4*5'
This should print -17 at the console.
Combining switches
If neither a filename argument nor -e are provided, the REPL will
launch. Conversely, if either one is present, the REPL will not
launch. However, you can use -i to force the REPL to launch in such
a case.
Arithmetic
Pratt Calc is at its most basic level an arithmetic expression
calculator over integers and floats. It currently supports +, -,
*, and \ with their usual meanings of addition, subtraction,
multiplication, and division respectively. In addition, unary negation
(e.g. -2.5), as well as exponentiation (^) and factorial (!) are
supported.
Note that an expression like 3.2! is first truncated to an integer
before evaluation, that is, 3.2! would evaluate to 6.
Parentheses are used to enforce precedence, viz.,
pratt-calc -e '(3 + 5) * 2' => 16
Trigonometric Functions
pratt-calc supports the following trigonometric functions:
- sin
- cos
- tan
- csc
- sec
- cot
The constant 𝝿 is also available as pi. Examples:
pratt-calc -e 'pi' => 3.141592653589793
pratt-calc -e 'cos(pi)' => -1.0
pratt-calc -e 'sin(1)^2 + cos(1)^2' => 1.0
A Note on the Implementation of Trig Functions
Trig functions are implemented as unary operators, as opposed to
function calls. Hence the parentheses used by sin and so forth are
merely there to enforce precedence, even though they conveniently
evoke the intuition of a function call.
Hence sin²(1) + cos²(1) can be written (somewhat misleadingly) as
follows:
sin 1^2 + cos 1^2
This evaluates to 1.0.
For this reason, parentheses in this case are always recommended.
Semicolons
Semicolons enable side-effect-based programming, which currently are a work in progress. For now, semicolons discard the result of whatever is to the left of them:
pratt-calc -e '3 + 3 ; 3 * 3; 3 ^ 3' => 27.0
That is, the result of the above expression is simply the value of the
last subexpression, namely, 3 ^ 3.
Within source files, semicolons are optional, since newlines are converted to semicolons in a preprocessing step. This means, however, that semicolons are still needed for multiple expressions occuring on the same line.
Variables
Arithmetic expressions can be assigned to variables using <-. Note
that the assignment operator binds more tightly than semicolon.
pratt-calc -e 'alice <- 3; bob <- 4; alice + bob => 7
Comments
Comments (mainly useful in source files) are delimited using /* */.
Quoted Expressions
Expressions can be saved for later execution using quoted expressions
delimited with curly braces ({}). Expressions are saved in a linear
buffer called the heap. The heap stores the same kinds of tokens
that are fed into the evaluator. Saving tokens into the heap in this
manner is referred to as compilation (my own terminology, though
this is vaugely inspired from Forth.)
User code can access objects stored in the heap using the numeric address of that object. Assume the following is found in a file "triangle.txt":
/* Triangle stores the _address_ of the quoted expression. *./
triangle <- {1 + 2 + 3 + 4}
/* 'call' then executes the quoted expression at the given address;
and so we should se '10' printed as the result. */
call triangle
Strings
Strings in Pratt Calc are handled similarly as with quoted
expressions: their content is saved in the heap for later use, while
the currently executing code sees a numeric handle to the string. This
style of implementation, unfortunately, requires a casting operation
(the str unary operator) whenever we want to print a number to the
console as a string:
message <- "hello"
/* Prints "hello" to the console. */
print(message)
mistake <- 0
/* Won't work, unless a string is stored at address 0 */
print(mistake)
/* This WILL work, printing "0" at the console. */
print(str mistake)
str compiles a string into the heap, then returns the string's
address so that print will handle it properly.
Conditionals
There is currently a simple mechanism for conditionally evaluating quoted expressions. The following example is taken directly from the tests:
x <- 1
block <- { x { print("hello") } ; (x - 1) { print("goodbye") } }
/* This should only print "hello". */
call block
x <- x - 1
/* This should only print "goodbye." */
call block
Note the use of nested quoted expressions (which I call "blocks"
here.) I was able to give a led action to { (see below) such
that the current accumulated result is available as a boolean flag: 0
for false, and anything else for true. This operation binds more
tightly than any other, and so parentheses are usually required when
using it, as you can see in the above code excerpt.
Ideas
Currently, time prohibits me from expanding on this project any further for now. However, I had at least a few ideas for expanding on the project.
One idea is for operators to work on tokens directly, instead of int
or float values. Currently, the evaluator constantly forwards the
current running result as an int or float down the stream of
tokens. However, this causes problems when, for example,
disambiguating a simple arithmetic result from a heap address. This
then requires casting numbers into strings before printing them. It
also complicates implementing conditional expressions in a sane
manner. A token would otherwise carry its type with it, and now we'd
have a simple duck-typing mechanism: print could act on whether its
argument was a string (dereference) or else a number (print directly.)
Conditionals would expect a BOOL token, which could be internally
generated by a user-provided boolean operator. Also, arithmetic
expressions could prohibit, say, adding a simple number to a heap
address.
A Note on Libraries Used
Pratt Calc, as a command-line app, is built using Typer.
It also uses the more-itertools library to implement the token stream used to drive expression evaluation.
The Pratt Parsing Algorithm
I'm including these notes in case they may be of help to anyone trying to learn about Pratt parsing (and to help me remember for future projects!) The algorithm is ultimately simple, but tricky. To be honest, I don't think I have a 100% perfect mental model of it, but I still want to present and possibly refine my understanding.
The Pratt parsing algorithm can be summarized as follows. Note that I may occasionally make the assumption here that the "parsed" expression is also simultaneously being evaluated, though it should be very easy to adjust the following explanation to do whatever you want, for example, construct an abstract syntax tree instead of accumulate a literal result.
parse(level):
t ← next(stream)
acc ← nud_dispatch(t) // Calls 'parse' recursively.
while level < precedence(peek(stream)):
t ← next(stream)
acc ← led_dispatch(t, acc) // Calls 'parse' recursively.
return acc
main():
parse(0)
-
parseis the top-level function. It accepts an operator-precedence level as an argument. -
nud_dispatch, which may or may not be a separate function, represents the logic that processes the next token as a prefix operator, ornud.The
nud_dispatchlogic serves to initializeacc, which represents the accumulated result of the computation. It can either recursively callparsewith alevelarugment equal to the unary-operator precedence oft, or else assigntdirectly toaccin the case wheretis a constant value (a number, for example).There is also an implicit assumption that the stream of tokens is structured properly, that is, a valid
nudwill be met with at this point in the code. For example, a stray infix operator will be caught as an error.nudis short for null denotation. -
precedenceis a function (or else some logic) that returns the precedence of a given token as an infix operator, orled. If said precedence is higher than the currentlevel, the next token is fetched, and determines the current action ofled_dispatch(which is logic that, by construction, should involveacc). For example, iftis+,led_dispatchshould assignacc + parse(ADDITION_PRECEDENCE)toacc.precedence, in this formulation, also checks whetherpeek(stream)is a validledtoken in the first place.The literature aptly notes that a
ledtechnically can be any non-prefix operator, for example, factorial (52!), or ternary conditional expressions (foo ? bar : baz). The only relevant characteristic of aledis that it utilizesaccin reassigning its computation back toacc.ledis short for left denotation. -
If the precedence of the next would-be token is lesser or equal, we're done for this
level: returnacc.
In general, it helps to think of the algorithm as traversing an expression along fluctuating "gradients" of operator precedence, such that the algorithm "ramps up" or "ramps down", depending on the operator seen.
There is also a "ramp-even" which is functionally equivalent to a ramp-down. This fact is important in understanding how the algorithm enforces associativity.
Example: evaluate 3 + 5 * 2 - 1.
Assume we have the following precedence levels:
NONE = 0
PLUS_MINUS = 1
TIMES_DIVIDE = 2
I'll now trace through what the above algorithm would do in this case.
I've deliberately kept this example simple to demonstrate the most salient aspects of the algorithm, but there are certain bespoke tricks that become manifest in more complex expressions involving, for example, parentheses and right-associative operators. Ideally, adequately explaining Pratt parsing would take at least several examples of varied complexity.
To differentiate the different stack
frames associated with
recursive calls to parse, I'll subscript each mention of the acc
variable according to the stack frame it belongs to, e.g., acc₀,
acc₁, etc.
Without further ado:
-
parseis called asparse(NONE). It's a good idea to have aNONEprecedence which bootstraps the precedence gradient. -
3is a validnud, because it's a constant. So performacc₀ ← 3 -
+, as peeked from the stream, is aled, and would ramp up the precedence toPLUS_MINUS: so we consume it and enter the while loop body. We dispatch+as aled, such thatacc₀ ← acc₀ + parse(PLUS_MINUS)We now enter the recursive call to
parse.The fact that we don't unconditionally consume tokens is important: this lets lower-precedence tokens function as sentinels that force the evaluation of more-tightly-bound expressions, something which we'll see in a bit.
-
We're now one frame deep in recursion, with a
levelofPLUS_MINUS. We find that5is a constantnud, so we assign it to theaccof our current stack frame:acc₁ ← 5 -
*is aled, and would ramp up the precedence toTIMES_DIVIDE, so consume it and enter the while loop body. We dispatch*as aled, such thatacc₁ ← acc₁ * parse(TIMES_DIVIDE)We now enter the recursive call to
parse. -
We're now two frames deep in recursion, with a
levelofTIMES_DIVIDE. We find that2is a validnud, so performacc₂ ← 2 -
Here it gets interesting. We peek the stream and find the
ledtoken-, binary subtraction, waiting for us. However, the current level isTIMES_DIVIDE, whileprecedence(-)isPLUS_MINUS. We've just hit our first ramp-down! So we don't consume the-: we returnacc₂, which is2, from the current frame. -
We're now back at stack frame #1, with a
levelofPLUS_MINUS. We had just executed the callacc₁ ← acc₁ * parse(TIMES_DIVIDE).Well, the recursive call evaluated to
2, so we're now left withacc₁ ← acc₁ * 2In this frame,
acc₁is 5, and so performacc₁ ← 10. -
We continue looping within the current stack frame. We again peek the stream and find the unconsumed
-from before: since this would be a ramp-even, we exit the loop and return10from the current frame. -
We're now back at stack frame #0, with a
levelofNONE. Theled_dispatchportion reduces as follows:
acc₀ ← acc₀ + parse(PLUS_MINUS)
acc₀ ← acc₀ + 10
acc₀ ← 3 + 10
acc₀ ← 13
-
We continue looping within the current stack frame. We again peek the stream and once again find the unconsumed
-from before. However, this time, it would ramp the precedence up toPLUS_MINUS, and so we enter the while loop body, finally consuming the-, and entering another stack frame to computeacc₀ ← acc₀ - parse(PLUS_MINUS) -
In the current frame (
level==PLUS_MINUS), we performacc₁ ← 1Next, something interesting happens. When we peek the stream, it looks like there are no more tokens; it looks like we've "fallen off the edge of the earth." To accomodate this necessary edge case, we always provide
EOFas the last token of any expression. We parseEOFas aledsuch thatprecedence(EOF) == NONE. Thus,EOFis a kind of sentinel value that forces an unconditional ramp-down/even at the end of every expression, triggering the evaluation of everything prior.In this case, that means that the while loop exits without consuming
EOF(a good thing, sinceEOFcan't really be dispatched as aled), and we return1from the current frame. -
Back at frame #0, we continue with
acc₀ ← acc₀ - parse(PLUS_MINUS)
acc₀ ← acc₀ - 1
acc₀ ← 13 - 1
acc₀ ← 12
- Continuing the loop within frame #0, we peek the stream and find
EOFstill sitting there (which it will always do.) SinceEOFby construction never has higher precedence than any other token, we exit the loop and return12as our final answer.
More Thoughts
Writing the above trace-through example actually made me realize a few more things about how Pratt parsing works! More complex examples are possible, maybe at another time though.
As a whole, the algorithm allows for a great deal of artistic license.
Also, the "dispatch functions" may not even be functions; for example,
in this project, these are represented by Python match statements
that assign to acc accordingly.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pratt_calc-2.0.5.tar.gz.
File metadata
- Download URL: pratt_calc-2.0.5.tar.gz
- Upload date:
- Size: 33.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.9.18 {"installer":{"name":"uv","version":"0.9.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a3a6d2c1a9162929c8ba83cac8547dfed051f29adb4fd806aeb58a7011988726
|
|
| MD5 |
e4f23ca6adf0c54bac899d5a5922e233
|
|
| BLAKE2b-256 |
41bdbaf3f2da8aeb15c139d2b2acd03f8134a69daedcc7366164c5f3b3553759
|
File details
Details for the file pratt_calc-2.0.5-py3-none-any.whl.
File metadata
- Download URL: pratt_calc-2.0.5-py3-none-any.whl
- Upload date:
- Size: 28.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.9.18 {"installer":{"name":"uv","version":"0.9.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2c07801b6151f7b53703b0a8f3b082a33828732dbd6340493994e805c256b10e
|
|
| MD5 |
3d8be512b03d6495be3506b0d0bfbe92
|
|
| BLAKE2b-256 |
86ee7ca9db53d31db2586badf98c867921cd02ca95622db217e7b1f6ec57abde
|