Skip to content

Commit 7880d62

Browse files
committed
rimport now does relinking step.
Resolves #14.
1 parent 51bbc14 commit 7880d62

8 files changed

Lines changed: 310 additions & 16 deletions

relink.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ def replace_files_with_symlinks(
222222
for file_path in find_owned_files_scandir(
223223
item_to_process, user_uid, inputdata_root
224224
):
225+
logger.info("'%s':", file_path)
225226
replace_one_file_with_symlink(
226227
inputdata_root, target_dir, file_path, dry_run=dry_run
227228
)
@@ -238,7 +239,6 @@ def replace_one_file_with_symlink(inputdata_root, target_dir, file_path, dry_run
238239
file_path (str): The path of the file to be replaced.
239240
dry_run (bool): If True, only show what would be done without making changes.
240241
"""
241-
logger.info("'%s':", file_path)
242242

243243
# Determine the relative path and the new link's destination
244244
relative_path = os.path.relpath(file_path, inputdata_root)
@@ -270,7 +270,7 @@ def replace_one_file_with_symlink(inputdata_root, target_dir, file_path, dry_run
270270
os.rename(link_name, link_name + ".tmp")
271271
logger.info("%sDeleted original file: %s", INDENT, link_name)
272272
except OSError as e:
273-
logger.error("%sError deleting file %s: %s. Skipping.", INDENT, link_name, e)
273+
logger.error("%sError deleting file %s: %s. Skipping relink.", INDENT, link_name, e)
274274
return
275275

276276
# Create the symbolic link, handling necessary parent directories
@@ -283,7 +283,7 @@ def replace_one_file_with_symlink(inputdata_root, target_dir, file_path, dry_run
283283
except OSError as e:
284284
os.rename(link_name + ".tmp", link_name)
285285
logger.error(
286-
"%sError creating symlink for %s: %s. Skipping.", INDENT, link_name, e
286+
"%sError creating symlink for %s: %s. Skipping relink.", INDENT, link_name, e
287287
)
288288

289289

rimport

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ from typing import Iterable, List
1818
from urllib.request import Request, urlopen
1919
from urllib.error import HTTPError
2020

21+
from relink import replace_one_file_with_symlink
22+
2123
import shared
2224
INDENT = shared.INDENT
2325

@@ -143,10 +145,24 @@ def normalize_paths(root: Path, relnames: Iterable[str]) -> List[Path]:
143145
return paths
144146

145147

148+
def check_relink_worked(src: Path, dst: Path) -> None:
149+
"""Check whether relink worked
150+
151+
Args:
152+
src (Path): Source file (should have been converted to symlink)
153+
dst (Path): Destination file (symlink target)
154+
155+
Raises:
156+
RuntimeError: If src is not a symlink pointing to dst.
157+
"""
158+
if not (src.is_symlink() and src.resolve() == dst):
159+
raise RuntimeError("Error relinking during rimport")
160+
161+
146162
def stage_data(
147163
src: Path, inputdata_root: Path, staging_root: Path, check: bool = False
148164
) -> None:
149-
"""Stage a file by mirroring its path under `staging_root`.
165+
"""Stage a file by mirroring its path under `staging_root`, then replace with symlink to staged.
150166
151167
Destination path is computed by replacing the `inputdata_root` prefix of `src`
152168
with `staging_root`, i.e.:
@@ -162,6 +178,7 @@ def stage_data(
162178
RuntimeError: If `src` is a live symlink pointing outside staging, or if `src` is outside
163179
the inputdata root, or if `src` is already under staging directory.
164180
RuntimeError: If `src` is a broken symlink.
181+
RuntimeError: If it failed to replace `src` with a symlink to the staged file.
165182
FileNotFoundError: If `src` does not exist.
166183
167184
Guardrails:
@@ -199,20 +216,25 @@ def stage_data(
199216
dst = staging_root / rel
200217

201218
if dst.exists():
202-
logger.info("%sFile is already published but NOT linked; do", INDENT)
203-
logger.info("%srelink.py %s", 2 * INDENT, rel)
204-
logger.info("%sto resolve.", INDENT)
219+
logger.info("File is already published but NOT linked; linking now.")
220+
replace_one_file_with_symlink(inputdata_root, staging_root, str(src))
205221
print_can_file_be_downloaded(can_file_be_downloaded(rel, staging_root))
222+
check_relink_worked(src, dst)
206223
return
207224

208225
if check:
209226
logger.info("%sFile is not already published", INDENT)
210227
return
211228

229+
# Copy file to destination
212230
dst.parent.mkdir(parents=True, exist_ok=True)
213231
shutil.copy2(src, dst)
214232
logger.info("%s[rimport] staged %s -> %s", INDENT, src, dst)
215233

234+
# Replace original with symlink to destination
235+
replace_one_file_with_symlink(inputdata_root, staging_root, str(src))
236+
check_relink_worked(src, dst)
237+
216238

217239
def ensure_running_as(target_user: str, argv: list[str]) -> None:
218240
"""Ensure the script is running as the target user, re-executing via sudo if needed.

tests/relink/test_replace_files_with_symlinks.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from unittest.mock import patch, call
1313
import pytest
1414

15+
import shared
16+
1517
# Add parent directory to path to import relink module
1618
sys.path.insert(
1719
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -20,14 +22,23 @@
2022
import relink # noqa: E402
2123

2224

25+
@pytest.fixture(autouse=True)
26+
def configure_logging_for_tests():
27+
"""Configure logging for all tests in this module."""
28+
shared.configure_logging(logging.INFO)
29+
yield
30+
# Cleanup
31+
relink.logger.handlers.clear()
32+
33+
2334
@pytest.fixture(name="mock_replace_one")
2435
def fixture_mock_replace_one():
2536
"""Fixture that mocks relink.replace_one_file_with_symlink"""
2637
with patch("relink.replace_one_file_with_symlink") as mock:
2738
yield mock
2839

2940

30-
def test_basic_file_replacement_given_dir(temp_dirs, current_user, mock_replace_one):
41+
def test_basic_file_replacement_given_dir(temp_dirs, current_user, mock_replace_one, caplog):
3142
"""Test basic functionality: given directory, replace owned file with symlink."""
3243
inputdata_root, target_dir = temp_dirs
3344
username = current_user
@@ -55,8 +66,11 @@ def test_basic_file_replacement_given_dir(temp_dirs, current_user, mock_replace_
5566
dry_run=False,
5667
)
5768

69+
# Verify message with filename was printed
70+
assert f"'{source_file}':" in caplog.text
5871

59-
def test_basic_file_replacement_given_file(temp_dirs, current_user, mock_replace_one):
72+
73+
def test_basic_file_replacement_given_file(temp_dirs, current_user, mock_replace_one, caplog):
6074
"""Test basic functionality: given owned file, replace with symlink."""
6175
inputdata_root, target_dir = temp_dirs
6276
username = current_user
@@ -84,8 +98,11 @@ def test_basic_file_replacement_given_file(temp_dirs, current_user, mock_replace
8498
dry_run=False,
8599
)
86100

101+
# Verify message with filename was printed
102+
assert f"'{source_file}':" in caplog.text
103+
87104

88-
def test_dry_run(temp_dirs, current_user, mock_replace_one):
105+
def test_dry_run(temp_dirs, current_user, mock_replace_one, caplog):
89106
"""Test that dry_run=True is passed correctly."""
90107
inputdata_root, target_dir = temp_dirs
91108
username = current_user
@@ -117,6 +134,9 @@ def test_dry_run(temp_dirs, current_user, mock_replace_one):
117134
dry_run=True,
118135
)
119136

137+
# Verify message with filename was printed
138+
assert f"'{source_file}':" in caplog.text
139+
120140

121141
def test_nested_directory_structure(temp_dirs, current_user, mock_replace_one):
122142
"""Test with nested directory structures."""
@@ -175,6 +195,9 @@ def test_skip_existing_symlinks(temp_dirs, current_user, caplog, mock_replace_on
175195
# Verify replace_one_file_with_symlink() wasn't called
176196
mock_replace_one.assert_not_called()
177197

198+
# Verify message with filename was NOT printed
199+
assert f"'{source_link}':" not in caplog.text
200+
178201

179202
def test_missing_target_file(temp_dirs, current_user, caplog, mock_replace_one):
180203
"""Test behavior when target file doesn't exist."""
@@ -224,7 +247,7 @@ def test_invalid_username(temp_dirs, caplog, mock_replace_one):
224247
mock_replace_one.assert_not_called()
225248

226249

227-
def test_multiple_files(temp_dirs, current_user, mock_replace_one):
250+
def test_multiple_files(temp_dirs, current_user, mock_replace_one, caplog):
228251
"""Test with multiple files in the directory."""
229252
inputdata_root, target_dir = temp_dirs
230253
username = current_user
@@ -251,6 +274,11 @@ def test_multiple_files(temp_dirs, current_user, mock_replace_one):
251274
calls.append(call(inputdata_root, target_dir, source_file, dry_run=False))
252275
mock_replace_one.assert_has_calls(calls, any_order=True)
253276

277+
# Verify message with filename was printed
278+
for i in range(5):
279+
source_file = os.path.join(inputdata_root, f"file_{i}.txt")
280+
assert f"'{source_file}':" in caplog.text
281+
254282

255283
def test_multiple_files_nested(temp_dirs, current_user, mock_replace_one):
256284
"""Test with multiple files scattered throughout a nested directory tree."""

tests/relink/test_replace_one_file_with_symlink.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def test_absolute_paths(temp_dirs):
118118
os.chdir(cwd)
119119

120120

121-
def test_print_found_owned_file(temp_dirs, caplog):
121+
def test_no_print_found_owned_file(temp_dirs, caplog):
122122
"""Test that message with filename is printed."""
123123
source_dir, target_dir = temp_dirs
124124

@@ -135,9 +135,8 @@ def test_print_found_owned_file(temp_dirs, caplog):
135135
with caplog.at_level(logging.INFO):
136136
relink.replace_one_file_with_symlink(source_dir, target_dir, source_file)
137137

138-
# Check that message was logged
139-
assert f"'{source_file}':" in caplog.text
140-
assert source_file in caplog.text
138+
# Check that message was NOT logged (should happen in replace_files_with_symlinks instead)
139+
assert f"'{source_file}':" not in caplog.text
141140

142141

143142
def test_print_deleted_and_created_messages(temp_dirs, caplog):
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""
2+
Tests for check_relink_worked() function in rimport script.
3+
"""
4+
5+
import os
6+
import importlib.util
7+
from importlib.machinery import SourceFileLoader
8+
9+
import pytest
10+
11+
12+
# Import rimport module from file without .py extension
13+
rimport_path = os.path.join(
14+
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
15+
"rimport",
16+
)
17+
loader = SourceFileLoader("rimport", rimport_path)
18+
spec = importlib.util.spec_from_loader("rimport", loader)
19+
if spec is None:
20+
raise ImportError(f"Could not create spec for rimport from {rimport_path}")
21+
rimport = importlib.util.module_from_spec(spec)
22+
# Don't add to sys.modules to avoid conflict with other test files
23+
loader.exec_module(rimport)
24+
25+
def test_ok(tmp_path):
26+
"""Check that it doesn't error if src is a symlink pointing to dst"""
27+
# Set up
28+
dst = tmp_path / "dst.nc"
29+
src = tmp_path / "src.nc"
30+
src.symlink_to(dst)
31+
32+
# Shouldn't error
33+
rimport.check_relink_worked(src, dst)
34+
35+
def test_error_not_symlink(tmp_path):
36+
"""Check that it does error if src isn't a symlink"""
37+
# Set up
38+
dst = tmp_path / "dst.nc"
39+
src = tmp_path / "src.nc"
40+
41+
# Should error
42+
with pytest.raises(RuntimeError) as exc_info:
43+
rimport.check_relink_worked(src, dst)
44+
45+
# Verify error message was printed
46+
assert "Error relinking during rimport" in str(exc_info.value)
47+
48+
def test_error_symlink_but_not_to_dst(tmp_path):
49+
"""Check that it does error if src is a symlink but not pointing to dst"""
50+
# Set up
51+
dst = tmp_path / "dst.nc"
52+
other = tmp_path / "other.nc"
53+
src = tmp_path / "src.nc"
54+
src.symlink_to(other)
55+
56+
# Should error
57+
with pytest.raises(RuntimeError) as exc_info:
58+
rimport.check_relink_worked(src, dst)
59+
60+
# Verify error message was printed
61+
assert "Error relinking during rimport" in str(exc_info.value)

tests/rimport/test_cmdline.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ def test_file_option_stages_single_file(
8282
assert staged_file.exists()
8383
assert staged_file.read_text() == "test data"
8484

85+
# Verify file was relinked
86+
assert test_file.is_symlink()
87+
assert test_file.resolve() == staged_file
88+
8589
def test_list_option_stages_multiple_files(
8690
self, rimport_script, test_env, rimport_env
8791
):
@@ -127,6 +131,12 @@ def test_list_option_stages_multiple_files(
127131
assert (staging_root / "file1.nc").read_text() == "data1"
128132
assert (staging_root / "file2.nc").read_text() == "data2"
129133

134+
# Verify both files were relinked
135+
assert file1.is_symlink()
136+
assert file1.resolve() == (staging_root / "file1.nc")
137+
assert file2.is_symlink()
138+
assert file2.resolve() == (staging_root / "file2.nc")
139+
130140
def test_preserves_directory_structure(self, rimport_script, test_env, rimport_env):
131141
"""Test that directory structure is preserved in staging."""
132142
inputdata_root = test_env["inputdata_root"]
@@ -163,6 +173,10 @@ def test_preserves_directory_structure(self, rimport_script, test_env, rimport_e
163173
assert staged_file.exists()
164174
assert staged_file.read_text() == "nested data"
165175

176+
# Verify file was relinked
177+
assert nested_file.is_symlink()
178+
assert nested_file.resolve() == staged_file
179+
166180
def test_error_for_nonexistent_file(self, rimport_script, test_env, rimport_env):
167181
"""Test that error is reported for nonexistent file."""
168182
inputdata_root = test_env["inputdata_root"]
@@ -308,6 +322,12 @@ def test_list_with_comments_and_blanks(self, rimport_script, test_env, rimport_e
308322
assert (staging_root / "file1.nc").exists()
309323
assert (staging_root / "file2.nc").exists()
310324

325+
# Verify both files were relinked
326+
assert file1.is_symlink()
327+
assert file1.resolve() == (staging_root / "file1.nc")
328+
assert file2.is_symlink()
329+
assert file2.resolve() == (staging_root / "file2.nc")
330+
311331
def test_prints_and_exits_for_already_published_linked_file(
312332
self, rimport_script, test_env, rimport_env
313333
):
@@ -473,5 +493,8 @@ def test_check_doesnt_copy(self, rimport_script, test_env, rimport_env):
473493
staged_file = staging_root / "test.nc"
474494
assert not staged_file.exists()
475495

496+
# Verify file was not replaced with a symlink
497+
assert not test_file.is_symlink()
498+
476499
# Verify message was printed
477500
assert "not already published" in result.stdout

0 commit comments

Comments
 (0)