Skip to content

Commit 2fbd023

Browse files
committed
fix(downloader): create directories for identically named files (#178)
When a dataset contains files with the same file name in different directories (e.g. 0002/AO2D.root, 0003/AO2D.root), the downloader now preserves the directory structure instead of overwriting files. The common directory prefix is stripped to keep paths minimal. Closes #177
1 parent b28a25e commit 2fbd023

4 files changed

Lines changed: 152 additions & 8 deletions

File tree

cernopendata_client/cli.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# -*- coding: utf-8 -*-
22
# This file is part of cernopendata-client.
33
#
4-
# Copyright (C) 2019, 2020, 2021, 2023, 2025 CERN.
4+
# Copyright (C) 2019, 2020, 2021, 2023, 2025, 2026 CERN.
55
#
66
# cernopendata-client is free software; you can redistribute it and/or modify
77
# it under the terms of the GPLv3 license; see LICENSE file for more details.
@@ -29,6 +29,8 @@
2929
get_download_files_by_name,
3030
get_download_files_by_range,
3131
get_download_files_by_regexp,
32+
get_download_path,
33+
get_file_subdirectories,
3234
)
3335
from .validator import (
3436
validate_range,
@@ -355,21 +357,23 @@ def download_files(
355357
sys.exit(0)
356358

357359
total_files = len(download_file_locations)
358-
path = record_recid
359-
if not os.path.isdir(path):
360+
base_path = record_recid
361+
if not os.path.isdir(base_path):
360362
try:
361-
os.mkdir(path)
363+
os.mkdir(base_path)
362364
except OSError:
363365
display_message(
364366
msg_type="error",
365-
msg="Creation of the directory {} failed".format(path),
367+
msg="Creation of the directory {} failed".format(base_path),
366368
)
369+
file_subdirs = get_file_subdirectories(download_file_locations)
367370
if not download_engine:
368371
if protocol.startswith("http"):
369372
download_engine = "requests"
370373
elif protocol == "xrootd":
371374
download_engine = "xrootd"
372375
for file_location in download_file_locations:
376+
path = get_download_path(base_path, file_location, file_subdirs)
373377
display_message(
374378
msg_type="info",
375379
msg="Downloading file {} of {}".format(

cernopendata_client/config.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
"""cernopendata-client configuration."""
1010

11-
1211
SERVER_HTTP_URI = "http://opendata.cern.ch"
1312
"""Default CERN Open Data server to query over HTTP protocol."""
1413

cernopendata_client/downloader.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# -*- coding: utf-8 -*-
22
# This file is part of cernopendata-client.
33
#
4-
# Copyright (C) 2020, 2021 CERN.
4+
# Copyright (C) 2020, 2021, 2026 CERN.
55
#
66
# cernopendata-client is free software; you can redistribute it and/or modify
77
# it under the terms of the GPLv3 license; see LICENSE file for more details.
@@ -360,6 +360,69 @@ def download_single_file(
360360
return
361361

362362

363+
def get_file_subdirectories(file_locations):
364+
"""Return a mapping of file locations to subdirectory paths for disambiguation.
365+
366+
When multiple files share the same file name, compute subdirectory paths
367+
by stripping the longest common directory prefix from the URL paths.
368+
This preserves the original directory structure relative to the common root.
369+
370+
:param file_locations: List of remote file locations (URLs)
371+
:type file_locations: list
372+
373+
:return: Dictionary mapping each file location to its subdirectory (empty
374+
string if no subdirectory is needed)
375+
:rtype: dict
376+
"""
377+
from collections import Counter
378+
379+
file_names = [loc.split("/")[-1] for loc in file_locations]
380+
file_name_counts = Counter(file_names)
381+
has_duplicates = any(count > 1 for count in file_name_counts.values())
382+
383+
if not has_duplicates:
384+
return {loc: "" for loc in file_locations}
385+
386+
# Split each URL into directory components (excluding the filename)
387+
dir_parts_list = [loc.split("/")[:-1] for loc in file_locations]
388+
# Find the longest common prefix of all directory paths
389+
common_prefix_len = 0
390+
if dir_parts_list:
391+
min_len = min(len(parts) for parts in dir_parts_list)
392+
for i in range(min_len):
393+
if len(set(parts[i] for parts in dir_parts_list)) == 1:
394+
common_prefix_len = i + 1
395+
else:
396+
break
397+
# Build subdirectory for each file by stripping the common prefix
398+
result = {}
399+
for loc, dir_parts in zip(file_locations, dir_parts_list):
400+
subdir = "/".join(dir_parts[common_prefix_len:])
401+
result[loc] = subdir
402+
return result
403+
404+
405+
def get_download_path(base_path, file_location, file_subdirs):
406+
"""Return download path for a file, creating subdirectories if needed.
407+
408+
:param base_path: Base directory for downloads (e.g. record ID)
409+
:param file_location: Remote file location URL
410+
:param file_subdirs: Mapping from file locations to subdirectory paths
411+
:type base_path: str
412+
:type file_location: str
413+
:type file_subdirs: dict
414+
415+
:return: Local directory path where the file should be saved
416+
:rtype: str
417+
"""
418+
subdir = file_subdirs[file_location]
419+
if subdir:
420+
path = os.path.join(base_path, subdir)
421+
os.makedirs(path, exist_ok=True)
422+
return path
423+
return base_path
424+
425+
363426
def get_download_files_by_name(names=None, file_locations=None):
364427
"""Return the files filtered by file names.
365428

tests/test_downloader.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#
33
# This file is part of cernopendata-client.
44
#
5-
# Copyright (C) 2025 CERN.
5+
# Copyright (C) 2025, 2026 CERN.
66
#
77
# cernopendata-client is free software; you can redistribute it and/or modify
88
# it under the terms of the GPLv3 license; see LICENSE file for more details.
@@ -15,6 +15,7 @@
1515
get_download_files_by_name,
1616
get_download_files_by_regexp,
1717
get_download_files_by_range,
18+
get_file_subdirectories,
1819
)
1920

2021

@@ -180,3 +181,80 @@ def test_get_download_files_by_range_with_filtered_files():
180181
ranges=["1-2"], file_locations=file_locations, filtered_files=filtered_files
181182
)
182183
assert result == ["http://example.com/file1.txt", "http://example.com/file3.txt"]
184+
185+
186+
@pytest.mark.local
187+
def test_get_file_subdirectories_unique_names():
188+
"""Test that unique file names produce no subdirectories."""
189+
file_locations = [
190+
"http://example.com/dir1/a.txt",
191+
"http://example.com/dir2/b.txt",
192+
"http://example.com/dir3/c.txt",
193+
]
194+
result = get_file_subdirectories(file_locations)
195+
assert all(subdir == "" for subdir in result.values())
196+
197+
198+
@pytest.mark.local
199+
def test_get_file_subdirectories_duplicate_names_one_level():
200+
"""Test that duplicate names with different parent dirs use one level."""
201+
file_locations = [
202+
"http://opendata.cern.ch/eos/opendata/alice/2015/LHC15o/000245349/0002/AO2D.root",
203+
"http://opendata.cern.ch/eos/opendata/alice/2015/LHC15o/000245349/0003/AO2D.root",
204+
"http://opendata.cern.ch/eos/opendata/alice/2015/LHC15o/000245349/0004/AO2D.root",
205+
]
206+
result = get_file_subdirectories(file_locations)
207+
assert result[file_locations[0]] == "0002"
208+
assert result[file_locations[1]] == "0003"
209+
assert result[file_locations[2]] == "0004"
210+
211+
212+
@pytest.mark.local
213+
def test_get_file_subdirectories_duplicate_names_two_levels():
214+
"""Test that deeper disambiguation uses multiple directory levels."""
215+
file_locations = [
216+
"http://opendata.cern.ch/eos/opendata/alice/000245349/0002/AO2D.root",
217+
"http://opendata.cern.ch/eos/opendata/alice/000245350/0002/AO2D.root",
218+
]
219+
result = get_file_subdirectories(file_locations)
220+
assert result[file_locations[0]] == "000245349/0002"
221+
assert result[file_locations[1]] == "000245350/0002"
222+
223+
224+
@pytest.mark.local
225+
def test_get_file_subdirectories_mixed_duplicates():
226+
"""Test mixed unique and duplicate file names."""
227+
file_locations = [
228+
"http://example.com/dir1/0001/data.root",
229+
"http://example.com/dir1/0002/data.root",
230+
"http://example.com/dir1/unique.txt",
231+
]
232+
result = get_file_subdirectories(file_locations)
233+
# Common prefix is "http://example.com/dir1", so subdirs are relative to that
234+
assert result[file_locations[0]] == "0001"
235+
assert result[file_locations[1]] == "0002"
236+
assert result[file_locations[2]] == ""
237+
238+
239+
@pytest.mark.local
240+
def test_get_file_subdirectories_varying_depths():
241+
"""Test files at varying directory depths with common prefix stripping."""
242+
file_locations = [
243+
"http://opendata.cern.ch/eos/opendata/alice/mydata/foo/bar/data.root",
244+
"http://opendata.cern.ch/eos/opendata/alice/mydata/data.root",
245+
"http://opendata.cern.ch/eos/opendata/alice/mydata/foo/baz/data.root",
246+
"http://opendata.cern.ch/eos/opendata/alice/mydata/foo/data.root",
247+
]
248+
result = get_file_subdirectories(file_locations)
249+
# Common prefix is "http://opendata.cern.ch/eos/opendata/alice/mydata"
250+
assert result[file_locations[0]] == "foo/bar"
251+
assert result[file_locations[1]] == ""
252+
assert result[file_locations[2]] == "foo/baz"
253+
assert result[file_locations[3]] == "foo"
254+
255+
256+
@pytest.mark.local
257+
def test_get_file_subdirectories_empty_list():
258+
"""Test with an empty file list."""
259+
result = get_file_subdirectories([])
260+
assert result == {}

0 commit comments

Comments
 (0)