Repository navigation
fix: canonicalize input file paths for graphed duplicate refusal (#1767) - #1777
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files
|
ariostas
left a comment
There was a problem hiding this comment.
🤖 AI text below 🤖
Thanks for the fix! A few comments below. The main one: with known_base_form=, object paths are not normalized, so some duplicate spellings still get through (#1767). Case-insensitive filesystems (macOS) have a similar gap.
| ) | ||
| seen = set() | ||
| for file_path, object_path in source._file_tree: | ||
| canon_key = (uproot._util.canonical_path(file_path), object_path or "") |
There was a problem hiding this comment.
🤖 AI text below 🤖
Only the file path is canonicalized here, not the object path. With known_base_form=, no file is opened and file_tree keeps object paths exactly as typed, so the same tree spelled two ways is still accepted:
uproot.graphed({absp: "events", relp: "/events"}, known_base_form=bf)
uproot.graphed({absp: "events;1", relp: "events"}, known_base_form=bf)These give keys like (realpath, "events") and (realpath, "/events"), which differ, so the rows are written twice. That is the bug #1767 is about. None (single-tree file) vs "events" has the same problem. The object path probably needs normalizing too: strip the leading / and the ;cycle.
| "partition alone); 'files' with explicit 'steps' cannot be written — use " | ||
| "steps_per_file" | ||
| ) | ||
| seen = set() |
There was a problem hiding this comment.
🤖 AI text below 🤖
This loop repeats the duplicate check and error message from graphed.write.file_bases, just on different keys. Canonicalization is also added separately in three places (the Counter, the directories cache, and here). Could file_tree be canonicalized once in graphed() (both branches) instead? Then file_bases and _PartName would key on it, and every other consumer of _file_tree (e.g. uproot.dask, which per #1767 still reads duplicates twice) would get it for free.
| else parsed.netloc | ||
| ) | ||
| file_path = urllib.request.url2pathname(path_str) | ||
| return os.path.realpath(os.path.expanduser(file_path)) |
There was a problem hiding this comment.
🤖 AI text below 🤖
os.path.realpath only resolves symlinks. It doesn't fold case on case-insensitive filesystems (macOS's default APFS), and it doesn't detect hard links. On macOS, {"/data/A.root": "events", "/data/a.root": "events"} gives two different keys and the rows are written twice. For local files that exist, comparing (st_dev, st_ino) from os.stat (or using os.path.samefile) would catch both cases.
|
|
||
| if isinstance(file_path, bytes): | ||
| file_path = os.fsdecode(file_path) | ||
| if isinstance(file_path, os.PathLike): |
There was a problem hiding this comment.
🤖 AI text below 🤖
The bytes check runs before the PathLike conversion. A PathLike whose __fspath__ returns bytes is still bytes when it reaches "://" in file_path below, which raises TypeError: a bytes-like object is required, not 'str'. Calling os.fspath first and then os.fsdecode handles both cases.
| if not ("://" in file_path and parsed.scheme not in ("", "file", "local")): | ||
| if parsed.scheme in ("file", "local"): | ||
| path_str = parsed.path | ||
| if parsed.netloc and parsed.netloc != "localhost": |
There was a problem hiding this comment.
🤖 AI text below 🤖
A file:// URL with a non-localhost host becomes host/path with no leading slash, and realpath then resolves it relative to the current directory. file://somehost/tmp/a.root turns into <cwd>/somehost/tmp/a.root. If a local relative file somehost/tmp/a.root exists, the two would be wrongly treated as duplicates (or share one opened directory in _resolve_entries). Maybe leave non-local hosts un-canonicalized, or keep them as URLs?
| import re | ||
| import sys | ||
| import urllib.parse | ||
| import urllib.request |
There was a problem hiding this comment.
🤖 AI text below 🤖
urllib.request is only needed for url2pathname, but importing it at the top level loads it on every import uproot. Before this PR it wasn't in sys.modules after importing uproot. Could it be imported inside canonical_path instead?
| leaf ``TBranch``, or none when the first names a ``TTree``.""" | ||
| listed = collections.Counter(_file_and_path(f, o)[0] for f, o, *_ in files) | ||
| listed = collections.Counter( | ||
| uproot._util.canonical_path(_file_and_path(f, o)[0]) for f, o, *_ in files |
There was a problem hiding this comment.
🤖 AI text below 🤖
canonical_path calls realpath, which does filesystem calls for every path component. It now runs twice per entry here (once for the Counter, once in the loop) and a third time per entry in graphed_write. With thousands of inputs on EOS/NFS/FUSE, that adds up on the driver. Computing the canonical keys once per entry and reusing them would avoid the repeats.
| @@ -312,26 +312,27 @@ def _resolve_entries( | |||
| ``/``, which could end in a ``TTree`` or in a ``TBranch``; any other path names a ``TTree``, | |||
| or the first entry's leaf ``TBranch`` when it is the first entry's path. Every entry must select the first entry's | |||
| leaf ``TBranch``, or none when the first names a ``TTree``.""" | |||
There was a problem hiding this comment.
🤖 AI text below 🤖
Nit: the docstring above (lines 305-307) still says directories is {file_path: directory}, but it is now keyed by canonical_path(file_path). A caller looking up directories[file_path] with the path as typed would get a KeyError.
| return str(path) | ||
|
|
||
|
|
||
| def _relative_path(path): |
There was a problem hiding this comment.
🤖 AI text below 🤖
_relative_path builds <tmp>/../<tmp-basename>/a.root, which is still an absolute path, so the relative-to-cwd spelling from the issue's reproducer is never tested. A monkeypatch.chdir(tmp_path) plus a bare "a.root" would cover it.
| file_url = f"file://{local_p}" | ||
| assert canonical_path(file_url) == str(local_p.resolve()) | ||
| assert canonical_path(local_p.as_uri()) == str(local_p.resolve()) | ||
| assert canonical_path("file://somehost/tmp/test.root") is not None |
There was a problem hiding this comment.
🤖 AI text below 🤖
This asserts nothing useful (canonical_path never returns None for a str). It would be better to assert the expected value, which would also expose the host-handling issue noted in _util.py.
|
Thanks @ariostas for the detailed review! I have updated the branch to canonicalize |
… key canonical_path and canonical_object_path are identity keys for the graphed_write duplicate refusal, not paths to read: rewriting the cycle to ;1 read an older cycle (or none), None no longer meant the file's only TTree, and resolving symlinks or remote URLs on the driver changed what the workers open. Keep _file_tree as given and check the keys in graphed_write. Drop _macos_real_casing, which the inode check covers. Assisted-by: Claude Code:claude-opus-5-5
graphed's name= now sets the session source name, so the duplicate key for a None object path uses the first entry's tree name instead. Assisted-by: Claude Code:claude-opus-5-5
|
🤖 AI text below 🤖 I pushed a few commits to resolve the remaining issues and the conflict with
|
…its file POSIX normalization of a remote URL merged distinct objects: memory:// or an object store can hold different files at "a//b" and "a/b", and _resolve_entries then read the first for both. Remote keys are now fsspec's own normal form, unchanged when fsspec cannot parse them. Without opening the file, a None object path can only be the file's only TTree, so graphed_write refuses it beside any other input of that file, whichever is listed first. Assisted-by: Claude Code:claude-opus-5-5
ariostas
left a comment
There was a problem hiding this comment.
Thank you @sumitsaraswat362!
|
Thanks a lot for the cleanup and getting this merged! |
Fixes #1767.
Summary
uproot.graphed_writerefused duplicate inputs when spelled identically, but missed entries referring to the same file under different path spellings (such as relative vs absolute paths).Changes
uproot._util.canonical_pathto normalize local files viaos.path.realpathand remote URLs viafsspec/urllib.parse._resolve_entriesinuproot._graphedto count and cache files by canonical key, so duplicate entries under alternate path spellings resolve their tree path correctly.uproot.graphed_writeto refuse duplicate(canonical_path, object_path)inputs before building write plans.tests/test_1767_graphed_write_duplicate_paths.py.