Coverage for python/lsst/resources/tests.py: 8%

666 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-09 03:18 -0700

1# This file is part of lsst-resources. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11from __future__ import annotations 

12 

13__all__ = ["GenericReadWriteTestCase", "GenericTestCase"] 

14 

15import logging 

16import os 

17import pathlib 

18import random 

19import string 

20import tempfile 

21import unittest 

22import urllib.parse 

23import uuid 

24from collections.abc import Iterable 

25from typing import TYPE_CHECKING, Any 

26 

27try: 

28 import fsspec 

29except ImportError: 

30 fsspec = None 

31 

32from lsst.resources import ResourcePath 

33from lsst.resources.utils import makeTestTempDir, removeTestTempDir 

34 

35TESTDIR = os.path.abspath(os.path.dirname(__file__)) 

36 

37 

38def _check_open( 

39 test_case: _GenericTestCase | unittest.TestCase, 

40 uri: ResourcePath, 

41 *, 

42 mode_suffixes: Iterable[str] = ("", "t", "b"), 

43 **kwargs: Any, 

44) -> None: 

45 """Test an implementation of ButlerURI.open. 

46 

47 Parameters 

48 ---------- 

49 test_case : `unittest.TestCase` 

50 Test case to use for assertions. 

51 uri : `ResourcePath` 

52 URI to use for tests. Must point to a writeable location that is not 

53 yet occupied by a file. On return, the location may point to a file 

54 only if the test fails. 

55 mode_suffixes : `~collections.abc.Iterable` of `str` 

56 Suffixes to pass as part of the ``mode`` argument to 

57 `ResourcePath.open`, indicating whether to open as binary or as text; 

58 the only permitted elements are ``""``, ``"t"``, and ``"b"`. 

59 **kwargs 

60 Additional keyword arguments to forward to all calls to `open`. 

61 """ 

62 text_content = "abcdefghijklmnopqrstuvwxyz🙂" 

63 bytes_content = uuid.uuid4().bytes 

64 content_by_mode_suffix: dict[str, str | bytes] = { 

65 "": text_content, 

66 "t": text_content, 

67 "b": bytes_content, 

68 } 

69 empty_content_by_mode_suffix: dict[str, str | bytes] = { 

70 "": "", 

71 "t": "", 

72 "b": b"", 

73 } 

74 # To appease mypy 

75 double_content_by_mode_suffix: dict[str, str | bytes] = { 

76 "": text_content + text_content, 

77 "t": text_content + text_content, 

78 "b": bytes_content + bytes_content, 

79 } 

80 for mode_suffix in mode_suffixes: 

81 content = content_by_mode_suffix[mode_suffix] 

82 double_content = double_content_by_mode_suffix[mode_suffix] 

83 # Create file with mode='x', which prohibits overwriting. 

84 with uri.open("x" + mode_suffix, **kwargs) as write_buffer: 

85 write_buffer.write(content) 

86 test_case.assertTrue(uri.exists()) 

87 # Check that opening with 'x' now raises, and does not modify content. 

88 with test_case.assertRaises(FileExistsError): 

89 with uri.open("x" + mode_suffix, **kwargs) as write_buffer: 

90 write_buffer.write("bad") 

91 # Read the file we created and check the contents. 

92 with uri.open("r" + mode_suffix, **kwargs) as read_buffer: 

93 test_case.assertEqual(read_buffer.read(), content) 

94 # The names will not match if a local temporary is being written. 

95 if not kwargs.get("prefer_file_temporary"): 

96 test_case.assertIn(uri.basename(), read_buffer.name) 

97 # Check that we can read bytes in a loop and get EOF 

98 with uri.open("r" + mode_suffix, **kwargs) as read_buffer: 

99 # Seek off the end of the file and should read empty back. 

100 read_buffer.seek(1024) 

101 test_case.assertEqual(read_buffer.tell(), 1024) 

102 content_read = read_buffer.read() # Read as much as we can. 

103 test_case.assertEqual(len(content_read), 0, f"Read: {content_read!r}, expected empty.") 

104 

105 # First read more than the content. 

106 read_buffer.seek(0) 

107 size = len(content) * 3 

108 chunk_read = read_buffer.read(size) 

109 test_case.assertEqual(chunk_read, content) 

110 

111 # Repeated reads should always return empty string. 

112 chunk_read = read_buffer.read(size) 

113 test_case.assertEqual(len(chunk_read), 0) 

114 chunk_read = read_buffer.read(size) 

115 test_case.assertEqual(len(chunk_read), 0) 

116 

117 # Go back to start of file and read in smaller chunks. 

118 read_buffer.seek(0) 

119 size = len(content) // 3 

120 

121 content_read = empty_content_by_mode_suffix[mode_suffix] 

122 n_reads = 0 

123 while chunk_read := read_buffer.read(size): 

124 content_read += chunk_read 

125 n_reads += 1 

126 if n_reads > 10: # In case EOF never hits because of bug. 

127 raise AssertionError( 

128 f"Failed to stop reading from file after {n_reads} loops. " 

129 f"Read {len(content_read)} bytes/characters. Expected {len(content)}." 

130 ) 

131 test_case.assertEqual(content_read, content) 

132 

133 # Go back to start of file and read the entire thing. 

134 read_buffer.seek(0) 

135 content_read = read_buffer.read() 

136 test_case.assertEqual(content_read, content) 

137 

138 # Seek off the end of the file and should read empty back. 

139 # We run this check twice since in some cases the handle will 

140 # cache knowledge of the file size. 

141 read_buffer.seek(1024) 

142 test_case.assertEqual(read_buffer.tell(), 1024) 

143 content_read = read_buffer.read() 

144 test_case.assertEqual(len(content_read), 0, f"Read: {content_read!r}, expected empty.") 

145 

146 # Write multiple chunks with flushing to ensure that any handles that 

147 # cache without flushing work properly. 

148 n = 3 

149 with uri.open("w" + mode_suffix, **kwargs) as write_buffer: 

150 for _ in range(n): 

151 write_buffer.write(content) 

152 write_buffer.flush() 

153 with uri.open("r" + mode_suffix, **kwargs) as read_buffer: 

154 test_case.assertEqual(read_buffer.read(), content * n) 

155 

156 # Write two copies of the content, overwriting the single copy there. 

157 with uri.open("w" + mode_suffix, **kwargs) as write_buffer: 

158 write_buffer.write(double_content) 

159 # Read again, this time use mode='r+', which reads what is there and 

160 # then lets us write more; we'll use that to reset the file to one 

161 # copy of the content. 

162 with uri.open("r+" + mode_suffix, **kwargs) as rw_buffer: 

163 test_case.assertEqual(rw_buffer.read(), double_content) 

164 rw_buffer.seek(0) 

165 rw_buffer.truncate() 

166 rw_buffer.write(content) 

167 rw_buffer.seek(0) 

168 test_case.assertEqual(rw_buffer.read(), content) 

169 with uri.open("r" + mode_suffix, **kwargs) as read_buffer: 

170 test_case.assertEqual(read_buffer.read(), content) 

171 # Append some more content to the file; should now have two copies. 

172 with uri.open("a" + mode_suffix, **kwargs) as append_buffer: 

173 append_buffer.write(content) 

174 with uri.open("r" + mode_suffix, **kwargs) as read_buffer: 

175 test_case.assertEqual(read_buffer.read(), double_content) 

176 # Final mode to check is w+, which does read/write but truncates first. 

177 with uri.open("w+" + mode_suffix, **kwargs) as rw_buffer: 

178 test_case.assertEqual(rw_buffer.read(), empty_content_by_mode_suffix[mode_suffix]) 

179 rw_buffer.write(content) 

180 rw_buffer.seek(0) 

181 test_case.assertEqual(rw_buffer.read(), content) 

182 # Check that two seeks with reads to end return correctly. 

183 # Seek is only reliable with "b" mode. 

184 if mode_suffix == "b": 

185 with uri.open("r" + mode_suffix, **kwargs) as read_buffer: 

186 size = len(content) 

187 seek1 = 2 * size // 3 

188 read_buffer.seek(seek1) 

189 content1 = read_buffer.read() 

190 test_case.assertEqual(len(content1), size - seek1) 

191 # Seek earlier and then read to end. 

192 seek2 = size // 2 

193 read_buffer.seek(seek2) 

194 content2 = read_buffer.read() 

195 test_case.assertEqual(len(content2), size - seek2) 

196 # Check that we can seek from end and read and seek and read. 

197 # Negative seek only works in binary mode. 

198 with uri.open("rb", **kwargs) as read_buffer: 

199 read_buffer.seek(-5, 2) # Relative to end 

200 content_read = read_buffer.read() 

201 test_case.assertEqual(len(content_read), 5) 

202 read_buffer.seek(-10, 2) # Relative to end 

203 content_read = read_buffer.read() 

204 test_case.assertEqual(len(content_read), 10) 

205 with uri.open("r" + mode_suffix, **kwargs) as read_buffer: 

206 test_case.assertEqual(read_buffer.read(), content) 

207 # Remove file to make room for the next loop of tests with this URI. 

208 uri.remove() 

209 

210 

211if TYPE_CHECKING: 

212 

213 class TestCaseMixin(unittest.TestCase): 

214 """Base class for mixin test classes that use TestCase methods.""" 

215 

216 pass 

217 

218else: 

219 

220 class TestCaseMixin: 

221 """Do-nothing definition of mixin base class for regular execution.""" 

222 

223 pass 

224 

225 

226class _GenericTestCase(TestCaseMixin): 

227 """Generic base class for test mixin.""" 

228 

229 scheme: str | None = None 

230 netloc: str | None = None 

231 base_path: str | None = None 

232 path1 = "test_dir" 

233 path2 = "file.txt" 

234 

235 def _make_uri(self, path: str, netloc: str | None = None) -> str: 

236 if self.scheme is not None: 

237 if netloc is None: 

238 netloc = self.netloc 

239 if path.startswith("/"): 

240 path = path[1:] 

241 if self.base_path is not None: 

242 path = f"{self.base_path}/{path}".lstrip("/") 

243 

244 return f"{self.scheme}://{netloc}/{path}" 

245 else: 

246 return path 

247 

248 

249class GenericTestCase(_GenericTestCase): 

250 """Test cases for generic manipulation of a `ResourcePath`.""" 

251 

252 def setUp(self) -> None: 

253 if self.scheme is None: 

254 raise unittest.SkipTest("No scheme defined") 

255 self.root = self._make_uri("") 

256 self.root_uri = ResourcePath(self.root, forceDirectory=True, forceAbsolute=False) 

257 

258 def test_creation(self) -> None: 

259 self.assertEqual(self.root_uri.scheme, self.scheme) 

260 self.assertEqual(self.root_uri.netloc, self.netloc) 

261 self.assertFalse(self.root_uri.query) 

262 self.assertFalse(self.root_uri.params) 

263 

264 with self.assertRaises(ValueError): 

265 ResourcePath({}) # type: ignore 

266 

267 with self.assertRaises(RuntimeError): 

268 ResourcePath(self.root_uri, isTemporary=True) 

269 

270 file = self.root_uri.join("file.txt", forceDirectory=False) 

271 with self.assertRaises(RuntimeError): 

272 ResourcePath(file, forceDirectory=True) 

273 

274 file = self.root_uri.join("file.txt") 

275 file_as_dir = ResourcePath(file, forceDirectory=True) 

276 self.assertTrue(file_as_dir.isdir()) 

277 

278 dir = self._make_uri("a/b/c/") 

279 with self.assertRaises(ValueError): 

280 ResourcePath(dir, forceDirectory=False) 

281 

282 with self.assertRaises(NotImplementedError): 

283 ResourcePath("unknown://netloc") 

284 

285 replaced = file.replace(fragment="frag") 

286 self.assertEqual(replaced.fragment, "frag") 

287 

288 with self.assertRaises(ValueError): 

289 file.replace(scheme="new") 

290 

291 self.assertNotEqual(replaced, str(replaced)) 

292 self.assertNotEqual(str(replaced), replaced) 

293 

294 def test_extension(self) -> None: 

295 uri = ResourcePath(self._make_uri("dir/test.txt")) 

296 self.assertEqual(uri.updatedExtension(None), uri) 

297 self.assertEqual(uri.updatedExtension(".txt"), uri) 

298 self.assertEqual(id(uri.updatedExtension(".txt")), id(uri)) 

299 

300 fits = uri.updatedExtension(".fits.gz") 

301 self.assertEqual(fits.basename(), "test.fits.gz") 

302 self.assertEqual(fits.updatedExtension(".jpeg").basename(), "test.jpeg") 

303 

304 extensionless = self.root_uri.join("no_ext") 

305 self.assertEqual(extensionless.getExtension(), "") 

306 extension = extensionless.updatedExtension(".fits") 

307 self.assertEqual(extension.getExtension(), ".fits") 

308 

309 uri = ResourcePath("test.txt", forceAbsolute=False) 

310 self.assertEqual(uri.getExtension(), ".txt") 

311 uri = ResourcePath(self._make_uri("dir.1/dir.2/test.txt"), forceDirectory=False) 

312 self.assertEqual(uri.getExtension(), ".txt") 

313 uri = ResourcePath(self._make_uri("dir.1/dir.2/"), forceDirectory=True) 

314 self.assertEqual(uri.getExtension(), ".2") 

315 uri = ResourcePath(self._make_uri("dir.1/dir/"), forceDirectory=True) 

316 self.assertEqual(uri.getExtension(), "") 

317 

318 def test_relative(self) -> None: 

319 """Check that we can get subpaths back from two URIs.""" 

320 parent = ResourcePath(self._make_uri(self.path1), forceDirectory=True) 

321 self.assertTrue(parent.isdir()) 

322 child = parent.join("dir1/file.txt") 

323 

324 self.assertEqual(child.relative_to(parent), "dir1/file.txt") 

325 

326 not_child = ResourcePath("/a/b/dir1/file.txt") 

327 self.assertIsNone(not_child.relative_to(parent)) 

328 self.assertFalse(not_child.isdir()) 

329 

330 not_directory = parent.join("dir1/file2.txt") 

331 self.assertIsNone(child.relative_to(not_directory)) 

332 

333 # Relative URIs 

334 parent = ResourcePath("a/b/", forceAbsolute=False) 

335 child = ResourcePath("a/b/c/d.txt", forceAbsolute=False) 

336 self.assertFalse(child.scheme) 

337 self.assertEqual(child.relative_to(parent), "c/d.txt") 

338 

339 # forceAbsolute=True should work even on an existing ResourcePath 

340 self.assertTrue(pathlib.Path(ResourcePath(child, forceAbsolute=True).ospath).is_absolute()) 

341 

342 # Absolute URI and schemeless URI 

343 parent = self.root_uri.join("/a/b/c/") 

344 child = ResourcePath("e/f/g.txt", forceAbsolute=False) 

345 

346 # If the child is relative and the parent is absolute we assume 

347 # that the child is a child of the parent unless it uses ".." 

348 self.assertEqual(child.relative_to(parent), "e/f/g.txt", f"{child}.relative_to({parent})") 

349 

350 child = ResourcePath("../e/f/g.txt", forceAbsolute=False) 

351 self.assertIsNone(child.relative_to(parent)) 

352 

353 child = ResourcePath("../c/e/f/g.txt", forceAbsolute=False) 

354 self.assertEqual(child.relative_to(parent), "e/f/g.txt") 

355 

356 # Test with different netloc 

357 child = ResourcePath(self._make_uri("a/b/c.txt", netloc="my.host")) 

358 parent = ResourcePath(self._make_uri("a", netloc="other"), forceDirectory=True) 

359 self.assertIsNone(child.relative_to(parent), f"{child}.relative_to({parent})") 

360 

361 # This is an absolute path so will *always* return a file URI and 

362 # ignore the root parameter. 

363 parent = ResourcePath("/a/b/c", root=self.root_uri, forceDirectory=True) 

364 self.assertEqual(parent.geturl(), "file:///a/b/c/") 

365 

366 parent = ResourcePath(self._make_uri("/a/b/c"), forceDirectory=True) 

367 child = ResourcePath("d/e.txt", root=parent) 

368 self.assertEqual(child.relative_to(parent), "d/e.txt", f"{child}.relative_to({parent})") 

369 

370 parent = ResourcePath("c/", root=ResourcePath(self._make_uri("/a/b/"))) 

371 self.assertEqual(child.relative_to(parent), "d/e.txt", f"{child}.relative_to({parent})") 

372 

373 # Absolute schemeless child with relative parent will always fail. 

374 child = ResourcePath("d/e.txt", root="/a/b/c") 

375 parent = ResourcePath("d/e.txt", forceAbsolute=False) 

376 self.assertIsNone(child.relative_to(parent), f"{child}.relative_to({parent})") 

377 

378 def test_parents(self) -> None: 

379 """Test of splitting and parent walking.""" 

380 parent = ResourcePath(self._make_uri("somedir"), forceDirectory=True) 

381 child_file = parent.join("subdir/file.txt") 

382 self.assertFalse(child_file.isdir()) 

383 child_subdir, file = child_file.split() 

384 self.assertEqual(file, "file.txt") 

385 self.assertTrue(child_subdir.isdir()) 

386 self.assertEqual(child_file.dirname(), child_subdir) 

387 self.assertEqual(child_file.basename(), file) 

388 self.assertEqual(child_file.parent(), child_subdir) 

389 derived_parent = child_subdir.parent() 

390 self.assertEqual(derived_parent, parent) 

391 self.assertTrue(derived_parent.isdir()) 

392 self.assertEqual(child_file.parent().parent(), parent) 

393 self.assertEqual(child_subdir.dirname(), child_subdir) 

394 

395 # Make sure that the parent doesn't retain any fragment from the 

396 # child. 

397 child_fragment = child_subdir.join("a.txt#fragment") 

398 self.assertEqual(child_fragment.fragment, "fragment") 

399 fragment_parent = child_fragment.parent() 

400 self.assertEqual(fragment_parent.fragment, "") 

401 self.assertTrue(str(fragment_parent).endswith("/")) 

402 

403 def test_escapes(self) -> None: 

404 """Special characters in file paths.""" 

405 src = self.root_uri.join("bbb/???/test.txt") 

406 self.assertNotIn("???", src.path) 

407 self.assertIn("???", src.unquoted_path) 

408 

409 file = src.updatedFile("tests??.txt") 

410 self.assertNotIn("??.txt", file.path) 

411 

412 src = src.updatedFile("tests??.txt") 

413 self.assertIn("??.txt", src.unquoted_path) 

414 

415 # File URI and schemeless URI 

416 parent = ResourcePath(self._make_uri(urllib.parse.quote("/a/b/c/de/??/"))) 

417 child = ResourcePath("e/f/g.txt", forceAbsolute=False) 

418 self.assertEqual(child.relative_to(parent), "e/f/g.txt") 

419 

420 child = ResourcePath("e/f??#/g.txt", forceAbsolute=False) 

421 self.assertEqual(child.relative_to(parent), "e/f??#/g.txt") 

422 

423 child = ResourcePath(self._make_uri(urllib.parse.quote("/a/b/c/de/??/e/f??#/g.txt"))) 

424 self.assertEqual(child.relative_to(parent), "e/f??#/g.txt") 

425 

426 self.assertEqual(child.relativeToPathRoot, "a/b/c/de/??/e/f??#/g.txt") 

427 

428 # dir.join() morphs into a file scheme 

429 dir = ResourcePath(self._make_uri(urllib.parse.quote("bbb/???/"))) 

430 new = dir.join("test_j.txt") 

431 self.assertIn("???", new.unquoted_path, f"Checking {new}") 

432 

433 new2name = "###/test??.txt" 

434 new2 = dir.join(new2name) 

435 self.assertIn("???", new2.unquoted_path) 

436 self.assertTrue(new2.unquoted_path.endswith(new2name)) 

437 

438 fdir = dir.abspath() 

439 self.assertNotIn("???", fdir.path) 

440 self.assertIn("???", fdir.unquoted_path) 

441 self.assertEqual(fdir.scheme, self.scheme) 

442 

443 fnew2 = fdir.join(new2name) 

444 self.assertTrue(fnew2.unquoted_path.endswith(new2name)) 

445 self.assertNotIn("###", fnew2.path) 

446 

447 # Test that children relative to schemeless and file schemes 

448 # still return the same unquoted name 

449 self.assertEqual(fnew2.relative_to(fdir), new2name, f"{fnew2}.relative_to({fdir})") 

450 self.assertEqual(fnew2.relative_to(dir), new2name, f"{fnew2}.relative_to({dir})") 

451 self.assertEqual(new2.relative_to(fdir), new2name, f"{new2}.relative_to({fdir})") 

452 self.assertEqual(new2.relative_to(dir), new2name, f"{new2}.relative_to({dir})") 

453 

454 # Check for double quoting 

455 plus_path = "/a/b/c+d/" 

456 with self.assertLogs(level="WARNING"): 

457 uri = ResourcePath(urllib.parse.quote(plus_path), forceDirectory=True) 

458 self.assertEqual(uri.ospath, plus_path) 

459 

460 # Check that # is not escaped for schemeless URIs 

461 hash_path = "/a/b#/c&d#xyz" 

462 hpos = hash_path.rfind("#") 

463 uri = ResourcePath(hash_path) 

464 self.assertEqual(uri.ospath, hash_path[:hpos]) 

465 self.assertEqual(uri.fragment, hash_path[hpos + 1 :]) 

466 self.assertEqual(uri.unquoted_fragment, uri.fragment) 

467 

468 # Fragments can be quoted, although this is not enforced anywhere. 

469 with_frag = ResourcePath(self._make_uri("a/b.txt#" + urllib.parse.quote("zip-path=ingést"))) 

470 self.assertEqual(with_frag.fragment, "zip-path%3Ding%C3%A9st") 

471 self.assertEqual(with_frag.unquoted_fragment, "zip-path=ingést") 

472 

473 def test_hash(self) -> None: 

474 """Test that we can store URIs in sets and as keys.""" 

475 uri1 = self.root_uri 

476 uri2 = uri1.join("test/") 

477 s = {uri1, uri2} 

478 self.assertIn(uri1, s) 

479 

480 d = {uri1: "1", uri2: "2"} 

481 self.assertEqual(d[uri2], "2") 

482 

483 def test_root_uri(self) -> None: 

484 """Test ResourcePath.root_uri().""" 

485 uri = ResourcePath(self._make_uri("a/b/c.txt")) 

486 self.assertEqual(uri.root_uri().geturl(), self.root) 

487 

488 def test_join(self) -> None: 

489 """Test .join method.""" 

490 root_str = self.root 

491 root = self.root_uri 

492 

493 self.assertEqual(root.join("b/test.txt").geturl(), f"{root_str}b/test.txt") 

494 add_dir = root.join("b/c/d/") 

495 self.assertTrue(add_dir.isdir()) 

496 self.assertEqual(add_dir.geturl(), f"{root_str}b/c/d/") 

497 

498 up_relative = root.join("../b/c.txt") 

499 self.assertFalse(up_relative.isdir()) 

500 self.assertEqual(up_relative.geturl(), f"{root_str}b/c.txt") 

501 

502 # Check that fragment is passed through join (simple unquoted case). 

503 fnew3 = root.join("a/b.txt#fragment") 

504 self.assertEqual(fnew3.fragment, "fragment") 

505 self.assertEqual(fnew3.basename(), "b.txt", msg=f"Got: {fnew3._uri}") 

506 

507 # Check that fragment on the directory is dropped on join. 

508 frag_dir = add_dir.join("subdir/#dir_fragment") 

509 self.assertEqual(frag_dir.fragment, "dir_fragment") 

510 fnew4 = frag_dir.join("a.txt") 

511 self.assertEqual(fnew4.fragment, "") 

512 self.assertTrue(str(fnew4).endswith("/a.txt")) 

513 

514 # Join a resource path. 

515 subpath = ResourcePath("a/b.txt#fragment2", forceAbsolute=False, forceDirectory=False) 

516 fnew3 = root.join(subpath) 

517 self.assertEqual(fnew3.fragment, "fragment2") 

518 self.assertEqual(fnew3.basename(), "b.txt", msg=f"Got: {fnew3._uri}") 

519 

520 # Quoted string with fragment. 

521 quote_example = "hsc/payload/b&c.t@x#t" 

522 needs_quote = root.join(quote_example) 

523 self.assertEqual(needs_quote.unquoted_path, "/" + quote_example[:-2]) 

524 self.assertEqual(needs_quote.fragment, "t") 

525 

526 other = ResourcePath(f"{self.root}test.txt") 

527 self.assertEqual(root.join(other), other) 

528 self.assertEqual(other.join("b/new.txt").geturl(), f"{self.root}test.txt/b/new.txt") 

529 

530 other = ResourcePath(f"{self.root}text.txt", forceDirectory=False) 

531 with self.assertRaises(ValueError): 

532 other.join("b/new.text") 

533 

534 joined = ResourcePath(f"{self.root}hsc/payload/").join( 

535 ResourcePath("test.qgraph", forceAbsolute=False) 

536 ) 

537 self.assertEqual(joined, ResourcePath(f"{self.root}hsc/payload/test.qgraph")) 

538 

539 qgraph = ResourcePath("test.qgraph") # Absolute URI 

540 joined = ResourcePath(f"{self.root}hsc/payload/").join(qgraph) 

541 self.assertEqual(joined, qgraph) 

542 

543 with self.assertRaises(ValueError): 

544 root.join("dir/", forceDirectory=False) 

545 

546 temp = root.join("dir2/", isTemporary=True) 

547 with self.assertRaises(RuntimeError): 

548 temp.join("test.txt", isTemporary=False) 

549 

550 rel = ResourcePath("new.txt", forceAbsolute=False, forceDirectory=False) 

551 with self.assertRaises(RuntimeError): 

552 root.join(rel, forceDirectory=True) 

553 

554 def test_quoting(self) -> None: 

555 """Check that quoting works.""" 

556 parent = ResourcePath(self._make_uri("rootdir"), forceDirectory=True) 

557 subpath = "rootdir/dir1+/file?.txt" 

558 child = ResourcePath(self._make_uri(urllib.parse.quote(subpath))) 

559 

560 self.assertEqual(child.relative_to(parent), "dir1+/file?.txt") 

561 self.assertEqual(child.basename(), "file?.txt") 

562 self.assertEqual(child.relativeToPathRoot, subpath) 

563 self.assertIn("%", child.path) 

564 self.assertEqual(child.unquoted_path, "/" + subpath) 

565 

566 def test_ordering(self) -> None: 

567 """Check that greater/less comparison operators work.""" 

568 a = self._make_uri("a.txt") 

569 b = self._make_uri("b/") 

570 self.assertLess(a, b) 

571 self.assertFalse(a < a) 

572 self.assertLessEqual(a, b) 

573 self.assertLessEqual(a, a) 

574 self.assertGreater(b, a) 

575 self.assertFalse(b > b) 

576 self.assertGreaterEqual(b, a) 

577 self.assertGreaterEqual(b, b) 

578 

579 

580class GenericReadWriteTestCase(_GenericTestCase): 

581 """Test schemes that can read and write using concrete resources.""" 

582 

583 transfer_modes: tuple[str, ...] = ("copy", "move") 

584 testdir: str | None = None 

585 # Number of files to use for mremove() testing to ensure difference code 

586 # paths are hit. Do not want to generically use many files for schemes 

587 # where it makes no difference. 

588 n_mremove_files: int = 15 

589 

590 def setUp(self) -> None: 

591 if self.scheme is None: 

592 raise unittest.SkipTest("No scheme defined") 

593 self.root = self._make_uri("") 

594 self.root_uri = ResourcePath(self.root, forceDirectory=True, forceAbsolute=False) 

595 

596 if self.scheme == "file": 

597 # Use a local tempdir because on macOS the temp dirs use symlinks 

598 # so relsymlink gets quite confused. 

599 self.tmpdir = ResourcePath(makeTestTempDir(self.testdir), forceDirectory=True) 

600 else: 

601 # Create random tmp directory relative to the test root. 

602 self.tmpdir = self.root_uri.join( 

603 "TESTING-" + "".join(random.choices(string.ascii_lowercase + string.digits, k=8)), 

604 forceDirectory=True, 

605 ) 

606 self.tmpdir.mkdir() 

607 

608 def tearDown(self) -> None: 

609 if self.tmpdir and self.tmpdir.isLocal: 

610 removeTestTempDir(self.tmpdir.ospath) 

611 

612 def test_file(self) -> None: 

613 uri = self.tmpdir.join("test.txt") 

614 self.assertFalse(uri.exists(), f"{uri} should not exist") 

615 self.assertTrue(uri.path.endswith("test.txt")) 

616 

617 content = "abcdefghijklmnopqrstuv\n" 

618 uri.write(content.encode()) 

619 self.assertTrue(uri.exists(), f"{uri} should now exist") 

620 self.assertEqual(uri.read().decode(), content) 

621 self.assertEqual(uri.size(), len(content.encode())) 

622 

623 with self.assertRaises(FileExistsError): 

624 uri.write(b"", overwrite=False) 

625 

626 # Not all backends can tell if a remove fails so we can not 

627 # test that a remove of a non-existent entry is guaranteed to raise. 

628 uri.remove() 

629 self.assertFalse(uri.exists()) 

630 

631 # Ideally the test would remove the file again and raise a 

632 # FileNotFoundError. This is not reliable for remote resources 

633 # and doing an explicit check before trying to remove the resource 

634 # just to raise an exception is deemed an unacceptable overhead. 

635 

636 with self.assertRaises(FileNotFoundError): 

637 uri.read() 

638 

639 with self.assertRaises(FileNotFoundError): 

640 self.tmpdir.join("file/not/there.txt").size() 

641 

642 # Check that creating a URI from a URI returns the same thing 

643 uri2 = ResourcePath(uri) 

644 self.assertEqual(uri, uri2) 

645 self.assertEqual(id(uri), id(uri2)) 

646 

647 def test_mkdir(self) -> None: 

648 newdir = self.tmpdir.join("newdir/seconddir", forceDirectory=True) 

649 newdir.mkdir() 

650 self.assertTrue(newdir.exists()) 

651 self.assertEqual(newdir.size(), 0) 

652 

653 newfile = newdir.join("temp.txt") 

654 newfile.write(b"Data") 

655 self.assertTrue(newfile.exists()) 

656 

657 file = self.tmpdir.join("file.txt") 

658 # Some schemes will realize that the URI is not a file and so 

659 # will raise NotADirectoryError. The file scheme is more permissive 

660 # and lets you write anything but will raise NotADirectoryError 

661 # if a non-directory is already there. We therefore write something 

662 # to the file to ensure that we trigger a portable exception. 

663 file.write(b"") 

664 with self.assertRaises(NotADirectoryError): 

665 file.mkdir() 

666 

667 # The root should exist. 

668 self.root_uri.mkdir() 

669 self.assertTrue(self.root_uri.exists()) 

670 

671 def test_transfer(self) -> None: 

672 src = self.tmpdir.join("test.txt") 

673 content = "Content is some content\nwith something to say\n\n" 

674 src.write(content.encode()) 

675 

676 can_move = "move" in self.transfer_modes 

677 for mode in self.transfer_modes: 

678 if mode == "move": 

679 continue 

680 

681 dest = self.tmpdir.join(f"dest_{mode}.txt") 

682 # Ensure that we get some debugging output. 

683 with self.assertLogs("lsst.resources", level=logging.DEBUG) as cm: 

684 dest.transfer_from(src, transfer=mode) 

685 self.assertIn("Transferring ", "\n".join(cm.output)) 

686 self.assertTrue(dest.exists(), f"Check that {dest} exists (transfer={mode})") 

687 

688 new_content = dest.read().decode() 

689 self.assertEqual(new_content, content) 

690 

691 if mode in ("symlink", "relsymlink"): 

692 self.assertTrue(os.path.islink(dest.ospath), f"Check that {dest} is symlink") 

693 

694 # If the source and destination are hardlinks of each other 

695 # the transfer should work even if overwrite=False. 

696 if mode in ("link", "hardlink"): 

697 dest.transfer_from(src, transfer=mode) 

698 else: 

699 with self.assertRaises( 

700 FileExistsError, msg=f"Overwrite of {dest} should not be allowed ({mode})" 

701 ): 

702 dest.transfer_from(src, transfer=mode) 

703 

704 # Transfer again and overwrite. 

705 dest.transfer_from(src, transfer=mode, overwrite=True) 

706 

707 dest.remove() 

708 

709 b = src.read() 

710 self.assertEqual(b.decode(), new_content) 

711 

712 nbytes = 10 

713 subset = src.read(size=nbytes) 

714 self.assertEqual(len(subset), nbytes) 

715 self.assertEqual(subset.decode(), content[:nbytes]) 

716 

717 # Transferring to self should be okay. 

718 src.transfer_from(src, "auto") 

719 

720 with self.assertRaises(ValueError): 

721 src.transfer_from(src, transfer="unknown") 

722 

723 # A move transfer is special. 

724 if can_move: 

725 dest.transfer_from(src, transfer="move") 

726 self.assertFalse(src.exists()) 

727 self.assertTrue(dest.exists()) 

728 else: 

729 src.remove() 

730 

731 dest.remove() 

732 with self.assertRaises(FileNotFoundError): 

733 dest.transfer_from(src, "auto") 

734 

735 def test_mtransfer(self) -> None: 

736 n_files = 10 

737 sources = [self.tmpdir.join(f"test{n}.txt") for n in range(n_files)] 

738 destinations = [self.tmpdir.join(f"dest_test{n}.txt") for n in range(n_files)] 

739 

740 for i, src in enumerate(sources): 

741 content = f"{i}\nContent is some content\nwith something to say\n\n" 

742 src.write(content.encode()) 

743 

744 results = ResourcePath.mtransfer("copy", zip(sources, destinations, strict=True)) 

745 self.assertTrue(all(res.success for res in results.values())) 

746 self.assertTrue(all(dest.exists() for dest in results)) 

747 

748 for i, dest in enumerate(destinations): 

749 new_content = dest.read().decode() 

750 self.assertTrue(new_content.startswith(f"{i}\n")) 

751 

752 # Overwrite should work. 

753 results = ResourcePath.mtransfer("copy", zip(sources, destinations, strict=True), overwrite=True) 

754 

755 # Overwrite failure. 

756 results = ResourcePath.mtransfer( 

757 "copy", zip(sources, destinations, strict=True), overwrite=False, do_raise=False 

758 ) 

759 self.assertFalse(all(res.success for res in results.values())) 

760 

761 with self.assertRaises(ExceptionGroup): 

762 results = ResourcePath.mtransfer( 

763 "copy", zip(sources, destinations, strict=True), overwrite=False, do_raise=True 

764 ) 

765 

766 def test_local_transfer(self) -> None: 

767 """Test we can transfer to and from local file.""" 

768 remote_src = self.tmpdir.join("src.json") 

769 remote_src.write(b"42") 

770 remote_dest = self.tmpdir.join("dest.json") 

771 

772 with ResourcePath.temporary_uri(suffix=".json") as tmp: 

773 self.assertTrue(tmp.isLocal) 

774 tmp.transfer_from(remote_src, transfer="auto") 

775 self.assertEqual(tmp.read(), remote_src.read()) 

776 

777 remote_dest.transfer_from(tmp, transfer="auto") 

778 self.assertEqual(remote_dest.read(), tmp.read()) 

779 

780 # Temporary (possibly remote) resource. 

781 # Transfers between temporary resources. 

782 with ( 

783 ResourcePath.temporary_uri(prefix=self.tmpdir.join("tmp"), suffix=".json") as remote_tmp, 

784 ResourcePath.temporary_uri(suffix=".json") as local_tmp, 

785 ): 

786 remote_tmp.write(b"42") 

787 if not remote_tmp.isLocal: 

788 for transfer in ("link", "symlink", "hardlink", "relsymlink"): 

789 with self.assertRaises(RuntimeError): 

790 # Trying to symlink a remote resource is not going 

791 # to work. A hardlink could work but would rely 

792 # on the local temp space being on the same 

793 # filesystem as the target. 

794 local_tmp.transfer_from(remote_tmp, transfer) 

795 local_tmp.transfer_from(remote_tmp, "move") 

796 self.assertFalse(remote_tmp.exists()) 

797 remote_tmp.transfer_from(local_tmp, "auto", overwrite=True) 

798 self.assertEqual(local_tmp.read(), remote_tmp.read()) 

799 

800 # Transfer of missing remote. 

801 remote_tmp.remove() 

802 with self.assertRaises(FileNotFoundError): 

803 local_tmp.transfer_from(remote_tmp, "auto", overwrite=True) 

804 

805 def test_local(self) -> None: 

806 """Check that remote resources can be made local.""" 

807 src = self.tmpdir.join("test.txt") 

808 original_content = "Content is some content\nwith something to say\n\n" 

809 src.write(original_content.encode()) 

810 

811 # Run this twice to ensure use of cache in code coverage 

812 # if applicable. 

813 for _ in (1, 2): 

814 with src.as_local() as local_uri: 

815 self.assertTrue(local_uri.isLocal) 

816 content = local_uri.read().decode() 

817 self.assertEqual(content, original_content) 

818 

819 if src.isLocal: 

820 self.assertEqual(src, local_uri) 

821 

822 with self.assertRaises(IsADirectoryError): 

823 with self.root_uri.as_local() as local_uri: 

824 pass 

825 

826 if not src.isLocal: 

827 # as_local tmpdir can not be a remote resource. 

828 with self.assertRaises(ValueError): 

829 with src.as_local(tmpdir=self.root_uri) as local_uri: 

830 pass 

831 

832 # tmpdir is ignored for local file. 

833 with tempfile.TemporaryDirectory() as tmpdir: 

834 temp_dir = ResourcePath(tmpdir, forceDirectory=True) 

835 with src.as_local(tmpdir=temp_dir) as local_uri: 

836 self.assertEqual(local_uri.dirname(), temp_dir) 

837 self.assertTrue(local_uri.exists()) 

838 

839 def test_local_mtransfer(self) -> None: 

840 """Check that bulk transfer to/from local works.""" 

841 # Create remote resources 

842 n_files = 10 

843 sources = [self.tmpdir.join(f"test{n}.txt") for n in range(n_files)] 

844 

845 for i, src in enumerate(sources): 

846 content = f"{i}\nContent is some content\nwith something to say\n\n" 

847 src.write(content.encode()) 

848 

849 # Potentially remote to local. 

850 with tempfile.TemporaryDirectory() as tmpdir: 

851 temp_dir = ResourcePath(tmpdir, forceDirectory=True) 

852 destinations = [temp_dir.join(f"dest_test{n}.txt") for n in range(n_files)] 

853 

854 results = ResourcePath.mtransfer("copy", zip(sources, destinations, strict=True)) 

855 self.assertTrue(all(res.success for res in results.values())) 

856 self.assertTrue(all(dest.exists() for dest in results)) 

857 

858 # Overwrite should work. 

859 results = ResourcePath.mtransfer("copy", zip(sources, destinations, strict=True), overwrite=True) 

860 

861 # Now reverse so local to potentially remote. 

862 for src in sources: 

863 src.remove() 

864 results = ResourcePath.mtransfer("copy", zip(destinations, sources, strict=True), overwrite=False) 

865 self.assertTrue(all(res.success for res in results.values())) 

866 self.assertTrue(all(dest.exists() for dest in results)) 

867 

868 def test_walk(self) -> None: 

869 """Walk a directory hierarchy.""" 

870 root = self.tmpdir.join("walk/") 

871 

872 # Look for a file that is not there 

873 file = root.join("config/basic/butler.yaml") 

874 found_list = list(ResourcePath.findFileResources([file])) 

875 self.assertEqual(found_list[0], file) 

876 

877 # First create the files (content is irrelevant). 

878 expected_files = { 

879 "dir1/a.yaml", 

880 "dir1/b.yaml", 

881 "dir1/c.json", 

882 "dir2/d.json", 

883 "dir2/e.yaml", 

884 } 

885 expected_uris = {root.join(f) for f in expected_files} 

886 for uri in expected_uris: 

887 uri.write(b"") 

888 self.assertTrue(uri.exists()) 

889 

890 # Look for the files. 

891 found = set(ResourcePath.findFileResources([root])) 

892 self.assertEqual(found, expected_uris) 

893 

894 # Now solely the YAML files. 

895 expected_yaml = {u for u in expected_uris if u.getExtension() == ".yaml"} 

896 found = set(ResourcePath.findFileResources([root], file_filter=r".*\.yaml$")) 

897 self.assertEqual(found, expected_yaml) 

898 

899 # Now two explicit directories and a file 

900 expected = set(expected_yaml) 

901 expected.add(file) 

902 

903 found = set( 

904 ResourcePath.findFileResources( 

905 [file, root.join("dir1/"), root.join("dir2/")], 

906 file_filter=r".*\.yaml$", 

907 ) 

908 ) 

909 self.assertEqual(found, expected) 

910 

911 # Group by directory -- find everything and compare it with what 

912 # we expected to be there in total. 

913 found_yaml = set() 

914 counter = 0 

915 for uris in ResourcePath.findFileResources([file, root], file_filter=r".*\.yaml$", grouped=True): 

916 assert not isinstance(uris, ResourcePath) # for mypy. 

917 found_uris = set(uris) 

918 if found_uris: 

919 counter += 1 

920 

921 found_yaml.update(found_uris) 

922 

923 expected_yaml_2 = expected_yaml 

924 expected_yaml_2.add(file) 

925 self.assertEqual(found_yaml, expected_yaml) 

926 self.assertEqual(counter, 3) 

927 

928 # Grouping but check that single files are returned in a single group 

929 # at the end 

930 file2 = root.join("config/templates/templates-bad.yaml") 

931 found_grouped = [ 

932 list(group) 

933 for group in ResourcePath.findFileResources([file, file2, root.join("dir2/")], grouped=True) 

934 if not isinstance(group, ResourcePath) # For mypy. 

935 ] 

936 self.assertEqual(len(found_grouped), 2, f"Found: {list(found_grouped)}") 

937 self.assertEqual(list(found_grouped[1]), [file, file2]) 

938 

939 with self.assertRaises(ValueError): 

940 # The list forces the generator to run. 

941 list(file.walk()) 

942 

943 # A directory that does not exist returns nothing. 

944 self.assertEqual(list(root.join("dir3/").walk()), []) 

945 

946 def test_large_walk(self) -> None: 

947 # In some systems pagination is used so ensure that we can handle 

948 # large numbers of files. For example S3 limits us to 1000 responses 

949 # per listing call. 

950 created = set() 

951 counter = 1 

952 n_dir1 = 1100 

953 root = self.tmpdir.join("large_walk", forceDirectory=True) 

954 while counter <= n_dir1: 

955 new = ResourcePath(root.join(f"file{counter:04d}.txt")) 

956 new.write(f"{counter}".encode()) 

957 created.add(new) 

958 counter += 1 

959 counter = 1 

960 # Put some in a subdirectory to make sure we are looking in a 

961 # hierarchy. 

962 n_dir2 = 100 

963 subdir = root.join("subdir", forceDirectory=True) 

964 while counter <= n_dir2: 

965 new = ResourcePath(subdir.join(f"file{counter:04d}.txt")) 

966 new.write(f"{counter}".encode()) 

967 created.add(new) 

968 counter += 1 

969 

970 found = set(ResourcePath.findFileResources([root])) 

971 self.assertEqual(len(found), n_dir1 + n_dir2) 

972 self.assertEqual(found, created) 

973 

974 # Again with grouping. 

975 # (mypy gets upset not knowing which of the two options is being 

976 # returned so add useless instance check). 

977 found_list = [ 

978 list(group) 

979 for group in ResourcePath.findFileResources([root], grouped=True) 

980 if not isinstance(group, ResourcePath) # For mypy. 

981 ] 

982 self.assertEqual(len(found_list), 2) 

983 self.assertEqual(len(found_list[0]), n_dir1) 

984 self.assertEqual(len(found_list[1]), n_dir2) 

985 

986 def test_temporary(self) -> None: 

987 prefix = self.tmpdir.join("tmp", forceDirectory=True) 

988 with ResourcePath.temporary_uri(prefix=prefix, suffix=".json") as tmp: 

989 self.assertEqual(tmp.getExtension(), ".json", f"uri: {tmp}") 

990 self.assertTrue(tmp.isabs(), f"uri: {tmp}") 

991 self.assertFalse(tmp.exists(), f"uri: {tmp}") 

992 tmp.write(b"abcd") 

993 self.assertTrue(tmp.exists(), f"uri: {tmp}") 

994 self.assertTrue(tmp.isTemporary) 

995 self.assertFalse(tmp.exists(), f"uri: {tmp}") 

996 

997 tmpdir = ResourcePath(self.tmpdir, forceDirectory=True) 

998 with ResourcePath.temporary_uri(prefix=tmpdir) as tmp: 

999 # Use a specified tmpdir and check it is okay for the file 

1000 # to not be created. 

1001 self.assertFalse(tmp.getExtension()) 

1002 self.assertFalse(tmp.exists(), f"uri: {tmp}") 

1003 self.assertEqual(tmp.scheme, self.scheme) 

1004 self.assertTrue(tmp.isTemporary) 

1005 self.assertTrue(tmpdir.exists(), f"uri: {tmpdir} still exists") 

1006 

1007 # Fake a directory suffix. 

1008 with self.assertRaises(NotImplementedError): 

1009 with ResourcePath.temporary_uri(prefix=self.root_uri, suffix="xxx/") as tmp: 

1010 pass 

1011 

1012 @unittest.skipIf(fsspec is None, "fsspec is not available.") 

1013 def test_fsspec(self) -> None: 

1014 """Simple read of a file.""" 

1015 uri = self.tmpdir.join("test.txt") 

1016 self.assertFalse(uri.exists(), f"{uri} should not exist") 

1017 self.assertTrue(uri.path.endswith("test.txt")) 

1018 

1019 content = "abcdefghijklmnopqrstuv\n" 

1020 uri.write(content.encode()) 

1021 

1022 try: 

1023 fs, path = uri.to_fsspec() 

1024 except NotImplementedError as e: 

1025 raise unittest.SkipTest(str(e)) from e 

1026 except ImportError as e: 

1027 # HttpResourcePath.to_fsspec() raises if support 

1028 # of fsspec for webDAV back ends is disabled. 

1029 raise unittest.SkipTest(str(e)) from e 

1030 with fs.open(path, "r") as fd: 

1031 as_read = fd.read() 

1032 self.assertEqual(as_read, content) 

1033 

1034 def test_open(self) -> None: 

1035 tmpdir = ResourcePath(self.tmpdir, forceDirectory=True) 

1036 with ResourcePath.temporary_uri(prefix=tmpdir, suffix=".txt") as tmp: 

1037 _check_open(self, tmp, mode_suffixes=("", "t")) 

1038 _check_open(self, tmp, mode_suffixes=("t",), encoding="utf-16") 

1039 _check_open(self, tmp, mode_suffixes=("t",), prefer_file_temporary=True) 

1040 _check_open(self, tmp, mode_suffixes=("t",), encoding="utf-16", prefer_file_temporary=True) 

1041 with ResourcePath.temporary_uri(prefix=tmpdir, suffix=".dat") as tmp: 

1042 _check_open(self, tmp, mode_suffixes=("b",)) 

1043 _check_open(self, tmp, mode_suffixes=("b",), prefer_file_temporary=True) 

1044 

1045 with self.assertRaises(IsADirectoryError): 

1046 with self.root_uri.open(): 

1047 pass 

1048 

1049 def test_mexists(self) -> None: 

1050 root = self.tmpdir.join("mexists/") 

1051 

1052 # A file that is not there. 

1053 file = root.join("config/basic/butler.yaml") 

1054 

1055 # Create some files. Most schemes the code paths do not change for 10 

1056 # vs 1000 files but in some schemes it does. 

1057 expected_files = [f"dir1/f{n}.yaml" for n in range(self.n_mremove_files)] 

1058 expected_uris = [root.join(f) for f in expected_files] 

1059 for uri in expected_uris: 

1060 uri.write(b"") 

1061 self.assertTrue(uri.exists()) 

1062 expected_uris.append(file) 

1063 

1064 # Force to run with fewer workers than there are files. 

1065 multi = ResourcePath.mexists(expected_uris, num_workers=3) 

1066 

1067 for uri, is_there in multi.items(): 

1068 if uri == file: 

1069 self.assertFalse(is_there) 

1070 else: 

1071 self.assertTrue(is_there) 

1072 

1073 # Clean up. Unfortunately POSIX raises a FileNotFoundError but 

1074 # S3 boto does not complain if there is no key. 

1075 ResourcePath.mremove(expected_uris, do_raise=False) 

1076 

1077 # Check they were really removed. 

1078 multi = ResourcePath.mexists(expected_uris, num_workers=3) 

1079 for uri, is_there in multi.items(): 

1080 self.assertFalse(is_there) 

1081 

1082 # Clean up a subset of files that are already gone, but this can 

1083 # trigger a different code path. 

1084 ResourcePath.mremove(expected_uris[:5], do_raise=False)