rvt 0.4.0

Apache-2.0 clean-room reader for Autodesk Revit files (.rvt, .rfa, .rte, .rft) without Revit: metadata, previews and the embedded schema of 2016-2026 files, and typed elements with measured geometry from Revit 2024 and 2025 projects, exported as IFC4, glTF and plan SVG.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
# rvt-rs — Python bindings (v0.3.0)

`rvt` is a Python package built on top of the Rust `rvt` crate. It
exposes the file reader, metadata, schema introspection, the
`Global/ElemTable` index, the IFC4 exporter with its diagnostics, and
element and room schedules from the Rust core, without requiring a Rust
toolchain at install time. The Python surface is narrower than the Rust
surface: one class (`RevitFile`) and three module-level functions
(`rvt_to_ifc`, `rvt_to_ifc_diagnostics`, `read_metadata`), all documented
below.

Single source of truth for the runtime surface is
[`rvt-py/src/lib.rs`](../rvt-py/src/lib.rs); the hand-maintained type stubs
at [`python/rvt/__init__.pyi`](../python/rvt/__init__.pyi) mirror
it for mypy / pyright.

## Install

For all installation paths, including release smoke tests, see
[`install.md`](install.md).

### From PyPI

```bash
pip install rvt
```

**PyPI `rvt` 0.3.0 is the current release.** Wheels target Python ≥ 3.8
via pyo3's `abi3-py38` feature, so one wheel per OS/architecture covers
every supported Python minor. The matching Rust crate is on crates.io
(`cargo add rvt`); platforms and the other install channels are in
[`install.md`](install.md). This page describes `main`, which can be ahead
of the latest release: what has landed since 0.3.0 is under `[Unreleased]`
in [`CHANGELOG.md`](../CHANGELOG.md).

### From source

```bash
# Needs a Rust toolchain (>= 1.85) and maturin.
pip install maturin
git clone https://github.com/DrunkOnJava/rvt-rs
cd rvt-rs
maturin build --release --manifest-path rvt-py/Cargo.toml
pip install target/wheels/rvt-*.whl
```

For day-to-day development, `maturin develop --manifest-path rvt-py/Cargo.toml`
installs the extension in editable mode into the active virtualenv.

## Quick start

```python
import rvt

f = rvt.RevitFile("my-family.rfa")

# BasicFileInfo getters — each a cheap parse of the BasicFileInfo
# stream, cached by the Rust reader.
print(f.version)              # 2024
print(f.build)                # "20230308_1635(x64)"
print(f.guid)                 # "7f3c..." or None
print(f.original_path)        # save-time path from creator's machine
print(f.part_atom_title)      # e.g. "0610 x 0915mm" on family files

# Stream enumeration.
print(f.stream_names())       # 13 entries on the reference corpus
print(f.missing_required_streams())  # [] on a valid Revit file

# Schema counts (cheap).
summary = f.schema_summary()
print(summary["classes"], summary["fields"], summary["cpp_types"])
# 395 13570 ...   on the 2024 sample

# Full schema as JSON (~1-2 MB on typical families).
import json
schema = json.loads(f.schema_json())
adoc = next(c for c in schema["classes"] if c["name"] == "ADocument")

# Layer-5a walker — ADocument instance fields. Returns None when
# the entry-point detector can't confidently locate ADocument.
doc = f.read_adocument()
if doc is not None:
    for field in doc["fields"][-3:]:
        print(field["name"], "→", field["kind"], field.get("id"))
    # m_ownerFamilyId                 → element_id 27
    # m_ownerFamilyContainingGroupId  → element_id 31
    # m_devBranchInfo                 → element_id 35

# Document-level IFC4 STEP export.
with open("my-family.ifc", "w") as out:
    out.write(f.write_ifc())
```

## API reference

Every signature below is verified against
[`rvt-py/src/lib.rs`](../rvt-py/src/lib.rs) on `main` (0.3.0).

### Module-level

```python
rvt.__version__  # str — same as the Rust crate version (Cargo.toml)

rvt.rvt_to_ifc(path: str, mode: str = "scaffold") -> str
rvt.rvt_to_ifc_diagnostics(path: str) -> str
rvt.read_metadata(path: str, redact: bool = False) -> dict
```

`read_metadata(path)` returns the same document-identity dict as
`RevitFile(path).metadata()` (release, worksharing, central model path,
last saved time and user, document GUID, save counter) but reads only the
file's two identity streams, so it is the fast path for inventories:

```python
import pathlib, rvt

for path in pathlib.Path("projects").rglob("*.rvt"):
    m = rvt.read_metadata(str(path))
    print(m["revit_version"], m["worksharing"], m["last_saved"], path)
```

It raises `FileNotFoundError` / `OSError` on I/O failure and `ValueError`
when the file is not a readable Revit file. `redact=True` scrubs the user
name and Windows user folders.

`rvt_to_ifc(path, mode="scaffold")` opens the file, runs the
document-level IFC4 exporter (`ifc::RvtDocExporter`), and returns the
IFC4 STEP text. Equivalent to
`rvt.RevitFile(path).write_ifc(mode=mode)`. Raises `IOError` on open
failure, `ValueError` if the file parses as CFB but the exporter
can't build a model or the requested export mode cannot be satisfied.

`rvt_to_ifc_diagnostics(path)` returns the JSON diagnostics sidecar
for the same export path. The schema matches `rvt-ifc --diagnostics`
and is documented in [`docs/export-diagnostics.md`](export-diagnostics.md);
the machine-readable contract is
[`docs/schemas/export-diagnostics.schema.json`](schemas/export-diagnostics.schema.json).
The terms used in those diagnostics are defined in
[`docs/diagnostic-semantics.md`](diagnostic-semantics.md).

### `rvt.RevitFile`

#### Constructor

```python
rvt.RevitFile(
    path: str,
    max_file_bytes: int | None = None,
    max_stream_bytes: int | None = None,
    max_inflate_bytes: int | None = None,
    max_walker_scan_bytes: int | None = None,
    max_walker_candidates: int | None = None,
    max_walker_trial_offsets: int | None = None,
    max_walker_record_decode_bytes: int | None = None,
    max_walker_container_records: int | None = None,
) -> RevitFile
```

Opens a Revit file (`.rvt`, `.rfa`, `.rte`, or `.rft` — all four
share the same CFB container). Dispatch is purely on the CFB magic
`D0 CF 11 E0 A1 B1 1A E1`, so the extension is advisory.

Resource limits map directly to the Rust `OpenLimits` /
`InflateLimits` structs:

| Parameter | Default | Meaning |
|---|---|---|
| `max_file_bytes` | 2 GiB (`2 * 1024³`) | File size cap, enforced before reading |
| `max_stream_bytes` | 256 MiB (`256 * 1024²`) | Per-stream size cap in `read_stream` |
| `max_inflate_bytes` | 256 MiB (`256 * 1024²`) | Decompressed output cap per inflate call |
| `max_walker_scan_bytes` | 128 MiB (`128 * 1024²`) | Decompressed `Global/Latest` bytes scanned by walker fallback paths |
| `max_walker_candidates` | 100,000 | Maximum schema-scan candidates retained |
| `max_walker_trial_offsets` | 16,000,000 | Maximum trial decodes attempted by walker scans |
| `max_walker_record_decode_bytes` | 1 MiB (`1024²`) | Maximum bytes inspected while decoding one walker candidate |
| `max_walker_container_records` | 1,000 | Maximum reference-container records accepted by walker decoders |

Passing `None` for any parameter keeps the Rust-side default.
Hostile input that would otherwise force multi-GB allocations is
rejected up-front.

Raises `IOError` on missing files, non-CFB input, file-size over
`max_file_bytes`, or read errors.

#### Properties (read-only)

| Property | Type | Description |
|---|---|---|
| `version` | `int \| None` | Revit release year (e.g. `2024`), from `BasicFileInfo` |
| `original_path` | `str \| None` | Save-time path on the creator's machine |
| `build` | `str \| None` | Revit build tag, e.g. `"20230308_1635(x64)"` |
| `guid` | `str \| None` | Document GUID from `BasicFileInfo`, if present |
| `part_atom_title` | `str \| None` | Family document title from `PartAtom` XML |
| `schedule_csv(kind="elements", metric=False, excel=False)` | `str` | Element (`kind="elements"`) or room (`kind="rooms"`) schedule as CSV text; same columns as `rvt-schedule` |
| `metadata(redact=False)` | `dict` | Document identity: release, build, title, last saved, worksharing, central model path, last-saved-by user, document GUID, save counter, cloud flag, and every `BasicFileInfo` `Key: value` line (`docs/schemas/file-metadata.schema.json`) |

Each getter calls into the Rust parser on access. All four
`BasicFileInfo`-backed getters return `None` if the
`BasicFileInfo` stream fails to parse; `part_atom_title` returns
`None` when the file carries no `PartAtom` stream (common on
project `.rvt` files).

#### Methods

```python
stream_names(self) -> list[str]
```
All OLE stream paths in the file, sorted, `/`-separated
regardless of host OS. The reference corpus has 13 entries.

```python
read_stream(self, name: str) -> bytes
```
Raw bytes of the named OLE stream. Accepts either leading-slash
(`"/Formats/Latest"`) or bare (`"Formats/Latest"`) forms — both
resolve the same way. Returns *compressed* bytes on streams that
use truncated-gzip framing (most Revit streams do); callers who
want decompressed content need the Rust-side
`compression::inflate_at` equivalent, which is not yet exposed to
Python. Subject to `max_stream_bytes`. Raises `IOError` on
unknown stream names.

```python
missing_required_streams(self) -> list[str]
```
Required Revit stream names that are absent. Empty list on a
valid Revit file. Useful for pre-validating an input before
running heavy extractors.

```python
basic_file_info_json(self) -> str | None
basic_file_info_json_strict(self) -> str
```
Full `BasicFileInfo` as a JSON string (parseable via
`json.loads`). Single-call equivalent of the four individual
getters plus any future fields added to the Rust `BasicFileInfo`
struct. `basic_file_info_json` returns `None` if the `BasicFileInfo`
stream can't be parsed; `basic_file_info_json_strict` raises
`ValueError` instead.

```python
part_atom_json(self) -> str | None
```
Full `PartAtom` as a JSON string. Superset of the
`part_atom_title` getter — also includes `id`, `updated`,
`taxonomies`, `categories`, `omniclass`, and `raw_xml` (the
original XML bytes for lossless downstream reuse). Returns `None`
if the file has no `PartAtom` stream.

```python
schema_summary(self) -> dict[str, int]
```
Decoded schema counts. Cheap. Keys: `classes`, `fields`,
`cpp_types` (all `int`).

```python
schema_json(self) -> str
```
Full schema as a JSON string. The Rust-side `SchemaTable` type
derives `Serialize`, so the call is effectively zero-copy
relative to the in-memory schema. Parse with `json.loads()` to
get a structured dict.

Return shape after `json.loads`:

```python
{
    "classes": [
        {
            "name": "ADocument",
            "offset": int,
            "fields": [
                {"name": "m_elemTable", "cpp_type": "...",
                 "field_type": {"ElementId": null}},
                ...
            ],
            "tag": 4,
            "parent": None,
            "declared_field_count": 13,
            "was_parent_only": False,
            "ancestor_tag": None,
        },
        ...
    ],
    "cpp_types": ["ElementId", "std::pair< ElementId, double >", ...],
    "skipped_records": 0,
}
```

Typical reference-corpus schema is ~395 classes / ~13,570 fields
— the JSON string is on the order of 1-2 MB. For counts only,
prefer `schema_summary()`.

Stable JSON schemas for summary dictionaries, schema diagnostics,
decoded ADocument records, production decoded elements, element
counts, export diagnostics, and corpus reports are checked in under
[`docs/schemas/`](schemas/). Use those files for automation contracts
instead of scraping prose examples.

```python
read_adocument(self) -> dict | None
```
Run the Layer-5a walker and return `ADocument`'s instance fields
as a dict, or `None` if the entry-point detector can't
confidently locate an `ADocument` record in this file.

Return shape when present:

```python
{
    "entry_offset": int,   # byte offset in decompressed Global/Latest
    "version": int,        # Revit release year
    "fields": [
        {"name": "m_elemTable", "kind": "pointer",
         "slot_a": 0, "slot_b": 0},
        {"name": "m_appInfoArr", "kind": "ref_container",
         "count": 12, "col_a": [...], "col_b": [...]},
        {"name": "m_ownerFamilyId", "kind": "element_id",
         "tag": 0, "id": 27},
        # ... 10 more fields
    ],
}
```

Field kinds:

| `kind` | Extra keys | Meaning |
|---|---|---|
| `pointer` | `slot_a: int`, `slot_b: int` | Two u32 slots (raw container pointer) |
| `element_id` | `tag: int`, `id: int` | Tagged ElementId; `id` is the runtime value |
| `ref_container` | `count: int`, `col_a: list[int]`, `col_b: list[int]` | Two-column reference container (u16 per cell) |
| `bytes` | `len: int` | Raw bytes; field type not yet decoded |

```python
decoded_elements(self) -> list[dict]
element_counts(self) -> dict
export_diagnostics(self) -> dict
```

`decoded_elements()` returns production-walker elements. Each entry
has `id`, `class_name`, `byte_range`, `fields`, and `typed`. `typed`
is the Lane Five MVP projection for
`Level` / `Wall` / `Floor` / `Door` / `Window` / `Room` / `Material`
(also listed as `rvt.MVP_TYPED_CLASSES`), or `None` for other classes.
JSON contract: [`decoded-elements.schema.json`](schemas/decoded-elements.schema.json).
CLI mirror: `rvt-elements file.rvt`.

`element_counts()` returns `{"total": int, "by_class": {class: count}}`.
`total` matches
`export_diagnostics()["decoded"]["production_walker_elements"]`.
JSON contract: [`element-counts.schema.json`](schemas/element-counts.schema.json).
CLI mirror: `rvt-elements file.rvt --counts`.

`export_diagnostics()` / `export_diagnostics_json()` return the IFC
export diagnostics sidecar (same payload as `rvt-ifc --diagnostics`).

```python
write_ifc(self, mode: str = "scaffold") -> str
export_diagnostics_json(self) -> str
```
Produce an IFC4 STEP string via `ifc::RvtDocExporter`, the same
exporter `rvt-ifc` runs. On Revit 2023, 2024 and 2025 projects it writes
typed elements with bodies, storeys, types and materials (on
`2024_Core_Interior.rvt`, 992 elements, every one with a body); on other
releases and on family files, the spatial scaffold. What each release
recovers is in [`status.md`](status.md). Raises `ValueError` if the file
can't be parsed far enough to build a model.

`mode` is one of `scaffold`, `typed-no-geometry`, `geometry`, or
`strict`. `scaffold` accepts the historical spec-valid framework
output; stronger modes fail loudly when the recovered Revit data does
not meet that export quality.

`scaffold` means the export envelope succeeded. It can still be a partial
conversion failure if `export_diagnostics_json()` reports zero validated
building elements or zero geometry elements. Use `mode="strict"` in automation
when an incomplete real-model export should raise `ValueError` instead of
returning IFC text. On synthetic / scaffold-only fixtures, only `scaffold`
typically succeeds until Lane 7 geometry recovery lands.

`export_diagnostics_json()` returns the JSON diagnostics sidecar
for the default IFC export without writing files.

```python
read_adocument_strict(self) -> dict
read_adocument_lossy(self) -> dict
```
Variants of `read_adocument()`. `read_adocument_strict` raises
`ValueError` when the `ADocument` record is not located or any of its
fields fell back to raw bytes. `read_adocument_lossy` returns
`{"value", "complete", "partial_fields", "failed_streams", "warnings",
"confidence"}` and raises `OSError` only on stream-level failures.

```python
schema_diagnostics(self) -> dict
```
Integer counts describing the parsed schema: `class_count`,
`parsed_field_count`, `declared_field_count_sum`,
`field_count_mismatches`, `tagged_class_count`,
`parent_only_class_count`, `ancestor_tag_count`, `skipped_records`,
`cpp_type_count`. Raises `ValueError` if the schema can't be parsed.

```python
elem_table_header(self) -> dict
elem_table_records(self) -> list[dict]
declared_element_ids(self) -> list[int]
```
The `Global/ElemTable` index. `elem_table_header()` returns
`{element_count, record_count, header_flag, decompressed_bytes}`
(`element_count` is a per-release class tag, not a count; see
`rvt-elem-table`). `elem_table_records()` returns one
`{offset, id_primary, id_secondary, owner_id}` dict per record, where
`owner_id` is the element the record's element belongs to, or `None`
(RE-31; experimental). `declared_element_ids()` is the sorted,
deduplicated set of ElementIds the table declares. CLI mirror:
`rvt-elem-table file.rvt --json`.

```python
__repr__(self) -> str
```
Returns `"RevitFile(version=2024)"` or `"RevitFile(version=?)"`
if `BasicFileInfo` can't be parsed.

### Errors

| Exception | Raised when |
|---|---|
| `IOError` | File missing, non-CFB input, file-size over `max_file_bytes`, unknown stream name, read error |
| `ValueError` | File parsed as CFB but IFC export couldn't build a model |

`IOError` is the Python alias of `OSError` — both are caught by
`except OSError` or `except IOError`.

## Parse-safety controls

The constructor accepts optional `Option<u64/usize>` caps
that pass through to the Rust `OpenLimits` / `InflateLimits`
structs defined in [`src/reader.rs`](../src/reader.rs) and
[`src/compression.rs`](../src/compression.rs), plus walker scan
caps from [`src/walker.rs`](../src/walker.rs):

- `max_file_bytes` (default 2 GiB) — enforced with a `stat`
  pre-check; over-sized files error before any read.
- `max_stream_bytes` (default 256 MiB) — enforced per
  `read_stream` call.
- `max_inflate_bytes` (default 256 MiB) — enforced per inflate
  call in the truncated-gzip decompressor; protects against
  compressed-bomb input.
- `max_walker_scan_bytes`, `max_walker_candidates`,
  `max_walker_trial_offsets`, `max_walker_record_decode_bytes`,
  and `max_walker_container_records` — bound schema-directed
  fallback scans and return warnings in lossy diagnostics when hit.

All three defaults are conservative for desktop use. Tighten them
for untrusted input:

```python
# Reject anything over 100 MiB; cap inflate at 64 MiB.
f = rvt.RevitFile(
    untrusted_path,
    max_file_bytes=100 * 1024 * 1024,
    max_stream_bytes=100 * 1024 * 1024,
    max_inflate_bytes=64 * 1024 * 1024,
    max_walker_scan_bytes=16 * 1024 * 1024,
)
```

A lower-level `InflateLimits` binding is not yet exposed — if you
call `read_stream` and want to decompress the returned bytes, the
only caps applied are the constructor-level ones. Issue #152 /
#149 track tightening the Python-side limit surface.

## Version support

The Python bindings inherit the Rust core's version support
matrix verbatim:

| Revit release | Open + metadata + schema | Walker (ADocument) | IFC4 document export |
|---|---|---|---|
| 2016 – 2022 | yes | `None` (entry-point detector: partial) | spatial scaffold |
| 2023 | yes | `None` (entry-point detector: partial) | typed elements from element records |
| 2024 – 2025 | yes | full | typed elements with measured geometry |
| 2026 | yes | full | spatial scaffold |

Full matrix and per-column definitions are in
[`docs/compatibility.md`](./compatibility.md). Expanding the generic
walker to 2016–2023 requires per-version entry-point heuristics and is
tracked in [`ROADMAP.md`](../ROADMAP.md) — active work, not shipped.

File types: `.rvt`, `.rfa`, `.rte`, `.rft` all read through the
same code path (CFB magic dispatch). The reference models, projects and
families, are listed in [`reference-models.md`](reference-models.md).

## What isn't exposed yet

The Rust crate is broader than these bindings. The Python surface
intentionally stays minimal; if you need more, either file an
issue or drop to the Rust CLIs (`rvt-info`, `rvt-analyze`,
`rvt-schema`, `rvt-history`, `rvt-diff`, `rvt-corpus`, `rvt-dump`,
`rvt-doc`, `rvt-ifc`).

Not in Python today:

- **Per-element decoders.** The walker reads `ADocument`; the 80
  Layer-5b element decoder structs in `elements::all_decoders()`
  (`Wall`, `Floor`, `Door`, etc. — see `compatibility.md` §3) aren't
  yet exposed to Python. Even in Rust they are a synthesized-fixture
  registry, not wired into `iter_elements` on real project files.
  `rvt-doc` and the Rust `elements::all_decoders()` API cover the
  registry surface.
- **The export model itself.** `write_ifc()` returns the IFC text and
  `export_diagnostics()` its diagnostics, but the in-memory `IfcModel`
  (entities, storeys, materials) is not exposed as Python objects, and
  neither are the glTF and plan-SVG writers (`rvt-gltf`, `rvt-sheet`).
- **Decompression helper.** `read_stream` returns compressed
  bytes on truncated-gzip streams. The Rust
  `compression::inflate_at_with_limits` function isn't bound yet.
- **History / diff.** The `rvt-history` and `rvt-diff` CLIs have
  no Python equivalent.
- **Writing Revit files.** The Rust crate's byte-preserving
  writer (`writer.rs`) is not exposed. Open an issue if you have
  a round-trip use case.
- **Streaming large files.** The entire file is read into memory.
  There is no chunked / streaming reader.
- **Strict variants for every getter.** `basic_file_info_json` and
  `read_adocument` have `_strict` (and `read_adocument_lossy`) variants;
  `part_atom_json` and `schema_json` do not yet.

## Troubleshooting

### `ImportError: cannot import name 'rvt'`

Likely an older / different `rvt` package in the environment. Run
`pip uninstall rvt` and reinstall.

### `IOError: NotACfbFile`

The file doesn't start with OLE2 magic bytes
(`D0 CF 11 E0 A1 B1 1A E1`). Likely causes: it's not a Revit file,
it's a zero-byte Git LFS placeholder, or a transfer layer mangled
the bytes (e.g. line-ending conversion).

### `IOError: FileTooLarge`

The file exceeds `max_file_bytes` (default 2 GiB). Either pass a
larger cap explicitly in the constructor or verify the file isn't
corrupt.

### `read_adocument()` returns `None`

The Layer-5a walker's entry-point detector didn't find an
`ADocument` record. Expected on Revit 2016–2023 files (see version
support matrix). For 2024–2026 files this is unexpected on the
11-release reference corpus — all samples resolve — but may occur
on exotic layouts. File an issue with the Revit release year and
`f.stream_names()` output.

### Wheel won't build from source

`maturin build --manifest-path rvt-py/Cargo.toml` needs:

- Rust ≥ 1.85
- Python development headers (`python3-dev` on Linux, Xcode
  command-line tools on macOS, python.org Python on Windows)

On Apple Silicon, verify `pip install maturin` resolved a
`universal2` or `arm64` wheel, not a stale `x86_64` one.

## Contributing

See [`CONTRIBUTING.md`](../CONTRIBUTING.md). Python-specific
changes land in:

- [`rvt-py/src/lib.rs`]../rvt-py/src/lib.rs — the pyo3 binding layer
- [`python/rvt/__init__.pyi`]../python/rvt/__init__.pyi — type
  stubs (keep in sync)
- [`tests/python/test_rvt.py`]../tests/python/test_rvt.py —
  pytest coverage against the reference corpus

Do not add Rust code solely to expose something to Python — wire
through the existing Rust public API where possible.