Skip to main content

A linter for LibreOffice Calc spreadsheets (.ods and flat .fods)

Project description

odslint

A linter for LibreOffice Calc spreadsheets. It reads a .ods or flat .fods file, builds a document model, runs rules over it, and reports findings anchored to a sheet and cell — the spreadsheet equivalent of ruff or eslint.

Spreadsheets are software, but nothing checks them. odslint looks for the things that make a sheet unmaintainable or quietly wrong: formulas built from bare cell addresses, hardcoded constants, a fill that one cell breaks out of, numbers that were imported as text.

$ odslint budget.ods
budget.ods:Model!D1: warning [formula/prefer-named-range] references Model!B1 directly, but the named expression 'TaxRate' already covers exactly that range
    hint: replace [.$B$1] with TaxRate
budget.ods:Totals!D4: error [formula/inconsistent-in-range] formula '=SUM([.A4:.B4])' breaks the pattern of D2:D6 (4 of 5 cells share one shape)
    hint: the block otherwise reads like '=SUM([.A2:.C2])' in D2
2 problems (1 error, 1 warning)

Install

uv tool install git+https://github.com/fwilhe2/odslint      # odslint on your PATH
uvx --from git+https://github.com/fwilhe2/odslint odslint   # one-off, nothing installed

uv sync          # development
uv run odslint --help

Both entry points, odslint and odslint-clean, come with the install.

Rules

Rule Default What it catches
formula/prefer-named-range warning Addresses where a name exists, and absolute/cross-sheet references to constants that deserve one
formula/inconsistent-in-range error A formula that breaks the R1C1 pattern of the block around it — the classic copy-paste bug
formula/magic-number warning Numeric literals like *1.19 that belong in a labelled input cell
data/number-stored-as-text error String cells that read as numbers or dates, which silently drop out of SUM

odslint --list-rules prints the live list.

Configuration

.odslintrc.toml, discovered upward from the file being linted:

[odslint]
fail-on = "warning"          # error | warning | info | never

[rules."formula/magic-number"]
severity = "info"            # or "off" to disable the rule
allowed = [0, 1, -1, 100]

[rules."formula/inconsistent-in-range"]
min_run = 4

Unknown rule ids and unknown option names are errors, so a typo fails loudly instead of silently doing nothing.

Suppressing a finding

A spreadsheet has no comment syntax, so directives live in cell annotations (Insert > Comment). An annotation on the offending cell containing:

odslint-disable
odslint-disable formula/magic-number
odslint-disable formula/magic-number, data/number-stored-as-text

suppresses all, or the listed, rules for that cell. The rest of the annotation is ignored, so the directive can sit next to a real note explaining why.

Exit codes

0 clean · 1 findings at or above fail-on · 2 tool error (unreadable file, bad config).

Keeping .fods files in git

A flat .fods is text, so it can live in version control like source — except that LibreOffice rewrites a great deal on every save that has nothing to do with what you changed. It renumbers its internal styles (ce1 becomes ce24), emits a default set of number formats nothing uses, re-declares three dozen namespaces on every style element, bumps meta:editing-cycles, and re-renders cached bitmaps for embedded charts. Edit one cell, commit a four-thousand-line diff.

odslint-clean strips all of that, in place:

$ odslint-clean model.fods
cleaned model.fods

$ odslint-clean --check *.fods      # exit 1 if anything would change; writes nothing

It also reformats start tags to one attribute per line, so changing an attribute shows up as a one-line diff. Running it twice changes nothing the second time, which makes it usable as a pre-commit hook:

- repo: local
  hooks:
    - id: odslint-clean
      name: normalize flat ODF
      entry: odslint-clean
      language: system
      files: \.fods$

Two caveats. This is the one part of odslint that writes to your files, and it is lossy on purpose: unused styles, office:settings, office:scripts, volatile metadata and cached replacement images are all dropped. That is right for a document whose source of truth is git, and wrong for one you keep only as a binary. It works on flat .fods only — a .ods package is a ZIP and has nothing useful to diff.

The cleanup itself is not ours — it is LibreOffice's own flat-odf-cleanup.py, vendored under the MPL-2.0 (see Third-party code); odslint.cleanup is a typed wrapper around it.

Scope

Linting reasons statically over the stored document. It does not recalculate formulas, and it never writes to your files — odslint-clean is a separate command precisely because it does.

Third-party code

One file in this repository was not written for it and is not under its license.

src/odslint/vendor/flat_odf_cleanup.py

License Mozilla Public License 2.0 — full text in LICENSES/MPL-2.0.txt
Copyright The LibreOffice contributors, per the notice in the file's header
Origin bin/flat-odf-cleanup.py in the LibreOffice core repository
Taken from scripts/flat-odf-cleanup.py in fwilhe2/office-in-git, at commit 46c8770 (2026-07-21), which carries LibreOffice's script plus several additions
Modifications None. Byte-identical to the source above, header and license notice intact.
Used by src/odslint/cleanup.py, which drives it

It is kept verbatim so it can be resynced with a plain curl, which is also why it is excluded from ruff and mypy in pyproject.toml:

$ curl -o src/odslint/vendor/flat_odf_cleanup.py \
    https://raw.githubusercontent.com/fwilhe2/office-in-git/main/scripts/flat-odf-cleanup.py
$ uv run pytest tests/test_cleanup.py

If you ever do need to change it, the MPL is a per-file copyleft: the modified file stays MPL-2.0 and the change has to be published under the same license. Prefer sending the fix upstream and re-vendoring.

Nothing else here is third-party. The only runtime dependency is lxml (BSD-3-Clause), which is installed normally rather than vendored.

License

MIT — see LICENSE. The one exception is the vendored file described under Third-party code, which remains under the MPL-2.0. The MPL and the MIT license are compatible; distributing the two together, as this project does, is exactly the case the MPL's file-scoped copyleft is designed for.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

odslint-0.1.0.tar.gz (94.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

odslint-0.1.0-py3-none-any.whl (54.7 kB view details)

Uploaded Python 3

File details

Details for the file odslint-0.1.0.tar.gz.

File metadata

  • Download URL: odslint-0.1.0.tar.gz
  • Upload date:
  • Size: 94.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for odslint-0.1.0.tar.gz
Algorithm Hash digest
SHA256 abd3b213efd56f701bda187a2d45500d32981be80fb64e37333d6b8e06086b43
MD5 00ef3a49b0709ce0c9d53d1778b4f1b1
BLAKE2b-256 241feb9821aaddabc5647a3accf1c3700354a6771515db29dec175c64a00e203

See more details on using hashes here.

File details

Details for the file odslint-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: odslint-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 54.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for odslint-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 dbd75a9c0b6498afbd867c075e1e18b7248d85df5322b172f32f03bd1a904b37
MD5 bf71cd56189566ede835ef909e20399b
BLAKE2b-256 129ae46e2ae67ee40c6e07105357effe30c8d0878cb14ec03275266c085edf25

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page