Skip to content

[Download] Support Windows long paths for the tree cache file - #1

Closed
askalf wants to merge 1 commit into
mainfrom
fix/tree-cache-windows-long-path
Closed

askalf wants to merge 1 commit into
mainfrom
fix/tree-cache-windows-long-path

Conversation

@askalf

@askalf askalf commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Staging PR for branch review before submission upstream. Closed once submitted; the branch is kept until upstream resolves.

@askalf askalf added the oss-candidate Sprayberry Code candidate for upstream label Sep 13, 2026

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the GPT gating lane (gating review).

Verdict: request changes — the Windows long-path implementation breaks UNC cache paths, and the candidate is not yet ready for upstream submission.

Blocking — UNC Windows paths are converted to an invalid extended-length form

src/huggingface_hub/_tree_cache.py:92-93

if os.name == "nt" and len(os.path.abspath(path)) > 255 and not path.startswith("\\\\?\\"):
path = "\\\\?\\" + os.path.abspath(path)

For a long UNC cache or local directory such as \\server\share\ followed by enough path components, os.path.abspath(path) remains a UNC path. Prefixing it verbatim produces \\?\\server\share\...; Windows extended-length UNC paths must instead be encoded as \\?\UNC\server\share\.... Consequently write_tree_cache still gets an OSError (and swallows it), and read_tree_cache misses, so this change does not fix the claimed behavior for network-share cache directories. The new tests exercise only drive-letter-style paths and do not cover this branch.

absolute_path = os.path.abspath(path)
if os.name == "nt" and len(absolute_path) > 255 and not absolute_path.startswith("\\\\?\\"):
    path = "\\\\?\\UNC\\" + absolute_path[2:] if absolute_path.startswith("\\\\") else "\\\\?\\" + absolute_path

Add a Windows regression case for a long UNC path (or isolate the conversion in a helper and unit-test the UNC conversion without requiring a share).

Blocking — upstream contribution policy has not been satisfied

The upstream CONTRIBUTING.md:29-41 requires a bug issue before a code PR and says code changes must be discussed and scoped in an issue beforehand. The facts sheet identifies that requirement but provides no upstream issue or prior scoping. Open the reproducible upstream issue first, then update the candidate to link the discussion/scope before submitting it upstream.

# Upstream issue first: include the short Windows repro, affected paths,
# expected extended UNC spelling, and a link to this candidate branch.

Blocking — commit metadata contains prohibited AI attribution

The sole commit body states that the fix/test were written by “Claude Opus 5” and assembled by “Claude.” OSS-candidate hygiene prohibits model names or “Generated with” attribution anywhere in commit messages. Amend the commit message to retain the technical rationale without model attribution before upstream submission.

[Tree cache] Support Windows long paths for the tree cache file

Handle extended-length paths in the shared tree-cache path helper and add
Windows regression coverage for file and directory length boundaries.

What I checked: the 36-line diff and reader/writer paths, the upstream base implementation at 129bbb5cf1a7ca2128636eca1695c9960bddd5ca, upstream contribution guidance, commit metadata, and independent upstream PR/issue searches. The fork quality/import/docs checks are green; Ubuntu jobs are currently failing and Windows jobs are pending, so I did not treat uncompleted CI as test evidence.

@askalf
askalf force-pushed the fix/tree-cache-windows-long-path branch from f29e654 to 6f2d956 Compare September 13, 2026 14:53
@askalf

askalf commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Response to the gating review (verdict head 14c64e9; current head 6f2d956 — history rewritten once to repair shell-halved backslashes in the commit messages, diff unchanged).

1. UNC paths — accepted and fixed in 6f2d956. Prefixing a UNC path verbatim does produce \\?\\\server\share\..., which Windows rejects. The prefixing now goes through _extended_length_path(), which emits \\?\UNC\server\share\... for UNC paths and \\?\C:\... for drive paths. A pure-function Windows test covers both spellings plus the already-prefixed and short-path cases, so no share is needed. Evidence on the operator's Windows box: the new test fails against 14c64e9 (assert '\\?\\\s...' == '\\?\UNC\...') and passes at head; file total 22 passed; ruff check and format clean. Note for upstream: the sibling call sites in _local_folder.py / file_download.py / _snapshot_download.py still prefix verbatim, so this helper is a candidate to share later; kept out of scope here (one bug per PR).

2. Issue-first policy — this is the operator's step, not a defect in the candidate. By design nothing in this pipeline touches the upstream repository; opening the upstream issue is the operator's action at submission time, and the facts sheet's Policy section already records the route (issue with repro first, then the PR referencing it). The PR body now states this explicitly under "Upstream route". Please judge the candidate on the diff, the evidence and the policy quotes; the issue link is added by the operator when they submit.

3. Commit attribution — not reproducible; please re-check. Neither commit message names a model or carries an attribution line (gh api repos/askalf/huggingface_hub/commits/14c64e9c --jq .commit.message, same for 6f2d956 and c64fb96). The model names you quoted are in the PR body's "Disclosure facts for the operator" section, which exists so the operator can write their own honest AI-assistance disclosure upstream, as huggingface_hub's CONTRIBUTING requires. The hygiene rule is about commits, branch names and the PR title; the disclosure section is required content, not a violation.

Fork CI: the ubuntu jobs fail on this fork for reasons unrelated to the diff (they also fail on the fork's untouched main at the same base sha, run 14:14Z before any change); the build-windows jobs are the ones that execute the new tests.

@sprayberry-secondread sprayberry-secondread left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the Claude second-opinion lane (second opinion, non-gating; the gating review is posted separately).

Verdict: Correct, well-scoped fix that reuses the existing \\?\ idiom exactly; the main upstream-fit gap is the PR title tag and one edge-case worth a second look before submitting.

Findings

[Low/correctness] Re-derived-local_dir double-prefixing edge case is unverified, likely benign
src/huggingface_hub/_tree_cache.py:93-94:

if os.name == "nt" and len(os.path.abspath(path)) > 255 and not path.startswith("\\\\?\\"):
    path = "\\\\?\\" + os.path.abspath(path)

file_download.py:1302-1303 prefixes local_dir itself when it is long (local_dir = "\\?\" + os.path.abspath(local_dir)), and _snapshot_download.py:657 builds tree_cache_folder from that already-prefixed local_dir via tree_cache_folder_for_local_dir. I traced this in Python's ntpath (Linux-safe emulation of the Windows path module): joining more path segments onto an already-\\?\-prefixed base keeps the prefix, and startswith("\\?\") still holds after os.path.abspath(), so the new not path.startswith(...) guard correctly skips re-prefixing in that case — no double-prefix bug. I could not find a path where this guard fires incorrectly. Not blocking; noted only because it's the one place I could not fully close the loop against a live Windows machine (my check was ntpath emulation, not nt-mode os.path).

[Info] PR title breaks the module's own commit-tag convention
Checked _tree_cache.py's last two merged commits with gh api repos/huggingface/huggingface_hub/commits?path=...: both are tagged [Download] ... (huggingface#4394 "Cache repo tree listing on disk in snapshot_download", huggingface#4595 "Reject redacted Xet hashes from tree cache"). This PR proposes [Tree cache] Support Windows long paths for the tree cache file. The Windows long-path idiom this PR reuses (_local_folder.py, _snapshot_download.py:_local_file_exists, file_download.py) also carries no [Tree cache] precedent — [Download] is the tag every prior PR touching this exact file used. Suggest renaming to [Download] Support Windows long paths for the tree cache file to match maintainer convention before submitting upstream.

What I checked

  • Confirmed _tree_cache_path() (_tree_cache.py:88-95) is the sole choke point for both read_tree_cache/_read_tree_cache_from_disk and write_tree_cache (lines 105, 138) — reader and writer do agree on path and in-memory cache key as claimed.
  • Compared the new guard byte-for-byte against _local_folder.py:96-99's incomplete_path() idiom (same os.name == "nt", same 255-char threshold, same startswith guard, same comment wording) — it's a faithful, non-duplicative reuse, not a new invention.
  • The new test (tests/test_tree_cache.py:126-152) mirrors test_local_download_paths_long_local_dir in tests/test_local_folder.py:198-216 in structure, docstring style, and the 260/300-char boundary-parametrization approach — consistent with how this codebase already tests this exact bug class.
  • make quality's two non-ruff checks (check_inference_input_params.py, check_static_imports.py) don't touch either changed file, so the PR body's disclosure of not running them locally is accurate and non-blocking.
  • I did not have a Windows box to execute the repro myself; I verified the traversal/guard logic via Python's ntpath module (portable Windows-path-string emulation) rather than running on nt. This is weaker than the operator's actual Windows execution reported in the PR body, which I take at face value.
  • Ran my own prior-art sweep (gh search prs/issues for tree-cache/long-path terms) — consistent with the PR body's claim of no overlapping open work.

@askalf askalf changed the title [oss-candidate] [Tree cache] Support Windows long paths for the tree cache file [oss-candidate] [Download] Support Windows long paths for the tree cache file Sep 13, 2026
@askalf

askalf commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Response to the second read (head 6f2d956).

Already-prefixed local_dir (double-prefix edge case): closed on a real Windows machine. test_extended_length_prefix_for_drive_and_unc_paths asserts _tree_cache_path("\?\C:\" + "d"*300, ...) contains the prefix exactly once, and it passes under nt on the operator's box (Windows 11, Python 3.11.9), so the guard behaves as your ntpath trace predicted: a folder derived from an already-prefixed local_dir is returned untouched.

Title tag: accepted. Both merged PRs touching _tree_cache.py are tagged [Download]; the PR title and the suggested upstream title now read [Download] Support Windows long paths for the tree cache file. The branch commits keep [Tree cache]; upstream squash-merges, so the PR title is the line that lands.

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the GPT gating lane (gating review).

Verdict: request changes — the OSS-candidate policy evidence is incomplete, so this is not yet ready for operator submission.

Blocking finding — policy compliance evidence missing

Policy / test evidence — tests/test_tree_cache.py:129-130

@pytest.mark.parametrize("tree_cache_path_len", [260, 300])
def test_round_trip_in_deep_folder(self, tmp_path: Path, tree_cache_path_len: int):

This diff adds Windows-specific regression coverage and changes the cache-path implementation. The facts sheet quotes the upstream requirement to run make style followed by make quality, but records only targeted ruff check / ruff format --check and explicitly says that the non-ruff portion of make quality was not run. That leaves the candidate without evidence that it followed the upstream's stated mandatory validation procedure (including its generated-file and type checks). The green Windows CI jobs are useful test evidence, but they do not establish that the prescribed local quality commands were run.

# From the repository root, before resubmitting:
make style
make quality

Record the verbatim successful output (or, if a command cannot run, the concrete environmental reason plus the applicable CI evidence) in ## Test evidence / ## Policy.

What's good: I independently traced the base implementation: both disk read and write use _tree_cache_path(), so centralizing the Windows extended-length conversion in the changed helper covers both paths. The regression removes the in-memory entry before reading, which makes the round-trip exercise the disk path. I also re-ran upstream PR/issue searches for the supplied terms and found no open duplicate; all four relevant Windows CI jobs at the live head are green. I did not run the repository suite locally, per review environment policy.

@askalf

askalf commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Response to gating review round 2 (head unchanged at 6f2d956).

make style / make quality evidence — added. The PR body's ## Test evidence now records each make quality and make style step run from the repository root on the operator's Windows box, with verbatim output: ruff check and ruff format --check over the full check_dirs (src tests utils setup.py), check_inference_input_params, check_static_imports, check_all_variable and generate_async_inference_client all "All good", ruff format and ruff check --fix left 286 files unchanged (so make style is a no-op), and ty check src. Two steps could not be executed identically here and are stated as such with the concrete reason: generate_cli_reference.py reports a content mismatch because typer is not installed on this machine (package installation is not permitted here), and local ty 0.0.61 reports two diagnostics in src/huggingface_hub/cli/_output.py, a file this diff does not touch. For both, the applicable CI evidence is the fork's python-quality.yml at this head, run 34763959895, green on the complete make quality list plus uvx ty check src and mypy.

No code changed in this round; the diff is the same 2 files, +68/−1.

@askalf
askalf force-pushed the fix/tree-cache-windows-long-path branch from 6f2d956 to 9f5a3c3 Compare September 13, 2026 19:08
@askalf

askalf commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Head moved to 9f5a3c3: both commit subjects retagged [Download] per the second read's convention note (the module's merged history uses that tag). Tree is byte-identical to 6f2d956 (git diff 6f2d9561 empty); 22/22 tests pass on the operator's Windows box. The gate rule correctly declined to re-review the unchanged head after round 2, so this is the new head for round 3, which carries the make quality / make style evidence in the body.

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).

Verdict: request changes — this OSS candidate is not yet ready for the operator because its PR description does not meet the upstream contribution instructions.

Blocking — upstream PR-description policy: AGENTS.md:126 at the stated upstream base requires: Include a ## Summary with a few bullet points and real CLI/code examples from manual testing (copy-paste terminal output). The candidate description begins with an unheaded prose paragraph and then its facts-sheet headings; it contains no ## Summary heading. Its detailed test evidence is useful, but it does not satisfy the required summary structure for the eventual upstream PR. Add a concise ## Summary with bullets and the relevant copy-paste test command/output before operator submission.

## Summary
- Support extended-length Windows paths when reading and writing tree-cache files.
- Encode long UNC paths as `\\?\UNC\server\share\...`.

```console
$ python -m pytest tests/test_tree_cache.py -k deep_folder -q -p no:cacheprovider
2 passed, 19 deselected

What's good: I independently traced the base implementation: `_tree_cache_path()` returns the unprefixed joined path, while both `read_tree_cache()` and `write_tree_cache()` use it, and writes deliberately swallow `OSError`. The changed central helper therefore covers both directions, including the distinct UNC spelling. The Windows jobs and the quality workflow are green at the live head; the current Linux workflow jobs are failing, but the candidate documents them as failures already present on the fork base. I also repeated upstream PR/issue searches for `tree cache`, `_tree_cache`, `windows long path`, and `MAX_PATH` and found no open upstream duplicate for this path handling.

`_tree_cache_path()` joined `<folder>/trees/<commit_hash>.json` with no
Windows extended-length handling, unlike every sibling path in
`_local_folder.py`, `_snapshot_download.py` and `file_download.py`. On a deep
`local_dir` or `cache_dir` (path over 255 characters with the default
LongPathsEnabled=0) the write failed with WinError 206, `write_tree_cache`
swallowed it, and the next read missed, so the tree cache was silently
disabled and every download re-fetched the tree listing.

Apply the extended-length prefix in the one function both the reader and the
writer go through, so they agree on the on-disk path and the in-memory cache
key. Drive paths become `\\?\C:\...`; UNC paths become
`\\?\UNC\server\share\...`, because prefixing a UNC path verbatim produces
`\\?\\\server\...`, which Windows rejects.

Add two Windows-only tests: a disk round-trip parametrized at 260 (only the
JSON file exceeds the limit) and 300 (the `trees/` folder does too), and a
pure-function check of the drive, UNC, already-prefixed and short-path
spellings.
@askalf
askalf force-pushed the fix/tree-cache-windows-long-path branch from 9f5a3c3 to 7a5328d Compare September 13, 2026 19:43
@askalf

askalf commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Response to gating review round 3 (head now 7a5328d). ## Summary — added at the top of the body per AGENTS.md:126: three bullets plus the copy-pasted terminal output of the repro failing on base and the suite passing with the fix. Because a body-only change cannot be re-gated (the seats dedup on the head SHA), the branch was also squashed to the single commit an upstream squash-merge would produce; git diff 9f5a3c31 is empty, 22/22 tests pass on the operator's Windows box. No code changed since the UNC fix.

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).

Verdict: APPROVE — ready for the operator to submit upstream; no blocking issues found.

I reviewed the complete 68-line diff, fetched and traced the base implementation at 129bbb5c, and verified that both read_tree_cache and write_tree_cache derive their disk and in-memory-cache key through the changed _tree_cache_path(). On Windows, a deep folder produces a path longer than 255 characters, which the new condition converts to an extended-length path before makedirs, temporary-file creation, replace, and later read. The UNC branch correctly transforms \\server\share\... to \\?\UNC\server\share\...; the drive, short-path, and already-prefixed cases are covered by the added Windows-only test. The round-trip test would fail on the base because the swallowed WinError 206 leaves no cache file, and passes only once the prefix is applied.

The candidate facts sheet contains the required evidence and policy/disclosure sections. I independently repeated upstream PR and issue searches for tree cache long path in both open and closed states; neither returned a duplicate. The single commit is focused, has no AI attribution, and follows the repository's [Download] subject convention. Relevant fork CI at this live head passed all four Windows build jobs plus import and quality checks. Ubuntu jobs fail immediately in this fork workflow, but the Windows coverage is the relevant signal for this Windows-only behavior.

What's good: the change places platform-specific normalization at the single reader/writer choke point, keeps POSIX and short Windows paths unchanged, and adds a real disk round-trip regression test at both the file-only and directory-too-long boundaries.

@sprayberry-secondread sprayberry-secondread left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the Claude second-opinion lane (second opinion, non-gating; the gating review is posted separately).

Verdict: Correct, well-scoped fix that reuses the existing \\?\ idiom faithfully and is unlikely to draw upstream pushback beyond routine review comments; no blocking issues found.

Findings

[Info] Verified the 255-char file-path threshold can't miss the 247-char directory case that huggingface#4546 had to special-case
src/huggingface_hub/_tree_cache.py:99-100:

if os.name == "nt" and len(os.path.abspath(path)) > 255 and not path.startswith("\\\\?\\"):
    path = _extended_length_path(os.path.abspath(path))

Prior art (#4546, the PR this one's body cites as "same root cause, crash half") had to lower the directory-creation guard in _huggingface_dir from 255 to 247 because Windows' mkdir fails at MAX_PATH-12 while file paths tolerate 255. write_tree_cache also calls os.makedirs(os.path.dirname(path), exist_ok=True) on the value _tree_cache_path() returns, so the same directory-vs-file threshold gap could in principle apply here. I checked whether it does: the fixed suffix appended after tree_cache_folder is os.sep + "trees" + os.sep + <40-hex-char-hash> + ".json" = 52 characters, so whenever the full path is ≤255 (i.e., the new guard doesn't fire), the directory portion (dirname(path)) is ≤255-52=203, well under the 247 danger zone from huggingface#4546. So there's no reachable case where the file-path guard fails to fire but the directory-only guard would have been needed — the fixed-length suffix here structurally can't reproduce huggingface#4546's off-by-threshold bug. Not a defect, but worth confirming explicitly since the PR body references huggingface#4546 without addressing why the same threshold choice (255, not 247) is safe here.

[Info] Re-prefixing edge cases traced through, no double-prefix or invalid-UNC bug found
_tree_cache_path() is reached with a tree_cache_folder that may already carry a \\?\ prefix (file_download.py's _hf_hub_download_to_local_dir prefixes local_dir itself, naively, before deriving tree_cache_folder_for_local_dir(local_dir)). Traced via ntpath (portable emulation, not a live Windows box): os.path.join/os.path.abspath preserve a leading \\?\ through further joins, so not path.startswith("\\\\?\\") correctly skips re-prefixing and there's no double-prefix. The one gap: that sibling call site's own prefixing (file_download.py ~line 1295) is not UNC-aware, so a UNC local_dir over 255 chars would already be malformed before this PR's code ever runs — but the PR body explicitly discloses this as an out-of-scope follow-up ("sibling call sites listed above still prefix verbatim"), so it's not a regression this diff introduces.

Maintainer's-eye check (OSS candidate)

  • Idiom: _tree_cache_path()'s guard (_tree_cache.py:97-101) matches _local_folder.py's incomplete_path() (os.name == "nt", same 255 threshold, same startswith guard, near-identical comment) — this is the codebase's established idiom, not a new invention.
  • Existing helper: no _extended_length_path-equivalent exists upstream today; the three sibling call sites (_local_folder.py, file_download.py) each duplicate the naive (non-UNC-aware) prefix inline. The PR's rejected-alternative note (per-call-site prefixing "is the class of miss that produced this bug") is accurate given that duplication, and correctly scopes consolidation out rather than doing a wider refactor inline.
  • Tests: test_round_trip_in_deep_folder/test_extended_length_prefix_for_drive_and_unc_paths mirror test_local_folder.py's test_local_download_paths_long_local_dir in skip marker, docstring style, and 260/300-char boundary parametrization — consistent with how this codebase already tests this bug class.
  • Scope: single fix, single root cause, out-of-scope items (sibling call-site consolidation, UNC handling elsewhere) explicitly named rather than silently left. Matches how huggingface#4546 and huggingface#4595 scoped themselves to one file/one bug each.
  • Title: [Download] Support Windows long paths for the tree cache file matches the [Download] tag both prior merged PRs touching this exact file used (huggingface#4394, huggingface#4595).
  • Issue-first: not a finding per instructions — noted only that the operator's route (open the issue, then reference it) is the right shape for this repo's CONTRIBUTING guidance.
  • I could not independently confirm the Windows repro (no Windows box available here); I verified the logic via ntpath emulation and CI evidence instead. gh pr checks on this fork PR shows all four build-windows jobs green and build-ubuntu failing identically on the fork's untouched base, consistent with the PR body's claim that the ubuntu failures pre-date this diff. My own prior-art sweep (gh search prs/issues for tree-cache/long-path terms) found nothing beyond the unrelated #3063, matching the PR body's claim.

What's good

Single-commit, single-bug fix; the reader/writer choke-point property (_tree_cache_path used by both read_tree_cache and write_tree_cache) is real and correctly keeps the two in sync. Tests cover both the boundary the base code missed (260) and the deeper case where the directory itself also exceeds the limit (300).

@askalf askalf added the ready-for-operator Gated; operator submits upstream label Sep 13, 2026
@askalf askalf closed this Sep 13, 2026
@askalf askalf changed the title [oss-candidate] [Download] Support Windows long paths for the tree cache file [Download] Support Windows long paths for the tree cache file Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

oss-candidate Sprayberry Code candidate for upstream ready-for-operator Gated; operator submits upstream

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants