feat: Add in-place TTree branch addition and row extension - #1690
feat: Add in-place TTree branch addition and row extension#1690Yokubas wants to merge 49 commits into
Conversation
❌ 4 Tests Failed:
View the top 3 failed test(s) by shortest run time
To view more test analytics, go to the Test Analytics Dashboard |
ariostas
left a comment
There was a problem hiding this comment.
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.
Thanks for the feedback! I understand the concern about manual byte patching — it's fragile and hard to maintain. For the RNTuple case, we could reconstruct a WritableNTuple because uproot already had full read/write support for the format. For TTree, the challenge is that _cascadetree.py handles writing new trees from scratch, but there's no path to deserialize an existing TTree blob back into a WritableTree with self._cascading set. Would you be able to point me toward what would need to change in _cascadetree.py to support this? Specifically — how to reconstruct the cascade objects (branches, baskets, etc.) from an existing serialized TTree so we can use the existing extend/write machinery? |
|
Hello @Yokubas, cc @ariostas as reviewer - there are many people watching this important package and I'm sure I'm not the only one noticing that you often get trivial pre-commit updates on your commits. This results in many more email notifications, which could be trivially spared to everyone if you would run pre-commit locally. This standard way of proceding is even mentioned in the CONTRIBUTING file. Could you kindly follow the guidelines and save us all tens of meaningless email notifications? Thank you in advance. |
|
Apologies for the noise! I've set up pre-commit locally now — won't happen again. |
…/uproot5 into Yokubas/ttree-inplace-v2
| def __repr__(self): | ||
| return "<WritableTree {} at 0x{:012x}>".format( | ||
| repr("/" + "/".join(self._path)), id(self) | ||
| ) | ||
|
|
||
| @property | ||
| def path(self): | ||
| """ | ||
| Path of directory names to this TTree as a tuple of strings. | ||
| """ | ||
| return self._path | ||
|
|
||
| @property | ||
| def object_path(self) -> str: | ||
| """ | ||
| Path of directory names to this TTree as a single string, delimited by | ||
| slashes. | ||
| """ | ||
| return "/".join(("", *self._path, "")).replace("//", "/") | ||
|
|
||
| @property | ||
| def file_path(self) -> str | None: | ||
| """ | ||
| Filesystem path of the open file, or None if using a file-like object. | ||
| """ | ||
| return self._file.file_path | ||
|
|
||
| @property | ||
| def file(self): | ||
| """ | ||
| Handle to the :doc:`uproot.writing.writable.WritableDirectory` in which | ||
| this directory can be found. | ||
| """ | ||
| return self._file | ||
|
|
||
| def close(self): | ||
| """ | ||
| Explicitly close the file. | ||
|
|
||
| (Files can also be closed with the Python ``with`` statement, as context | ||
| managers.) | ||
|
|
||
| After closing, objects cannot be read from or written to the file. | ||
| """ | ||
| self._file.close() | ||
|
|
||
| @property | ||
| def closed(self) -> bool: | ||
| """ | ||
| True if the file has been closed; False otherwise. | ||
|
|
||
| The file may have been closed explicitly with | ||
| :ref:`uproot.writing.writable.WritableFile.close` or implicitly in the Python | ||
| ``with`` statement, as a context manager. | ||
|
|
||
| After closing, objects cannot be read from or written to the file. | ||
| def add_branches(self, branches): | ||
| """ | ||
| return self._file.closed | ||
| Args: | ||
| branches (dict of str -> array): Names and data of new branches. | ||
|
|
||
| def __enter__(self): | ||
| self._file.sink.__enter__() | ||
| return self | ||
| Adds new branches to this TTree in-place. Only the new branch data and | ||
| an updated TTree header are written; existing data is never touched. | ||
| Works with both simple TBranch and TBranchElement files. | ||
|
|
||
| def __exit__(self, exception_type, exception_value, traceback): | ||
| self._file.sink.__exit__(exception_type, exception_value, traceback) | ||
| .. code-block:: python | ||
|
|
||
| @property | ||
| def compression(self): |
There was a problem hiding this comment.
Seems like you accidentally deleted a bunch of properties here. We should keep those
There was a problem hiding this comment.
Restored the accidentally deleted WritableTree properties (__repr__, path, object_path, file_path, file, close, closed, __enter__, __exit__, compression) — they got removed when I was cleaning up _extend_inplace
| existing_names = [bd["fName"] for bd in self._cascading._branch_data] | ||
| new_fields = {k: v for k, v in data.items() if k not in existing_names} | ||
| missing = [b for b in existing_names if b not in data] | ||
| if missing: |
There was a problem hiding this comment.
As you saw from the failed tests, there is one exception here. When you have a jagged array it creates another branch with the same name, but with an n prefix that stores how many elements each row has. And they are automatically generated by Uproot/ROOT, so the user is not expected to pass them in.
There was a problem hiding this comment.
I think also records need to be skipped. So the fix could probably be
existing_names = [bd["fName"] for bd in self._cascading._branch_data if bd.datum["kind"] not in ("counter", "record")]There was a problem hiding this comment.
Counter and record branches are now excluded from the missing check in extend() — existing_names now filters by bd.datum["kind"] not in ("counter", "record") as you suggested. Also fixed _load_existing_ttree to correctly reconstruct counter branch kind and references so that jagged array extend works in uproot.update sessions too. 22 tests passing
There was a problem hiding this comment.
One thing to flag — test_writable_vs_readable_tree in test_0406_write_a_ttree.py expects a TypeError when accessing a ROOT-written TTree via uproot.update. This was the old behavior when uproot couldn't handle existing TTrees. Since our PR now supports this, the test expectation is outdated — should this test be updated to reflect the new behavior?
| existing_names = [bd["fName"] for bd in self._cascading._branch_data] | ||
| new_fields = {k: v for k, v in data.items() if k not in existing_names} | ||
| missing = [b for b in existing_names if b not in data] | ||
| if missing: |
| key = self._file._cascading.rootdirectory.data.get_key(source, 1) | ||
| casc = self._file.root_directory._load_existing_ttree(key)._cascading |
There was a problem hiding this comment.
Do these two lines work if the tree is in a subdirectory?
There was a problem hiding this comment.
Fixed the subdirectory issue — add_branches now navigates through self._path[:-1] to find the correct directory instead of always using rootdirectory. Also fixed casc._directory to point to the correct directory so write_anew works properly. Tested with a tree in a subdirectory and it works correctly
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 dataf["tree"].extend({"x": array1, "y": array2})— append new entries to one or more existing branchesf["tree"].extend({"x": array1, "new_y": array2}, accept_new_fields=True)— auto-add new branches back-filled with zeros, then extendHow it works
Uses uproot's cascade machinery instead of manual byte patching:
extend: deserializes the existing TTree using uproot's reading side (branch members, cursor positions), reconstructs act.Treecascade object, then delegates to the existing cascade write machinery — which appends new baskets and patchesfBasketSeek,fBasketBytes,fBasketEntry,fWriteBasket,fEntryNumber, andfEntriesin the TTree blobadd_branches: creates new branch dict via_branch_np, callswrite_anewto 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 updatedaccept_new_fields=True: callsadd_brancheswith zeros for existing entries, then extends with the provided data using the updated cascadeMetadata positions (
metadata_start,basket_metadata_start) are found by searching for known byte patterns in the blob rather than fixed offsets, ensuring correctness for branches added viaadd_branchesas well as freshly created ones.Tests (23 passing)
add_branchesandextendfor simple TBranch filesadd_branchescalls across separate sessionsadd_branchesthenextendin the same sessionextendcalls across separate sessionsextendafteradd_branchesin a new sessionaccept_new_fieldsbehaviorKnown limitations
particle.phi) is not yet implementedadd_branchesfor TBranchElement files is not yet supported