gaebboss.dev

GAEB tenders, bids and orders, read by the book in Rust.

gaebboss reads GAEB DA XML 3.0 to 3.3 in every phase from X80 to X89, validates the bill of quantities, writes schema-valid X83, X84 and X86, and turns a tender into a priced bid. The typed model, the readers, the writers and the per-phase rules are generated from the official gaeb.de schemas; money and quantities are fixed-point. One Rust core behind a CLI and Python bindings. MIT OR Apache-2.0.

$pip install gaebboss$cargo install gaebboss-cli

Prebuilt abi3 wheels for CPython 3.12+ on Linux, macOS and Windows, no toolchain required.

The command line

From tender to bid in three commands.

The gaebboss binary ships info, items, text, json, validate, roundtrip and bid. Below it reads the official BVBS sample tender, prices it from a CSV of unit prices and a TOML file with the bidder, and checks the resulting X84 against the tender it answers.

prices.csv is OZ;unit price; a - marks an item not offered and an optional third column carries the quantity of a free-quantity item. validate exits 1 on errors, so it drops straight into CI.

Command line guide →

~/tenders · gaebboss
The gaebboss CLI: info and items on the official BVBS X83 tender, bid pricing it into an X84, validate reporting 0 errors against the tender, and the bid's total

Python and Rust

The same engine, one import away.

pip install gaebboss gives immutable documents that can be shared between threads. Reading, item walks, validation, writing and bidding run with the GIL released, and every GAEB number arrives as a decimal.Decimal built from its exact text. Floats are refused where money goes in.

Python guide →Rust API →

bid.py
from decimal import Decimal
import gaebboss

tender = gaebboss.Document("tender.X83")  # or Document(data=raw_bytes, name="tender.X83")
print(tender.version, tender.phase, tender.project.name)
for item in tender.items():  # items(kind="counting") for what the total counts
    print(item.oz, item.kind, item.quantity, item.unit, item.short_text)
for warning in tender.warnings:  # every tolerated defect, with its byte offset
    print(warning.code, warning.offset, warning.message)

bidder = {"name1": "Bau GmbH", "street": "Weg 1", "pcode": "12345", "city": "Berlin"}
bid = gaebboss.Bid.from_tender(tender, bidder)
for item in tender.items():
    bid.price(item.id, Decimal("12.50"))
bid.vat_percent(Decimal("19"))
x84 = bid.finish()  # raises on unpriced items
open("bid.X84", "wb").write(x84.write())
print(x84.validate_against(tender))
main.rs
use gaebboss_core::bid::{Bid, Bidder};
use gaebboss_core::decimal::Decimal;
use gaebboss_core::Document;

let tender = Document::open(Path::new("tender.X83"))?;
for item in tender.items() {
    println!("{} {:?} {}", item.oz, item.quantity(), item.short_text());
}

let bidder = Bidder { name1: "Bau GmbH".into(), ..Bidder::default() };
let mut bid = Bid::from_tender(&tender, bidder)?;
for item in tender.items() {
    bid.price(item.id(), Decimal::parse("12.50")?)?;
}
let x84 = bid.finish()?;
std::fs::write("bid.X84", x84.write(&x84.x84_options())?)?;

The rules

What a receiving AVA system would check.

Bid::from_tender applies unit prices, price components, not-offered marks, free quantities, bidder text complements, discount and VAT, and writes the X84 the way the “Regeln für X84” demand it. The worked example of the Fachdokumentation (73.789 × 9.77 = 720.92, 710.11 after 1.5 %) is a unit test.

From tender to bid

  • Project, bill and structure taken over unchanged from the tender.
  • CTR made mandatory; OWN, requester, site and owner texts dropped.
  • Totals on every category and on the bill.
  • No unit price on items not offered, a quantity only on free-quantity items, no item total on items that do not count.
  • Item totals round2(Qty × UP); discounts apply to the rounded total and round again; categories sum rounded items.
  • Answered in the tender's own version: 3.2 and 3.3 in kind, 3.0 and 3.1 in 3.2.

Read more →

Validation

  • Elements a phase does not allow or requires.
  • Duplicate or dangling ids; Ordnungszahlen that are not unique, not ascending or too long.
  • Decimals with more places than the schema gives them.
  • Item totals that do not match quantity times price; category and bill totals that do not match their items.
  • Unit prices on items not offered, quantities on non-free items in an X84.
  • --tender adds the X84 against its X83: items missing or added, ids and structure that differ.

Read more →

Lenient, and it says so

  • BOM, UTF-16, Latin-1 and CP1252 input normalized and repaired.
  • Mismatched tags closed; nesting past 256 levels refused with an error.
  • Unknown vendor elements kept and written back verbatim.
  • Every tolerated defect a Warning with its byte offset.
  • Detected and named, not parsed: GAEB 90, GAEB 2000, DA XML 2.x, ÖNORM.
  • Panics denied by the workspace lints.

Read more →

Benchmarks

The fastest GAEB reader we could measure.

Against pygaeb, the fullest open GAEB reader, and raw lxml over 40 real-world files from a 164-file corpus of third-party exports (22 MB, DA XML 3.0 to 3.3), best of three per file, single-threaded, from the installed Python packages.

700 MB/s

Opening and parsing real files from Python.

210× pygaeb

On the same files, open and parse.

2.6× raw lxml

Which builds no GAEB model at all.

29 ms

The 75 MB X81 in the corpus, read end to end.

Open and parse

40 real-world files · MB/s, higher is better

gaebboss700
lxml (no model)269
pygaeb3.3

The lxml row parses the file and iterates items reading RNoPart, Qty and span text: no Ordnungszahl, no kind, no decimals. It is a floor for any lxml-based reader, not a competitor.

Walk every item

Ordnungszahl, quantity and short text · MB/s

gaebboss387
lxml (no model)204
pygaeb3.3

pygaeb reads every text eagerly, so its two rows coincide: parsing already does the work.

The Rust stages, criterion
StageDense synthetic 10 MB X8375 MB real X81
tokenize2.36 GiB/s15.2 GiB/s
typed parse675 MiB/s2.59 GiB/s
write back497 MiB/s

Measured on an aarch64 workstation (Cortex-X925, 20 cores). Over the whole corpus, cargo xtask corpus reads 189.6 MB at 866 MB/s with zero errors and writes it back at 421 MB/s. The corpus is not redistributable; reproduce on your own files with benchmarks/bench.py.

Generated from the standard

Built from the XSDs, checked against them.

The typed model is one struct per schema type, generated from the 3.3 Lib and extended with what only 3.1 and 3.2 carry. Strings are spans into the input, numbers are Decimal { mantissa, scale }, and every struct keeps the children it does not know as raw nodes that are written back verbatim.

Schema coverage report →

525

elements of DA XML 3.1, 3.2 and 3.3 typed, read and written straight from the vendored XSDs.

164

real-world corpus files plus every official sample read with zero errors; writing what was read gives the same bytes back.

8 / 8

X81 to X84 files written from the official fixtures validate against the official XSDs with lxml.

10

phases, X80 to X89, read losslessly in DA XML 3.0 to 3.3; cargo xtask gen --check fails CI when the code no longer matches the schemas.

Scope: read DA XML 3.0 to 3.3, phases X80 to X89; write X83, X84 and X86 in 3.2 and 3.3 with phase rules. The trade, cost and quantity phases are typed but carry no business rules. The schema files remain the work of GAEB / DIN. Limitations →