pycuf 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycuf-0.1.0/CHANGELOG.md +46 -0
- pycuf-0.1.0/CITATION.cff +30 -0
- pycuf-0.1.0/LICENSE +21 -0
- pycuf-0.1.0/PKG-INFO +461 -0
- pycuf-0.1.0/README.md +410 -0
- pycuf-0.1.0/docs/getting-started.md +145 -0
- pycuf-0.1.0/examples/begroting-ibis.xml +142 -0
- pycuf-0.1.0/examples/begroting.xml +114 -0
- pycuf-0.1.0/pyproject.toml +282 -0
- pycuf-0.1.0/pyproject.toml.orig +196 -0
- pycuf-0.1.0/src/pycuf/__init__.py +93 -0
- pycuf-0.1.0/src/pycuf/__main__.py +34 -0
- pycuf-0.1.0/src/pycuf/_arrow.py +166 -0
- pycuf-0.1.0/src/pycuf/_build.py +596 -0
- pycuf-0.1.0/src/pycuf/_encoding.py +179 -0
- pycuf-0.1.0/src/pycuf/_numeric.py +52 -0
- pycuf-0.1.0/src/pycuf/_optional.py +21 -0
- pycuf-0.1.0/src/pycuf/_source.py +90 -0
- pycuf-0.1.0/src/pycuf/_xml.py +197 -0
- pycuf-0.1.0/src/pycuf/calc.py +398 -0
- pycuf-0.1.0/src/pycuf/cli.py +401 -0
- pycuf-0.1.0/src/pycuf/errors.py +55 -0
- pycuf-0.1.0/src/pycuf/findings.py +255 -0
- pycuf-0.1.0/src/pycuf/models.py +524 -0
- pycuf-0.1.0/src/pycuf/policy.py +222 -0
- pycuf-0.1.0/src/pycuf/py.typed +0 -0
- pycuf-0.1.0/src/pycuf/raw.py +100 -0
- pycuf-0.1.0/src/pycuf/reader.py +403 -0
- pycuf-0.1.0/src/pycuf/spec.py +561 -0
- pycuf-0.1.0/src/pycuf/tables.py +441 -0
- pycuf-0.1.0/src/pycuf/validate.py +272 -0
- pycuf-0.1.0/src/pycuf/values.py +199 -0
- pycuf-0.1.0/tests/conftest.py +34 -0
- pycuf-0.1.0/tests/cufgen.py +457 -0
- pycuf-0.1.0/tests/test_calc.py +241 -0
- pycuf-0.1.0/tests/test_cli.py +94 -0
- pycuf-0.1.0/tests/test_corpus.py +32 -0
- pycuf-0.1.0/tests/test_doc_examples.py +43 -0
- pycuf-0.1.0/tests/test_metadata.py +34 -0
- pycuf-0.1.0/tests/test_policy.py +92 -0
- pycuf-0.1.0/tests/test_properties.py +48 -0
- pycuf-0.1.0/tests/test_reader.py +411 -0
- pycuf-0.1.0/tests/test_readme.py +71 -0
- pycuf-0.1.0/tests/test_security.py +65 -0
- pycuf-0.1.0/tests/test_tables.py +214 -0
- pycuf-0.1.0/tests/test_validate.py +71 -0
- pycuf-0.1.0/tests/test_values.py +125 -0
pycuf-0.1.0/CHANGELOG.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
6
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Until 1.0 the public API may
|
|
7
|
+
change in minor releases. Finding codes are stable: a code is never renumbered or reused.
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [0.1.0] - 2026-10-01
|
|
12
|
+
|
|
13
|
+
Initial release.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Reading CUF-XML 4.000–4.003** with `pycuf.read()` into a typed, English-named model
|
|
18
|
+
(project, sort-code schemes, estimate tree of bundles and lines, resource and quantity
|
|
19
|
+
take-off lines in document order, tail), with the parsed text of every element and attribute
|
|
20
|
+
kept in `raw` and vendor attributes in `extra`.
|
|
21
|
+
- Tolerant reading of real exporter output: Windows-1252 and other declared encodings, UTF-16
|
|
22
|
+
and UTF-32 in either byte order, namespaces that are not valid URIs, empty required
|
|
23
|
+
attributes, decimal commas, d-m-yyyy dates and `true`/`false` booleans (opt-out), Bakker &
|
|
24
|
+
Spees attribute-style sort codes, legacy `SC1`–`SC6` attributes, and opt-in repairs for bare
|
|
25
|
+
ampersands and control characters, applied to decoded characters so they never change valid
|
|
26
|
+
text in any encoding.
|
|
27
|
+
- **Calculation** of every bundle, line and resource line with exact `Decimal` arithmetic in
|
|
28
|
+
pycuf's own decimal context, independent of the caller's (`CufFile.totals()`), under an
|
|
29
|
+
immutable, validated `Policy` with the presets `usage-rules` (default), `schema` and `erp`.
|
|
30
|
+
- **Validation** (`pycuf.validate()`, `CufFile.validate()`) with graded findings and stable
|
|
31
|
+
`CUF<nnnn>` codes: encoding, XML, structure and values, sort codes, stated totals against
|
|
32
|
+
computed totals, resource lines and the contract sum. Finding limits shorten the list but
|
|
33
|
+
never change `ok`, `max_severity` or the exit code (`ValidationReport.severity_counts`).
|
|
34
|
+
- **Normalized tables** exported to CSV and JSONL without dependencies, and to Arrow (PyCapsule
|
|
35
|
+
interface), polars, pandas, DuckDB and Parquet through extras.
|
|
36
|
+
- The `pycuf` command line (`info`, `totals`, `validate`, `export`, `codes`) with the `cli` extra;
|
|
37
|
+
invalid options and unwritable output exit with code 3 and a one-line message.
|
|
38
|
+
- Hardened parsing: DOCTYPE/ENTITY declarations refused, size and depth limits, no recover mode
|
|
39
|
+
(only NUL and Ctrl-Z padding after the document is tolerated), and numbers limited to the XDR
|
|
40
|
+
range (zero or magnitudes from `2.2250738585072014E-308` to `1.7976931348623157E+308`, at most
|
|
41
|
+
1,074 decimals; others are `CUF3018`), so a short attribute cannot make calculations or
|
|
42
|
+
exports enormous.
|
|
43
|
+
- Synthetic sample files in `examples/`.
|
|
44
|
+
|
|
45
|
+
[Unreleased]: https://github.com/SpireflyHQ/pycuf/compare/v0.1.0...HEAD
|
|
46
|
+
[0.1.0]: https://github.com/SpireflyHQ/pycuf/releases/tag/v0.1.0
|
pycuf-0.1.0/CITATION.cff
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "If you use pycuf in research or professional work, please cite it as below."
|
|
3
|
+
type: software
|
|
4
|
+
title: "pycuf: read, check and analyse CUF-XML construction cost estimates"
|
|
5
|
+
abstract: >-
|
|
6
|
+
pycuf is a Python library that reads CUF-XML (Calculatie Uitwissel Formaat), the Dutch
|
|
7
|
+
exchange format for construction cost estimates, into a typed model. It computes costs with
|
|
8
|
+
exact decimals under explicit, configurable interpretation policies, reports structural and
|
|
9
|
+
calculation problems as graded, coded findings instead of refusing to load a file, and exports
|
|
10
|
+
normalized tables to CSV, JSONL, Arrow, polars, pandas and Parquet. The core has no
|
|
11
|
+
dependencies outside the Python standard library.
|
|
12
|
+
authors:
|
|
13
|
+
- family-names: "van der Burgh"
|
|
14
|
+
given-names: "Ben"
|
|
15
|
+
email: "ben@spirefly.com"
|
|
16
|
+
affiliation: "Spirefly"
|
|
17
|
+
repository-code: "https://github.com/SpireflyHQ/pycuf"
|
|
18
|
+
url: "https://spireflyhq.github.io/pycuf/"
|
|
19
|
+
license: MIT
|
|
20
|
+
version: "0.1.0"
|
|
21
|
+
date-released: "2026-10-01"
|
|
22
|
+
keywords:
|
|
23
|
+
- CUF
|
|
24
|
+
- CUF-XML
|
|
25
|
+
- "Calculatie Uitwissel Formaat"
|
|
26
|
+
- "cost estimate"
|
|
27
|
+
- construction
|
|
28
|
+
- "quantity surveying"
|
|
29
|
+
- XML
|
|
30
|
+
- Python
|
pycuf-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ben van der Burgh and the pycuf contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pycuf-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pycuf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read, check and analyse CUF-XML construction cost estimates (Calculatie Uitwissel Formaat) in Python.
|
|
5
|
+
Keywords: cuf,cuf-xml,calculatie uitwissel formaat,begroting,cost estimate,construction,bouw,quantity surveying,ketenstandaard,xml,validation,cli
|
|
6
|
+
Author: Ben van der Burgh
|
|
7
|
+
Author-email: Ben van der Burgh <ben@spirefly.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Other Audience
|
|
13
|
+
Classifier: Natural Language :: English
|
|
14
|
+
Classifier: Natural Language :: Dutch
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Office/Business
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering
|
|
24
|
+
Classifier: Topic :: Text Processing :: Markup :: XML
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Dist: pycuf[arrow,polars,pandas,parquet,cli] ; extra == 'all'
|
|
27
|
+
Requires-Dist: nanoarrow>=0.7 ; extra == 'arrow'
|
|
28
|
+
Requires-Dist: typer>=0.27.2 ; extra == 'cli'
|
|
29
|
+
Requires-Dist: pandas>=2.2 ; extra == 'pandas'
|
|
30
|
+
Requires-Dist: pyarrow>=17 ; extra == 'pandas'
|
|
31
|
+
Requires-Dist: nanoarrow>=0.7 ; extra == 'pandas'
|
|
32
|
+
Requires-Dist: pyarrow>=17 ; extra == 'parquet'
|
|
33
|
+
Requires-Dist: nanoarrow>=0.7 ; extra == 'parquet'
|
|
34
|
+
Requires-Dist: polars>=1.44.1 ; extra == 'polars'
|
|
35
|
+
Requires-Dist: nanoarrow>=0.7 ; extra == 'polars'
|
|
36
|
+
Maintainer: Ben van der Burgh
|
|
37
|
+
Maintainer-email: Ben van der Burgh <ben@spirefly.com>
|
|
38
|
+
Requires-Python: >=3.11
|
|
39
|
+
Project-URL: Homepage, https://github.com/SpireflyHQ/pycuf
|
|
40
|
+
Project-URL: Documentation, https://spireflyhq.github.io/pycuf/
|
|
41
|
+
Project-URL: Repository, https://github.com/SpireflyHQ/pycuf
|
|
42
|
+
Project-URL: Issues, https://github.com/SpireflyHQ/pycuf/issues
|
|
43
|
+
Project-URL: Changelog, https://github.com/SpireflyHQ/pycuf/blob/main/CHANGELOG.md
|
|
44
|
+
Provides-Extra: all
|
|
45
|
+
Provides-Extra: arrow
|
|
46
|
+
Provides-Extra: cli
|
|
47
|
+
Provides-Extra: pandas
|
|
48
|
+
Provides-Extra: parquet
|
|
49
|
+
Provides-Extra: polars
|
|
50
|
+
Description-Content-Type: text/markdown
|
|
51
|
+
|
|
52
|
+
# 🧱 pycuf
|
|
53
|
+
|
|
54
|
+
[](https://github.com/SpireflyHQ/pycuf/actions/workflows/ci.yml)
|
|
55
|
+
[](https://pypi.org/project/pycuf/)
|
|
56
|
+
[](https://pypi.org/project/pycuf/)
|
|
57
|
+
[](https://github.com/SpireflyHQ/pycuf/blob/main/LICENSE)
|
|
58
|
+
|
|
59
|
+
**Read, check and analyse CUF-XML construction cost estimates in plain Python.**
|
|
60
|
+
|
|
61
|
+
CUF-XML (*Calculatie Uitwissel Formaat*, "estimate exchange format") is how Dutch construction
|
|
62
|
+
software passes a cost estimate, a *begroting*, from one program to the next: from estimating
|
|
63
|
+
packages such as Ibis, Kraan, Bakker & Spees CIVIEL and Matrix to ERP systems such as AFAS, Exact
|
|
64
|
+
and 4PS. pycuf reads CUF-XML 4.003 files into one clean, typed model with English names,
|
|
65
|
+
computes their costs with exact decimals, tells you precisely what is wrong with a file instead of
|
|
66
|
+
refusing it, and turns it into tables for your spreadsheet, dataframe or database. No runtime
|
|
67
|
+
dependencies, no upload to anyone's server.
|
|
68
|
+
|
|
69
|
+
## 🧭 Table of contents
|
|
70
|
+
|
|
71
|
+
- [Why pycuf](#-why-pycuf)
|
|
72
|
+
- [Installation](#-installation)
|
|
73
|
+
- [Quickstart](#-quickstart)
|
|
74
|
+
- [1. Read the file](#1-read-the-file)
|
|
75
|
+
- [2. Compute the totals](#2-compute-the-totals)
|
|
76
|
+
- [3. Validate it](#3-validate-it)
|
|
77
|
+
- [4. Or use the command line](#4-or-use-the-command-line)
|
|
78
|
+
- [More examples](#-more-examples)
|
|
79
|
+
- [Choose how to read the rules](#choose-how-to-read-the-rules)
|
|
80
|
+
- [Walk the tree](#walk-the-tree)
|
|
81
|
+
- [Export to tables](#export-to-tables)
|
|
82
|
+
- [Query with DuckDB, polars or pandas](#query-with-duckdb-polars-or-pandas)
|
|
83
|
+
- [Files that need a little help](#files-that-need-a-little-help)
|
|
84
|
+
- [What is CUF-XML?](#-what-is-cuf-xml)
|
|
85
|
+
- [Documentation](#-documentation)
|
|
86
|
+
- [Contributing](#-contributing)
|
|
87
|
+
- [License and attribution](#-license-and-attribution)
|
|
88
|
+
|
|
89
|
+
## ✨ Why pycuf
|
|
90
|
+
|
|
91
|
+
A begroting leaves Ibis as Windows-1252 with an empty `BTW` on every line. The official example
|
|
92
|
+
file declares a namespace with spaces in it. Another exporter writes dates as `3-5-2024`, and the
|
|
93
|
+
totals at the top of a file often do not match the lines underneath. The specification itself
|
|
94
|
+
comes in two texts that disagree. pycuf was built for exactly these files.
|
|
95
|
+
|
|
96
|
+
- **One typed model, in English.** `BEGROTINGSREGEL` becomes `Line`, `HOEVEELHEID` becomes
|
|
97
|
+
`quantity`, `MAMO_REGEL` becomes `ResourceLine`. The raw layer keeps every element and
|
|
98
|
+
attribute as parsed, vendor extensions included.
|
|
99
|
+
- **Lenient reading, honest reporting.** pycuf does not refuse a file because of bad data. Every
|
|
100
|
+
problem becomes a graded finding with a stable code, such as `CUF5001 WARNING`, so nothing is
|
|
101
|
+
silently guessed or dropped.
|
|
102
|
+
- **Exact money.** Every number is a `decimal.Decimal`, never a `float`, and costs are computed at
|
|
103
|
+
60 significant digits.
|
|
104
|
+
- **Explicit calculation rules.** Where CUF-XML is ambiguous, a *policy* makes the choice
|
|
105
|
+
visible, with presets for the official usage rules, the literal schema and ERP practice.
|
|
106
|
+
- **Checks the totals for you.** pycuf recomputes every bundle and the estimate from the lines
|
|
107
|
+
and compares the result with the stated totals, within a tolerance you choose.
|
|
108
|
+
- **Tables everywhere.** Nine normalized tables, written to CSV or JSONL with the standard library,
|
|
109
|
+
or handed to Arrow, polars, pandas, DuckDB and Parquet.
|
|
110
|
+
- **Lightweight and safe.** Pure, fully typed Python with zero runtime dependencies. No DTDs, no
|
|
111
|
+
entity expansion, no network access, and limits on size and depth.
|
|
112
|
+
|
|
113
|
+
## 📦 Installation
|
|
114
|
+
|
|
115
|
+
pycuf needs Python 3.11 or newer.
|
|
116
|
+
|
|
117
|
+
```console
|
|
118
|
+
pip install pycuf
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The core has no dependencies. Add an extra for each optional feature you need:
|
|
122
|
+
|
|
123
|
+
| Extra | Adds | Install |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| `cli` | the `pycuf` command line | `pip install "pycuf[cli]"` |
|
|
126
|
+
| `arrow` | Arrow streams for DuckDB, pyarrow and friends (nanoarrow) | `pip install "pycuf[arrow]"` |
|
|
127
|
+
| `polars` | `to_polars()`, without pyarrow | `pip install "pycuf[polars]"` |
|
|
128
|
+
| `pandas` | `to_pandas()` with Arrow-backed types | `pip install "pycuf[pandas]"` |
|
|
129
|
+
| `parquet` | Parquet export | `pip install "pycuf[parquet]"` |
|
|
130
|
+
| `all` | everything above | `pip install "pycuf[all]"` |
|
|
131
|
+
|
|
132
|
+
Using uv? `uv add pycuf` works the same way, for example `uv add "pycuf[cli,polars]"`.
|
|
133
|
+
|
|
134
|
+
## 🚀 Quickstart
|
|
135
|
+
|
|
136
|
+
Download the two sample files into an empty folder. They hold the same small, made-up estimate
|
|
137
|
+
for a housing project: `begroting.xml` is a tidy file, and `begroting-ibis.xml` is written the way
|
|
138
|
+
Ibis writes it, with one extra surprise in its totals.
|
|
139
|
+
|
|
140
|
+
```console
|
|
141
|
+
curl -L -O https://raw.githubusercontent.com/SpireflyHQ/pycuf/main/examples/begroting.xml \
|
|
142
|
+
-O https://raw.githubusercontent.com/SpireflyHQ/pycuf/main/examples/begroting-ibis.xml
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Your own exports work exactly the same way.
|
|
146
|
+
|
|
147
|
+
### 1. Read the file
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
import pycuf
|
|
151
|
+
|
|
152
|
+
cuf = pycuf.read("begroting.xml")
|
|
153
|
+
print(cuf.project.number, cuf.project.name)
|
|
154
|
+
print(
|
|
155
|
+
len(cuf.bundles),
|
|
156
|
+
"bundles,",
|
|
157
|
+
len(cuf.lines),
|
|
158
|
+
"lines,",
|
|
159
|
+
len(cuf.resource_lines),
|
|
160
|
+
"resource lines",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
for line in cuf.lines[:3]:
|
|
164
|
+
print(line.code, line.description, line.quantity, line.unit, line.material_price)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
```text
|
|
168
|
+
2026-001 Voorbeeldproject Woningbouw
|
|
169
|
+
9 bundles, 12 lines, 42 resource lines
|
|
170
|
+
000003 wapening 3 326.836 kg 239.38
|
|
171
|
+
000004 stucwerk 4 360.23 m2 68.88
|
|
172
|
+
000006 riolering 6 163.004 kg 119.12
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The Dutch names stay in the file; in Python you get English ones (`HOEVEELHEID` is `quantity`,
|
|
176
|
+
`MATERIAALPRIJS` is `material_price`). The Ibis file opens just as easily, Windows-1252 and all:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
ibis = pycuf.read("begroting-ibis.xml")
|
|
180
|
+
print(ibis.encoding.effective, ibis.project.software_house, ibis.project.name)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
```text
|
|
184
|
+
cp1252 BRINK Voorbeeldproject Woningbouw – €
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### 2. Compute the totals
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
totals = cuf.totals()
|
|
191
|
+
print(totals.estimate.total)
|
|
192
|
+
print(totals.estimate.hours, "hours of labour")
|
|
193
|
+
|
|
194
|
+
bundle = cuf.bundles[0]
|
|
195
|
+
print(bundle.code, bundle.description, totals[bundle].total)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
```text
|
|
199
|
+
769240.4340286860
|
|
200
|
+
5120.38296190 hours of labour
|
|
201
|
+
1 beton (element 1) 360184.72901480
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Every amount is an exact `Decimal`, computed from quantities, hour norms, rates and prices with
|
|
205
|
+
the formulas from the CUF-XML specification. The total is the direct cost of the work, before
|
|
206
|
+
markups and VAT.
|
|
207
|
+
|
|
208
|
+
### 3. Validate it
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
report = pycuf.validate("begroting-ibis.xml")
|
|
212
|
+
print(report.ok, len(report.findings), "findings")
|
|
213
|
+
for finding in report.findings[:4]:
|
|
214
|
+
print(finding.code, finding.severity, finding.line, finding.message)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Here is the surprise: in the Ibis file, every bundle claims €100 more labour than its lines add
|
|
218
|
+
up to. pycuf recomputes each one and catches all of them, and it also notices the empty `BTW`
|
|
219
|
+
(VAT) attribute that Ibis leaves on every line:
|
|
220
|
+
|
|
221
|
+
```text
|
|
222
|
+
True 12 findings
|
|
223
|
+
CUF5002 WARNING 6 BEGROTING LOONKOSTEN is 301077.313035186, computed 300977.313035186 (difference 100)
|
|
224
|
+
CUF5001 WARNING 7 BUNDELING '1' LOONKOSTEN is 158710.5947748, computed 158610.5947748 (difference 100)
|
|
225
|
+
CUF3017 WARNING 8 BEGROTINGSREGEL has an empty value for the required attribute BTW (21 times; first at line 8)
|
|
226
|
+
CUF5001 WARNING 11 BUNDELING '1.1' LOONKOSTEN is 100932.4429188, computed 100832.4429188 (difference 100)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`report.ok` is still `True`: wrong control totals are warnings, not errors, because the estimate
|
|
230
|
+
lines are what counts. Every finding has a stable code, a severity (`ERROR`, `WARNING` or `INFO`)
|
|
231
|
+
and, where possible, a line number. The
|
|
232
|
+
[finding-code reference](https://spireflyhq.github.io/pycuf/reference/codes/) explains each one.
|
|
233
|
+
|
|
234
|
+
### 4. Or use the command line
|
|
235
|
+
|
|
236
|
+
With `pycuf[cli]` installed:
|
|
237
|
+
|
|
238
|
+
```console
|
|
239
|
+
$ pycuf validate begroting.xml
|
|
240
|
+
begroting.xml: 0 error(s), 0 warning(s), 1 info
|
|
241
|
+
line 3:2 INFO CUF3001 CUF_VERSIE is 4.003 ['4.003']
|
|
242
|
+
|
|
243
|
+
$ pycuf info begroting-ibis.xml
|
|
244
|
+
file begroting-ibis.xml
|
|
245
|
+
cuf version 4.003
|
|
246
|
+
software house BRINK
|
|
247
|
+
created 2026-10-01T10:15:00
|
|
248
|
+
encoding cp1252
|
|
249
|
+
project 2026-001 – Voorbeeldproject Woningbouw – €
|
|
250
|
+
...
|
|
251
|
+
|
|
252
|
+
$ pycuf totals begroting-ibis.xml --depth 1
|
|
253
|
+
begroting-ibis.xml (traditional estimate, policy usage-rules)
|
|
254
|
+
bundle total labour material subcontr.
|
|
255
|
+
----------------------------------------------------------------------------------------------------
|
|
256
|
+
1 beton (element 1) 360,184.73 158,610.59 149,219.78 26,527.98 ≠ stated by +100.00
|
|
257
|
+
2 tegelwerk (element 2) 278,790.21 106,385.24 161,213.89 0.00 ≠ stated by +100.00
|
|
258
|
+
3 voegwerk (element 3) 130,265.49 35,981.48 84,665.94 3,029.15 ≠ stated by +100.00
|
|
259
|
+
----------------------------------------------------------------------------------------------------
|
|
260
|
+
BEGROTING 769,240.43 300,977.31 395,099.61 29,557.13 ≠ stated by +100.00
|
|
261
|
+
hours 5120.38
|
|
262
|
+
contract sum (stated, excl. VAT) 838,472.07
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`pycuf validate` exits with 0 when the file is fine, 1 for warnings (with `--strict`), 2 for
|
|
266
|
+
errors and 3 when pycuf itself could not do its job, so it slots straight into scripts and CI.
|
|
267
|
+
`pycuf export` writes the tables, and `pycuf codes` lists every finding code.
|
|
268
|
+
|
|
269
|
+
## 🧰 More examples
|
|
270
|
+
|
|
271
|
+
### Choose how to read the rules
|
|
272
|
+
|
|
273
|
+
The CUF-XML schema and the official usage rules disagree in a few places. Take a line with
|
|
274
|
+
`HOEVEELHEID_FACTOR="0"`: the usage rules say a factor of 0 counts as 1, the literal schema says
|
|
275
|
+
0 is 0. pycuf follows the usage rules by default and lets you pick:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
toy = b"""<?xml version="1.0" encoding="UTF-8"?>
|
|
279
|
+
<CUF AANMAAKDATUMTIJD="2026-10-01T10:15:00">
|
|
280
|
+
<PROJECTGEGEVENS CUF_VERSIE="4.003" PROJECTNUMMER="1" PROJECTNAAM="Schuurtje"/>
|
|
281
|
+
<BEGROTING>
|
|
282
|
+
<BEGROTINGSREGEL OMSCHRIJVING="metselwerk" HOEVEELHEID="10" HOEVEELHEID_FACTOR="0" MATERIAALPRIJS="50" BTW="21"/>
|
|
283
|
+
</BEGROTING>
|
|
284
|
+
</CUF>"""
|
|
285
|
+
|
|
286
|
+
shed = pycuf.read(toy)
|
|
287
|
+
print(shed.totals().estimate.total) # usage rules: a factor of 0 counts as 1
|
|
288
|
+
print(shed.totals(policy="schema").estimate.total) # the literal schema: 0 is 0
|
|
289
|
+
print(shed.findings[0])
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
```text
|
|
293
|
+
500
|
|
294
|
+
0
|
|
295
|
+
line 2:0 ERROR CUF3010 CUF lacks the required element STAARTGEGEVENS
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The toy file is far from complete, and pycuf still reads it, while listing everything it lacks.
|
|
299
|
+
The presets are `"usage-rules"` (the default), `"schema"` and `"erp"`; a
|
|
300
|
+
[`Policy`](https://spireflyhq.github.io/pycuf/guide/calculation/#policies) can change any single
|
|
301
|
+
choice, such as the tolerance for checking stated totals.
|
|
302
|
+
|
|
303
|
+
### Walk the tree
|
|
304
|
+
|
|
305
|
+
Bundles nest to any depth, and sort codes on a bundle apply to everything beneath it:
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
line = cuf.lines[0]
|
|
309
|
+
print([b.code for b in cuf.ancestors(line)], cuf.sort_codes(line))
|
|
310
|
+
print(totals.extended(line).total) # the line's share of the estimate, multipliers applied
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
```text
|
|
314
|
+
['1', '1.1'] {'PLANCODE': '200-10'}
|
|
315
|
+
132655.65640060
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### Export to tables
|
|
319
|
+
|
|
320
|
+
pycuf turns a file into nine normalized tables, such as `bundles`, `lines` and
|
|
321
|
+
`resource_lines`, with fixed columns and the computed costs next to the stated ones:
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
cuf.export("out/") # one CSV file per table, standard library only; also format="jsonl"
|
|
325
|
+
cuf.export("out/", format="parquet") # pycuf[parquet]
|
|
326
|
+
frames = cuf.to_polars() # pycuf[polars]: a dict of DataFrames
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Numbers are written as exact strings (`3612.16`, never `3612.1600000001`) and dates in ISO 8601.
|
|
330
|
+
|
|
331
|
+
### Query with DuckDB, polars or pandas
|
|
332
|
+
|
|
333
|
+
Tables speak the Arrow PyCapsule interface, so Arrow-aware tools read them directly, without
|
|
334
|
+
pyarrow (needs `pycuf[arrow]`):
|
|
335
|
+
|
|
336
|
+
```python
|
|
337
|
+
import duckdb
|
|
338
|
+
|
|
339
|
+
lines = cuf.tables["lines"]
|
|
340
|
+
duckdb.sql("""
|
|
341
|
+
select path, count(*) as lines, round(sum(total), 2) as total
|
|
342
|
+
from lines group by path order by path
|
|
343
|
+
""").show()
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
```text
|
|
347
|
+
┌─────────┬───────┬───────────────┐
|
|
348
|
+
│ path │ lines │ total │
|
|
349
|
+
│ varchar │ int64 │ decimal(38,2) │
|
|
350
|
+
├─────────┼───────┼───────────────┤
|
|
351
|
+
│ 1 / 1.1 │ 2 │ 219087.20 │
|
|
352
|
+
│ 1 / 1.2 │ 2 │ 141097.53 │
|
|
353
|
+
│ 2 / 2.1 │ 2 │ 139688.14 │
|
|
354
|
+
│ 2 / 2.2 │ 2 │ 139102.07 │
|
|
355
|
+
│ 3 / 3.1 │ 2 │ 49022.04 │
|
|
356
|
+
│ 3 / 3.2 │ 2 │ 81243.45 │
|
|
357
|
+
└─────────┴───────┴───────────────┘
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
`pl.DataFrame(lines)` works the same way in polars, and `cuf.to_pandas()` (needs
|
|
361
|
+
`pycuf[pandas]`) gives a dict of pandas DataFrames whose decimal columns stay exact.
|
|
362
|
+
|
|
363
|
+
### Files that need a little help
|
|
364
|
+
|
|
365
|
+
```python
|
|
366
|
+
# no XML declaration, but really Windows-1252
|
|
367
|
+
cuf = pycuf.read("old.xml", encoding="cp1252")
|
|
368
|
+
|
|
369
|
+
# a bare "&" in SYSTEEMHUIS="Bakker & Spees", or stray control characters
|
|
370
|
+
cuf = pycuf.read("export.xml", repair={"bare-ampersand", "control-chars"})
|
|
371
|
+
|
|
372
|
+
# strict parsing: no decimal commas, no 3-5-2024 dates, no true/false booleans
|
|
373
|
+
cuf = pycuf.read("begroting.xml", lenient=())
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Repairs are opt-in and every repair shows up as a finding, so you always know what was changed.
|
|
377
|
+
|
|
378
|
+
<details>
|
|
379
|
+
<summary><b>Go deeper: the raw layer and vendor attributes</b></summary>
|
|
380
|
+
|
|
381
|
+
```python
|
|
382
|
+
line = ibis.lines[0]
|
|
383
|
+
line.raw.get("BTW") # '' – the attribute's text, as parsed
|
|
384
|
+
line.raw.line, line.raw.path # where it sits in the file
|
|
385
|
+
line.extra # attributes that CUF-XML 4.003 does not define, such as vendor extensions
|
|
386
|
+
ibis.raw.iter("MAMO_REGEL") # walk the raw tree yourself
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
</details>
|
|
390
|
+
|
|
391
|
+
## 📐 What is CUF-XML?
|
|
392
|
+
|
|
393
|
+
CUF was created by Forum Systeemhuizen Bouw, an association of Dutch construction software
|
|
394
|
+
vendors. Version 4.000 moved it to XML, and 4.003, with its usage rules of 2006, is still the
|
|
395
|
+
latest version. Since the end of 2021 it has been maintained, as-is, by
|
|
396
|
+
[Ketenstandaard Bouw en Techniek](https://ketenstandaard.nl/cuf-xml).
|
|
397
|
+
|
|
398
|
+
A CUF-XML file is a tree of Dutch elements. These are the ones you will meet most:
|
|
399
|
+
|
|
400
|
+
| CUF-XML | In English | In pycuf |
|
|
401
|
+
|---|---|---|
|
|
402
|
+
| `BEGROTING` | the cost estimate | `Estimate` |
|
|
403
|
+
| `BUNDELING` | a bundle of lines, such as a building element | `Bundle` |
|
|
404
|
+
| `BEGROTINGSREGEL` | an estimate line: quantity, hour norm, unit prices | `Line` |
|
|
405
|
+
| `MAMO_REGEL` | a resource line that breaks one cost type down | `ResourceLine` |
|
|
406
|
+
| `SORTEERCODE` | a sort code, such as a planning or NL-SfB code | `SortCode` |
|
|
407
|
+
| `STAARTGEGEVENS` | the tail: markups and the contract sum (`AANNEEMSOM`) | `Tail` |
|
|
408
|
+
|
|
409
|
+
pycuf reads CUF-XML 4.000 to 4.003 and knows the dialects of Ibis, Bakker & Spees, Dataviewers,
|
|
410
|
+
the Forum's own example file and others. The
|
|
411
|
+
[format guide](https://spireflyhq.github.io/pycuf/guide/format/) explains the differences, and
|
|
412
|
+
the [glossary](https://spireflyhq.github.io/pycuf/reference/glossary/) maps every Dutch element
|
|
413
|
+
and attribute to its English name.
|
|
414
|
+
|
|
415
|
+
## 📚 Documentation
|
|
416
|
+
|
|
417
|
+
The full documentation lives at **<https://spireflyhq.github.io/pycuf/>**:
|
|
418
|
+
|
|
419
|
+
- [Reading CUF files](https://spireflyhq.github.io/pycuf/guide/reading/): the data model, the
|
|
420
|
+
raw layer, encodings and lenient parsing
|
|
421
|
+
- [Calculating](https://spireflyhq.github.io/pycuf/guide/calculation/): the formulas,
|
|
422
|
+
multipliers, resource lines and policies
|
|
423
|
+
- [Validation](https://spireflyhq.github.io/pycuf/guide/validation/) and the
|
|
424
|
+
[finding codes](https://spireflyhq.github.io/pycuf/reference/codes/)
|
|
425
|
+
- [Interoperability](https://spireflyhq.github.io/pycuf/guide/interop/), the
|
|
426
|
+
[table schemas](https://spireflyhq.github.io/pycuf/reference/tables/) and the
|
|
427
|
+
[command line](https://spireflyhq.github.io/pycuf/reference/cli/)
|
|
428
|
+
- [Security](https://spireflyhq.github.io/pycuf/security/) and
|
|
429
|
+
[versioning](https://spireflyhq.github.io/pycuf/versioning/)
|
|
430
|
+
|
|
431
|
+
Release notes are in the [changelog](https://github.com/SpireflyHQ/pycuf/blob/main/CHANGELOG.md).
|
|
432
|
+
For questions, see [SUPPORT.md](https://github.com/SpireflyHQ/pycuf/blob/main/SUPPORT.md).
|
|
433
|
+
|
|
434
|
+
## 🤝 Contributing
|
|
435
|
+
|
|
436
|
+
Contributions are very welcome, especially reports of files from software that pycuf does not
|
|
437
|
+
handle well yet: use the
|
|
438
|
+
[file compatibility report](https://github.com/SpireflyHQ/pycuf/issues/new?template=file_compatibility.yml).
|
|
439
|
+
Start with [CONTRIBUTING.md](https://github.com/SpireflyHQ/pycuf/blob/main/CONTRIBUTING.md) for
|
|
440
|
+
the development setup, and [ARCHITECTURE.md](https://github.com/SpireflyHQ/pycuf/blob/main/ARCHITECTURE.md)
|
|
441
|
+
for a map of the code.
|
|
442
|
+
|
|
443
|
+
> **Never attach a real begroting** to an issue or pull request. Estimates contain confidential
|
|
444
|
+
> prices and names. The exporting software and its version, the finding codes and a small,
|
|
445
|
+
> anonymised snippet are all we need.
|
|
446
|
+
|
|
447
|
+
Security problems are reported privately, as described in
|
|
448
|
+
[SECURITY.md](https://github.com/SpireflyHQ/pycuf/blob/main/SECURITY.md).
|
|
449
|
+
|
|
450
|
+
## 📄 License and attribution
|
|
451
|
+
|
|
452
|
+
pycuf is released under the [MIT license](https://github.com/SpireflyHQ/pycuf/blob/main/LICENSE).
|
|
453
|
+
If you use it in research or professional work, you can cite it with
|
|
454
|
+
[CITATION.cff](https://github.com/SpireflyHQ/pycuf/blob/main/CITATION.cff).
|
|
455
|
+
|
|
456
|
+
The CUF-XML specification documents are not bundled; pycuf describes the format in its own
|
|
457
|
+
words. The sample files are synthetic: every project, price and name in them is made up.
|
|
458
|
+
|
|
459
|
+
pycuf is an independent open-source project. It is not affiliated with or endorsed by
|
|
460
|
+
Ketenstandaard Bouw en Techniek, Forum Systeemhuizen Bouw or any software vendor, and a clean
|
|
461
|
+
pycuf report does not guarantee that another program will import a file the way you expect.
|