Skip to content

feat: Add in-place TTree branch addition and row extension - #1690

Merged
ariostas merged 68 commits into
scikit-hep:mainfrom
Yokubas:Yokubas/ttree-inplace-v2
Sep 2, 2026
Merged

ariostas merged 68 commits into
scikit-hep:mainfrom
Yokubas:Yokubas/ttree-inplace-v2

Conversation

@Yokubas

@Yokubas Yokubas commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements in-place modification of existing TTrees:

  • f["tree"].add_branches({"new_x": array1, "new_y": array2, ...}) — add one or more new branches back-filled with provided data
  • f["tree"].extend({"x": array1, "y": array2}) — append new entries to one or more existing branches
  • f["tree"].extend({"x": array1, "new_y": array2}, accept_new_fields=True) — auto-add new branches back-filled with zeros, then extend

How it works

Uses uproot's cascade machinery instead of manual byte patching:

  • For extend: deserializes the existing TTree using uproot's reading side (branch members, cursor positions), reconstructs a ct.Tree cascade object, then delegates to the existing cascade write machinery — which appends new baskets and patches fBasketSeek, fBasketBytes, fBasketEntry, fWriteBasket, fEntryNumber, and fEntries in the TTree blob
  • For add_branches: creates new branch dict via _branch_np, calls write_anew to rewrite the TTree metadata blob with the new branch included, then writes one basket per new branch. Existing basket data is never touched — the metadata blob just gains new branch headers and the basket seek arrays are updated
  • For accept_new_fields=True: calls add_branches with zeros for existing entries, then extends with the provided data using the updated cascade
  • Metadata positions (metadata_start, basket_metadata_start) are derived structurally, by walking the same layout write_anew itself emits (Tree._build_out()), rather than by searching the blob for byte patterns. The original byte-pattern-search approach broke whenever the value being searched for was 0 (e.g. a freshly created tree with no baskets yet, or a branch that had never been extended) — a search for zero bytes matches arbitrary unrelated data and silently corrupted the file. Deriving the offsets from the same code path that writes them removes that whole class of bug.

Known limitations

  • Only top-level branches supported — adding subfields (e.g. particle.phi) is not yet implemented
  • add_branches/extend for TBranchElement (split-object) files is not yet supported — this is now enforced with a clean NotImplementedError and covered by tests; previously it could crash on plain access to such a file, or silently desynchronize entry counts across branches
  • File-like objects not supported (requires file path for re-reading metadata)
  • add_branches/extend require every branch to agree on basket count and capacity. This is always true for a tree Uproot itself wrote (and hasn't touched with add_branches since), but a ROOT-written tree commonly has divergent per-branch basket counts (branches flush baskets at different rates depending on per-entry size) — this case is now detected and rejected with a clear NotImplementedError rather than corrupting the file.

Fixes made during review

Several data-corruption and crash bugs were found and fixed after the initial implementation, all confirmed with reproductions and covered by regression tests:

  • Jagged branch extend in update mode wrote garbage instead of the real values — _load_existing_ttree derived a jagged branch's dtype from the wrong place (the interpretation's own numpy_dtype, which is object for a jagged array) instead of its content dtype.
  • Extending a tree with zero baskets (freshly mktree'd, never extended) corrupted the file, for the same "search for zero bytes" reason described above.
  • String branches couldn't be extended after reopening a file — _load_existing_ttree had no handling for the AsStrings interpretation.
  • Fixed-size array branches (e.g. float[3]) crashed on plain access, not just extend — _load_existing_ttree didn't account for a branch's shape.
  • add_branches on a tree that already had more than one basket wrote an incorrect fWriteBasket for the new branch (the whole tree's basket count instead of the new branch's real count of 1), corrupting the new branch's layout.
  • add_branches leaked a stale cache entry in WritableFile._trees on every call after the tree relocated; now cleaned up via the same _move_tree path extend's basket-capacity growth already uses.
  • add_branches unconditionally re-read and re-parsed the whole tree from disk on every call even though the in-memory state was already current; now reuses it directly.
  • A jagged/counter-branch could be silently misattached to an unrelated same-named branch (e.g. a string branch "id" incorrectly paired with an unrelated int branch "nid"); the counter-inference heuristic now excludes string branches.

Tests (36 passing)

  • Basic add_branches and extend for simple TBranch files
  • Multiple branches added or extended in a single call
  • Different dtypes (float32, int32)
  • Preserves existing data across operations
  • Sequential add_branches calls across separate sessions
  • add_branches then extend in the same session
  • Multiple extend calls across separate sessions
  • extend after add_branches in a new session
  • accept_new_fields behavior
  • ROOT verification for simple TBranch files
  • Corner cases: nonexistent branch, mismatched lengths, nonexistent tree, missing branch in extend, accept_new_fields error without flag
  • Jagged array extend (counter branch not required from user)
  • Extend after many extends (fMaxBaskets > 10)
  • Jagged branch extend across a fresh uproot.update() session (regression test for the dtype-corruption fix)
  • Extending a zero-basket tree, and a zero-basket tree with multiple branches (regression tests for the byte-search corruption fix)
  • String branch extend, including from zero baskets (regression tests for the missing AsStrings handling)
  • Fixed-size array branch access (regression test for the shape-handling crash)
  • TBranchElement access without crashing, and clean NotImplementedError from extend/add_branches on such files (regression tests)
  • Divergent per-branch basket counts correctly rejected, verified against a real PyROOT-written fixture with mismatched basket counts
  • add_branches after multiple extends (regression test for the fWriteBasket corruption fix), including the correct rejection of a follow-up extend once basket counts diverge
  • add_branches does not leak WritableFile._trees cache entries (regression test)
  • add_branches does not re-read the tree from disk (regression test)
  • Counter-branch inference does not misattach a string branch to a coincidentally-named sibling (regression test)

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.33816% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.15%. Comparing base (7916fe6) to head (f2fac6a).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/uproot/writing/writable.py 89.30% 11 Missing and 9 partials ⚠️
Additional details and impacted files
Files with missing lines Coverage Δ
src/uproot/writing/_cascadetree.py 84.80% <100.00%> (+0.70%) ⬆️
src/uproot/writing/writable.py 81.98% <89.30%> (+2.13%) ⬆️

... and 5 files with indirect coverage changes

@Yokubas Yokubas changed the title Add in-place TTree branch addition and row extension feat: Add in-place TTree branch addition and row extension Jul 27, 2026

@ariostas ariostas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking really promising! Thanks for the hard work!

I left some comments for specific locations.

But one more general comment is that this seems to be doing a lot of manual scanning and patching. It would be better to try to do what you did for the RNTuple one. Deserializing what you need to construct a WritableTree (with self._cascading properly built) and then let the existing functionality take care of as much of the serialization as possible.

Comment thread src/uproot/writing/writable.py Outdated
Comment thread src/uproot/writing/writable.py Outdated
Comment thread src/uproot/writing/writable.py Outdated
Comment thread src/uproot/writing/writable.py Outdated
Comment thread src/uproot/writing/writable.py
Comment thread src/uproot/writing/writable.py
Comment thread src/uproot/writing/writable.py Outdated
Comment thread src/uproot/writing/writable.py Outdated
Comment thread src/uproot/writing/writable.py Outdated
Comment thread src/uproot/writing/writable.py Outdated
@TaiSakuma TaiSakuma added the type/feat PR title type: feat (set automatically) label Aug 14, 2026
Yokubas added 16 commits August 24, 2026 12:00
…f numpy_dtype, preventing silent data corruption when extending an existing TTree via uproot.update()
… content-independent byte markers instead of searching for zero-valued fields, preventing file corruption when extending a tree with zero baskets
…ing AsStrings interpretation in _load_existing_ttree
…stead of crashing on access, and make the add_branches/extend TBranchElement guards actually reachable
…e search, support fixed-size array branches, and reject divergent per-branch basket counts
…ead of stamping them with the whole tree's basket count
…ates the tree, instead of leaking a stale one
…y reloading and re-parsing the tree from disk
…h on a preexisting tree, fixing silent corruption when extending a ROOT-written TTree
…ta, and reject jagged accept_new_fields cleanly
… default field_name convention when reopened via uproot.update()
Comment thread tests/test_1690_ttree_inplace.py
Comment thread tests/test_1690_ttree_inplace.py Outdated
Comment thread tests/test_1690_ttree_inplace.py Outdated

@ariostas ariostas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for all the hard work, @Yokubas! This is great! It's awesome that not only did you finish the project Zoe was working on, but you also ended up also adding the functionality to add more entries to the TTree, which we had not even planned.

@ariostas
ariostas merged commit 4a41da3 into scikit-hep:main Sep 2, 2026
25 of 26 checks passed
ariostas added a commit to Yokubas/uproot5 that referenced this pull request Sep 3, 2026
Resolves conflicts with scikit-hep#1690 (in-place TTree branch addition and row
extension), which landed on main and touches the same regions of
writable.py.

The two features are complementary rather than overlapping:

- WritableDirectory._get: main routes preexisting TTrees to
  _load_existing_ttree, this branch routes preexisting RNTuples to
  _load_existing_ntuple. Both branches kept; each side's "cannot view
  preexisting" TypeError is now dead and removed.

- The new methods between _get and _del: main added
  _load_existing_ttree, this branch added _read_ntuple_envelope and
  _load_existing_ntuple. Git interleaved them because they share
  boilerplate (sink.flush, _get_chunk, _ReadForUpdate). All three kept
  verbatim from their respective sides.

Full test suite passes (1106 passed, 94 skipped), including both
tests/test_1687_rntuple_update.py and tests/test_1690_ttree_inplace.py.

Assisted-by: claude-code:claude-opus-5[1m]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/feat PR title type: feat (set automatically)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants