/Garden
Published: 2026-03-10
Table of contents

Roll Your Own Calculator... in Python

In the early days of Classic Mac OS, when they were working on the first versions of the Calculator desk accessory, the ever-omniscient Steve Jobs was remarkably picky on the exact layout, sizing and texture of the calculator’s buttons.

After several iterations and rejections from Jobs, an employee who was tasked with the work, Chris Espinosa, eventually decided to give Jobs himself the tools to do so, creating “the Steve Jobs Roll Your Own Calculator Construction Set”. The same calculator design stuck for the entirety of the Classic Mac OS era, until the Aqua interface and general architectural shakeup of Mac OS X in 2001 brought in a completely new calculator app.

What I just said is completely irrelevant to this article – that was a cool fun fact, a nice opening hook and a clunky attempt at topic disambiguation all at once; a nice 3-for-1 value pack. We aren’t doing anything with fancy calculator buttons and UX design or anything like that (maybe another day).

What I’ll actually be talking about here is building your own personalised command-line calculator with Python.


Whetting Your Appetite

Python prides itself on many things – its ease of use, massive ecosystem of third-party libraries, and what we’re interested in here, on being “a handy desk calculator”.

Python can be run as a REPL (read-eval-print loop), where you can feed it commands statement-by-statement to be executed interactively, and get instant feedback on results.

The stock Python REPL is a very nicely environment out-of-the-box – just launch python on your terminal, and you can immediately do basic arithmetic expressions. You also have a nice set of built-in functions to play with, and if that’s not enough, you can also import stuff from the standard library as you wish:

$ python
>>> 1 + 1  
2
>>> 30 // 70  
0
>>> abs(54 - 100)  
46  
>>> pow(10, 100)  
10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000  
>>> from math import factorial  
>>> factorial(25)  
15511210043330985984000000  
>>>

But, say you want to do some very specific custom operations or functions and you don’t wanna type it out or redefine it every session, or you find yourself importing the same common libraries for most sessions. For these scenarios, it’d be really handy if you can preload some startup code to set up functions and imports before you get dropped into the REPL.

So, how do we do that?


Getting Started

Let’s start off with the native Python way – it’s simple, easy to setup, needs nothing extra other than python, and might be all you need if you just want persistent functions and imports and don’t care about blinging out your environment. As mentioned, all you need is python – but for this article I’ll be using the uv toolchain.

First, we’ll create a Python script where we’ll define the common functions and other things we want in scope for all sessions. You can call the file anything you want.

You can put anything here - math functions, bytes/string operations, the sky’s the limit! You can even do from imports to bring functions from other modules into scope.

#!/usr/bin/env -S uv run python -i
# my-calculator.py

from math import factorial
from collections.abc import Sequence

def concat_digits(seq: Sequence, /, *, base: int = 10) -> int:
    """
    Converts a sequence of ints into one integer.

    Works best for ints between 0 and 9, but theoretically works for ints outside that range.

    Specify custom bases with the `base` kwarg (defaults to 10).
    """
    return sum( \
	    value * (base ** (len(seq) - 1 - place)) \
	    for place, value in enumerate(seq)) # fmt: skip

def sponge_case(string: str, /, *, start_upper: bool = False) -> str:
    """
    Convert a string into sPoNgE CaSe.
    """
    return "".join( \
	    c.upper() \
	    if idx % 2 == (0 if start_upper else 1) \
	    else c.lower() \
	    for idx, c in enumerate(string)) # fmt: skip
    
if __name__ == "__main__":
	print("My custom Python calculator.")

The -i flag effectively tells Python to, instead of exiting at the end of the script, drop you into the Python REPL, with any global variables defined or imported by the script available in scope.

Notice how we’ve also included a shebang at the top, to indicate to the interpreter that the script should be run as, in this case, Python via uv. Because of that, we can simply first add the executable permission to the file with chmod (this only needs to be done once), and invoke it directly.

$ chmod +x my-calculator.py
$ ./my-calculator.py
My custom Python calculator.
>>> sponge_case("Readability counts.")
'rEaDaBiLiTy cOuNtS.'
>>> concat_digits((1, 2, 9)) + 1
130
>>> factorial(concat_digits((1, 2)))
479001600

Ooh, as a bonus: the built-in help() function actually can give help text for your custom functions, based on the function signature (including type annotations) and docstring comments.

>>> help(concat_digits)  
──────────────────────────────────
Help on function concat_digits in module __main__:  
  
concat_digits(seq: collections.abc.Sequence[int], /, *, base: int = 10) -> int  
   Converts a sequence of ints into one integer.  
  
   Works best for ints between 0 and 9, but theoretically works for ints outside that range.  
  
   Specify custom bases with the `base` kwarg (defaults to 10).  
──────────────────────────────────

Adding Dependencies

But what if you want to pull in a third-party package, such as Polars? This is where uv and our shebang comes in.

uv run lets you specify third-party packages to be installed and available at runtime, using the --with flag. We’ll add them to the shebang like so:

#!/usr/bin/env -S uv run --with <packages>,<go>,<here> python -i
# -- snip --

Note that if we want to add multiple packages, we need to seperate them with commas (make sure there are no extra spaces between the package names, or else it’ll confuse the shebang interpreter).

#!/usr/bin/env -S uv run --with polars,pyfiglet python -i
# my-calculator.py

from math import factorial
from collections.abc import Sequence
import polars as pl
# -- snip --

And now we have third-party packages available in our calculator!

On the first run after adding a new package, uv will first download and install it into a global cache (to be reused for future runs) and load it before running the calculator script. For larger libraries like Polars, Pandas or NumPy, this may take a while depending on your download speed.

$ ./my-calculator.py
My custom Python calculator.
>>> concat_digits((2, 2, 0, 3))
2203
>>> pl.DataFrame({"name": ["Alice", "Bob"], "favourite_food": ["Pizza", "Anchovies"]})
shape: (2, 2)  
┌───────┬────────────────┐  
│ name  ┆ favourite_food │  
│ ---   ┆ ---            │  
│ str   ┆ str            │  
╞═══════╪════════════════╡  
│ Alice ┆ Pizza          │  
│ Bob   ┆ Anchovies      │  
└───────┴────────────────┘
>>> print(figlet.figlet_format("yippee", font="block"))
                                                    
          _|                                             
_|    _|      _|_|_|    _|_|_|      _|_|      _|_|       
_|    _|  _|  _|    _|  _|    _|  _|_|_|_|  _|_|_|_|     
_|    _|  _|  _|    _|  _|    _|  _|        _|           
  _|_|_|  _|  _|_|_|    _|_|_|      _|_|_|    _|_|_|     
      _|      _|        _|                               
  _|_|        _|        _|

>>>

Beyond the Standard Interpreter

The stock Python interpreter may be all that you need, and is what I recommend if you’re using it to assist in standard Python development tasks, but it isn’t the most flexible option. For one, it doesn’t let you modify the prompt indicator, and all functions require parens to invoke them (excluding some builtins like exit).

For these features, we need IPython, which among other things, provides a richer and more customisable REPL experience than the default REPL.

With uv, swapping out the standard REPL with IPython is a drop-in replacement. Simply add ipython into your --with dependency list, and replace the python executable with ipython.

- #!/usr/bin/env -S uv run --with polars,pyfiglet python -i
+ #!/usr/bin/env -S uv run --with polars,pyfiglet,ipython ipython -i
  # -- snip --

Running the script again, we see that (after IPython is installed), we get a new REPL style showing line counts, and some extra builtin commands.

./my-calculator.py  
Python 3.13.5 (main, Jul  1 2025, 18:37:36) [Clang 20.1.4 ]  
Type 'copyright', 'credits' or 'license' for more information  
IPython 9.11.0 -- An enhanced Interactive Python. Type '?' for help.  
Tip: You can use `%hist` to view history, see the options with `%history?`  
My custom Python calculator.  
  
In [1]: print(figlet.figlet_format(sponge_case("yippee"), font="slant"))  
          ____      ____       ______  
   __  __/  _/___  / __ \___  / ____/  
  / / / // // __ \/ /_/ / _ \/ __/      
 / /_/ // // /_/ / ____/  __/ /___      
 \__, /___/ .___/_/    \___/_____/      
/____/   /_/
  
In [2]:

Now, let’s customise this environment!

Removing the default message

If you have your own header message, you’ll notice that it gets placed after the IPython header information. This is done with the IPython configuration options. Ordinarily, you’d need a full IPython profile set up to take advantage of this, but we can alternatively specify configs as ipython arguments, which we can include in the shebang.

- #!/usr/bin/env -S uv run --with polars,pyfiglet,ipython ipython -i
+ #!/usr/bin/env -S uv run --with polars,pyfiglet,ipython ipython --TerminalIPythonApp.display_banner=false -i
  # -- snip --

You’ll also notice that our shebang is becoming very long and unwieldy. Unfortunately, shebangs are inherently limited to one line, and is only meant for pointing to simple binaries and not for tacking on multiple long arguments like this, so we’re gonna have to deal with it. There are some esoteric ways to simulate a shebang with multiple lines, but things might break when using those – here be dragons!

Customising the Prompt

IPython lets you customise the prompts that display when inputting and outputting data interactively. There are four main places we can customise the prompt: input, continuation (for multi-line inputs), rewrite (for editing previous lines) and output (for expression return values).

These can be overriden with a simple subclass definition, as shown below.

# -- snip --
from IPython.core.getipython import get_ipython
from IPython.terminal import prompts

class MyPrompt(prompts.Prompts):
    def in_prompt_tokens(self):
        return [(prompts.Token.Prompt, "ipy » ")]

    def continuation_prompt_tokens(self, *args, **kwargs):
        return [(prompts.Token.Prompt, "    · ")]

    def rewrite_prompt_tokens(self):
        return [(prompts.Token.Prompt, "    * ")]

    def out_prompt_tokens(self):
        return [(prompts.Token.Prompt, "    = ")]

ipy = get_ipython()
ipy.prompts = MyPrompt(ipy)
# -- snip --
$ ./my-calculator.py
My custom Python calculator.

ipy » 1 + 1
    = 2

ipy » 

See IPython as a system shell § Prompt customization for further documentation.

Defining Magic Commands

In IPython, magic commands are special functions that can be invoked without parens if prefixed with a %. You can define your own with the @register_line_magic decorator.

# -- snip --
from IPython.core.magic import register_line_magic

@register_line_magic
def echo(msg: str):
    print(msg)
    
@register_line_magic
def echo_sponge_case(msg: str):
    print(sponge_case(msg))
# -- snip --

Note that registered functions must take 1 positional argument: a string that contains any arguments passed when invoking the command. You can discard it if you wish, but the argument must be defined.

$ ./my-calculator.py
My custom Python calculator.

ipy » %echo hello world!
hello world!

ipy » %echo_sponge_case hello world!
hElLo wOrLd!

ipy »

Conclusion

And with that, we’ve officially built our own custom calculator in Python, with custom functions, prompts, customisations, and third-party libraries. From here, it’s all up to you – you can add in functions and commands that you frequently use, all in a convenient Python script, ready to go at any moment.

Full text of calculator.py
#!/usr/bin/env -S uv run --with polars,pyfiglet,ipython ipython --TerminalIPythonApp.display_banner=false -i
# my-calculator.py
#
# MIT License
#
# Copyright (c) 2026 Jahin Z.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

from math import factorial  # noqa: F401

from collections.abc import Sequence
from IPython.core.getipython import get_ipython
from IPython.core.magic import register_line_magic
from IPython.terminal import prompts

import polars as pl  # noqa: F401  # ty:ignore[unresolved-import]
import pyfiglet as figlet  # noqa: F401  # ty:ignore[unresolved-import]

ipy = get_ipython()

# region prompt


class MyPrompt(prompts.Prompts):
    def in_prompt_tokens(self):
        return [(prompts.Token.Prompt, "ipy » ")]

    def continuation_prompt_tokens(self, *args, **kwargs):
        return [(prompts.Token.Prompt, "    · ")]

    def rewrite_prompt_tokens(self):
        return [(prompts.Token.Prompt, "    * ")]

    def out_prompt_tokens(self):
        return [(prompts.Token.Prompt, "    = ")]


ipy.prompts = MyPrompt(ipy)

# endregion

# region functions


def concat_digits(seq: Sequence, /, *, base: int = 10) -> int:
    """
    Converts a sequence of ints into one integer.

    Works best for ints between 0 and 9, but theoretically works for ints outside that range.

    Specify custom bases with the `base` kwarg (defaults to 10).
    """
    return sum( \
	    value * (base ** (len(seq) - 1 - place)) \
	    for place, value in enumerate(seq))  # fmt: skip


def sponge_case(string: str, /, *, start_upper: bool = False) -> str:
    """
    Convert a string into sPoNgE CaSe.
    """
    return "".join( \
	    c.upper() \
	    if idx % 2 == (0 if start_upper else 1) \
	    else c.lower() \
	    for idx, c in enumerate(string))  # fmt: skip


# endregion

# region magics


@register_line_magic
def echo(msg: str):
    """
    Prints all arguments back to stdout.
    """
    print(msg)


@register_line_magic
def echo_sponge_case(msg: str):
    """
    Prints all arguments back to stdout in sPoNgE CaSe.
    """
    print(sponge_case(msg))


# endregion

# region main

if __name__ == "__main__":
    print("My custom Python calculator.")

# endregion