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.
Files changed (47) hide show
  1. pycuf-0.1.0/CHANGELOG.md +46 -0
  2. pycuf-0.1.0/CITATION.cff +30 -0
  3. pycuf-0.1.0/LICENSE +21 -0
  4. pycuf-0.1.0/PKG-INFO +461 -0
  5. pycuf-0.1.0/README.md +410 -0
  6. pycuf-0.1.0/docs/getting-started.md +145 -0
  7. pycuf-0.1.0/examples/begroting-ibis.xml +142 -0
  8. pycuf-0.1.0/examples/begroting.xml +114 -0
  9. pycuf-0.1.0/pyproject.toml +282 -0
  10. pycuf-0.1.0/pyproject.toml.orig +196 -0
  11. pycuf-0.1.0/src/pycuf/__init__.py +93 -0
  12. pycuf-0.1.0/src/pycuf/__main__.py +34 -0
  13. pycuf-0.1.0/src/pycuf/_arrow.py +166 -0
  14. pycuf-0.1.0/src/pycuf/_build.py +596 -0
  15. pycuf-0.1.0/src/pycuf/_encoding.py +179 -0
  16. pycuf-0.1.0/src/pycuf/_numeric.py +52 -0
  17. pycuf-0.1.0/src/pycuf/_optional.py +21 -0
  18. pycuf-0.1.0/src/pycuf/_source.py +90 -0
  19. pycuf-0.1.0/src/pycuf/_xml.py +197 -0
  20. pycuf-0.1.0/src/pycuf/calc.py +398 -0
  21. pycuf-0.1.0/src/pycuf/cli.py +401 -0
  22. pycuf-0.1.0/src/pycuf/errors.py +55 -0
  23. pycuf-0.1.0/src/pycuf/findings.py +255 -0
  24. pycuf-0.1.0/src/pycuf/models.py +524 -0
  25. pycuf-0.1.0/src/pycuf/policy.py +222 -0
  26. pycuf-0.1.0/src/pycuf/py.typed +0 -0
  27. pycuf-0.1.0/src/pycuf/raw.py +100 -0
  28. pycuf-0.1.0/src/pycuf/reader.py +403 -0
  29. pycuf-0.1.0/src/pycuf/spec.py +561 -0
  30. pycuf-0.1.0/src/pycuf/tables.py +441 -0
  31. pycuf-0.1.0/src/pycuf/validate.py +272 -0
  32. pycuf-0.1.0/src/pycuf/values.py +199 -0
  33. pycuf-0.1.0/tests/conftest.py +34 -0
  34. pycuf-0.1.0/tests/cufgen.py +457 -0
  35. pycuf-0.1.0/tests/test_calc.py +241 -0
  36. pycuf-0.1.0/tests/test_cli.py +94 -0
  37. pycuf-0.1.0/tests/test_corpus.py +32 -0
  38. pycuf-0.1.0/tests/test_doc_examples.py +43 -0
  39. pycuf-0.1.0/tests/test_metadata.py +34 -0
  40. pycuf-0.1.0/tests/test_policy.py +92 -0
  41. pycuf-0.1.0/tests/test_properties.py +48 -0
  42. pycuf-0.1.0/tests/test_reader.py +411 -0
  43. pycuf-0.1.0/tests/test_readme.py +71 -0
  44. pycuf-0.1.0/tests/test_security.py +65 -0
  45. pycuf-0.1.0/tests/test_tables.py +214 -0
  46. pycuf-0.1.0/tests/test_validate.py +71 -0
  47. pycuf-0.1.0/tests/test_values.py +125 -0
@@ -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
@@ -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
+ [![CI status](https://github.com/SpireflyHQ/pycuf/actions/workflows/ci.yml/badge.svg)](https://github.com/SpireflyHQ/pycuf/actions/workflows/ci.yml)
55
+ [![PyPI version](https://img.shields.io/pypi/v/pycuf)](https://pypi.org/project/pycuf/)
56
+ [![Supported Python versions](https://img.shields.io/pypi/pyversions/pycuf)](https://pypi.org/project/pycuf/)
57
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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.