Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion synapseclient/core/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- Last reviewed: 2026-03 -->
<!-- Last reviewed: 2026-07 -->

## Project

Expand Down Expand Up @@ -44,6 +44,7 @@ Maps Java class names from Synapse REST API for polymorphic deserialization. Whe
- `log_dataclass_diff(obj1, obj2)` — logs field-by-field differences between two dataclass instances
- `snake_case(name)` — converts camelCase to snake_case
- `normalize_whitespace(s)` — collapses whitespace
- `normalize_path(path)` — absolute path with forward slashes. Deliberately does NOT call `os.path.normcase()` (which lowercases on Windows and corrupted derived entity names — SYNR-1534). Case-insensitive cache matching lives in `cache.py::_match_cache_map_key`, which prefers an exact match and falls back to `os.path.normcase` so legacy lowercased cache keys still resolve without forcing re-downloads.
- `MB`, `KB`, `GB` — byte size constants
- `make_bogus_data_file()`, `make_bogus_binary_file(n)`, `make_bogus_uuid_file()` — test file generators (in production code, used by tests)

Expand Down
70 changes: 56 additions & 14 deletions synapseclient/core/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,37 @@ def _get_modified_time(path):
return None


def _match_cache_map_key(
cache_map: dict, path: typing.Union[str, None]
) -> typing.Union[str, None]:
"""
Find the key in ``cache_map`` that corresponds to ``path``.
An exact match is tried first. If none exists, fall back to a
case-insensitive (``os.path.normcase``) comparison so cache entries
written by older clients, which lowercased keys via ``os.path.normcase``
on Windows, are still found — preserving path casing in
``utils.normalize_path`` doesn't force a re-download of already-cached
files. On POSIX, ``os.path.normcase`` is a no-op, so the fallback never
changes behavior there.

Arguments:
cache_map: The parsed ``.cacheMap`` contents (path -> entry).
path: A path already run through ``utils.normalize_path``.

Returns:
The matching key from ``cache_map``, or ``None`` if there is no match.
"""
if path is None:
return None
if path in cache_map:
return path
normalized_path = os.path.normcase(path)
for key in cache_map:
if os.path.normcase(key) == normalized_path:
return key
return None


class Cache:
"""
Represent a cache in which files are accessed by file handle ID.
Expand Down Expand Up @@ -202,7 +233,6 @@ def _cache_item_unmodified(
"""
cached_time = self._get_cache_modified_time(cache_map_entry)
cached_md5 = self._get_cache_content_md5(cache_map_entry)

# compare_timestamps has an implicit check for whether the path exists
return compare_timestamps(_get_modified_time(path), cached_time) and (
cached_md5 is None or cached_md5 == utils.md5_for_file(path).hexdigest()
Expand All @@ -228,7 +258,10 @@ def contains(

path = utils.normalize_path(path)

cached_time = self._get_cache_modified_time(cache_map.get(path, None))
matched_key = _match_cache_map_key(cache_map, path)
cached_time = self._get_cache_modified_time(
cache_map.get(matched_key, None)
)

if cached_time:
return compare_timestamps(_get_modified_time(path), cached_time)
Expand Down Expand Up @@ -275,18 +308,21 @@ def get(
# but has been modified, we need to indicate no match by returning
# None. The logic for updating a synapse entity depends on this to
# determine the need to upload a new file.

if path is not None:
# If we're given a path to a directory, look for a cached file in that directory
if os.path.isdir(path):
matching_unmodified_directory = None
removed_entry_from_cache = (
False # determines if cache_map needs to be rewritten to disk
)

# iterate a copy of cache_map to allow modifying original cache_map
for cached_file_path, cache_map_entry in dict(cache_map).items():
if path == os.path.dirname(cached_file_path):
# Compare case-insensitively via os.path.normcase (a
# no-op on POSIX) so directories cached by older clients
# with lowercased keys on Windows are still matched.
if os.path.normcase(path) == os.path.normcase(
os.path.dirname(cached_file_path)
):
Comment thread
danlu1 marked this conversation as resolved.
if self._cache_item_unmodified(
cache_map_entry, cached_file_path
):
Expand All @@ -311,7 +347,8 @@ def get(

# if we're given a full file path, look up a matching file in the cache
else:
cache_map_entry = cache_map.get(path, None)
matched_key = _match_cache_map_key(cache_map, path)
cache_map_entry = cache_map.get(matched_key, None)
if cache_map_entry:
matching_file_path = (
path
Expand All @@ -335,7 +372,6 @@ def get(
if self._cache_item_unmodified(cache_map_entry, cached_file_path):
trace.get_current_span().set_attributes({"synapse.cache.hit": True})
return cached_file_path

trace.get_current_span().set_attributes({"synapse.cache.hit": False})
return None

Expand All @@ -350,13 +386,18 @@ def add(
"""
if not path or not os.path.exists(path):
raise ValueError('Can\'t find file "%s"' % path)

cache_dir = self.get_cache_dir(file_handle_id)
content_md5 = md5 or utils.md5_for_file(path).hexdigest()
with Lock(self.cache_map_file_name, dir=cache_dir):
cache_map = self._read_cache_map(cache_dir)

path = utils.normalize_path(path)
# Drop any pre-existing entry that refers to the same file under a
# different casing (e.g. a lowercased key written by an older client
# on Windows) so the cache map migrates to the case-preserving key
# instead of accumulating duplicates.
existing_key = _match_cache_map_key(cache_map, path)
if existing_key is not None and existing_key != path:
del cache_map[existing_key]
# write .000 milliseconds for backward compatibility
cache_map[path] = {
"modified_time": epoch_time_to_iso(
Expand Down Expand Up @@ -409,11 +450,12 @@ def remove(
cache_map = {}
else:
path = utils.normalize_path(path)
if path in cache_map:
if delete is True and os.path.exists(path):
os.remove(path)
del cache_map[path]
removed.append(path)
matched_key = _match_cache_map_key(cache_map, path)
if matched_key is not None:
if delete is True and os.path.exists(matched_key):
os.remove(matched_key)
del cache_map[matched_key]
removed.append(matched_key)

self._write_cache_map(cache_dir, cache_map)

Expand Down
13 changes: 11 additions & 2 deletions synapseclient/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,10 +406,19 @@ def guess_file_name(string):


def normalize_path(path):
"""Transforms a path into an absolute path with forward slashes only."""
"""Transforms a path into an absolute path with forward slashes only.

Note: this intentionally does NOT use os.path.normcase(). On Windows
normcase() lowercases the entire path, which corrupted derived entity names
(SYNR-1534) and would rename downloaded files on disk. os.path.abspath()
already normalizes separators to the OS default; the re.sub then converts
them to forward slashes. Case-insensitive matching for the local file cache
is handled separately in core/cache.py so that this function can preserve
casing without breaking cache lookups on case-insensitive Windows volumes.
"""
if path is None:
return None
return re.sub(r"\\", "/", os.path.normcase(os.path.abspath(path)))
return re.sub(r"\\", "/", os.path.abspath(path))


def equal_paths(path1, path2):
Expand Down
Loading
Loading