visualize-my-expenses
Turn a month of budget rows into a Sankey diagram you can share as a PNG.
pip install vme-py
vme render budget.csv -c PLN -o august.png --month 2026-08
Money flows left to right: income sources → your budget → categories → what you actually bought. Whatever you did not spend leaves as Savings / left over, so the picture always balances.
Install
pip install vme-py
The package is called vme-py on PyPI; the command it installs and the module
you import are both vme. If you only want the command, give it an environment
of its own:
uv tool install vme-py # or: pipx install vme-py
Python 3.10 or newer. The core needs only click and matplotlib — no browser,
no headless Chrome, no network. Three optional extras:
| Extra | Adds | For |
|---|---|---|
vme-py[excel] |
openpyxl |
reading .xlsx workbooks |
vme-py[html] |
plotly |
writing an interactive .html version |
vme-py[seaborn] |
seaborn (and with it pandas) |
the seaborn look: --engine seaborn |
vme-py[all] |
all three |
pip install "vme-py[all]"
To work on the code, install from a clone instead:
git clone https://github.com/oskar-j/visualize-my-expenses
cd visualize-my-expenses
uv sync # .venv as pinned by uv.lock: the package, every extra, the dev tools
# or: python -m venv .venv && source .venv/bin/activate && pip install -e ".[all]"
Try it in 30 seconds
vme sample budget.csv # writes an example file
vme render budget.csv -c PLN -o august.png # draws it
Input formats
Point vme at whatever your bank or budget app exports; the format is guessed
from the file name, and --format overrides the guess.
| Format | Extensions | Notes |
|---|---|---|
| CSV / TSV | .csv .tsv .txt |
separator and encoding are sniffed |
| JSON | .json |
an array of rows, or {"expenses": [...]} |
| JSON Lines | .jsonl .ndjson |
one row per line |
| OFX / QFX | .ofx .qfx |
most US banks, Quicken, MS Money |
| QIF | .qif |
older Quicken exports |
| ISO 20022 camt | .xml .camt |
camt.052/053/054 SEPA statements |
| Excel | .xlsx .xlsm |
needs the excel extra |
vme formats prints the same list.
Column names are matched loosely, so a bank export usually works untouched —
Posted Date, Description, Transaction Amount, Debit/Credit, kwota and
waluta are all understood. Amounts written as 1,234.56, 1.234,56,
1 234,56, 1.234.567, (12.00), 120.00-, 500,-, $1,234.56,
Rs. 1,500 or 1.5E+3 all parse.
The minimum a CSV needs is an amount:
date,category,label,amount,currency,kind
2026-08-01,Income,Salary,9800.00,PLN,income
2026-08-02,Housing,Rent,3200.00,PLN,expense
2026-08-04,Groceries,Biedronka,412.85,PLN,expense
category groups, label is the detail inside the group, kind is income or
expense. Leave kind out and the direction is inferred: if any amount is
negative the file is read as a bank statement, where the sign alone decides
(negative = money out). If none is, every row is spending except the ones whose
category or label names income — Income, Salary, Wages, Payroll,
Bonus — and does not also name a cost, so Income tax stays spending.
Dates such as 03/04/2026 are read the same way through the whole file. A
single date that can only be read one way (13/04/2026 or 04/13/2026)
settles the order. If every date could go either way, they are read day-first;
--monthfirst or --dayfirst says otherwise. A file that has dates written
both ways is refused rather than guessed at. A time after the date
(15.08.2026 10:23) is ignored. A date that cannot be read at all is reported
as a note, with its file and line; the row still loads, but without a date, so
--month, --year and --period leave it out.
Multiple currencies
Each row carries its own currency, and -c/--currency says which one the
picture is drawn in. Anything else needs a rate — one unit of that currency
expressed in the report currency:
vme render trip.csv -c PLN --rate EUR=4.30 --rate UAH=0.095 --rate USD=3.95
Rates can live in a file instead (--rates rates.json), as JSON or CSV:
{ "base": "PLN", "rates": { "EUR": 4.30, "USD": 3.95, "UAH": 0.095 } }
currency,rate
EUR,4.30
UAH,0.095
A --rate flag overrides the same currency in the file. Rates are never fetched
from the internet: the same input has to draw the same picture next month, and a
rate that quietly changes between two runs would break that.
Around 50 currencies — including EUR, PLN, UAH, CZK, GBP, USD, JPY and BTC —
know their own symbol, decimal places and separators, so amounts are written the
way that currency is normally written (1 234,50 zł, €1,234.50, ¥1,235).
Anything else still works and prints its ISO code. vme currencies lists them.
Forget a rate and the error tells you what to add:
error: rows are in EUR (Euro), UAH (Ukrainian hryvnia) but the report is in PLN.
Give a rate for each one, for example: --rate EUR=4.30 (one EUR is worth 4.30 PLN)
Engines
The chart is always drawn by matplotlib; the engine decides how it looks.
vme render budget.csv # vme's own look
vme render budget.csv --engine seaborn # seaborn's
vme render budget.csv --engine seaborn --palette colorblind --context talk
matplotlib(the default) is vme's own palette, checked for colour-blind separation and for contrast against its background, and vme's font stack.seaborntakes seaborn's look: a seaborn palette for the categories (deepunless--palettenames another, such ascolorblind,mutedorSet2), seaborn's fonts, and the type sizes of a seaborn context (notebookunless--contextsayspaper,talkorposter). Bigger type gets a taller image. Install it withpip install "vme-py[seaborn]".
Seaborn has no Sankey chart of its own, so it styles what matplotlib draws, and
installing it always brings matplotlib along. Whichever engine you pick, the
background (--theme light or dark) and the colours of income, savings and
"Other" stay vme's. A seaborn palette loses its grey, which vme keeps for
"Other", and gives at most 8 colours. vme does not check seaborn's palettes for
colour-blind separation or contrast the way it checks its own.
Command line
vme render SOURCE draw a Sankey diagram
vme summary SOURCE print the same breakdown as a table
vme check SOURCE report anything that would stop it being plotted
vme formats list the input formats
vme currencies list the known currencies
vme sample FILE write an example file to start from
render, summary and check read a file the same way, with the same
options: -f, -c, --rate, --rates, -m/-y/-p, --sign, --sheet,
--encoding, --delimiter and --dayfirst/--monthfirst. A file that
check passes, render draws.
Useful render options:
| Option | Does |
|---|---|
-o out.png |
.png, .svg, .pdf or .html — picked by extension |
-c PLN |
currency the report is drawn in |
-m 2026-08 |
one month; also -y 2026 and -p START..END |
--top 8 |
keep the 8 biggest categories, fold the rest into "Other" (your own "Other", if the data has one) |
--min-share 1 |
fold anything under 1% of the total |
--max-labels 6 |
most detail rows to show inside one category |
--no-detail |
stop at categories, skip the label column |
--theme dark |
dark background |
--engine seaborn |
seaborn's palette, fonts and type sizes (see Engines) |
--palette colorblind |
which seaborn palette; --context talk for bigger type |
--width 1600 --dpi 200 |
image size; height scales with the row count |
--transparent |
transparent background |
--sign statement |
force how a negative amount is read |
--open |
open the file when it is done |
Sharing a picture in a chat app: the defaults (1600px wide, 200 dpi, light
theme) are already sized for it. For a dark-mode chat, add --theme dark.
Python API
Visualizer is the whole public surface.
from vme import Visualizer
Visualizer.from_file("august.csv", currency="PLN").create_png("august.png")
Or build the rows yourself — dicts, namedtuples and Expense objects all work:
from vme import Expense, Visualizer
rows = [
Expense(category="Income", label="Salary", amount=9800, currency="PLN", kind="income"),
Expense(category="Housing", label="Rent", amount=3200, currency="PLN"),
Expense(category="Food", label="Lidl", amount=412, currency="PLN"),
]
v = Visualizer(rows, currency="PLN", title="August 2026", theme="light")
v.print_to_console()
v.create_png("august.png", width=1600, dpi=200)
| Method | Does |
|---|---|
Visualizer.from_file(path, fmt=None, ...) |
read any supported file |
create_png(path) |
write a PNG (also .svg, .pdf) |
create_html(path) |
write an interactive plotly page — needs the html extra |
save(path) |
pick the writer from the extension |
show_plot() |
open an interactive window |
print_to_console() |
the breakdown as text |
graph() |
the SankeyGraph, if you want to draw it yourself |
problems() |
what is wrong with the rows, as a list |
Constructor arguments: currency, rates, period, sign, title,
subtitle, theme, engine, palette, context, top_categories,
max_labels, min_share, detail, show_savings, group_income, verbose.
For example, Visualizer(rows, engine="seaborn", palette="colorblind").
Below Visualizer sit vme.io (loading), vme.sankey (rows → graph),
vme.plotting (graph → picture) and vme.currencies; each is usable on its own.
Development
uv sync # or: pip install -e ".[all]" pytest ruff
uv run pytest
uv run ruff check src tests
uv.lock pins the development environment, and CI installs from it with
--locked, so after changing a dependency in pyproject.toml run uv lock and
commit both files. The lock never reaches PyPI — pip install vme-py gets the
version ranges from pyproject.toml — and working from a clone with pip simply
ignores it.
Layout:
src/vme/
models.py Expense, Node, Link, SankeyGraph
currencies.py currency metadata, formatting, rate files
tools.py amount/date/direction parsing, row coercion
io/ one module per input format, self-registering
data_store.py row storage, sign conventions, conversion, validation
sankey.py rows -> graph (folding, savings branch)
plotting.py layout + the matplotlib and plotly backends
engines.py the look an engine gives: palette, fonts, type scale
theme.py light and dark palettes
cli.py the click commands
The colour palettes are checked for colour-blind separation and for contrast against their own background, and every node carries a visible label, so no reading of the chart depends on telling two colours apart.
Releasing
Every pull request is linted, tested (Linux on Python 3.10–3.14, plus macOS and
Windows) and built into a wheel that is installed and run. A merge to master
does all of that again and then publishes to PyPI — but only if the version in
pyproject.toml is not on PyPI yet. So a release is three steps:
- in the pull request, bump the version with
uv version --bump patch(orminor), which updatespyproject.tomlanduv.locktogether — editpyproject.tomlby hand and you needuv lockas well, or CI fails; - describe it in
CHANGELOG.md: a## [x.y.z]section at the top and its release link at the bottom. The tests fail without them, and the PR'sversion checkrun shows the notes as they will be published; - merge it.
CI uploads through PyPI's trusted publishing, so no API token is stored
anywhere; then it tags the commit vX.Y.Z and creates a GitHub release whose
notes are that changelog section, with the built files attached. A merge that
leaves the version alone publishes nothing. PyPI never takes the same version
twice, so a broken release is fixed by the next one.
Licence
MIT — see LICENSE.
Metadata
Release files for vme-py 0.2.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| vme_py-0.2.2.tar.gz | 83.3 kB | With provenance | Details for vme_py-0.2.2.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| vme_py-0.2.2-py3-none-any.whl | Python 3 | none | any | Details for vme_py-0.2.2-py3-none-any.whl |
Total release size: 147.1 kB
Release files / vme_py-0.2.2.tar.gz
| Download URL | vme_py-0.2.2.tar.gz |
|---|---|
| Size | 83.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b70a4b562013a2bcd25c3e650a3a216fa3276785d1843708f620876986669816
|
|
BLAKE2b-256 checksum How to use checksums |
dd3501ef604ff6a55ea43839ef2cd46133cc956d3d7ab41204a40c0ad4800cd7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 29, 2026.
Transparency logRelease files / vme_py-0.2.2-py3-none-any.whl
| Download URL | vme_py-0.2.2-py3-none-any.whl |
|---|---|
| Size | 63.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
53929d52ad2a40c6c5858bef3e5e855205cd6155a5918597a95f6ec769df83b4
|
|
BLAKE2b-256 checksum How to use checksums |
735408152fc322a2130cc382fc401bdf7cb69cb667256b5fc1eec68ad95fbf99
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 29, 2026.
Transparency logSecurity for vme-py 0.2.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for vme_py-0.2.2.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 29, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.