Repository: resultwizard/ResultWizard (language: python)

Issue:
Feature list ValueWizard
What we wish to have as features:

- [x] `Res` object to give our results: numerical value, error, unit, 
- [x] Output customizable via global options (no TOML file instead global export config)
- [x] Multiple error values: systematic, statistical, and further
- [x] unit in siunitx
- [x] significant digits: if multiple errors are present: take the biggest error, let this one determine the number of digits of the main result, and display the other errors according to their own precision
- [x] manually specify number of significant digits (`sigfigs`), this overrides the logic of the program: usually it determines the number of decimal places based on the "error value", but with `sigfigs` specified, it will base this decision off the "main value"
- [x] globally import just like `plt` interface from `matplotlib`
- [ ] allow `pm` in `qty` and arbitrary string such that we can do things like `(1.0 +- 0.1) \cdot 10^2` -> outsource as much as possible to `siunitx`, use `\num{..}`; if we have to implement this on our own: allow internationalization for the $\cdot$ sign, e.g. use $\times$ in AE
- [ ] `resint`, e.g. wiz.resint("i", i)
- [x] `wizLaengeValueOnly`, see #11 
- [x] #5 `wiz.printByDefault()` or `wiz.res("laenge", 5, 0.01).print()`
- [x] #7 turn rounding off for a variable -> current problem with this: we pass in values as floats and don't know how many digits they have; workaround: if user specifies number as string, then don't round that number, also for any number, e.g. errors
- [x] at first, we don't implement any tables at all
- [x] #15 on result object: `get_latex()`
- [x] #11 variable without unit: `value only`
- [x] `wiz.export()`
- [ ] `wiz.clear()` as workaround for #6
- [ ] user faulty values -> output exception directly

Edit:
- [x] export a variable as latex code directly (e.g. copy to clipboard)
- [x] "no unit" usecase
- [ ] do we assume usage of variable in math mode in latex?
- [ ] `\qty` vs. `\SI`, especially assert compatibility with the `physics` package
- [x] Config options: automatic print with every `res` call. AND: option to automatically recreate the `results.tex` upon every `res` call.

## TODO
- [x] publishing to pip
- [x] naming of lib
- [x] CI/CD, branch flow




Discussion / hints:
```py
Res(1.5, 0.1, r"\milli\meter")      # 1.50 +- 0.10

Res(0.0005, 0.1, r"\meter", autoprefix=True)    # 0.5 mm oder 500 um

Res(0.5, sys=0.1, stat=0.2)

Res(0.5, errors=[Error(0.2, "stat1"), Error(0.3, "stat2"), Error(0.4, "sys")])

Res(1.5, 0.1, r"\milli\meter", sigfigs=2)   # 1.5 +- 0.1

Res(1.5, 0.1, r"\milli\meter", decimal_places=2)    # 1.50 +- 0.10

vw.res("laenge", 1.5, r"\mm")

wiz.res("abw", 1.5, r"\sigma")

wiz.resint("i", i)

wiz.printByDefault()
```

Fix patch:
diff --git a/.github/actions/changed_files/action.yml b/.github/actions/changed_files/action.yml
new file mode 100644
index 00000000..ecab3e9d
--- /dev/null
+++ b/.github/actions/changed_files/action.yml
@@ -0,0 +1,42 @@
+# Why is this file in a subdirectory? Because GitHub Actions requires it to be :(
+# see: https://github.com/orgs/community/discussions/26245#discussioncomment-5962450
+name: "Get changed files"
+description: "Checks out the code and returns the filenames of files that have changed in the pull request"
+
+inputs:
+  file-extensions:
+    # for example: "\.py$" or something like "\.py$|\.py.myfile$"
+    description: "Regex expressions for grep to filter for specific files"
+    required: true
+
+outputs:
+  changed-files:
+    description: "A space-separated list of the files that have changed in the pull request"
+    value: ${{ steps.get-changed-files.outputs.files }}
+
+runs:
+  using: "composite"
+  steps:
+    # This has to be done in the main workflow, not in the action, because
+    # otherwise this reusable action is not available in the workflow.
+    # - name: "Checkout code (on a PR branch)"
+    #   uses: actions/checkout@v4
+    #   with:
+    #     fetch-depth: 2 # to also fetch parent of PR
+
+    # Adapted from this great comment [1]. Git diff adapted from [2].
+    # "|| test $? = 1;" is used to ignore the exit code of grep when no files
+    # are found matching the pattern. For the "three dots" ... syntax, see [3].
+    #
+    # Resources:
+    # number [1] being most important
+    # [1] https://github.com/actions/checkout/issues/520#issuecomment-1167205721
+    # [2] https://robertfaldo.medium.com/commands-to-run-rubocop-and-specs-you-changed-in-your-branch-e6d2f2e4110b
+    # [3] https://community.atlassian.com/t5/Bitbucket-questions/Git-diff-show-different-files-than-PR-Pull-Request/qaq-p/2331786
+    - name: Get changed files
+      shell: bash
+      id: get-changed-files
+      run: |
+        files_pretty=$(git diff --name-only --diff-filter=ACMR -r HEAD^1...HEAD | egrep '${{inputs.file-extensions}}' || test $? = 1;)
+        printf "🎴 Changed files: \n$files_pretty"
+        echo "files=$(echo ${files_pretty} | xargs)" >> $GITHUB_OUTPUT
diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml
new file mode 100644
index 00000000..4178ceba
--- /dev/null
+++ b/.github/workflows/linter.yml
@@ -0,0 +1,50 @@
+name: Linting
+
+# for "synchronize", see [1]
+on:
+  pull_request:
+    types: [opened, reopened, synchronize, ready_for_review]
+
+jobs:
+  pylint:
+      name: Pylint
+      runs-on: ubuntu-latest
+      steps:
+        - name: Checkout code
+          uses: actions/checkout@v4
+          with:
+            fetch-depth: 2 # to also fetch parent of PR (used to get changed files)
+
+        - name: Get changed files
+          id: py-changed
+          uses: ./.github/actions/changed_files/
+          with:
+            file-extensions: \.py$
+
+        - name: Setup Python
+          if: ${{ steps.py-changed.outputs.changed-files != ''}}
+          uses: actions/setup-python@v5
+          with:
+            python-version: '3.11'
+            cache: 'pipenv'
+
+        # see Caching [2]
+        - name: Install pipenv
+          if: ${{ steps.py-changed.outputs.changed-files != ''}}
+          run: pip install pipenv
+
+        - name: Install dependencies
+          if: ${{ steps.py-changed.outputs.changed-files != ''}}
+          run: pipenv install --dev
+
+        # For GitHub output format, see [3]
+        - name: Run Pylint
+          if: ${{ steps.py-changed.outputs.changed-files != ''}}
+          run: |
+            echo "🚨 Running Pylint version: $(pipenv run python3 -m pylint --version)"
+            pipenv run python3 -m pylint ${{ steps.py-changed.outputs.changed-files }}
+
+
+# [1] https://github.com/orgs/community/discussions/26366
+# [2] https://github.com/actions/setup-python/blob/main/docs/advanced-usage.md#caching-packages
+# [3] https://github.com/pylint-dev/pylint/issues/9443
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..d9c0044e
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,41 @@
+name: Release package
+
+on:
+  release:
+    types: [published]
+
+jobs:
+  releasepypi:
+    # see https://docs.pypi.org/trusted-publishers/using-a-publisher/
+    # and https://packaging.python.org/en/latest/tutorials/packaging-projects/#uploading-the-distribution-archives
+    name: Release to PyPI
+    runs-on: "ubuntu-latest"
+    environment: release
+    permissions:
+      id-token: write
+
+    steps:
+      - name: Checkout source
+        uses: actions/checkout@v4
+
+      - name: Setup Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: 3.11
+          cache: 'pipenv'
+
+      - name: Run tests (one last time before release)
+        run: |
+          pip install pipenv
+          pipenv install --dev
+          pipenv run pip3 install --editable .
+          pipenv run pytest tests/
+
+      - name: Install build tooling
+        run: python3 -m pip install --upgrade build
+
+      - name: Build distributions
+        run: python3 -m build
+
+      - name: Upload to PyPI
+        uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.gitignore b/.gitignore
index 73684666..899fa46d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,5 @@
-.vscode/
 /test/
 */__pycache__
 __pycache__
-*.tex
\ No newline at end of file
+*.tex
+*.egg-info
\ No newline at end of file
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 00000000..432b4cf9
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,9 @@
+{
+    "recommendations": [
+        "streetsidesoftware.code-spell-checker",
+        "ms-python.pylint",
+        "ms-python.black-formatter",
+        "littlefoxteam.vscode-python-test-adapter",
+        "hbenl.vscode-test-explorer"
+    ]
+}
\ No newline at end of file
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 00000000..59f68038
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,19 @@
+{
+    // Use IntelliSense to learn about possible attributes.
+    // Hover to view descriptions of existing attributes.
+    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "name": "Debug test (only use for tests)",
+            "type": "python",
+            "request": "attach",
+            "console": "integratedTerminal",
+            "justMyCode": true,
+            "stopOnEntry": false,
+            "purpose": [
+                "debug-test"
+            ]
+        }
+    ]
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 00000000..288fa045
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,87 @@
+{
+    //////////////////////////////////////
+    // Python (linting & formatting)
+    //////////////////////////////////////
+    "[python]": {
+        "editor.defaultFormatter": "ms-python.black-formatter",
+        "editor.formatOnSave": true
+    },
+    "pylint.importStrategy": "fromEnvironment",
+    "python.analysis.typeCheckingMode": "basic",
+    "python.languageServer": "Pylance",
+    "python.analysis.completeFunctionParens": true,
+    "python.analysis.diagnosticSeverityOverrides": {
+        "reportGeneralTypeIssues": "warning",
+        "reportUndefinedVariable": "warning",
+        "reportOptionalMemberAccess": "warning"
+    },
+    // Python Tests
+    "pythonTestExplorer.testFramework": "pytest",
+    "testExplorer.codeLens": true,
+    "testExplorer.errorDecorationHover": true,
+    "python.testing.pytestArgs": [
+        "."
+    ],
+    "python.testing.unittestEnabled": false,
+    "python.testing.pytestEnabled": true,
+    //////////////////////////////////////
+    // Files
+    //////////////////////////////////////
+    "files.exclude": {
+        "**/__pycache__/": true,
+        "**/*.egg-info/": true,
+        ".pytest_cache/": true
+    },
+    //////////////////////////////////////
+    // Editor
+    //////////////////////////////////////
+    "editor.tabSize": 4,
+    "editor.insertSpaces": true,
+    "editor.detectIndentation": false,
+    "editor.wordWrap": "wordWrapColumn",
+    "editor.wordWrapColumn": 100, // toggle via Alt + Z shortcut
+    "editor.mouseWheelZoom": true,
+    "editor.rulers": [
+        {
+            "column": 80, // soft limit
+            "color": "#e5e5e5"
+        },
+        {
+            "column": 100, // hard limit
+            "color": "#c9c9c9"
+        }
+    ],
+    //////////////////////////////////////
+    // Git
+    //////////////////////////////////////
+    "git.inputValidation": true,
+    "git.inputValidationSubjectLength": 50,
+    "git.inputValidationLength": 72,
+    //////////////////////////////////////
+    // Spell Checker
+    //////////////////////////////////////
+    "cSpell.words": [
+        "github",
+        "ifthen",
+        "ifthenelse",
+        "justfile",
+        "latexer",
+        "newcommand",
+        "normalsize",
+        "pipenv",
+        "pydantic",
+        "pylint",
+        "pytest",
+        "resultwizard",
+        "scriptsize",
+        "scriptstyle",
+        "sigfigs",
+        "siunitx",
+        "Stringifier",
+        "textbf",
+        "texttt",
+        "TLDR",
+        "uncert",
+        "usepackage"
+    ]
+}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..4f7e8678
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,3 @@
+# Changelog
+
+👀 Nothing here yet
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
new file mode 100644
index 00000000..0ef362b0
--- /dev/null
+++ b/DEVELOPMENT.md
@@ -0,0 +1,68 @@
+# Development Guideline/Conventions/Installation
+
+Just a dump for right now. In construction... Maybe a wiki would also be a good place for this.
+
+
+## Checklist
+Getting ready:
+- [ ] GPG key setup to sign commits
+- [ ] Recommended VSCode extensions installed (especially the formatter. It should automatically format on every save!)
+- [ ] on branch `value-wizard` with latest commit pulled
+- [ ] Work through the `Setup` section below (especially to install the necessary dependencies)
+- [ ] Read the [`README.md`](https://github.com/paul019/ResultWizard/tree/value-wizard/src#code-structure) in the `src` folder (to get to know the code structure) & see our [feature list](https://github.com/paul019/ResultWizard/issues/16)
+
+Verify that everything worked:
+- [ ] try to run the tests, see the instructions in [`tests/playground.py`](./tests/playground.py)
+
+Then start working on the code:
+- [ ] search for "TODO"s in the code, they will guide you to the next steps
+- [ ] document and append to `tests/playground.py` what we expect as user when using this library (we will use this as a reference to test the library later on)
+- [ ] try to add doc-strings early on (in relevant places), so that I know what you are doing (and you know what I'm doing :))
+- [ ] discuss when you want to restructure `domain` objects (e.g. `_Value`), e.g. if you are annoyed of having to deal with strings instead of floats (but I think with strings we are more flexible)
+
+
+## Setup
+
+- Install the recommended VSCode extensions. This includes `pylint` (Python linting) and `black` (Python formatting). VSCode is setup via the `settings.json` to format on save.
+
+> [!Tip]
+> I've specified recommended extensions, you can install them in VSCode by using [the "recommended" filter](https://code.visualstudio.com/docs/editor/extension-marketplace#_extensions-view-filter-and-commands).
+
+- Install [pipenv](https://pipenv.pypa.io/en/latest/installation.html). I added this to my `.bashrc`:
+  ```bash
+  alias python3=python3.11
+  export PATH="$PATH:~/.local/bin"
+  ```
+- Install the dependencies: `pipenv install`
+- Type in `pipenv` to see available commands, e.g. `pipenv shell` etc.
+
+
+## TODO
+- Setup `.editorconfig` for non-VSCode users. At least provide very basic setup like max line length etc.
+- A [`justfile`](https://github.com/casey/just) would also be beneficial.
+
+
+
+## Testing ❌✅
+
+As always, make sure you are in the `pipenv shell`.
+
+Make sure you have all (dev) dependencies installed:
+
+```
+pipenv install -d
+```
+
+Then you can run the tests via the command line:
+
+```
+pytest
+```
+
+But with VSCode, you get direct integration into the **Test Explorer UI**. You will have to install the recommended extensions first. Then switch to the Testing tab and you will find the tests there. You can run them from there, jump to the file where they are defined and even start debugging them. You can also do the same via the code lens (it shows a little menu "Run | Debug | ..." over every test method) in the test files themselves. Try to set a breakpoint and debug it by clicking "Debug" in the code lens. While debugging, go the debug panel and see some variables there or enter one in the watch tab. Try to modify a test such that it fails and get to know where you see the error.
+
+Also try adding `import pytest` as first line of a test file. Does it give you a warning? If so, you have selected the wrong VSCode Python interpreter. Choose the one provided by the pipenv virtual environment where all dependencies are installed.
+
+Note that tests are also run on every commit via a GitHub action.
+
+In order to learn how to write the tests with pytest, start with the [`Get Started` guide](https://docs.pytest.org/en/8.0.x/getting-started.html#create-your-first-test). Probably also relevant: ["How to use fixtures"](https://docs.pytest.org/en/8.0.x/how-to/fixtures.html). There are lots of [How-to guides](https://docs.pytest.org/en/8.0.x/how-to/index.html) available.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..fd1ced8b
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023-2024 Paul Obernolte (paul019), Dominic Plein (Splines)
+
+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.
\ No newline at end of file
diff --git a/Pipfile b/Pipfile
new file mode 100644
index 00000000..cc9fe33d
--- /dev/null
+++ b/Pipfile
@@ -0,0 +1,14 @@
+[[source]]
+url = "https://pypi.org/simple"
+verify_ssl = true
+name = "pypi"
+
+[packages]
+plum-dispatch = "~=2.3"
+
+[dev-packages]
+pylint = "~=3.0"
+pytest = "~=8.1"
+
+[requires]
+python_version = "3.11"
diff --git a/Pipfile.lock b/Pipfile.lock
new file mode 100644
index 00000000..37d8af23
--- /dev/null
+++ b/Pipfile.lock
@@ -0,0 +1,161 @@
+{
+    "_meta": {
+        "hash": {
+            "sha256": "cbec9547e5137a6a3a0f7ba14ff98a24a53adb966549045956b3e506835e0bdf"
+        },
+        "pipfile-spec": 6,
+        "requires": {
+            "python_version": "3.11"
+        },
+        "sources": [
+            {
+                "name": "pypi",
+                "url": "https://pypi.org/simple",
+                "verify_ssl": true
+            }
+        ]
+    },
+    "default": {
+        "beartype": {
+            "hashes": [
+                "sha256:c22b21e1f785cfcf5c4d3d13070f532b6243a3ad67e68d2298ff08d539847dce",
+                "sha256:e911e1ae7de4bccd15745f7643609d8732f64de5c2fb844e89cbbed1c5a8d495"
+            ],
+            "markers": "python_full_version >= '3.8.0'",
+            "version": "==0.17.2"
+        },
+        "markdown-it-py": {
+            "hashes": [
+                "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1",
+                "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"
+            ],
+            "markers": "python_version >= '3.8'",
+            "version": "==3.0.0"
+        },
+        "mdurl": {
+            "hashes": [
+                "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8",
+                "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==0.1.2"
+        },
+        "plum-dispatch": {
+            "hashes": [
+                "sha256:96f519d416accf9a009117682f689114eb23e867bb6f977eed74ef85ef7fef9d",
+                "sha256:f49f00dfdf7ab0f16c9b85cc27cc5241ffb59aee02218bac671ec7c1ac65e139"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.8'",
+            "version": "==2.3.2"
+        },
+        "pygments": {
+            "hashes": [
+                "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c",
+                "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==2.17.2"
+        },
+        "rich": {
+            "hashes": [
+                "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222",
+                "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"
+            ],
+            "markers": "python_full_version >= '3.7.0'",
+            "version": "==13.7.1"
+        }
+    },
+    "develop": {
+        "astroid": {
+            "hashes": [
+                "sha256:951798f922990137ac090c53af473db7ab4e70c770e6d7fae0cec59f74411819",
+                "sha256:ac248253bfa4bd924a0de213707e7ebeeb3138abeb48d798784ead1e56d419d4"
+            ],
+            "markers": "python_full_version >= '3.8.0'",
+            "version": "==3.1.0"
+        },
+        "dill": {
+            "hashes": [
+                "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca",
+                "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"
+            ],
+            "markers": "python_version >= '3.11'",
+            "version": "==0.3.8"
+        },
+        "iniconfig": {
+            "hashes": [
+                "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3",
+                "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==2.0.0"
+        },
+        "isort": {
+            "hashes": [
+                "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109",
+                "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"
+            ],
+            "markers": "python_full_version >= '3.8.0'",
+            "version": "==5.13.2"
+        },
+        "mccabe": {
+            "hashes": [
+                "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325",
+                "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==0.7.0"
+        },
+        "packaging": {
+            "hashes": [
+                "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5",
+                "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==24.0"
+        },
+        "platformdirs": {
+            "hashes": [
+                "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068",
+                "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"
+            ],
+            "markers": "python_version >= '3.8'",
+            "version": "==4.2.0"
+        },
+        "pluggy": {
+            "hashes": [
+                "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981",
+                "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"
+            ],
+            "markers": "python_version >= '3.8'",
+            "version": "==1.4.0"
+        },
+        "pylint": {
+            "hashes": [
+                "sha256:507a5b60953874766d8a366e8e8c7af63e058b26345cfcb5f91f89d987fd6b74",
+                "sha256:6a69beb4a6f63debebaab0a3477ecd0f559aa726af4954fc948c51f7a2549e23"
+            ],
+            "index": "pypi",
+            "markers": "python_full_version >= '3.8.0'",
+            "version": "==3.1.0"
+        },
+        "pytest": {
+            "hashes": [
+                "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7",
+                "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.8'",
+            "version": "==8.1.1"
+        },
+        "tomlkit": {
+            "hashes": [
+                "sha256:5cd82d48a3dd89dee1f9d64420aa20ae65cfbd00668d6f094d7578a78efbb77b",
+                "sha256:7ca1cfc12232806517a8515047ba66a19369e71edf2439d0f5824f91032b6cc3"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==0.12.4"
+        }
+    }
+}
diff --git a/README.md b/README.md
index ed5dc1ee..a453d279 100644
--- a/README.md
+++ b/README.md
@@ -1,148 +1,139 @@
-# Python to LaTeX
+<div align="center">
+  <img src="https://github.com/paul019/ResultWizard/assets/37160523/8576038a-3867-470b-8f42-90b60ea92042" width="120px" />
+  <div align="center">
+    <h3 align="center">ResultWizard</h3>
+    <p><strong>Intelligent interface between Python-computed values and your LaTeX work</strong></p>
+  </div>
+</div>
 
-This package uses reflections to read the values of your global python variables and export them to LaTeX. This makes possible a seamless integrated workflow between Jupyter notebooks and LaTeX documents.
+> [!important]  
+> ResultWizard is currently fully functional but still in its *alpha* stage. We're happy to receive your feedback. Basic usage is as follows. A more comprehensive documentation will be available as soon as the package is stable.
 
-## 1. Use case
 
-For your LaTeX document, you might use a Jupyter notebook to calculate some values. For example, you might have calculated the acceleration of gravity as well as its error:
+## Installation & usage
+Install the package via pip.
 
-```
-g = 9.810173743
-error_g = 0.014193993
-# notebook.ipynb
+```sh
+pip install resultwizard
 ```
 
-Instead of copying and pasting those values to your LaTeX code by hand, this package can do this automatically reading your variables in python. It generates LaTeX code like this:
+Then you can use `ResultWizard` in your Python code:
 
-```
-\newcommand*{\resultG}{9.810 \pm 0.014\ \unit{\meter\per\second\squared}}
-% results.tex
-```
+```python
+import resultwizard as wiz
+wiz.config_init(print_auto=True)
 
-You can reference this value in your LaTeX code as easy as follows:
+# your complex calculations
+# ...
+value = 42.0
+uncertainty = 3.14
+wiz.res("length atom", value, uncertainty, r"\per\mm\cubed")
 
-```
-We measured the acceleration of gravity to be $\resultG$.
-% main.tex
+wiz.export("results.tex")
 ```
 
-You can even use autocompletion in LaTeX to reference your python variables!
-
-To see how it works in details, read through the following sections.
+Then add the following line to your LaTeX document right before `\begin{document}`:
 
+```
+\input{results.tex}
+```
 
-## 2. Setup and folder structure
+Note that `ResultWizard` requires the following LaTeX packages: [`siunitx`](https://ctan.org/pkg/siunitx) and [`ifthen`](https://ctan.org/pkg/ifthen). They are imported in the `results.tex` file via `\usepackage{...}` statements. The package `ifthen` is always presented in a LaTeX distribution. You may have to install the `siunitx` package, which is a widely used package in the scientific community to typeset units, e.g. you can use strings like `\kg\per\cm`.
 
-This package was designed to be used in the following folder structure setup:
+You can now go ahead and reference the variable in your LaTeX document in any math environment, e.g.:
 
+```latex
+\begin{align}
+    \resLengthAtom
+\end{align}
 ```
-src
-│   main.tex
-│   main.pdf
-│   results.tex
-│   
-└───python
-        notebook.ipynb
-        python_to_latex.py
 
-```
+You can also only use a specific part of the result, e.g. the unit, the value itself etc.
 
-Here is a quick overview:
+```latex
+\begin{align}
+    \resLengthAtom[unit]
+\end{align}
+```
 
-- `main.tex` is your entry point to your LaTeX document.
-- `main.pdf` is the pdf of your LaTeX document.
-- `results.tex` is automatically generated by this package.
-- `notebook.ipynb` is your Jupyter notebook.
-- `python_to_latex.py` is where the magic takes place. Download and copy this file from this package.
 
-## 3. How to export variables from python
+## Troubleshooting: LaTeX doesn't build
 
-For this package to work, you need to make slight modifications both in your python code and at the end of it.
+If your LaTeX document doesn't build, there might be several reasons. Try to find out what's wrong by looking at the log file of your LaTeX compiler (sometimes you have to scroll way up to find the error responsible for the failing build). Also open the `results.tex` file to see if your editor/IDE shows any errors there. You might encounter one of the following errors:
 
-### 3.1 In your code
+> [!WARNING] 
+> This troubleshooting section does not yet apply to the first alpha release of `ResultWizard`. It's just here for one of the next alpha versions.
 
-Let's say you calculated the acceleration of gravity $g$ and its error $\Delta g$ in your Jupyter notebook:
+<details>
 
-```
-g = 9.810173743
-error_g = 0.014193993
-# notebook.ipynb
-```
+<summary><strong>Package siunitx: Invalid number.</strong></summary>
 
-Now, there are two possibilities to export these variables:
+TL;DR: You have an **old version of `siunitx`**. Please update it or use the `siunitx_fallback` option in the `config_init` method.
 
-#### 3.1.1 Possibility 1
+In version [`v3.1.0 (2022-04-25)`](https://github.com/josephwright/siunitx/blob/main/CHANGELOG.md#v310---2022-04-25), `siunitx` introduced "support for multiple uncertainty values in both short and long form in input". We make use of this feature in `ResultWizard`.
 
-Write:
+Unfortunately, it may be the case that you're using an older version of `siunitx`. Especially if you've installed LaTeX via a package manager (e.g. you installed `siunitx` via `sudo apt install texlive-science`). To determine your version, include the following line in your LaTeX document:
 
+```latex
+\listfiles % add this before \begin{document}
 ```
-result_g = 9.810173743
-result_error_g = 0.014193993
-result_unit_g = '/meters/per/second/squared'
-# notebook.ipynb
-```
-
-Explanation:
 
-- Variables that start with `result_` automatically get exported.
-- Variables that start with `result_error_` are assumed to carry the corresponding error value.
-- Variables that start with `result_unit_` are assumed to carry the corresponding unit. Unit strings have to be in the language of the LaTeX package `siunitx`.
+Then, compile your document and check the log for the version of `siunitx`. If it's older than `v3.1.0 (2022-04-25)`, don't despair. We have two solutions for you:
 
-This package automatically does the correct rounding for you! To be precise, it uses one or two significant digits for the error value depending on whether the first error digit is less than or equal to 2.
+**Solution 1: Don't update `siunitx` and stick with your old version**
 
-If you want to overwrite this behavior, you can set the desired number of significant figures for the value itself:
+Sure, fine, we won't force you to update `siunitx` (although we'd recommend it). To keep using your old version, specify the following key in the `config_init` method:
 
+```python
+wiz.config_init(siunitx_fallback=True)
 ```
-result_sigfigs_g = 3
-# notebook.ipynb
-```
-
-#### 3.1.2 Possibility 2
 
-Instead of using separate global variables for a value, its error and its unit, you can use a tuple:
+Note that with this "solution", you won't be able to fully customize the output of the result in your LaTeX document. For example, we will use a `±` between the value and the uncertainty, e.g. `3.14 ± 0.02`. You won't be able to change this in your `sisetup` by doing:
 
-```
-result_g = (9.810173743, 0.014193993, '/meters/per/second/squared')
-# notebook.ipynb
+```latex
+\sisetup{separate-uncertainty=false}
 ```
 
-### 3.2 At the end of your code
+to get another format like `3.14(2)`. There are also some [other `siunitx` options](https://texdoc.org/serve/siunitx/0) that won't work with `ResultWizard`, e.g. `exponent-product`. If you're fine with this, go ahead and use the `siunitx_fallback` option. If not, consider updating `siunitx` to at least version `v3.1.0`.
 
-At the end of your Jupyter notebook, you have to put (and run!) the following code block:
+**Solution 2: Update `siunitx` (recommended)**
 
-```
-from python_to_latex import export
-export(globals())
-# notebook.ipynb
-```
+How the update process works depends on your LaTeX distribution and how you installed it. E.g. you might be using `TeX Live` on `Ubuntu` and installed packages via `apt`, e.g. `sudo apt install texlive-science` (which includes the LaTeX `siunitx`). These pre-built packages are often outdated, e.g. for Ubuntu 22.04 LTS (jammy), the `siunitx` package that comes with the `texlive-science` package is `3.0.4`. Therefore, you might have to update `siunitx` manually. See an overview on how to install individual LaTeX packages on Linux [here](https://tex.stackexchange.com/a/73017/).
 
-This will automatically export your desired global variables to python.
+A quick solution might be to simply install a new version of `siunitx` manually to your system. There's a great and short Ubuntu guide on how to install LaTeX packages manually [here](https://help.ubuntu.com/community/LaTeX#Installing_packages_manually). The following commands are based on this guide. We will download the version `3.1.11 (2022-12-05)` from GitHub (this is the last version before `3.2` where things might get more complicated to install) and install it locally. Nothing too fancy. Execute the following commands in your terminal:
 
-## 4. How to import variables in LaTeX
+```sh
+# Install "unzip", a tool to extract zip files
+sudo apt install unzip
 
-For the units to work, you need to import the package `siunitx` in your `main.tex`:
+# Download v3.1.11 of siunitx from GitHub
+curl -L https://github.com/josephwright/siunitx/releases/download/v3.1.11/siunitx-ctan.zip > siunitx-ctan-3.1.11.zip
 
-```
-\usepackage{siunitx}
-% main.tex
-```
+# Unzip the file
+unzip ./siunitx-ctan-3.1.11.zip
+cd siunitx/
 
-Next, you have to import the file generated by this package:
+# Run LaTeX on the .ins file to produce a usable .sty file
+# (The .sty file is needed when you use \usepackage{siunitx}
+# in your LaTeX document)
+latex siunitx.ins
 
-```
-\input{./results}
-% main.tex
-```
+# Create a new directory in your home directory
+# to store the new package .sty file
+mkdir -p ~/texmf/tex/latex/siunitx
+cp siunitx.sty ~/texmf/tex/latex/siunitx/
 
-Now, you can use the variables that you exported from the Jupyter notebook:
+# Make LaTeX recognize the new package by pointing it to the new directory
+texhash ~/texmf/
 
-```
-We measured the acceleration of gravity to be $\resultG$.
-% main.tex
-```
+# 🙌 Done. Try to rebuild your LaTeX document again.
 
-Note, that the variable names are converted from snake case in python to camel case in LaTeX.
+# If you don't wan't the new siunitx version anymore, just run the following
+# command to remove the .sty file. LaTeX will then use the version of siunitx
+# it finds somewhere else in your system.
+rm ~/texmf/tex/latex/siunitx/siunitx.sty
+```
 
-## 5. Disclaimer
+Compiling your latex document again, you should see version `v3.1.11` of `siunitx` in the log file. And it should build, yeah 🎉. Don't forget to remove the `\listfiles` from your LaTeX document to avoid cluttering your log file (which is ironic for LaTeX, we know).
 
-The author of this package does not take any responsibility for errors done by this package.
\ No newline at end of file
+</details>
diff --git a/__init__.py b/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..b65bb567
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,73 @@
+# See the specification of the pyproject.toml file at:
+# https://packaging.python.org/en/latest/specifications/pyproject-toml/
+
+[build-system]
+requires = ["setuptools>=69.0"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.packages.find]
+where = ["src"]
+include = ["api", "application", "domain", "resultwizard"]
+namespaces = true
+
+[project]
+name = "resultwizard"
+version = "0.1"
+authors = [
+    { name = "Paul Obernolte (paul019)" },
+    { name = "Dominic Plein (Splines)" },
+]
+description = "Intelligent interface between Python-computed values and your LaTeX work"
+keywords = [
+    "latex",
+    "variable",
+    "science",
+    "result",
+    "value",
+    "rounding",
+    "significant figures",
+    "jupyter",
+]
+readme = "README.md"
+requires-python = ">=3.8"
+license = { file = "LICENSE" }
+# https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#classifiers
+classifiers = [
+    "Development Status :: 3 - Alpha",
+    "Topic :: Scientific/Engineering :: Physics",
+    "Topic :: Software Development :: Build Tools",
+    "Topic :: Text Processing :: Markup :: LaTeX",
+    "Intended Audience :: Science/Research",
+    "License :: OSI Approved :: MIT License",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
+    "Programming Language :: Python :: 3.13",
+]
+dependencies = [
+    "plum-dispatch ~= 2.3"
+]
+
+[project.urls]
+Homepage = "https://github.com/paul019/ResultWizard"
+Repository = "https://github.com/paul019/ResultWizard"
+Issues = "https://github.com/paul019/ResultWizard/issues"
+Changelog = "https://github.com/paul019/ResultWizard/blob/main/CHANGELOG.md"
+
+# TODO: Add these checks back
+[tool.pylint."messages control"]
+disable = [
+    "missing-module-docstring",
+    "missing-function-docstring",
+    "missing-class-docstring",
+    "fixme",
+    "too-few-public-methods",
+]
+
+[tool.pylint.format]
+max-line-length = 100
+max-module-lines = 500
+
+[tool.black]
+line-length = 100
diff --git a/python_to_latex.py b/python_to_latex.py
deleted file mode 100644
index ad595c58..00000000
--- a/python_to_latex.py
+++ /dev/null
@@ -1,60 +0,0 @@
-import os
-from src.config import ExportConfig
-from src.globals_to_latex import globals_to_latex
-from src.var_to_latex import var_to_latex_newcommand
-from src.table_to_latex import table_to_latex_newcommand
-
-
-class PythonToLatex:
-    def __init__(self, config: ExportConfig = ExportConfig()) -> None:
-        self._config = config
-        self._export_str = ""
-
-    def var(
-        self,
-        name: str,
-        value: float | int,
-        error: float = 0,
-        unit: str = "",
-        sig_figs: int = -1,
-    ):
-        self._export_str += var_to_latex_newcommand(
-            name,
-            value,
-            error,
-            unit,
-            sig_figs,
-            self._config,
-        )
-
-    def table(
-        self,
-        name: str,
-        columns: list,
-        caption: str = "",
-        float_mode: str = "h!",
-        config: ExportConfig = ExportConfig(),
-    ):
-        self._export_str += table_to_latex_newcommand(
-            name,
-            columns,
-            caption,
-            float_mode,
-            config,
-        )
-
-    def export(self, path: str, globals: dict[str, any] = {}):
-        abs_path = os.path.realpath(os.path.join(os.getcwd(), path))
-
-        # Globals to latex:
-        self._export_str += globals_to_latex(globals, self._config)
-
-        # Delete current file:
-        try:
-            os.remove(abs_path)
-        except:
-            pass
-
-        # Write file:
-        with open(abs_path, "w") as f:
-            f.write(self._export_str)
diff --git a/src/README.md b/src/README.md
new file mode 100644
index 00000000..d62045b4
--- /dev/null
+++ b/src/README.md
@@ -0,0 +1,15 @@
+# Code structure
+
+We use the clean architecture paradigm. Modules in outer layers are allowed to import from inner layers, but never the other way around. E.g. `api` might need definitions from `domain`, but `domain` must never import anything from `api`. This allows for better separation of concerns and makes it easier to test the code.
+
+From outer layer to inner layer:
+
+- `resultwizard/`: Entrypoint for the PIP package; mainly only import statements
+- `api/`: User-facing API, e.g. `res()` and `export()` method
+- `application/`: Application code that uses the domain logic to solve specific problems
+- `domain/`: Domain logic, e.g. definition of what a `value`, how things are represented internally
+
+
+## Conventions
+
+- Prepend everything that is not public-facing with an underscore `_`, e.g. `_Result` instead of `Result`. This is to make it clear what is public and what is not as python does not have a concept of private methods or variables.
diff --git a/src/api/config.py b/src/api/config.py
new file mode 100644
index 00000000..122bb068
--- /dev/null
+++ b/src/api/config.py
@@ -0,0 +1,156 @@
+from typing import Union, cast
+
+from dataclasses import dataclass
+from application.stringifier import StringifierConfig
+from application.rounder import RoundingConfig
+
+
+@dataclass
+# pylint: disable-next=too-many-instance-attributes
+class Config:
+    """Configuration settings for the application.
+
+    Args:
+        sigfigs (int): The number of significant figures to round to.
+        decimal_places (int): The number of decimal places to round to.
+        print_auto (bool): Whether to print each result directly to the console.
+        min_exponent_for_non_scientific_notation (int): The minimum exponent
+            for non-scientific notation.
+        max_exponent_for_non_scientific_notation (int): The maximum exponent
+            for non-scientific notation.
+        identifier (str): The identifier for the result variable in LaTeX. This
+            identifier will be prefix each result variable name.
+        sigfigs_fallback (int): The number of significant figures to use as a
+            fallback if other rounding rules don't apply.
+        decimal_places_fallback (int): The number of decimal places to use as
+            a fallback if other rounding rules don't apply.
+    """
+
+    sigfigs: int
+    decimal_places: int
+    print_auto: bool
+    min_exponent_for_non_scientific_notation: int
+    max_exponent_for_non_scientific_notation: int
+    identifier: str
+    sigfigs_fallback: int
+    decimal_places_fallback: int
+
+    def to_stringifier_config(self) -> StringifierConfig:
+        return StringifierConfig(
+            self.min_exponent_for_non_scientific_notation,
+            self.max_exponent_for_non_scientific_notation,
+            self.identifier,
+        )
+
+    def to_rounding_config(self) -> RoundingConfig:
+        return RoundingConfig(
+            self.sigfigs,
+            self.decimal_places,
+            self.sigfigs_fallback,
+            self.decimal_places_fallback,
+        )
+
+
+def _check_config() -> None:
+    if configuration.sigfigs > -1 and configuration.decimal_places > -1:
+        raise ValueError(
+            "You can't set both sigfigs and decimal places at the same time. "
+            "Please choose one or the other."
+        )
+
+    if configuration.sigfigs_fallback > -1 and configuration.decimal_places_fallback > -1:
+        raise ValueError(
+            "You can't set both sigfigs_fallback and decimal_places_fallback at the same time. "
+            "Please choose one or the other."
+        )
+
+    if configuration.sigfigs_fallback <= -1 and configuration.decimal_places_fallback <= -1:
+        raise ValueError(
+            "You need to set either sigfigs_fallback or decimal_places_fallback. "
+            "Please choose one."
+        )
+
+    if configuration.sigfigs == 0:
+        raise ValueError("sigfigs must be greater than 0 (or -1).")
+
+    if configuration.sigfigs_fallback == 0:
+        raise ValueError("sigfigs_fallback must be greater than 0 (or -1).")
+
+
+# pylint: disable-next=too-many-arguments
+def config_init(
+    sigfigs: int = -1,  # -1: "per default use rounding rules instead"
+    decimal_places: int = -1,  # -1: "per default use rounding rules instead"
+    print_auto: bool = False,
+    min_exponent_for_non_scientific_notation: int = -2,
+    max_exponent_for_non_scientific_notation: int = 3,
+    identifier: str = "res",
+    sigfigs_fallback: int = 2,
+    decimal_places_fallback: int = -1,  # -1: "per default use sigfigs as fallback instead"
+) -> None:
+    global configuration  # pylint: disable=global-statement
+
+    configuration = Config(
+        sigfigs,
+        decimal_places,
+        print_auto,
+        min_exponent_for_non_scientific_notation,
+        max_exponent_for_non_scientific_notation,
+        identifier,
+        sigfigs_fallback,
+        decimal_places_fallback,
+    )
+
+    _check_config()
+
+
+configuration = cast(Config, None)  # pylint: disable=invalid-name
+config_init()
+
+
+def config(
+    sigfigs: Union[int, None] = None,
+    decimal_places: Union[int, None] = None,
+    print_auto: Union[bool, None] = None,
+    sigfigs_fallback: Union[int, None] = None,
+    decimal_places_fallback: Union[int, None] = None,
+):
+    if sigfigs is not None and sigfigs > -1 and decimal_places is not None and decimal_places > -1:
+        raise ValueError(
+            "You can't set both sigfigs and decimal places at the same time. "
+            "Please choose one or the other."
+        )
+
+    if (
+        sigfigs_fallback is not None
+        and sigfigs_fallback > -1
+        and decimal_places_fallback is not None
+        and decimal_places_fallback > -1
+    ):
+        raise ValueError(
+            "You can't set both sigfigs_fallback and decimal_places_fallback at the same time. "
+            "Please choose one or the other."
+        )
+
+    if sigfigs is not None:
+        configuration.sigfigs = sigfigs
+        if sigfigs > -1:
+            configuration.decimal_places = -1
+    if decimal_places is not None:
+        configuration.decimal_places = decimal_places
+        if decimal_places > -1:
+            configuration.sigfigs = -1
+
+    if print_auto is not None:
+        configuration.print_auto = print_auto
+
+    if sigfigs_fallback is not None:
+        configuration.sigfigs_fallback = sigfigs_fallback
+        if sigfigs_fallback > -1:
+            configuration.decimal_places_fallback = -1
+    if decimal_places_fallback is not None:
+        configuration.decimal_places_fallback = decimal_places_fallback
+        if decimal_places_fallback > -1:
+            configuration.sigfigs_fallback = -1
+
+    _check_config()
diff --git a/src/api/console_stringifier.py b/src/api/console_stringifier.py
new file mode 100644
index 00000000..d3527d4b
--- /dev/null
+++ b/src/api/console_stringifier.py
@@ -0,0 +1,46 @@
+from domain.result import Result
+from application.stringifier import Stringifier
+
+
+class ConsoleStringifier(Stringifier):
+    plus_minus = " ± "
+    negative_sign = "-"
+    positive_sign = ""
+
+    left_parenthesis = "("
+    right_parenthesis = ")"
+
+    value_prefix = r""
+    value_suffix = r""
+
+    error_name_prefix = " ("
+    error_name_suffix = ")"
+
+    scientific_notation_prefix = "e"
+    scientific_notation_suffix = ""
+
+    unit_prefix = " "
+    unit_suffix = ""
+
+    def result_to_str(self, result: Result):
+        """
+        Returns the result as human-readable string.
+        """
+
+        return f"{result.name} = {self.create_str(result.value, result.uncertainties, result.unit)}"
+
+    def _modify_unit(self, unit: str) -> str:
+        """
+        Returns the modified unit.
+        """
+        unit = (
+            unit.replace(r"\squared", "^2")
+            .replace(r"\cubed", "^3")
+            .replace(r"\per", "/")
+            .replace("\\", "")
+        )
+
+        if unit[0] == "/":
+            unit = f"1{unit}"
+
+        return unit
diff --git a/src/api/export.py b/src/api/export.py
new file mode 100644
index 00000000..3c13dbb2
--- /dev/null
+++ b/src/api/export.py
@@ -0,0 +1,36 @@
+from api.res import _res_cache
+import api.config as c
+from application.latex_stringifier import LatexStringifier
+
+
+def export(filepath: str):
+    """
+    Rounds all results according to the significant figures and writes them
+    to a .tex file at the given filepath.
+    """
+    results = _res_cache.get_all_results()
+    print(f"Processing {len(results)} result(s)")
+
+    # Round and convert to LaTeX commands
+    cmds = [
+        r"%",
+        r"% In your `main.tex` file, put this line directly before `\begin{document}`:",
+        r"%   \input{" + filepath.split("/")[-1].split(".")[0] + r"}",
+        r"%",
+        r"",
+        r"% Import required package:",
+        r"\usepackage{siunitx}",
+        r"\usepackage{ifthen}",
+        r"",
+        r"% Define commands to print the results:",
+    ]
+
+    latexer = LatexStringifier(c.configuration.to_stringifier_config())
+    for result in results:
+        result_str = latexer.result_to_latex_cmd(result)
+        cmds.append(result_str)
+
+    # Write to file
+    with open(filepath, "w", encoding="utf-8") as f:
+        f.write("\n".join(cmds))
+    print(f'Exported to "{filepath}"')
diff --git a/src/api/parsers.py b/src/api/parsers.py
new file mode 100644
index 00000000..8d1bdbbe
--- /dev/null
+++ b/src/api/parsers.py
@@ -0,0 +1,212 @@
+from typing import Union, List, Tuple
+
+from application.helpers import Helpers
+from domain.value import Value
+from domain.uncertainty import Uncertainty
+
+
+def check_if_number_string(value: str) -> None:
+    """Raises a ValueError if the string is not a valid number."""
+    try:
+        float(value)
+    except ValueError as exc:
+        raise ValueError(f"String value must be a valid number, not {value}") from exc
+
+
+def parse_name(name: str) -> str:
+    """Parses the name."""
+    if not isinstance(name, str):
+        raise TypeError(f"`name` must be a string, not {type(name)}")
+
+    if name == "":
+        raise ValueError("`name` must not be empty")
+
+    name = (
+        name.replace("ä", "ae")
+        .replace("Ä", "Ae")
+        .replace("ö", "oe")
+        .replace("Ö", "Oe")
+        .replace("ü", "ue")
+        .replace("Ü", "Ue")
+        .replace("ß", "ss")
+        # we use "SS" instead of "Ss" as replacement for "ẞ"
+        # since "ẞ" is only allowed in uppercase names in German
+        .replace("ẞ", "SS")
+    )
+
+    parsed_name = ""
+    next_char_upper = False
+    ignored_chars = set()
+
+    while name != "":
+        char = name[0]
+
+        if char.isalpha():
+            if next_char_upper:
+                parsed_name += char.upper()
+                next_char_upper = False
+            else:
+                parsed_name += char
+        elif char.isdigit():
+            num_digits = _greedily_count_digits_at_start_of_str(name)
+            word = Helpers.number_to_word(int(name[:num_digits]))
+            if parsed_name != "":
+                word = Helpers.capitalize(word)
+            parsed_name += word
+            next_char_upper = True
+            name = name[num_digits:]  # Skip the parsed digits
+            continue
+        elif char in [" ", "_", "-"]:
+            next_char_upper = True
+        else:
+            ignored_chars.add(char)
+
+        name = name[1:]
+
+    if len(ignored_chars) > 0:
+        print(f"Invalid characters in name were ignored: {', '.join(ignored_chars)}")
+
+    if parsed_name == "":
+        raise ValueError("After ignoring invalid characters, the specified name is empty.")
+
+    return parsed_name
+
+
+def _greedily_count_digits_at_start_of_str(word: str) -> int:
+    """Counts the number of digits at the start of the string."""
+    num_digits = 0
+    for c in word:
+        if c.isdigit():
+            num_digits += 1
+        else:
+            break
+    return num_digits
+
+
+def parse_unit(unit: str) -> str:
+    """Parses the unit."""
+    if not isinstance(unit, str):
+        raise TypeError(f"`unit` must be a string, not {type(unit)}")
+
+    # TODO: maybe add some basic checks to catch siunitx errors, e.g.
+    # unsupported symbols etc. But maybe leave this to LaTeX and just return
+    # the LaTeX later on. But catching it here would be more user-friendly,
+    # as the user would get immediate feedback and not only once they try to
+    # export the results.
+    return unit
+
+
+def parse_sigfigs(sigfigs: Union[int, None]) -> Union[int, None]:
+    """Parses the number of sigfigs."""
+    if sigfigs is None:
+        return None
+
+    if not isinstance(sigfigs, int):
+        raise TypeError(f"`sigfigs` must be an int, not {type(sigfigs)}")
+
+    if sigfigs < 1:
+        raise ValueError("`sigfigs` must be positive")
+
+    return sigfigs
+
+
+def parse_decimal_places(decimal_places: Union[int, None]) -> Union[int, None]:
+    """Parses the number of sigfigs."""
+    if decimal_places is None:
+        return None
+
+    if not isinstance(decimal_places, int):
+        raise TypeError(f"`decimal_places` must be an int, not {type(decimal_places)}")
+
+    if decimal_places < 0:
+        raise ValueError("`decimal_places` must be non-negative")
+
+    return decimal_places
+
+
+def parse_value(value: Union[float, int, str]) -> Value:
+    """Converts the value to a _Value object."""
+    if not isinstance(value, (float, int, str)):
+        raise TypeError(f"`value` must be a float, int or string, not {type(value)}")
+
+    if isinstance(value, str):
+        check_if_number_string(value)
+        return parse_exact_value(value)
+
+    if isinstance(value, int):
+        value = float(value)
+
+    return Value(value)
+
+
+def parse_exact_value(value: str) -> Value:
+    # Determine min exponent:
+    exponent_offset = 0
+    value_str = value
+    if "e" in value_str:
+        exponent_offset = int(value_str[value_str.index("e") + 1 :])
+        value_str = value_str[0 : value_str.index("e")]
+    if "." in value:
+        decimal_places = len(value_str) - value_str.index(".") - 1
+        min_exponent = -decimal_places + exponent_offset
+    else:
+        min_exponent = exponent_offset
+
+    return Value(float(value), min_exponent)
+
+
+def parse_uncertainties(
+    uncertainties: Union[
+        float,
+        int,
+        str,
+        Tuple[Union[float, int, str], str],
+        List[Union[float, str, Tuple[Union[float, int, str], str]]],
+    ]
+) -> List[Uncertainty]:
+    """Converts the uncertainties to a list of _Uncertainty objects."""
+    uncertainties_res = []
+
+    # no list, but a single value was given
+    if isinstance(uncertainties, (float, int, str, Tuple)):
+        uncertainties = [uncertainties]
+
+    assert isinstance(uncertainties, List)
+
+    for uncert in uncertainties:
+        if isinstance(uncert, (float, int, str)):
+            uncertainties_res.append(Uncertainty(_parse_uncertainty_value(uncert)))
+
+        elif isinstance(uncert, Tuple):
+            if not isinstance(uncert[0], (float, int, str)):
+                raise TypeError(
+                    "First argument of uncertainty-tuple must be a float,"
+                    + f" int or a string, not {type(uncert[0])}"
+                )
+            uncertainties_res.append(
+                Uncertainty(_parse_uncertainty_value(uncert[0]), parse_name(uncert[1]))
+            )
+
+        else:
+            raise TypeError(
+                f"Each uncertainty must be a tuple or a float/int/str, not {type(uncert)}"
+            )
+
+    return uncertainties_res
+
+
+def _parse_uncertainty_value(value: Union[float, int, str]) -> Value:
+    """Parses the value of an uncertainty."""
+
+    if isinstance(value, str):
+        check_if_number_string(value)
+        return_value = parse_exact_value(value)
+    else:
+        if isinstance(value, int):
+            value = float(value)
+        return_value = Value(value)
+
+    if return_value.get() <= 0:
+        raise ValueError("Uncertainty must be positive.")
+
+    return return_value
diff --git a/src/api/printable_result.py b/src/api/printable_result.py
new file mode 100644
index 00000000..fc44739d
--- /dev/null
+++ b/src/api/printable_result.py
@@ -0,0 +1,24 @@
+from api.console_stringifier import ConsoleStringifier
+import api.config as c
+from application.latex_stringifier import LatexStringifier
+from domain.result import Result
+
+
+class PrintableResult:
+    def __init__(self, result: Result):
+        self._result = result
+
+    def print(self):
+        """Prints the result to the console."""
+        stringifier = ConsoleStringifier(c.configuration.to_stringifier_config())
+        print(stringifier.result_to_str(self._result))
+
+    def to_latex_str(self) -> str:
+        """Converts the result to a string that can be used in LaTeX documents.
+
+        Note that you might also want to use the `export` method to export
+        all your results to a file, which can then be included in your LaTeX
+        document.
+        """
+        latexer = LatexStringifier(c.configuration.to_stringifier_config())
+        return latexer.result_to_latex_str(self._result)
diff --git a/src/api/res.py b/src/api/res.py
new file mode 100644
index 00000000..139871a8
--- /dev/null
+++ b/src/api/res.py
@@ -0,0 +1,130 @@
+from typing import Union, List, Tuple
+from plum import dispatch, overload
+
+from api.printable_result import PrintableResult
+from api import parsers
+import api.config as c
+from application.cache import ResultsCache
+from application.rounder import Rounder
+from domain.result import Result
+
+_res_cache = ResultsCache()
+
+
+@overload
+def res(
+    name: str,
+    value: Union[float, int, str],
+    unit: str = "",
+    sigfigs: Union[int, None] = None,
+    decimal_places: Union[int, None] = None,
+) -> PrintableResult:
+    return res(name, value, [], unit, sigfigs, decimal_places)
+
+
+@overload
+def res(
+    name: str,
+    value: Union[float, int, str],
+    uncert: Union[
+        float,
+        str,
+        Tuple[Union[float, int, str], str],
+        List[Union[float, int, str, Tuple[Union[float, int, str], str]]],
+        None,
+    ] = None,
+    sigfigs: Union[int, None] = None,
+    decimal_places: Union[int, None] = None,
+) -> PrintableResult:
+    return res(name, value, uncert, "", sigfigs, decimal_places)
+
+
+@overload
+def res(
+    name: str,
+    value: Union[float, int, str],
+    sigfigs: Union[int, None] = None,
+    decimal_places: Union[int, None] = None,
+) -> PrintableResult:
+    return res(name, value, [], "", sigfigs, decimal_places)
+
+
+@overload
+# pylint: disable=too-many-arguments
+def res(
+    name: str,
+    value: Union[float, int, str],
+    sys: float,
+    stat: float,
+    unit: str = "",
+    sigfigs: Union[int, None] = None,
+    decimal_places: Union[int, None] = None,
+) -> PrintableResult:
+    return res(name, value, [(sys, "sys"), (stat, "stat")], unit, sigfigs, decimal_places)
+
+
+@overload
+# pylint: disable=too-many-arguments
+def res(
+    name: str,
+    value: Union[float, int, str],
+    uncert: Union[
+        float,
+        str,
+        Tuple[Union[float, int, str], str],
+        List[Union[float, int, str, Tuple[Union[float, int, str], str]]],
+        None,
+    ] = None,
+    unit: str = "",
+    sigfigs: Union[int, None] = None,
+    decimal_places: Union[int, None] = None,
+) -> PrintableResult:
+    if uncert is None:
+        uncert = []
+
+    if sigfigs is not None and decimal_places is not None:
+        raise ValueError(
+            "You can't set both sigfigs and decimal places at the same time. "
+            "Please choose one or the other."
+        )
+
+    if sigfigs is not None and isinstance(value, str):
+        raise ValueError(
+            "You can't set sigfigs and supply an exact value. Please do one or the other."
+        )
+
+    if decimal_places is not None and isinstance(value, str):
+        raise ValueError(
+            "You can't set decimal places and supply an exact value. Please do one or the other."
+        )
+
+    # Parse user input
+    name_res = parsers.parse_name(name)
+    value_res = parsers.parse_value(value)
+    uncertainties_res = parsers.parse_uncertainties(uncert)
+    unit_res = parsers.parse_unit(unit)
+    sigfigs_res = parsers.parse_sigfigs(sigfigs)
+    decimal_places_res = parsers.parse_decimal_places(decimal_places)
+
+    # Assemble the result
+    result = Result(
+        name_res, value_res, unit_res, uncertainties_res, sigfigs_res, decimal_places_res
+    )
+    Rounder.round_result(result, c.configuration.to_rounding_config())
+    _res_cache.add(name_res, result)
+
+    printable_result = PrintableResult(result)
+    if c.configuration.print_auto:
+        printable_result.print()
+
+    return printable_result
+
+
+# Hack for method "overloading" in Python
+# see https://beartype.github.io/plum/integration.html
+# This is a good writeup: https://stackoverflow.com/a/29091980/
+@dispatch
+def res(*args, **kwargs) -> object:  # pylint: disable=unused-argument
+    # This method only scans for all `overload`-decorated methods
+    # and properly adds them as Plum methods.
+    pass
diff --git a/src/application/cache.py b/src/application/cache.py
new file mode 100644
index 00000000..fc758051
--- /dev/null
+++ b/src/application/cache.py
@@ -0,0 +1,23 @@
+from domain.result import Result
+
+
+class ResultsCache:
+    """
+    A cache for all user-defined results. Results are hashed by their name.
+    If the user tries to add a result with a name that already exists in the cache,
+    the new result will replace the old one ("shadowing").
+    """
+
+    def __init__(self):
+        self.cache: dict[str, Result] = {}
+
+    def add(self, name, result: Result):
+        if name in self.cache:
+            print(
+                f"Warning: A result with the name '{name}' already exists and will be overwritten."
+            )
+
+        self.cache[name] = result
+
+    def get_all_results(self) -> list[Result]:
+        return list(self.cache.values())
diff --git a/src/application/helpers.py b/src/application/helpers.py
new file mode 100644
index 00000000..6c072fb4
--- /dev/null
+++ b/src/application/helpers.py
@@ -0,0 +1,70 @@
+import math
+
+_NUMBER_TO_WORD = {
+    0: "zero",
+    1: "one",
+    2: "two",
+    3: "three",
+    4: "four",
+    5: "five",
+    6: "six",
+    7: "seven",
+    8: "eight",
+    9: "nine",
+    10: "ten",
+    11: "eleven",
+    12: "twelve",
+    13: "thirteen",
+    14: "fourteen",
+    15: "fifteen",
+    16: "sixteen",
+    17: "seventeen",
+    18: "eighteen",
+    19: "nineteen",
+    20: "twenty",
+    30: "thirty",
+    40: "forty",
+    50: "fifty",
+    60: "sixty",
+    70: "seventy",
+    80: "eighty",
+    90: "ninety",
+}
+
+
+class Helpers:
+    @classmethod
+    def get_exponent(cls, value: float) -> int:
+        return math.floor(math.log10(abs(value)))
+
+    @classmethod
+    def get_first_digit(cls, value: float) -> int:
+        n = abs(value) * 10 ** (-cls.get_exponent(value))
+        return math.floor(n)
+
+    @classmethod
+    def round_to_n_decimal_places(cls, value: float, n: int):
+        return f"{value:.{int(abs(n))}f}"
+
+    @classmethod
+    def number_to_word(cls, number: int) -> str:
+        if 0 <= number <= 19:
+            return _NUMBER_TO_WORD[number]
+        if 0 <= number <= 99:
+            tens = number // 10 * 10
+            ones = number % 10
+            if ones == 0:
+                return _NUMBER_TO_WORD[tens]
+            return _NUMBER_TO_WORD[tens] + cls.capitalize(_NUMBER_TO_WORD[ones])
+        if 0 <= number <= 999:
+            hundreds = number // 100
+            tens = number % 100
+            if tens == 0:
+                return _NUMBER_TO_WORD[hundreds] + "Hundred"
+            return _NUMBER_TO_WORD[hundreds] + "Hundred" + cls.capitalize(cls.number_to_word(tens))
+
+        raise ValueError(f"For variable names, only use numbers between 0 and 999. Got {number}.")
+
+    @classmethod
+    def capitalize(cls, s: str) -> str:
+        return s[0].upper() + s[1:]
diff --git a/src/application/latex_ifelse.py b/src/application/latex_ifelse.py
new file mode 100644
index 00000000..3dc85289
--- /dev/null
+++ b/src/application/latex_ifelse.py
@@ -0,0 +1,33 @@
+class LatexIfElseBuilder:
+    def __init__(self):
+        self.latex: str = ""
+        self._num_parentheses_to_close: int = 0
+        self.keywords: list[str] = []
+
+    def add_branch(self, keyword: str, body: str):
+        # Condition
+        if self.latex == "":
+            self.latex += rf"    \ifthenelse{{\equal{{#1}}{{{keyword}}}}}{{"
+        else:
+            self.latex += "\n"
+            self.latex += rf"    }}{{\ifthenelse{{\equal{{#1}}{{{keyword}}}}}{{"
+            self._num_parentheses_to_close += 1
+
+        if keyword != "":
+            self.keywords.append(keyword)
+
+        # Body
+        self.latex += "\n"
+        self.latex += rf"        {body}"
+
+    def add_else(self, body: str):
+        self.latex += "\n"
+        self.latex += r"    }{"
+        self.latex += rf"{body}"
+        self._num_parentheses_to_close += 1
+
+    def build(self) -> str:
+        for _ in range(self._num_parentheses_to_close):
+            self.latex += "}"
+
+        return self.latex
diff --git a/src/application/latex_stringifier.py b/src/application/latex_stringifier.py
new file mode 100644
index 00000000..6de80777
--- /dev/null
+++ b/src/application/latex_stringifier.py
@@ -0,0 +1,115 @@
+from domain.result import Result
+from application.helpers import Helpers
+from application.latex_ifelse import LatexIfElseBuilder
+from application.stringifier import Stringifier
+
+
+class LatexStringifier(Stringifier):
+    """
+    Provides methods to convert results to LaTeX commands.
+
+    We assume the result to already be correctly rounded at this point.
+    """
+
+    plus_minus = r" \pm "
+    negative_sign = "-"
+    positive_sign = ""
+
+    left_parenthesis = r"\left("
+    right_parenthesis = r"\right)"
+
+    value_prefix = r"\num{"
+    value_suffix = r"}"
+
+    error_name_prefix = r"_{\text{"
+    error_name_suffix = r"}}"
+
+    scientific_notation_prefix = r" \cdot 10^{"
+    scientific_notation_suffix = "}"
+
+    unit_prefix = r"\, \unit{"
+    unit_suffix = "}"
+
+    def result_to_latex_cmd(self, result: Result) -> str:
+        """
+        Returns the result as LaTeX command to be used in a .tex file.
+        """
+        builder = LatexIfElseBuilder()
+
+        cmd_name = f"{self.config.identifier}{Helpers.capitalize(result.name)}"
+        latex_str = rf"\newcommand*{{\{cmd_name}}}[1][]{{" + "\n"
+
+        # Default case (full result) & value
+        builder.add_branch("", self.result_to_latex_str(result))
+        builder.add_branch("value", self.result_to_latex_str_value(result))
+
+        # Without error
+        if len(result.uncertainties) > 0:
+            builder.add_branch("withoutError", self.result_to_latex_str_without_error(result))
+
+        # Single uncertainties
+        for i, u in enumerate(result.uncertainties):
+            if len(result.uncertainties) == 1:
+                uncertainty_name = "error"
+            else:
+                uncertainty_name = u.name if u.name != "" else Helpers.number_to_word(i + 1)
+                uncertainty_name = f"error{Helpers.capitalize(uncertainty_name)}"
+            error_latex_str = self.create_str(u.uncertainty, [], result.unit)
+            builder.add_branch(uncertainty_name, error_latex_str)
+
+        # Total uncertainty and short result
+        if len(result.uncertainties) >= 2:
+            short_result = result.get_short_result()
+            if short_result is None:
+                raise RuntimeError(
+                    "Short result is None, but there should be at least two uncertainties."
+                )
+            error_latex_str = self.create_str(
+                short_result.uncertainties[0].uncertainty, [], result.unit
+            )
+            builder.add_branch("errorTotal", error_latex_str)
+            builder.add_branch("short", self.result_to_latex_str(short_result))
+
+        # Unit
+        if result.unit != "":
+            builder.add_branch("unit", rf"\unit{{{result.unit}}}")
+            builder.add_branch("withoutUnit", self.result_to_latex_str_without_unit(result))
+
+        # Error message
+        keywords = builder.keywords
+        if len(keywords) > 0:
+            error_message = "Please specify one of the following keywords: "
+            error_message += ", ".join([rf"\texttt{{{k}}}" for k in keywords])
+            error_message += " or don't use any keyword at all."
+        else:
+            error_message = "This variable can only be used without keywords."
+        builder.add_else(rf"\scriptsize{{\textbf{{{error_message}}}}}")
+
+        latex_str += builder.build()
+        latex_str += "\n}"
+
+        return latex_str
+
+    def result_to_latex_str(self, result: Result) -> str:
+        """
+        Returns the result as LaTeX string making use of the siunitx package.
+        """
+        return self.create_str(result.value, result.uncertainties, result.unit)
+
+    def result_to_latex_str_value(self, result: Result) -> str:
+        """
+        Returns only the value as LaTeX string making use of the siunitx package.
+        """
+        return self.create_str(result.value, [], "")
+
+    def result_to_latex_str_without_error(self, result: Result) -> str:
+        """
+        Returns the result without error as LaTeX string making use of the siunitx package.
+        """
+        return self.create_str(result.value, [], result.unit)
+
+    def result_to_latex_str_without_unit(self, result: Result) -> str:
+        """
+        Returns the result without unit as LaTeX string making use of the siunitx package.
+        """
+        return self.create_str(result.value, result.uncertainties, "")
diff --git a/src/application/rounder.py b/src/application/rounder.py
new file mode 100644
index 00000000..161f216e
--- /dev/null
+++ b/src/application/rounder.py
@@ -0,0 +1,139 @@
+from typing import List
+
+from dataclasses import dataclass
+from domain.result import Result
+from domain.uncertainty import Uncertainty
+from application.helpers import Helpers
+
+
+@dataclass
+class RoundingConfig:
+    sigfigs: int
+    decimal_places: int
+    sigfigs_fallback: int
+    decimal_places_fallback: int
+
+
+class Rounder:
+
+    @classmethod
+    def round_result(cls, result: Result, config: RoundingConfig) -> None:
+        """
+        In-place rounds all numerical fields of a result to the correct
+        number of significant figures.
+
+        # Rounding hierarchy:
+
+        1. Is uncertainty exact? Do not round.
+        2. Round uncertainty according to the hierarchy below.
+
+
+        # Rounding hierarchy for inexact uncertainty:
+
+        1. Is result value exact?
+           Round uncertainty according to result value.
+
+        2. Is number of sigfigs of result given?
+           Round value according to number of sigfigs.
+           Round uncertainties according to value.
+
+        3. Is number of decimal places of result given?
+           Round value according to number of decimal places.
+           Round uncertainties according to value.
+
+        4. Is default for sigfigs given (not -1) (see config)?
+           Round value according to number of sigfigs.
+           Round uncertainties according to value.
+
+        5. Is default for decimal places given (not -1) (see config)?
+           Round value according to number of decimal places.
+           Round uncertainties according to value.
+
+        6. Is at least one uncertainty given?
+           Round each uncertainty according to standard rules.
+           Round value according to uncertainty with lowest min exponent.
+
+        7. Is fallback for sigfigs given (not -1) (see config)?
+           Round value according to number of sigfigs.
+
+        8. Is fallback for decimal places given (not -1) (see config)?
+           Round value according to number of decimal places.
+        """
+        cls._round_result(result, config)
+
+        short = result.get_short_result()
+        if short:
+            cls._round_result(short, config)
+
+    @classmethod
+    # pylint: disable-next=too-many-branches
+    def _round_result(cls, result: Result, config: RoundingConfig) -> None:
+        """See the docstring of the public `round_result` for details."""
+
+        value = result.value
+        uncertainties = result.uncertainties
+
+        # Rounding hierarchy 1:
+        if value.is_exact():
+            cls._uncertainties_set_min_exponents(uncertainties, value.get_min_exponent())
+
+        # Rounding hierarchy 2:
+        elif result.sigfigs is not None:
+            value.set_sigfigs(result.sigfigs)
+            cls._uncertainties_set_min_exponents(uncertainties, value.get_min_exponent())
+
+        # Rounding hierarchy 3:
+        elif result.decimal_places is not None:
+            value.set_min_exponent(-result.decimal_places)
+            cls._uncertainties_set_min_exponents(uncertainties, value.get_min_exponent())
+
+        # Rounding hierarchy 4:
+        elif config.sigfigs > -1:
+            value.set_sigfigs(config.sigfigs)
+            cls._uncertainties_set_min_exponents(uncertainties, value.get_min_exponent())
+
+        # Rounding hierarchy 5:
+        elif config.decimal_places > -1:
+            min_exponent = -config.decimal_places
+            value.set_min_exponent(min_exponent)
+            cls._uncertainties_set_min_exponents(uncertainties, min_exponent)
+
+        # Rounding hierarchy 6:
+        elif len(uncertainties) > 0:
+            for u in uncertainties:
+                if u.uncertainty.is_exact():
+                    continue
+
+                normalized_value = abs(u.uncertainty.get()) * 10 ** (
+                    -Helpers.get_exponent(u.uncertainty.get())
+                )
+
+                if round(normalized_value, 1) >= 3.0:
+                    u.uncertainty.set_sigfigs(1)
+                else:
+                    u.uncertainty.set_sigfigs(2)
+
+            min_exponent = min(u.uncertainty.get_min_exponent() for u in uncertainties)
+            value.set_min_exponent(min_exponent)
+
+        # Rounding hierarchy 7:
+        elif config.sigfigs_fallback > -1:
+            value.set_sigfigs(config.sigfigs_fallback)
+
+        # Rounding hierarchy 8:
+        elif config.decimal_places_fallback > -1:
+            min_exponent = -config.decimal_places_fallback
+            value.set_min_exponent(min_exponent)
+
+        else:
+            # This branch cannot be reached, because the config makes sure that
+            # either`sigfigs_fallback` or `decimal_places_fallback` is set.
+            raise RuntimeError("Internal rounder hierarchy error. Please report this bug.")
+
+    @classmethod
+    def _uncertainties_set_min_exponents(
+        cls, uncertainties: List[Uncertainty], min_exponent: int
+    ) -> None:
+        for u in uncertainties:
+            if not u.uncertainty.is_exact():
+                u.uncertainty.set_min_exponent(min_exponent)
diff --git a/src/application/stringifier.py b/src/application/stringifier.py
new file mode 100644
index 00000000..cb043fdb
--- /dev/null
+++ b/src/application/stringifier.py
@@ -0,0 +1,134 @@
+from dataclasses import dataclass
+from typing import List
+from typing import Protocol, ClassVar
+
+# for why we use a Protocol instead of a ABC class, see
+# https://github.com/microsoft/pyright/issues/2601#issuecomment-977053380
+
+from domain.value import Value
+from domain.uncertainty import Uncertainty
+from application.helpers import Helpers
+
+
+@dataclass
+class StringifierConfig:
+    min_exponent_for_non_scientific_notation: int
+    max_exponent_for_non_scientific_notation: int
+    identifier: str
+
+
+class Stringifier(Protocol):
+    """
+    Provides methods to convert results to strings of customizable pattern.
+
+    We assume the result to already be correctly rounded at this point.
+    """
+
+    config: StringifierConfig
+
+    plus_minus: ClassVar[str]
+    negative_sign: ClassVar[str]
+    positive_sign: ClassVar[str]
+
+    left_parenthesis: ClassVar[str]
+    right_parenthesis: ClassVar[str]
+
+    value_prefix: ClassVar[str]
+    value_suffix: ClassVar[str]
+
+    error_name_prefix: ClassVar[str]
+    error_name_suffix: ClassVar[str]
+
+    scientific_notation_prefix: ClassVar[str]
+    scientific_notation_suffix: ClassVar[str]
+
+    unit_prefix: ClassVar[str]
+    unit_suffix: ClassVar[str]
+
+    def __init__(self, config: StringifierConfig):
+        self.config = config
+
+    # pylint: disable-next=too-many-locals
+    def create_str(self, value: Value, uncertainties: List[Uncertainty], unit: str) -> str:
+        """
+        Returns the result as LaTeX string making use of the siunitx package.
+
+        This string does not yet contain "\newcommand*{}".
+        """
+        string = ""
+
+        use_scientific_notation = self._should_use_scientific_notation(value, uncertainties)
+        has_unit = unit != ""
+        should_use_parentheses = len(uncertainties) > 0 and (use_scientific_notation or has_unit)
+
+        sign = self.negative_sign if value.get() < 0 else self.positive_sign
+        exponent = value.get_exponent()
+        factor = 10 ** (-exponent) if use_scientific_notation else 1.0
+        value_normalized = value.get_abs() * factor
+        decimal_places = (
+            value.get_sig_figs() - 1 if use_scientific_notation else value.get_decimal_place()
+        )
+
+        if should_use_parentheses:
+            string += self.left_parenthesis
+        string += sign
+        value_str = Helpers.round_to_n_decimal_places(value_normalized, decimal_places)
+        string += self.value_prefix + value_str + self.value_suffix
+
+        for u in uncertainties:
+            uncertainty_normalized = u.uncertainty.get_abs() * factor
+            decimal_places = (
+                exponent - u.uncertainty.get_min_exponent()
+                if use_scientific_notation
+                else u.uncertainty.get_decimal_place()
+            )
+            string += self.plus_minus
+            uncert_str = Helpers.round_to_n_decimal_places(uncertainty_normalized, decimal_places)
+            string += self.value_prefix + uncert_str + self.value_suffix
+            if u.name != "":
+                string += f"{self.error_name_prefix}{u.name}{self.error_name_suffix}"
+
+        if should_use_parentheses:
+            string += self.right_parenthesis
+        if use_scientific_notation:
+            string += (
+                f"{self.scientific_notation_prefix}{str(exponent)}{self.scientific_notation_suffix}"
+            )
+        if has_unit:
+            string += f"{self.unit_prefix}{self._modify_unit(unit)}{self.unit_suffix}"
+
+        return string
+
+    def _should_use_scientific_notation(
+        self, value: Value, uncertainties: List[Uncertainty]
+    ) -> bool:
+        """
+        Returns whether scientific notation should be used for the given value and uncertainties.
+        """
+        exponent = value.get_exponent()
+        if (
+            exponent < self.config.min_exponent_for_non_scientific_notation
+            or exponent > self.config.max_exponent_for_non_scientific_notation
+        ):
+            return True
+
+        if value.get_min_exponent() > 0:
+            return True
+
+        for u in uncertainties:
+            if u.uncertainty.get_min_exponent() > 0:
+                return True
+
+        return False
+
+    def _modify_unit(self, unit: str) -> str:
+        """
+        Returns the modified unit.
+        """
+        return unit
+
+    def _modify_value(self, value: str) -> str:
+        """
+        Returns the modified value (as string).
+        """
+        return value
diff --git a/src/config.py b/src/config.py
deleted file mode 100644
index d1d38449..00000000
--- a/src/config.py
+++ /dev/null
@@ -1,39 +0,0 @@
-class ExportConfig:
-    def __init__(
-        self,
-        identifier="result",
-        error_identifier="error",
-        unit_identifier="unit",
-        sig_figs_identifier="sigfigs",
-        table_identifier="table",
-        min_exponent_for_non_scientific_notation=-2,
-        max_exponent_for_non_scientific_notation=3,
-        default_sig_figs=2,
-    ) -> None:
-        self.identifier = identifier
-        self.error_identifier = error_identifier
-        self.unit_identifier = unit_identifier
-        self.sig_figs_identifier = sig_figs_identifier
-        self.table_identifier = table_identifier
-        self.min_exponent_for_non_scientific_notation = (
-            min_exponent_for_non_scientific_notation
-        )
-        self.max_exponent_for_non_scientific_notation = (
-            max_exponent_for_non_scientific_notation
-        )
-        self.default_sig_figs = default_sig_figs
-
-    def complete_identifier(self):
-        return self.identifier + "_"
-
-    def complete_error_identifier(self):
-        return self.identifier + "_" + self.error_identifier + "_"
-
-    def complete_unit_identifier(self):
-        return self.identifier + "_" + self.unit_identifier + "_"
-
-    def complete_sig_figs_identifier(self):
-        return self.identifier + "_" + self.sig_figs_identifier + "_"
-    
-    def complete_table_identifier(self):
-        return self.table_identifier + "_"
diff --git a/src/domain/result.py b/src/domain/result.py
new file mode 100644
index 00000000..01880ddf
--- /dev/null
+++ b/src/domain/result.py
@@ -0,0 +1,48 @@
+from dataclasses import dataclass, field
+from typing import Union
+from copy import copy
+
+from domain.uncertainty import Uncertainty
+from domain.value import Value
+
+
+@dataclass
+class Result:
+    """
+    A general-purpose result, i.e. a value that was somehow measured or calculated,
+    along with a unit and optional uncertainties (list might be empty).
+    """
+
+    name: str
+    value: Value
+    unit: str
+    uncertainties: list[Uncertainty]
+    sigfigs: Union[int, None]
+    decimal_places: Union[int, None]
+
+    total_uncertainty: Union[Uncertainty, None] = field(init=False)
+
+    def __post_init__(self):
+        if len(self.uncertainties) >= 2:
+            self.total_uncertainty = self._calculate_total_uncertainty()
+        else:
+            self.total_uncertainty = None
+
+    def _calculate_total_uncertainty(self) -> Uncertainty:
+        total = 0
+        for u in self.uncertainties:
+            total += u.uncertainty.get() ** 2
+        return Uncertainty(Value(total**0.5))
+
+    def get_short_result(self) -> Union["Result", None]:
+        if self.total_uncertainty is None:
+            return None
+
+        return Result(
+            self.name,
+            copy(self.value),
+            self.unit,
+            [copy(self.total_uncertainty)],
+            self.sigfigs,
+            self.decimal_places,
+        )
diff --git a/src/domain/uncertainty.py b/src/domain/uncertainty.py
new file mode 100644
index 00000000..57bcbf7b
--- /dev/null
+++ b/src/domain/uncertainty.py
@@ -0,0 +1,18 @@
+from domain.value import Value
+
+
+class Uncertainty:
+    """
+    A named uncertainty value, e.g. a systematic uncertainty of ±0.1cm
+    when measuring a length. In this case the uncertainty would be 0.1 and the name
+    would be "systematic". As Uncertainties are always directly associated with a
+    Result, we don't store the unit of the value here.
+
+    We don't use the word "error" to distinguish between errors in our code,
+    and uncertainties in the physical sense. In reality, these words might be used
+    interchangeably.
+    """
+
+    def __init__(self, uncertainty: Value, name: str = ""):
+        self.uncertainty = uncertainty
+        self.name = name
diff --git a/src/domain/value.py b/src/domain/value.py
new file mode 100644
index 00000000..106d703e
--- /dev/null
+++ b/src/domain/value.py
@@ -0,0 +1,61 @@
+from typing import Union
+
+from application.helpers import Helpers
+
+
+class Value:
+    """
+    A floating-point value represented as string that is either treated as exact
+    (does not have any uncertainties) or as inexact (has uncertainties).
+    Values that are exact will be exempt from significant figures rounding.
+
+    Note that is_exact signifies if the value is to be taken as a *literal* value,
+    i.e. "3.14000" will be output as "3.14000" and not "3.14" if is_exact is True.
+    """
+
+    _value: float
+    _is_exact: bool
+    _max_exponent: int
+    _min_exponent: int
+
+    def __init__(self, value: float, min_exponent: Union[int, None] = None):
+        self._value = value
+
+        if min_exponent is not None:
+            self._min_exponent = min_exponent
+            self._is_exact = True
+        else:
+            self._is_exact = False
+
+        self._max_exponent = Helpers.get_exponent(self._value)
+
+    def set_min_exponent(self, min_exponent: int):
+        self._min_exponent = min_exponent
+
+    def get_min_exponent(self) -> int:
+        return self._min_exponent
+
+    def set_sigfigs(self, sigfigs: int):
+        self._min_exponent = self._max_exponent - sigfigs + 1
+
+    def is_exact(self) -> bool:
+        return self._is_exact
+
+    def get(self) -> float:
+        return self._value
+
+    def get_abs(self) -> float:
+        return abs(self._value)
+
+    def get_exponent(self) -> int:
+        return self._max_exponent
+
+    def get_sig_figs(self) -> int:
+        return self._max_exponent - self._min_exponent + 1
+
+    def get_decimal_place(self) -> int:
+        if self._min_exponent is None:
+            # This should not happen as `_min_exponent` should be set
+            # by the time this method is called.
+            raise RuntimeError("Internal min_exponent not set error. Please report this bug.")
+        return -self._min_exponent
diff --git a/src/globals_to_latex.py b/src/globals_to_latex.py
deleted file mode 100644
index ed057da4..00000000
--- a/src/globals_to_latex.py
+++ /dev/null
@@ -1,70 +0,0 @@
-from src.config import ExportConfig
-from src.helpers import snake_case_to_camel_case
-from src.var_to_latex import var_to_latex_newcommand
-
-
-def globals_to_latex(variables: dict[str, any], config=ExportConfig()) -> str:
-    # Get relevant keys:
-    keys = [
-        key
-        for key in variables.keys()
-        if key.startswith(config.complete_identifier())
-        and not key.startswith(config.complete_error_identifier())
-        and not key.startswith(config.complete_unit_identifier())
-        and not key.startswith(config.complete_sig_figs_identifier())
-    ]
-
-    # Create export string:
-    export = ""
-
-    # Iterate through keys:
-    for key in keys:
-        # Get variable name:
-        name = key[key.index("_") + 1 :]
-        camel_case_name = snake_case_to_camel_case(key)
-
-        # Read variable:
-        if type(variables[key]) is tuple:
-            value = variables[key][0]
-
-            if len(variables[key]) > 1:
-                error = variables[key][1]
-            else:
-                error = 0
-
-            if len(variables[key]) > 2:
-                unit = variables[key][2]
-            else:
-                unit = ""
-
-            if len(variables[key]) > 3:
-                sig_figs = variables[key][3]
-            else:
-                sig_figs = -1
-        else:
-            value = variables[key]
-
-            error_key = f"{config.complete_error_identifier()}{name}"
-            has_error = error_key in variables.keys()
-            if has_error:
-                error = variables[error_key]
-            else:
-                error = 0
-
-            unit_key = f"{config.complete_unit_identifier()}{name}"
-            has_unit = unit_key in variables.keys()
-            if has_unit:
-                unit = variables[unit_key]
-            else:
-                unit = ""
-
-            sig_figs_key = f"{config.complete_sig_figs_identifier()}{name}"
-            has_sig_figs = sig_figs_key in variables.keys()
-            if has_sig_figs:
-                sig_figs = variables[sig_figs_key]
-            else:
-                sig_figs = -1
-
-        export += var_to_latex_newcommand(camel_case_name, value, error, unit, sig_figs, config)
-
-    return export
diff --git a/src/helpers.py b/src/helpers.py
deleted file mode 100644
index baf65077..00000000
--- a/src/helpers.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import numpy as np
-
-
-def snake_case_to_camel_case(snake_case_name: str):
-    name_parts = snake_case_name.split("_")
-    camel_case_name = name_parts[0]
-    for name_part in name_parts[1:]:
-        camel_case_name += name_part[0].upper() + name_part[1:]
-    return camel_case_name
-
-
-def snake_case_to_kebab_case(snake_case_name: str):
-    return snake_case_name.replace("_", "-")
-
-
-def round_to_n_decimal_places(v: float, n: int):
-    return "{:.{}f}".format(v, int(n))
-
-
-def get_exponent(v: float):
-    return np.floor(np.log10(v))
diff --git a/src/resultwizard/__init__.py b/src/resultwizard/__init__.py
new file mode 100644
index 00000000..f00a02ca
--- /dev/null
+++ b/src/resultwizard/__init__.py
@@ -0,0 +1,3 @@
+from api.config import config_init, config
+from api.res import res
+from api.export import export
diff --git a/src/table_to_latex.py b/src/table_to_latex.py
deleted file mode 100644
index d04f6b8a..00000000
--- a/src/table_to_latex.py
+++ /dev/null
@@ -1,127 +0,0 @@
-from src.helpers import round_to_n_decimal_places
-from src.config import ExportConfig
-from src.helpers import snake_case_to_camel_case, snake_case_to_kebab_case
-from src.var_to_latex import var_to_latex
-
-
-def table_to_latex_newcommand(
-    name: str,
-    columns: list,
-    caption: str = "",
-    float_mode: str = "h!",
-    config: ExportConfig = ExportConfig(),
-):
-    # Create name:
-    if not name.startswith(config.table_identifier):
-        name = config.complete_table_identifier() + name
-    camel_case_name = snake_case_to_camel_case(name)
-
-    return (
-        "\\newcommand*{\\"
-        + camel_case_name
-        + "}{%\n"
-        + table_to_latex(name, columns, caption, float_mode, config)
-        + "}\n"
-    )
-
-
-def table_to_latex(
-    name: str,
-    columns: list,
-    caption: str = "",
-    float_mode: str = "h!",
-    config: ExportConfig = ExportConfig(),
-) -> str:
-    # Create name:
-    if not name.startswith(config.table_identifier):
-        name = config.complete_table_identifier() + name
-    kebab_case_name = snake_case_to_kebab_case(name)
-
-    # Check for faulty things:
-    is_faulty = False
-
-    if is_faulty:
-        print(f"There was an error with the table {name}.")
-        return "ERROR"
-
-    # Create output string:
-    output = ""
-
-    # Table header:
-    output += "\\begin{" + "table" + "}[" + float_mode + "]\n"
-    output += "\\begin{" + "center" + "}\n"
-    output += "\\begin{" + "tabular" + "}{|"
-    for i in range(len(columns)):
-        output += "c|"
-    output += "}\n"
-    output += "\\hline\n"
-
-    # Header row:
-    is_first_column = True
-    for column in columns:
-        if not is_first_column:
-            output += "&"
-        is_first_column = False
-
-        output += "\\textbf{" + column[0] + "}"
-
-    # Unit row:
-    exist_units = False
-    for column in columns:
-        if column[1] != "":
-            exist_units = True
-    if exist_units:
-        output += "\\\\\n"
-        is_first_column = True
-        for column in columns:
-            if not is_first_column:
-                output += "&"
-            is_first_column = False
-
-            if column[1] != "":
-                output += "$[\\unit{" + column[1] + "}]$"
-    output += "\\\\ \\hline \n"
-
-    # Value rows:
-    for i in range(len(columns[0][2])):
-        is_first_column = True
-        for column in columns:
-            if not is_first_column:
-                output += "&"
-            is_first_column = False
-
-            if type(column[2][i]) is str:
-                output += column[2][i]
-            else:
-                value = column[2][i]
-                try:
-                    error = column[3][i]
-                except:
-                    error = 0
-                try:
-                    sig_figs = column[4]
-                    if type(sig_figs) is list:
-                        sig_figs = sig_figs[i]
-                except:
-                    sig_figs = -1
-                try:
-                    unit = column[5]
-                    if type(unit) is list:
-                        unit = unit[i]
-                except:
-                    unit = ""
-
-                output += "$" + var_to_latex(value, error, unit, sig_figs, config) + "$"
-
-        output += "\\\\\n"
-
-    # Table footer:
-    output += "\\hline\n"
-    output += "\\end{" + "tabular" + "}\n"
-    if caption != "":
-        output += "\\caption{" + caption + "}\n"
-    output += "\\end{" + "center" + "}\n"
-    output += "\\label{" + kebab_case_name + "}\n"
-    output += "\\end{" + "table" + "}\n"
-
-    return output
diff --git a/src/var_to_latex.py b/src/var_to_latex.py
deleted file mode 100644
index bdb6b244..00000000
--- a/src/var_to_latex.py
+++ /dev/null
@@ -1,190 +0,0 @@
-import numpy as np
-from src.config import ExportConfig
-from src.helpers import (
-    snake_case_to_camel_case,
-    get_exponent,
-    round_to_n_decimal_places,
-)
-
-
-def var_to_latex_newcommand(
-    name: str,
-    value: float | int,
-    error: float = 0,
-    unit: str = "",
-    sig_figs: int = -1,
-    config: ExportConfig = ExportConfig(),
-):
-    # Create name:
-    if not name.startswith(config.identifier):
-        name = config.complete_identifier() + name
-    camel_case_name = snake_case_to_camel_case(name)
-
-    latex_str = var_to_latex(value, error, unit, sig_figs, config)
-
-    if latex_str == "ERROR":
-        print(f"There was an error with the variable {name}.")
-
-    return "\\newcommand*{\\" + camel_case_name + "}{" + latex_str + "}\n"
-
-
-def var_to_latex(
-    value: float | int,
-    error: float = 0,
-    unit: str = "",
-    sig_figs: int = -1,
-    config: ExportConfig = ExportConfig(),
-) -> str:
-    has_error = error != 0
-    has_unit = unit != ""
-    has_sig_figs = sig_figs != -1
-
-    # Allow for int values:
-    if type(value) is int:
-        value = float(value)
-        if not has_error:
-            sig_figs = int(get_exponent(value)) + 1
-            has_sig_figs = True
-
-    # Create output string:
-    output = ""
-
-    is_negative = value < 0
-    value = np.abs(value)
-
-    # Check for faulty values:
-    is_not_faulty = (
-        isinstance(value, (int, float))
-        and value != float("inf")
-        and value != float("-inf")
-        and isinstance(error, (int, float))
-        and error != float("inf")
-        and error != float("-inf")
-    )
-    if not is_not_faulty:
-        return "ERROR"
-
-    # Deal with the case that the user specified neither error nor sig figs:
-    if not has_sig_figs and not has_error:
-        sig_figs = config.default_sig_figs
-        has_sig_figs = True
-
-    # Get exponents:
-    if value == 0:
-        value_exponent = 0
-    else:
-        value_exponent = get_exponent(value)
-    if has_error:
-        error_exponent = get_exponent(error)
-
-    # Should use scientific notation?
-    use_scientific_notation = (
-        value_exponent < config.min_exponent_for_non_scientific_notation
-        or value_exponent > config.max_exponent_for_non_scientific_notation
-    )
-
-    # Set min exponents:
-    if has_sig_figs:
-        value_min_exponent = value_exponent - sig_figs + 1
-        error_min_exponent = value_min_exponent
-    elif has_error:
-        # Get error sig figs:
-        error_first_digit = np.round(error / 10**error_exponent)
-        if error_first_digit <= 2:
-            error_sig_figs = 2
-        else:
-            error_sig_figs = 1
-
-        error_min_exponent = error_exponent - error_sig_figs + 1
-        value_min_exponent = error_min_exponent
-
-        # Get number of sig figs:
-        sig_figs = value_exponent - value_min_exponent + 1
-
-    # Round and normalize error:
-    if has_error:
-        error_rounded_normalized = (
-            np.round(error / 10**error_min_exponent) / 10**sig_figs * 10
-        )
-
-    # Round and normalize value:
-    value_rounded_normalized = (
-        np.round(value / 10**value_min_exponent) / 10**sig_figs * 10
-    )
-
-    # Account for rounding like 0.999 -> 1.000 that change value exponent:
-    if has_error:
-        if get_exponent(error_rounded_normalized) > 0:
-            error_exponent += 1
-            error_min_exponent += 1
-            error_rounded_normalized = (
-                np.round(error / 10**error_min_exponent) / 10**sig_figs * 10
-            )
-
-        # Use scientific for errors like 120
-        if error_min_exponent > 0:
-            use_scientific_notation = True
-    else:
-        if get_exponent(value_rounded_normalized) > 0:
-            value_exponent += 1
-            value_min_exponent += 1
-            value_rounded_normalized = (
-                np.round(value / 10**value_min_exponent) / 10**sig_figs * 10
-            )
-
-        if value_min_exponent > 0:
-            use_scientific_notation = True
-
-    # Generate output:
-    if is_negative:
-        sign = "-"
-    else:
-        sign = ""
-
-    if use_scientific_notation:
-        if has_error:
-            output += (
-                "("
-                + sign
-                + round_to_n_decimal_places(value_rounded_normalized, sig_figs - 1)
-                + " \\pm "
-                + round_to_n_decimal_places(error_rounded_normalized, sig_figs - 1)
-                + ") \\cdot 10^{"
-                + round_to_n_decimal_places(value_exponent, 0)
-                + "}"
-            )
-        else:
-            output += (
-                sign
-                + round_to_n_decimal_places(value_rounded_normalized, sig_figs - 1)
-                + "\\cdot 10^{"
-                + round_to_n_decimal_places(value_exponent, 0)
-                + "}"
-            )
-    else:
-        decimal_places = max(0, -value_min_exponent)
-        if has_error:
-            if has_unit:
-                output += "("
-            output += (
-                sign
-                + round_to_n_decimal_places(
-                    value_rounded_normalized * 10**value_exponent, decimal_places
-                )
-                + " \\pm "
-                + round_to_n_decimal_places(
-                    error_rounded_normalized * 10**value_exponent, decimal_places
-                )
-            )
-            if has_unit:
-                output += ")"
-        else:
-            output += sign + round_to_n_decimal_places(
-                value_rounded_normalized * 10**value_exponent, decimal_places
-            )
-
-    # Add unit:
-    if has_unit:
-        output += "\\ \\unit{" + unit + "}"
-
-    return output


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 00000000..d6da7942
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,40 @@
+name: Testing
+
+on:
+  push:
+    branches:
+      - main
+      
+  # for "synchronize", see [1]
+  pull_request:
+    types: [opened, reopened, synchronize, ready_for_review]
+
+jobs:
+    pytest:
+        name: Pytest
+        runs-on: ubuntu-latest
+        steps:
+          - name: Checkout code
+            uses: actions/checkout@v4
+          
+          - name: Setup Python
+            uses: actions/setup-python@v5
+            with:
+              python-version: 3.11
+              cache: 'pipenv'
+            
+          - name: Install pipenv
+            run: pip install pipenv
+
+          - name: Install dependencies (including dev dependencies)
+            run: pipenv install --dev
+
+          - name: Install ResultWizard itself (as editable)
+            run: pipenv run pip3 install --editable .
+
+          - name: Run Pytest
+            run: pipenv run pytest tests/
+
+
+
+# [1] https://github.com/orgs/community/discussions/26366
diff --git a/tests/number_word_test.py b/tests/number_word_test.py
new file mode 100644
index 00000000..30541160
--- /dev/null
+++ b/tests/number_word_test.py
@@ -0,0 +1,31 @@
+import pytest
+
+from application.helpers import Helpers
+
+
+class TestNumberWord:
+    @pytest.mark.parametrize(
+        "value, expected",
+        [
+            (0, "zero"),
+            (1, "one"),
+            (2, "two"),
+            (3, "three"),
+            (10, "ten"),
+            (18, "eighteen"),
+            (76, "seventySix"),
+            (100, "oneHundred"),
+            (101, "oneHundredOne"),
+            (123, "oneHundredTwentyThree"),
+            (305, "threeHundredFive"),
+            (911, "nineHundredEleven"),
+            (999, "nineHundredNinetyNine"),
+        ],
+    )
+    def test_number_to_word(self, value, expected):
+        assert Helpers.number_to_word(value) == expected
+
+    @pytest.mark.parametrize("value", [1000, 1001, -1, -500])
+    def test_number_to_word_raises(self, value):
+        with pytest.raises(ValueError, match="numbers between 0 and 999"):
+            Helpers.number_to_word(value)
diff --git a/tests/parsers_test.py b/tests/parsers_test.py
new file mode 100644
index 00000000..4e6d3893
--- /dev/null
+++ b/tests/parsers_test.py
@@ -0,0 +1,91 @@
+import pytest
+
+from api import parsers
+from domain.value import Value
+
+
+class TestNameParser:
+
+    @pytest.mark.parametrize(
+        "name, expected",
+        [
+            ("a12", "aTwelve"),
+            ("a01", "aOne"),
+            ("042", "fortyTwo"),
+            ("a911bc13", "aNineHundredElevenBcThirteen"),
+            ("13_600", "thirteenSixHundred"),
+            ("a_5_6b7_$5", "aFiveSixBSevenFive"),
+        ],
+    )
+    def test_substitutes_numbers(self, name: str, expected: str):
+        assert parsers.parse_name(name) == expected
+
+    @pytest.mark.parametrize(
+        "name, expected",
+        [
+            ("ä", "ae"),
+            ("Ä", "Ae"),
+            ("ü", "ue"),
+            ("Ü", "Ue"),
+            ("ö", "oe"),
+            ("Ö", "Oe"),
+            ("ß", "ss"),
+            ("ẞ", "SS"),
+            ("äh", "aeh"),
+            ("Füße", "Fuesse"),
+            ("GIEẞEN", "GIESSEN"),
+        ],
+    )
+    def test_replaces_umlauts(self, name: str, expected: str):
+        assert parsers.parse_name(name) == expected
+
+    @pytest.mark.parametrize(
+        "name, expected",
+        [
+            ("!a$", "a"),
+            ("!a$b", "ab"),
+            ("!a$b", "ab"),
+            ("!%a&/(=*)s.,'@\"§d", "asd"),
+        ],
+    )
+    def test_strips_invalid_chars(self, name: str, expected: str):
+        assert parsers.parse_name(name) == expected
+
+    @pytest.mark.parametrize("name", ["", "!", "    ", "_  ", "§ _ '*"])
+    def test_empty_name_fails(self, name):
+        with pytest.raises(ValueError):
+            parsers.parse_name(name)
+
+
+class TestValueParser:
+
+    @pytest.mark.parametrize(
+        "value, expected",
+        [
+            # plain numbers
+            ("012", Value(12.0, min_exponent=0)),
+            ("12", Value(12.0, min_exponent=0)),
+            ("-42", Value(-42.0, min_exponent=0)),
+            ("10050", Value(10050, min_exponent=0)),
+            # plain numbers scientific
+            ("13e3", Value(13000.0, min_exponent=3)),
+            # plain decimal
+            ("3.1415", Value(3.1415, min_exponent=-4)),
+            ("0.005", Value(0.005, min_exponent=-3)),
+            ("0.010", Value(0.01, min_exponent=-3)),
+            # decimal & scientific
+            ("3.1415e3", Value(3141.5, min_exponent=-1)),
+            ("2.71828e2", Value(271.828, min_exponent=-3)),
+            ("2.5e-1", Value(0.25, min_exponent=-2)),
+            ("0.1e2", Value(10.0, min_exponent=1)),
+            ("1.2e5", Value(120000.0, min_exponent=4)),
+            ("1.20e5", Value(120000.0, min_exponent=3)),
+            ("103.1570e-30", Value(0.0000000000000000000000000001031570, min_exponent=-34)),
+        ],
+    )
+    def test_parse_exact_value(self, value: str, expected: Value):
+        v = parsers.parse_exact_value(value)
+        # pylint: disable=protected-access
+        assert v._value == expected._value
+        assert v._min_exponent == expected._min_exponent
+        # pylint: enable=protected-access
diff --git a/tests/playground.py b/tests/playground.py
new file mode 100644
index 00000000..5a887b22
--- /dev/null
+++ b/tests/playground.py
@@ -0,0 +1,86 @@
+# TLDR: `pip3 install -e .` from the root directory of the package
+# Then: `python3 ./tests/playground.py`
+#
+# development mode: "setuptools allows you to install a package without
+# copying any files to your interpreter directory
+# (e.g. the site-packages directory)."
+# From: https://setuptools.pypa.io/en/latest/userguide/quickstart.html#development-mode
+
+
+import resultwizard as wiz
+
+print("#############################")
+print("### Playground")
+print("#############################")
+print()
+
+
+wiz.config_init(print_auto=True, decimal_places=3)
+# wiz.config(sigfigs=2)
+# wiz.config(decimal_places=2)
+
+#############################
+# EXAMPLES
+#############################
+
+print("### RESULTS API")
+
+# wiz.res("", 42.0).print()
+# -> Error: "name must not be empty"
+
+wiz.res("a911", 1.0, r"\mm")
+# a: 1.0 \mm
+
+wiz.res("1 b", 1.0, 0.01, r"\per\mm\cubed").print()
+# b: (1.0 ± 0.01) \mm
+
+wiz.config(decimal_places=-1, sigfigs_fallback=3)
+
+wiz.res("c big", 1.0, (0.01, "systematic"), r"\mm").print()
+# c: (1.0 ± 0.01 systematic) \mm
+
+wiz.res(
+    "d", 1.0e10, [(0.01e10, "systematic"), (0.0294999999e10, "stat")], r"\mm\per\second\squared"
+).print()
+# d: (1.0 ± 0.01 systematic ± 0.02 stat) \mm
+
+wiz.res("e", "1.0", r"\mm").print()
+# e: 1.0 \mm
+
+wiz.res("f", "1.0e1", 25e-1).print()
+# f: 1.0
+
+wiz.res("g", 42).print()
+
+wiz.res("h", 42, 13.0, 24.0)
+wiz.res("h&", 42, 13.0, 24.0)
+
+
+# wiz.res("g", 1.0, sys=0.01, stat=0.02, unit=r"\mm").print()
+# g: (1.0 ± 0.01 sys ± 0.02 stat) \mm
+# TODO: Why does this not work?
+# -> This fix might help: https://github.com/beartype/plum/issues/40#issuecomment-1836613508
+
+# The following wont' work as we can't have positional arguments (here: unit)
+# after keyword arguments (here: uncert)
+# wiz.res("d", 1.0, uncert=[(0.01, "systematic"), (0.02, "stat")], r"\mm").print()
+
+# wiz.table(
+#     "name",
+#     {
+#         "Header 1": ["Test", "Test2", ...],
+#         "Header 2": [wiz.cell_res(...), wiz.cell_res(...), ...],
+#         "Header 3": [wiz.cell_res(values[i], errors[i], r"\mm") for i in range(10)],
+#     },
+#     "description",
+#     horizontal = True,
+# )
+
+
+#############################
+# Export
+#############################
+
+print()
+print("### EXPORT")
+wiz.export("results.tex")
diff --git a/tests/rounder_test.py b/tests/rounder_test.py
new file mode 100644
index 00000000..9658b929
--- /dev/null
+++ b/tests/rounder_test.py
@@ -0,0 +1,96 @@
+# pylint: disable=redefined-outer-name
+
+import pytest
+
+from application.rounder import Rounder, RoundingConfig
+from domain.result import Result
+from domain.value import Value
+from domain.uncertainty import Uncertainty
+
+
+@pytest.fixture
+def config_defaults():
+    return RoundingConfig(-1, -1, 2, -1)
+
+
+class TestRounder:
+    def test_hierarchy_1(self, config_defaults):
+        res = Result("", Value(1.0000, min_exponent=-4), "", [], 2, 10)
+        Rounder.round_result(res, config_defaults)
+        assert res.value.get_min_exponent() == -4
+
+        res = Result("", Value(1.0000, min_exponent=-4), "", [Uncertainty(Value(0.1))], None, None)
+        Rounder.round_result(res, config_defaults)
+        assert res.value.get_min_exponent() == -4
+        assert res.uncertainties[0].uncertainty.get_min_exponent() == -4
+
+        res = Result(
+            "",
+            Value(1.0000, min_exponent=-4),
+            "",
+            [Uncertainty(Value(0.1, min_exponent=-1))],
+            None,
+            None,
+        )
+        Rounder.round_result(res, config_defaults)
+        assert res.value.get_min_exponent() == -4
+        assert res.uncertainties[0].uncertainty.get_min_exponent() == -1
+
+    def test_hierarchy_3(self, config_defaults):
+        res = Result("", Value(1.0), "", [], 10, 2)
+        Rounder.round_result(res, config_defaults)
+        assert res.value.get_min_exponent() == -9
+
+        res = Result("", Value(1.0), "", [Uncertainty(Value(0.1))], 10, None)
+        Rounder.round_result(res, config_defaults)
+        assert res.value.get_min_exponent() == -9
+        assert res.uncertainties[0].uncertainty.get_min_exponent() == -9
+
+        res = Result("", Value(1.0), "", [Uncertainty(Value(0.1, min_exponent=-1))], 10, None)
+        Rounder.round_result(res, config_defaults)
+        assert res.value.get_min_exponent() == -9
+        assert res.uncertainties[0].uncertainty.get_min_exponent() == -1
+
+    def test_hierarchy_4(self, config_defaults):
+        res = Result("", Value(1.0), "", [], None, 2)
+        Rounder.round_result(res, config_defaults)
+        assert res.value.get_min_exponent() == -2
+
+        res = Result("", Value(1.0), "", [Uncertainty(Value(0.1))], None, 2)
+        Rounder.round_result(res, config_defaults)
+        assert res.value.get_min_exponent() == -2
+        assert res.uncertainties[0].uncertainty.get_min_exponent() == -2
+
+        res = Result("", Value(1.0), "", [Uncertainty(Value(0.1e-5, min_exponent=-6))], None, 2)
+        Rounder.round_result(res, config_defaults)
+        assert res.value.get_min_exponent() == -2
+        assert res.uncertainties[0].uncertainty.get_min_exponent() == -6
+
+    def test_hierarchy_5(self, config_defaults):
+        res = Result("", Value(1.0), "", [Uncertainty(Value(0.11))], None, None)
+        Rounder.round_result(res, config_defaults)
+        assert res.value.get_min_exponent() == -2
+        assert res.uncertainties[0].uncertainty.get_min_exponent() == -2
+
+        res = Result("", Value(1.0), "", [Uncertainty(Value(0.294999))], None, None)
+        Rounder.round_result(res, config_defaults)
+        assert res.value.get_min_exponent() == -2
+        assert res.uncertainties[0].uncertainty.get_min_exponent() == -2
+
+        res = Result("", Value(1.0), "", [Uncertainty(Value(0.295001))], None, None)
+        Rounder.round_result(res, config_defaults)
+        assert res.value.get_min_exponent() == -1
+        assert res.uncertainties[0].uncertainty.get_min_exponent() == -1
+
+        res = Result(
+            "", Value(1.0), "", [Uncertainty(Value(0.4)), Uncertainty(Value(0.04))], None, None
+        )
+        Rounder.round_result(res, config_defaults)
+        assert res.value.get_min_exponent() == -2
+        assert res.uncertainties[0].uncertainty.get_min_exponent() == -1
+        assert res.uncertainties[1].uncertainty.get_min_exponent() == -2
+
+    def test_hierarchy_6(self, config_defaults):
+        res = Result("", Value(1.0), "", [], None, None)
+        Rounder.round_result(res, config_defaults)
+        assert res.value.get_min_exponent() == -1