Coverage for pycommons / io / path.py: 98%

180 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-24 03:11 +0000

1""" 

2The class `Path` for handling paths to files and directories. 

3 

4The instances of :class:`Path` identify file system paths. 

5They are always fully canonicalized with all relative components resolved. 

6They thus allow the clear and unique identification of files and directories. 

7They also offer support for opening streams, creating paths to sub-folders, 

8and so on. 

9 

10The first goal is to encapsulate the functionality of the :mod:`os.path` 

11module into a single class. 

12The second goal is to make sure that we do not run into any dodgy situation 

13with paths pointing to security-sensitive locations or something due to 

14strange `.` and `..` trickery. 

15If you try to resolve a path inside a directory and the resulting canonical 

16path is outside that directory, you get an error raised, for example. 

17 

18>>> Path("/tmp/x/../1.txt") 

19'/tmp/1.txt' 

20""" 

21 

22import codecs 

23from io import TextIOBase 

24from os import O_CREAT, O_EXCL, O_TRUNC, makedirs, scandir 

25from os import close as osclose 

26from os import open as osopen 

27from os import remove as osremove 

28from os.path import ( 

29 abspath, 

30 commonpath, 

31 dirname, 

32 expanduser, 

33 expandvars, 

34 isdir, 

35 isfile, 

36 join, 

37 normcase, 

38 realpath, 

39 relpath, 

40) 

41from os.path import basename as osbasename 

42from os.path import exists as osexists 

43from shutil import rmtree 

44from typing import ( 

45 Any, 

46 Callable, 

47 Final, 

48 Generator, 

49 Iterable, 

50 Iterator, 

51 TextIO, 

52 cast, 

53) 

54 

55from pycommons.types import check_int_range, type_error 

56 

57#: the UTF-8 encoding 

58UTF8: Final[str] = "utf-8-sig" 

59 

60#: The list of possible text encodings 

61__ENCODINGS: Final[tuple[tuple[tuple[bytes, ...], str], ...]] = \ 

62 (((codecs.BOM_UTF8,), UTF8), 

63 ((codecs.BOM_UTF32_LE, codecs.BOM_UTF32_BE), "utf-32"), 

64 ((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE), "utf-16")) 

65 

66 

67def _get_text_encoding(filename: str) -> str: 

68 r""" 

69 Get the text encoding from a BOM if present. 

70 

71 If no encoding BOM can be found, we return the standard UTF-8 encoding. 

72 Adapted from https://stackoverflow.com/questions/13590749. 

73 

74 :param filename: the filename 

75 :returns: the encoding 

76 

77 >>> from tempfile import mkstemp 

78 >>> from os import close as osxclose 

79 >>> from os import remove as osremove 

80 >>> (h, tf) = mkstemp() 

81 >>> osxclose(h) 

82 >>> with open(tf, "wb") as out: 

83 ... out.write(b'\xef\xbb\xbf') 

84 3 

85 >>> _get_text_encoding(tf) 

86 'utf-8-sig' 

87 

88 >>> with open(tf, "wb") as out: 

89 ... out.write(b'\xff\xfe\x00\x00') 

90 4 

91 >>> _get_text_encoding(tf) 

92 'utf-32' 

93 

94 >>> with open(tf, "wb") as out: 

95 ... out.write(b'\x00\x00\xfe\xff') 

96 4 

97 >>> _get_text_encoding(tf) 

98 'utf-32' 

99 

100 >>> with open(tf, "wb") as out: 

101 ... out.write(b'\xff\xfe') 

102 2 

103 >>> _get_text_encoding(tf) 

104 'utf-16' 

105 

106 >>> with open(tf, "wb") as out: 

107 ... out.write(b'\xfe\xff') 

108 2 

109 >>> _get_text_encoding(tf) 

110 'utf-16' 

111 

112 >>> with open(tf, "wb") as out: 

113 ... out.write(b'\xaa\xf3') 

114 2 

115 >>> _get_text_encoding(tf) 

116 'utf-8-sig' 

117 

118 >>> osremove(tf) 

119 """ 

120 with open(filename, "rb") as f: 

121 header = f.read(4) # Read just the first four bytes. 

122 for boms, encoding in __ENCODINGS: 

123 for bom in boms: 

124 if header.find(bom) == 0: 

125 return encoding 

126 return UTF8 

127 

128 

129class Path(str): 

130 """ 

131 An immutable representation of a canonical path. 

132 

133 All instances of this class identify a fully-qualified path which does not 

134 contain any relative parts (`"."` or `".."`), is fully expanded, and, if 

135 the file system is case-insensitive, has the case normalized. A path is 

136 also an instance of `str`, so it can be used wherever strings are required 

137 and functions can be designed to accept `str` and receive `Path` instances 

138 instead. 

139 

140 >>> try: 

141 ... Path(1) 

142 ... except TypeError as te: 

143 ... print(te) 

144 descriptor '__len__' requires a 'str' object but received a 'int' 

145 

146 >>> try: 

147 ... Path(None) 

148 ... except TypeError as te: 

149 ... print(te) 

150 descriptor '__len__' requires a 'str' object but received a 'NoneType' 

151 

152 >>> try: 

153 ... Path("") 

154 ... except ValueError as ve: 

155 ... print(ve) 

156 Path must not be empty. 

157 

158 >>> try: 

159 ... Path(" ") 

160 ... except ValueError as ve: 

161 ... print(ve) 

162 Path must not start or end with white space, but ' ' does. 

163 

164 >>> from os.path import dirname 

165 >>> Path(dirname(realpath(__file__)) + '/..') == \ 

166dirname(dirname(realpath(__file__))) 

167 True 

168 

169 >>> Path(dirname(realpath(__file__)) + "/.") == \ 

170dirname(realpath(__file__)) 

171 True 

172 

173 >>> Path(__file__) == realpath(__file__) 

174 True 

175 

176 >>> from os import getcwd 

177 >>> Path(".") == realpath(getcwd()) 

178 True 

179 

180 >>> from os import getcwd 

181 >>> Path("..") == dirname(realpath(getcwd())) 

182 True 

183 

184 >>> from os import getcwd 

185 >>> Path("../.") == dirname(realpath(getcwd())) 

186 True 

187 

188 >>> from os import getcwd 

189 >>> Path("../1.txt") == \ 

190join(dirname(realpath(getcwd())), "1.txt") 

191 True 

192 

193 >>> from os import getcwd 

194 >>> Path("./1.txt") == join(realpath(getcwd()), "1.txt") 

195 True 

196 

197 >>> from os.path import isabs 

198 >>> isabs(Path("..")) 

199 True 

200 """ 

201 

202 # see https://docs.astral.sh/ruff/rules/no-slots-in-str-subclass/ 

203 __slots__ = () 

204 

205 def __new__(cls, value: Any): # noqa 

206 """ 

207 Construct the path object by normalizing the path string. 

208 

209 :param value: the string value 

210 :raises TypeError: if `value` is not a string 

211 :raises ValueError: if `value` is not a proper path 

212 

213 >>> isinstance(Path("."), Path) 

214 True 

215 >>> isinstance(Path("."), str) 

216 True 

217 >>> isinstance(Path(".")[-2:], Path) 

218 False 

219 >>> isinstance(Path(".")[-2:], str) 

220 True 

221 >>> isinstance(Path(__file__).strip(), Path) 

222 False 

223 

224 >>> isinstance(__file__, Path) 

225 False 

226 >>> isinstance(Path(__file__), Path) 

227 True 

228 >>> p = Path(__file__) 

229 >>> Path(p) is p 

230 True 

231 

232 >>> try: 

233 ... Path(None) 

234 ... except TypeError as te: 

235 ... print(te) 

236 descriptor '__len__' requires a 'str' object but received a 'NoneType' 

237 

238 >>> try: 

239 ... Path(1) 

240 ... except TypeError as te: 

241 ... print(te) 

242 descriptor '__len__' requires a 'str' object but received a 'int' 

243 

244 >>> try: 

245 ... Path("") 

246 ... except ValueError as ve: 

247 ... print(ve) 

248 Path must not be empty. 

249 """ 

250 if isinstance(value, Path): 

251 return cast("Path", value) 

252 

253 if str.__len__(value) <= 0: 

254 raise ValueError("Path must not be empty.") 

255 if str.strip(value) != value: 

256 raise ValueError("Path must not start or end with white space, " 

257 f"but {value!r} does.") 

258 value = normcase(abspath(realpath(expanduser(expandvars(value))))) 

259 if (str.__len__(value) <= 0) or (value in {".", ".."}): # impossible! 

260 raise ValueError(f"Canonicalization cannot yield {value!r}.") 

261 

262 return super().__new__(cls, value) 

263 

264 def exists(self) -> bool: 

265 """ 

266 Check if this path identifies an existing file or directory. 

267 

268 See also :meth:`~Path.is_file` and :meth:`~Path.is_dir`. 

269 

270 :returns: `True` if this path identifies an existing file, `False` 

271 otherwise. 

272 

273 >>> Path(__file__).exists() 

274 True 

275 >>> from os.path import dirname 

276 >>> Path(dirname(__file__)).exists() 

277 True 

278 >>> from tempfile import mkstemp 

279 >>> from os import close as osxclose 

280 >>> from os import remove as osremove 

281 >>> (h, tf) = mkstemp() 

282 >>> osxclose(h) 

283 >>> p = Path(tf) 

284 >>> p.exists() 

285 True 

286 >>> osremove(p) 

287 >>> p.exists() 

288 False 

289 """ 

290 return osexists(self) 

291 

292 def is_file(self) -> bool: 

293 """ 

294 Check if this path identifies an existing file. 

295 

296 See also :meth:`~enforce_file`, which raises an error if the `is_file` 

297 is not `True`. 

298 

299 :returns: `True` if this path identifies an existing file, `False` 

300 otherwise. 

301 

302 >>> Path(__file__).is_file() 

303 True 

304 >>> from os.path import dirname 

305 >>> Path(dirname(__file__)).is_file() 

306 False 

307 """ 

308 return isfile(self) 

309 

310 def enforce_file(self) -> None: 

311 """ 

312 Raise an error if the path does not reference an existing file. 

313 

314 This function uses :meth:`is_file` internally and raises a 

315 `ValueError` if it returns `False`. It is therefore a shorthand 

316 for situations where you want to have an error if a path does 

317 not identify a file. 

318 

319 :raises ValueError: if this path does not reference an existing file 

320 

321 >>> Path(__file__).enforce_file() # nothing happens 

322 >>> from os import getcwd 

323 >>> try: 

324 ... Path(getcwd()).enforce_file() 

325 ... except ValueError as ve: 

326 ... print(str(ve)[-25:]) 

327 does not identify a file. 

328 """ 

329 if not self.is_file(): 

330 raise ValueError(f"Path {self!r} does not identify a file.") 

331 

332 def is_dir(self) -> bool: 

333 """ 

334 Check if this path identifies an existing directory. 

335 

336 The method :meth:`~enforce_dir` also checks this, but raises an 

337 exception if it is not `True`. 

338 

339 :returns: `True` if this path identifies an existing directory, 

340 `False` otherwise. 

341 

342 >>> Path(__file__).is_dir() 

343 False 

344 >>> from os.path import dirname 

345 >>> Path(dirname(__file__)).is_dir() 

346 True 

347 """ 

348 return isdir(self) 

349 

350 def enforce_dir(self) -> None: 

351 """ 

352 Raise an error if the path does not reference an existing directory. 

353 

354 This function uses :meth:`is_dir` internally and raises a 

355 `ValueError` if it returns `False`. It is therefore a shorthand 

356 for situations where you want to have an error if a path does 

357 not identify a directory. 

358 

359 :raises ValueError: if this path does not reference an existing 

360 directory 

361 

362 >>> try: 

363 ... Path(__file__).enforce_dir() 

364 ... except ValueError as ve: 

365 ... print(str(ve)[-30:]) 

366 does not identify a directory. 

367 

368 >>> from os import getcwd 

369 >>> Path(getcwd()).enforce_dir() # nothing happens 

370 """ 

371 if not self.is_dir(): 

372 raise ValueError(f"Path {self!r} does not identify a directory.") 

373 

374 def contains(self, other: str) -> bool: 

375 r""" 

376 Check whether this path is a directory and contains another path. 

377 

378 A file can never contain anything else. A directory contains itself as 

379 well as any sub-directories, i.e., `a/b/` contains `a/b/` and `a/b/c`. 

380 The function :meth:`~enforce_contains` throws an exception if the 

381 path does not contain `other`. 

382 

383 :param other: the other path 

384 :returns: `True` is this path contains the other path, `False` of not 

385 

386 >>> from os.path import dirname 

387 >>> Path(dirname(__file__)).contains(__file__) 

388 True 

389 >>> Path(__file__).contains(__file__) 

390 False 

391 >>> Path(dirname(__file__)).contains(dirname(__file__)) 

392 True 

393 >>> Path(__file__).contains(dirname(__file__)) 

394 False 

395 >>> Path(join(dirname(__file__), "a")).contains( 

396 ... join(dirname(__file__), "b")) 

397 False 

398 

399 >>> try: 

400 ... Path(dirname(__file__)).contains(1) 

401 ... except TypeError as te: 

402 ... print(te) 

403 descriptor '__len__' requires a 'str' object but received a 'int' 

404 

405 >>> try: 

406 ... Path(dirname(__file__)).contains(None) 

407 ... except TypeError as te: 

408 ... print(te) 

409 descriptor '__len__' requires a 'str' object but received a 'NoneType' 

410 

411 >>> try: 

412 ... Path(dirname(__file__)).contains("") 

413 ... except ValueError as ve: 

414 ... print(ve) 

415 Path must not be empty. 

416 

417 >>> Path("E:\\").contains("C:\\") 

418 False 

419 """ 

420 if not self.is_dir(): 

421 return False 

422 other_path: Final[Path] = Path(other) 

423 try: 

424 return commonpath([self]) == commonpath([self, other_path]) 

425 except ValueError: # this happens if paths are on different drives 

426 return False # This cannot be reached on Linux systems. 

427 

428 def enforce_contains(self, other: str) -> None: 

429 """ 

430 Raise an exception if this is not a directory containing another path. 

431 

432 The method :meth:`contains` checks whether this path is a directory 

433 and contains the other path and returns the result of this check as a 

434 `bool`. This function here raises an exception if that check fails. 

435 

436 :param other: the other path 

437 :raises ValueError: if `other` is not a sub-path of this path. 

438 

439 >>> try: 

440 ... Path(__file__).enforce_contains(__file__) 

441 ... except ValueError as ve: 

442 ... print(str(ve)[-25:]) 

443 not identify a directory. 

444 

445 >>> from os.path import dirname 

446 >>> Path(dirname(__file__)).enforce_contains(__file__) # nothing 

447 >>> try: 

448 ... Path(join(dirname(__file__), "a")).enforce_contains(\ 

449Path(join(dirname(__file__), "b"))) 

450 ... except ValueError as ve: 

451 ... print(str(ve)[-25:]) 

452 not identify a directory. 

453 

454 >>> Path(dirname(__file__)).enforce_contains(Path(join(dirname(\ 

455__file__), "b"))) # nothing happens 

456 >>> try: 

457 ... Path(dirname(__file__)).enforce_contains(dirname(\ 

458dirname(__file__))) 

459 ... except ValueError as ve: 

460 ... print(str(ve)[:4]) 

461 ... print("does not contain" in str(ve)) 

462 Path 

463 True 

464 """ 

465 self.enforce_dir() 

466 if not self.contains(other): 

467 raise ValueError(f"Path {self!r} does not contain {other!r}.") 

468 

469 def resolve_inside(self, relative_path: str) -> "Path": 

470 """ 

471 Resolve a relative path to an absolute path inside this path. 

472 

473 Resolve the relative path inside this path. This path must identify 

474 a directory. The relative path cannot contain anything that makes it 

475 leave the directory, e.g., any `".."`. The paths are joined and then 

476 it is enforced that this path must contain the result via 

477 :meth:`enforce_contains` and otherwise an error is raised. 

478 

479 :param relative_path: the path to resolve 

480 :returns: the resolved child path 

481 :raises TypeError: If the `relative_path` is not a string. 

482 :raises ValueError: If the `relative_path` would resolve to something 

483 outside of this path and/or if it is empty. 

484 

485 >>> from os.path import dirname 

486 >>> Path(dirname(__file__)).resolve_inside("a.txt")[-5:] 

487 'a.txt' 

488 

489 >>> from os.path import basename 

490 >>> Path(dirname(__file__)).resolve_inside(basename(__file__)) \ 

491== Path(__file__) 

492 True 

493 

494 >>> try: 

495 ... Path(dirname(__file__)).resolve_inside("..") 

496 ... except ValueError as ve: 

497 ... print("does not contain" in str(ve)) 

498 True 

499 

500 >>> try: 

501 ... Path(__file__).resolve_inside("..") 

502 ... except ValueError as ve: 

503 ... print("does not identify a directory" in str(ve)) 

504 True 

505 

506 >>> try: 

507 ... Path(dirname(__file__)).resolve_inside(None) 

508 ... except TypeError as te: 

509 ... print(te) 

510 descriptor '__len__' requires a 'str' object but received a 'NoneType' 

511 

512 >>> try: 

513 ... Path(dirname(__file__)).resolve_inside(2) 

514 ... except TypeError as te: 

515 ... print(te) 

516 descriptor '__len__' requires a 'str' object but received a 'int' 

517 

518 >>> try: 

519 ... Path(__file__).resolve_inside("") 

520 ... except ValueError as ve: 

521 ... print(ve) 

522 Relative path must not be empty. 

523 

524 >>> try: 

525 ... Path(__file__).resolve_inside(" ") 

526 ... except ValueError as ve: 

527 ... print(ve) 

528 Relative path must not start or end with white space, but ' ' does. 

529 """ 

530 if str.__len__(relative_path) == 0: 

531 raise ValueError("Relative path must not be empty.") 

532 if str.strip(relative_path) != relative_path: 

533 raise ValueError("Relative path must not start or end with white " 

534 f"space, but {relative_path!r} does.") 

535 opath: Final[Path] = Path(join(self, relative_path)) 

536 self.enforce_contains(opath) 

537 return opath 

538 

539 def ensure_file_exists(self) -> bool: 

540 """ 

541 Atomically ensure that the file exists and create it otherwise. 

542 

543 While :meth:`is_file` checks if the path identifies an existing file 

544 and :meth:`enforce_file` raises an error if it does not, this method 

545 here creates the file if it does not exist. The method can only create 

546 the file if the directory already exists. 

547 

548 :returns: `True` if the file already existed and 

549 `False` if it was newly and atomically created. 

550 :raises: ValueError if anything goes wrong during the file creation 

551 

552 >>> print(Path(__file__).ensure_file_exists()) 

553 True 

554 

555 >>> from os.path import dirname 

556 >>> try: 

557 ... Path(dirname(__file__)).ensure_file_exists() 

558 ... print("??") 

559 ... except ValueError as ve: 

560 ... print("does not identify a file." in str(ve)) 

561 True 

562 

563 >>> try: 

564 ... Path(join(join(dirname(__file__), "a"), "b"))\ 

565.ensure_file_exists() 

566 ... print("??") 

567 ... except ValueError as ve: 

568 ... print("Error when trying to create file" in str(ve)) 

569 True 

570 """ 

571 existed: bool = False 

572 try: 

573 osclose(osopen(self, O_CREAT | O_EXCL)) 

574 except FileExistsError: 

575 existed = True 

576 except Exception as err: 

577 raise ValueError( 

578 f"Error when trying to create file {self!r}.") from err 

579 self.enforce_file() 

580 return existed 

581 

582 def create_file_or_truncate(self) -> None: 

583 """ 

584 Create the file identified by this path and truncate it if it exists. 

585 

586 :raises: ValueError if anything goes wrong during the file creation 

587 

588 >>> from tempfile import mkstemp 

589 >>> from os import close as osxclose 

590 >>> from os import remove as osremove 

591 >>> (h, tf) = mkstemp() 

592 >>> osxclose(h) 

593 

594 >>> pth = Path(tf) 

595 >>> pth.write_all_str("test") 

596 >>> print(pth.read_all_str()) 

597 test 

598 <BLANKLINE> 

599 

600 >>> pth.create_file_or_truncate() 

601 >>> pth.is_file() 

602 True 

603 

604 >>> try: 

605 ... pth.read_all_str() 

606 ... except ValueError as ve: 

607 ... print(str(ve)[-17:]) 

608 contains no text. 

609 

610 >>> osremove(pth) 

611 >>> pth.is_file() 

612 False 

613 

614 >>> pth.create_file_or_truncate() 

615 >>> pth.is_file() 

616 True 

617 

618 >>> osremove(pth) 

619 

620 >>> from os import makedirs as osmkdir 

621 >>> from os import rmdir as osrmdir 

622 >>> osmkdir(pth) 

623 

624 >>> try: 

625 ... pth.create_file_or_truncate() 

626 ... except ValueError as ve: 

627 ... print(str(ve)[:35]) 

628 Error when truncating/creating file 

629 

630 >>> osrmdir(pth) 

631 """ 

632 try: 

633 osclose(osopen(self, O_CREAT | O_TRUNC)) 

634 except BaseException as err: # noqa 

635 raise ValueError( 

636 f"Error when truncating/creating file {self!r}.") from err 

637 self.enforce_file() 

638 

639 def ensure_dir_exists(self) -> None: 

640 """ 

641 Make sure that the directory exists, create it otherwise. 

642 

643 Method :meth:`is_dir` checks whether the path identifies an 

644 existing directory, method :meth:`enforce_dir` raises an error if not, 

645 and this method creates the directory if it does not exist. 

646 

647 :raises ValueError: if the directory did not exist and creation failed 

648 

649 >>> from os.path import dirname 

650 >>> Path(dirname(__file__)).ensure_dir_exists() # nothing happens 

651 

652 >>> try: 

653 ... Path(__file__).ensure_dir_exists() 

654 ... except ValueError as ve: 

655 ... print("does not identify a directory" in str(ve)) 

656 True 

657 

658 >>> try: 

659 ... Path(join(__file__, "a")).ensure_dir_exists() 

660 ... except ValueError as ve: 

661 ... print("Error when trying to create directory" in str(ve)) 

662 True 

663 

664 >>> from tempfile import mkdtemp 

665 >>> from os import rmdir as osrmdirx 

666 >>> td = mkdtemp() 

667 >>> Path(td).ensure_dir_exists() 

668 >>> osrmdirx(td) 

669 >>> Path(td).ensure_dir_exists() 

670 >>> p = Path(td).resolve_inside("a") 

671 >>> p.ensure_dir_exists() 

672 >>> p2 = p.resolve_inside("b") 

673 >>> p2.ensure_dir_exists() 

674 >>> osrmdirx(p2) 

675 >>> osrmdirx(p) 

676 >>> osrmdirx(td) 

677 >>> p2.ensure_dir_exists() 

678 >>> osrmdirx(p2) 

679 >>> osrmdirx(p) 

680 >>> osrmdirx(td) 

681 """ 

682 try: 

683 makedirs(name=self, exist_ok=True) 

684 except FileExistsError: 

685 pass 

686 except Exception as err: 

687 raise ValueError( 

688 f"Error when trying to create directory {self!r}.") from err 

689 self.enforce_dir() 

690 

691 def ensure_parent_dir_exists(self) -> "Path": 

692 """ 

693 Make sure that the parent directory exists, create it otherwise. 

694 

695 This path may identify a file or directory to be created that does not 

696 yet exist. The parent directory of this path is ensured to exist, 

697 i.e., if it already exists, nothing happens, but if it does not yet 

698 exist, it is created. If the parent directory cannot be created, a 

699 :class:`ValueError` is raised. 

700 

701 :returns: the parent dir 

702 :raises ValueError: if the directory did not exist and creation failed 

703 

704 >>> from os.path import dirname 

705 >>> _ = Path(__file__).ensure_parent_dir_exists() # nothing happens 

706 

707 >>> try: 

708 ... _ = Path(join(__file__, "a")).ensure_parent_dir_exists() 

709 ... except ValueError as ve: 

710 ... print("does not identify a directory" in str(ve)) 

711 True 

712 

713 >>> from tempfile import mkdtemp 

714 >>> from os import rmdir as osrmdirx 

715 >>> td = mkdtemp() 

716 >>> tf = Path(join(td, "xxx")) 

717 >>> _ = tf.ensure_parent_dir_exists() 

718 >>> osrmdirx(td) 

719 >>> isdir(dirname(tf)) 

720 False 

721 >>> _ = tf.ensure_parent_dir_exists() 

722 >>> isdir(dirname(tf)) 

723 True 

724 >>> osrmdirx(td) 

725 

726 >>> td = mkdtemp() 

727 >>> isdir(td) 

728 True 

729 >>> td2 = join(td, "xxx") 

730 >>> isdir(td2) 

731 False 

732 >>> tf = join(td2, "xxx") 

733 >>> _ = Path(tf).ensure_parent_dir_exists() 

734 >>> isdir(td2) 

735 True 

736 >>> osrmdirx(td2) 

737 >>> osrmdirx(td) 

738 

739 >>> td = mkdtemp() 

740 >>> isdir(td) 

741 True 

742 >>> td2 = join(td, "xxx") 

743 >>> isdir(td2) 

744 False 

745 >>> td3 = join(td2, "xxx") 

746 >>> isdir(td3) 

747 False 

748 >>> tf = join(td3, "xxx") 

749 >>> _ = Path(tf).ensure_parent_dir_exists() 

750 >>> isdir(td3) 

751 True 

752 >>> isdir(td2) 

753 True 

754 >>> osrmdirx(td3) 

755 >>> osrmdirx(td2) 

756 >>> osrmdirx(td) 

757 """ 

758 pd: Final[Path] = Path(dirname(self)) 

759 Path.ensure_dir_exists(pd) 

760 return pd 

761 

762 def open_for_read(self) -> TextIOBase: 

763 r""" 

764 Open this file for reading text. 

765 

766 The resulting text stream will automatically use the right encoding 

767 and take any encoding error serious. If the path does not identify an 

768 existing file, an exception is thrown. 

769 

770 :returns: the file open for reading 

771 :raises ValueError: if the path does not identify a file 

772 

773 >>> with Path(__file__).open_for_read() as rd: 

774 ... print(f"{len(rd.readline())}") 

775 ... print(f"{rd.readline()!r}") 

776 4 

777 'The class `Path` for handling paths to files and directories.\n' 

778 

779 >>> from os.path import dirname 

780 >>> try: 

781 ... with Path(dirname(__file__)).open_for_read(): 

782 ... pass 

783 ... except ValueError as ve: 

784 ... print(str(ve)[-25:]) 

785 does not identify a file. 

786 """ 

787 self.enforce_file() 

788 return cast("TextIOBase", open( # noqa: SIM115 

789 self, encoding=_get_text_encoding(self), errors="strict")) 

790 

791 def read_all_str(self) -> str: 

792 r""" 

793 Read a file as a single string. 

794 

795 Read the complete contents of a file as a single string. If the file 

796 is empty, an exception will be raised. No modification is applied to 

797 the text that is read. 

798 

799 :returns: the single string of text 

800 :raises ValueError: if the path does not identify a file or if the 

801 file it identifies is empty 

802 

803 >>> Path(__file__).read_all_str()[4:30] 

804 'The class `Path` for handl' 

805 

806 >>> from os.path import dirname 

807 >>> try: 

808 ... Path(dirname(__file__)).read_all_str() 

809 ... except ValueError as ve: 

810 ... print(str(ve)[-25:]) 

811 does not identify a file. 

812 

813 >>> from tempfile import mkstemp 

814 >>> from os import remove as osremovex 

815 >>> h, p = mkstemp(text=True) 

816 >>> osclose(h) 

817 >>> try: 

818 ... Path(p).read_all_str() 

819 ... except ValueError as ve: 

820 ... print(str(ve)[-19:]) 

821 ' contains no text. 

822 

823 >>> with open(p, "wt") as tx: 

824 ... tx.write("aa\n") 

825 ... tx.write(" bb ") 

826 3 

827 6 

828 >>> Path(p).read_all_str() 

829 'aa\n bb ' 

830 >>> osremovex(p) 

831 """ 

832 with self.open_for_read() as reader: 

833 res: Final[str] = reader.read() 

834 if str.__len__(res) <= 0: 

835 raise ValueError(f"File {self!r} contains no text.") 

836 return res 

837 

838 def open_for_write(self) -> TextIOBase: 

839 """ 

840 Open the file for writing UTF-8 encoded text. 

841 

842 If the path cannot be opened for writing, some error will be raised. 

843 

844 :returns: the text io wrapper for writing 

845 :raises ValueError: if the path does not identify a file or such a 

846 file cannot be created 

847 

848 >>> from tempfile import mkstemp 

849 >>> from os import remove as osremovex 

850 >>> h, p = mkstemp(text=True) 

851 >>> osclose(h) 

852 >>> with Path(p).open_for_write() as wd: 

853 ... wd.write("1234") 

854 4 

855 >>> Path(p).read_all_str() 

856 '1234' 

857 >>> osremovex(p) 

858 

859 >>> from os.path import dirname 

860 >>> try: 

861 ... with Path(dirname(__file__)).open_for_write() as wd: 

862 ... pass 

863 ... except ValueError as ve: 

864 ... print("does not identify a file." in str(ve)) 

865 True 

866 """ 

867 self.ensure_file_exists() 

868 return cast("TextIOBase", open( # noqa: SIM115 

869 self, mode="w", encoding="utf-8", errors="strict")) 

870 

871 def write_all_str(self, contents: str) -> None: 

872 r""" 

873 Write the given string to the file. 

874 

875 The string `contents` is written to a file. If it does not end 

876 with `\n`, then `\n` will automatically be appended. No other changes 

877 are applied to `contents`. `contents` must be a `str` and it must not 

878 be empty. 

879 

880 :param contents: the contents to write 

881 :raises TypeError: if the contents are not a string or an `Iterable` 

882 of strings 

883 :raises ValueError: if the path is not a file or it cannot be opened 

884 as a file or the `contents` are an empty string 

885 

886 >>> from tempfile import mkstemp 

887 >>> from os import remove as osremovex 

888 >>> h, p = mkstemp(text=True) 

889 >>> osclose(h) 

890 

891 >>> try: 

892 ... Path(p).write_all_str(None) 

893 ... except TypeError as te: 

894 ... print(str(te)) 

895 descriptor '__len__' requires a 'str' object but received a 'NoneType' 

896 

897 >>> try: 

898 ... Path(p).write_all_str(["a"]) 

899 ... except TypeError as te: 

900 ... print(str(te)) 

901 descriptor '__len__' requires a 'str' object but received a 'list' 

902 

903 >>> Path(p).write_all_str("\na\nb") 

904 >>> Path(p).read_all_str() 

905 '\na\nb\n' 

906 

907 >>> Path(p).write_all_str(" \na\n b ") 

908 >>> Path(p).read_all_str() 

909 ' \na\n b \n' 

910 

911 >>> try: 

912 ... Path(p).write_all_str("") 

913 ... except ValueError as ve: 

914 ... print(str(ve)[:34]) 

915 Cannot write empty content to file 

916 

917 >>> osremovex(p) 

918 >>> from os.path import dirname 

919 >>> try: 

920 ... Path(dirname(__file__)).write_all_str("a") 

921 ... except ValueError as ve: 

922 ... print("does not identify a file." in str(ve)) 

923 True 

924 """ 

925 if str.__len__(contents) <= 0: 

926 raise ValueError(f"Cannot write empty content to file {self!r}.") 

927 with self.open_for_write() as writer: 

928 writer.write(contents) 

929 if contents[-1] != "\n": 

930 writer.write("\n") 

931 

932 def relative_to(self, base_path: str) -> str: 

933 """ 

934 Compute a relative path of this path towards the given base path. 

935 

936 :param base_path: the string 

937 :returns: a relative path 

938 :raises ValueError: if this path is not inside `base_path` or the 

939 relativization result is otherwise invalid 

940 

941 >>> from os.path import dirname 

942 >>> f = file_path(__file__) 

943 >>> d1 = directory_path(dirname(f)) 

944 >>> d2 = directory_path(dirname(d1)) 

945 >>> d3 = directory_path(dirname(d2)) 

946 >>> f.relative_to(d1) 

947 'path.py' 

948 >>> f.relative_to(d2) 

949 'io/path.py' 

950 >>> f.relative_to(d3) 

951 'pycommons/io/path.py' 

952 >>> d1.relative_to(d3) 

953 'pycommons/io' 

954 >>> d1.relative_to(d1) 

955 '.' 

956 

957 >>> try: 

958 ... d1.relative_to(f) 

959 ... except ValueError as ve: 

960 ... print(str(ve)[-30:]) 

961 does not identify a directory. 

962 

963 >>> try: 

964 ... d2.relative_to(d1) 

965 ... except ValueError as ve: 

966 ... print(str(ve)[-21:]) 

967 pycommons/pycommons'. 

968 """ 

969 opath: Final[Path] = Path(base_path) 

970 opath.enforce_contains(self) 

971 rv: Final[str] = relpath(self, opath) 

972 if (str.__len__(rv) == 0) or (str.strip(rv) is not rv): 

973 raise ValueError( # close to impossible 

974 f"Invalid relative path {rv!r} resulting from relativizing " 

975 f"{self!r} to {base_path!r}={opath!r}.") 

976 return rv 

977 

978 def up(self, levels: int = 1) -> "Path": 

979 """ 

980 Go up the directory tree for a given number of times. 

981 

982 Get a `Path` identifying the containing directory, or its containing 

983 directory, depending on the number of `levels` specified. 

984 

985 :param levels: the number levels to go up: `1` for getting the 

986 directly containing directory, `2` for the next higher directory, 

987 and so on. 

988 :returns: the resulting path 

989 

990 >>> f = file_path(__file__) 

991 >>> print(f.up()[-13:]) 

992 /pycommons/io 

993 >>> print(f.up(1)[-13:]) 

994 /pycommons/io 

995 >>> print(f.up(2)[-10:]) 

996 /pycommons 

997 

998 >>> try: 

999 ... f.up(0) 

1000 ... except ValueError as ve: 

1001 ... print(ve) 

1002 levels=0 is invalid, must be in 1..255. 

1003 

1004 >>> try: 

1005 ... f.up(None) 

1006 ... except TypeError as te: 

1007 ... print(te) 

1008 levels should be an instance of int but is None. 

1009 

1010 >>> try: 

1011 ... f.up('x') 

1012 ... except TypeError as te: 

1013 ... print(te) 

1014 levels should be an instance of int but is str, namely 'x'. 

1015 

1016 >>> try: 

1017 ... f.up(255) 

1018 ... except ValueError as ve: 

1019 ... print(str(ve)[:70]) 

1020 Cannot go up from directory '/' anymore when going up for 255 levels f 

1021 """ 

1022 s: str = self 

1023 for _ in range(check_int_range(levels, "levels", 1, 255)): 

1024 old: str = s 

1025 s = dirname(s) 

1026 if (str.__len__(s) == 0) or (s == old): 

1027 raise ValueError( 

1028 f"Cannot go up from directory {old!r} anymore when going " 

1029 f"up for {levels} levels from {self!r}.") 

1030 return directory_path(s) 

1031 

1032 def basename(self) -> str: 

1033 """ 

1034 Get the name of the file or directory identified by this path. 

1035 

1036 :returns: the name of the file or directory 

1037 

1038 >>> file_path(__file__).basename() 

1039 'path.py' 

1040 >>> file_path(__file__).up(2).basename() 

1041 'pycommons' 

1042 

1043 >>> try: 

1044 ... Path("/").basename() 

1045 ... except ValueError as ve: 

1046 ... print(ve) 

1047 Invalid basename '' of path '/'. 

1048 """ 

1049 s: Final[str] = osbasename(self) 

1050 if str.__len__(s) <= 0: 

1051 raise ValueError(f"Invalid basename {s!r} of path {self!r}.") 

1052 return s 

1053 

1054 def list_dir(self, files: bool = True, 

1055 directories: bool = True) -> Iterator["Path"]: 

1056 """ 

1057 List the files and/or sub-directories in this directory. 

1058 

1059 :returns: an iterable with the fully-qualified paths 

1060 

1061 >>> from tempfile import mkstemp, mkdtemp 

1062 >>> from os import close as osxclose 

1063 

1064 >>> dir1 = Path(mkdtemp()) 

1065 >>> dir2 = Path(mkdtemp(dir=dir1)) 

1066 >>> dir3 = Path(mkdtemp(dir=dir1)) 

1067 >>> (h, tf1) = mkstemp(dir=dir1) 

1068 >>> osclose(h) 

1069 >>> (h, tf2) = mkstemp(dir=dir1) 

1070 >>> osclose(h) 

1071 >>> file1 = Path(tf1) 

1072 >>> file2 = Path(tf2) 

1073 

1074 >>> set(dir1.list_dir()) == {dir2, dir3, file1, file2} 

1075 True 

1076 

1077 >>> set(dir1.list_dir(files=False)) == {dir2, dir3} 

1078 True 

1079 

1080 >>> set(dir1.list_dir(directories=False)) == {file1, file2} 

1081 True 

1082 

1083 >>> try: 

1084 ... dir1.list_dir(None) 

1085 ... except TypeError as te: 

1086 ... print(te) 

1087 files should be an instance of bool but is None. 

1088 

1089 >>> try: 

1090 ... dir1.list_dir(1) 

1091 ... except TypeError as te: 

1092 ... print(te) 

1093 files should be an instance of bool but is int, namely 1. 

1094 

1095 >>> try: 

1096 ... dir1.list_dir(True, None) 

1097 ... except TypeError as te: 

1098 ... print(te) 

1099 directories should be an instance of bool but is None. 

1100 

1101 >>> try: 

1102 ... dir1.list_dir(True, 1) 

1103 ... except TypeError as te: 

1104 ... print(te) 

1105 directories should be an instance of bool but is int, namely 1. 

1106 

1107 >>> try: 

1108 ... dir1.list_dir(False, False) 

1109 ... except ValueError as ve: 

1110 ... print(ve) 

1111 files and directories cannot both be False. 

1112 

1113 >>> delete_path(dir1) 

1114 """ 

1115 if not isinstance(files, bool): 

1116 raise type_error(files, "files", bool) 

1117 if not isinstance(directories, bool): 

1118 raise type_error(directories, "directories", bool) 

1119 if not (files or directories): 

1120 raise ValueError("files and directories cannot both be False.") 

1121 self.enforce_dir() 

1122 return map(self.resolve_inside, ( 

1123 f.name for f in scandir(self) if ( 

1124 directories and f.is_dir(follow_symlinks=False)) or ( 

1125 files and f.is_file(follow_symlinks=False)))) 

1126 

1127 

1128def file_path(pathstr: str) -> "Path": 

1129 """ 

1130 Get a path identifying an existing file. 

1131 

1132 This is a shorthand for creating a :class:`~Path` and then invoking 

1133 :meth:`~Path.enforce_file`. 

1134 

1135 :param pathstr: the path 

1136 :returns: the file 

1137 

1138 >>> file_path(__file__)[-20:] 

1139 'pycommons/io/path.py' 

1140 

1141 >>> from os.path import dirname 

1142 >>> try: 

1143 ... file_path(dirname(__file__)) 

1144 ... except ValueError as ve: 

1145 ... print("does not identify a file." in str(ve)) 

1146 True 

1147 """ 

1148 fi: Final[Path] = Path(pathstr) 

1149 fi.enforce_file() 

1150 return fi 

1151 

1152 

1153def directory_path(pathstr: str) -> "Path": 

1154 """ 

1155 Get a path identifying an existing directory. 

1156 

1157 This is a shorthand for creating a :class:`~Path` and then invoking 

1158 :meth:`~Path.enforce_dir`. 

1159 

1160 :param pathstr: the path 

1161 :returns: the file 

1162 

1163 >>> from os.path import dirname 

1164 >>> directory_path(dirname(__file__))[-12:] 

1165 'pycommons/io' 

1166 

1167 >>> try: 

1168 ... directory_path(__file__) 

1169 ... except ValueError as ve: 

1170 ... print("does not identify a directory." in str(ve)) 

1171 True 

1172 """ 

1173 fi: Final[Path] = Path(pathstr) 

1174 fi.enforce_dir() 

1175 return fi 

1176 

1177 

1178#: the ends-with check 

1179__ENDSWITH: Final[Callable[[str, str], bool]] = cast( 

1180 "Callable[[str, str], bool]", str.endswith) 

1181 

1182 

1183def line_writer(output: TextIO | TextIOBase) -> Callable[[str], None]: 

1184 r""" 

1185 Create a line-writing :class:`typing.Callable` from an output stream. 

1186 

1187 This function takes any string passed to it and writes it to the 

1188 :class:`typing.TextIO` instance. If the string does not end in `"\n"`, 

1189 it then writes `"\n"` as well to terminate the line. If something that 

1190 is not a :class:`str` is passed in, it will throw a :class:`TypeError`. 

1191 

1192 Notice that :meth:`~io.TextIOBase.write` and 

1193 :meth:`~io.IOBase.writelines` of class :class:`io.TextIOBase` do not 

1194 terminate lines that are written 

1195 with a `"\n"`. This means that, unless you manually make sure that all 

1196 lines are terminated by `"\n"`, they get written as a single line instead 

1197 of multiple lines. To solve this issue conveniently, we provide the 

1198 functions :func:`line_writer`, which wraps the 

1199 :meth:`~io.TextIOBase.write` into another function, which automatically 

1200 terminates all strings passed to it with `"\n"` unless they already end in 

1201 `"\n"`, and :func:`write_lines`, which iterates over a sequence of strings 

1202 and writes each of them to a given :class:`typing.TextIO` and automatically 

1203 adds the `"\n"` terminator to each of them if necessary. 

1204 

1205 :param output: the output stream 

1206 :returns: an instance of :class:`typing.Callable` that will write each 

1207 string it receives as a properly terminated line to the output 

1208 stream. 

1209 :raises TypeError: if `output` is not an instance of 

1210 :class:`io.TextIOBase`. 

1211 

1212 >>> from tempfile import mkstemp 

1213 >>> from os import close as osclose 

1214 >>> from os import remove as osremove 

1215 >>> (h, tf) = mkstemp() 

1216 >>> osclose(h) 

1217 

1218 >>> with open(tf, "wt") as out: 

1219 ... w = line_writer(out) 

1220 ... w("123") 

1221 >>> with open(tf, "rt") as inp: 

1222 ... print(list(inp)) 

1223 ['123\n'] 

1224 

1225 >>> with open(tf, "wt") as out: 

1226 ... w = line_writer(out) 

1227 ... w("") 

1228 >>> with open(tf, "rt") as inp: 

1229 ... print(list(inp)) 

1230 ['\n'] 

1231 

1232 >>> with open(tf, "wt") as out: 

1233 ... w = line_writer(out) 

1234 ... w("123\n") 

1235 >>> with open(tf, "rt") as inp: 

1236 ... print(list(inp)) 

1237 ['123\n'] 

1238 

1239 >>> with open(tf, "wt") as out: 

1240 ... w = line_writer(out) 

1241 ... w("\n") 

1242 >>> with open(tf, "rt") as inp: 

1243 ... print(list(inp)) 

1244 ['\n'] 

1245 

1246 >>> with open(tf, "wt") as out: 

1247 ... w = line_writer(out) 

1248 ... w("123") 

1249 ... w("456") 

1250 >>> with open(tf, "rt") as inp: 

1251 ... print(list(inp)) 

1252 ['123\n', '456\n'] 

1253 

1254 >>> with open(tf, "wt") as out: 

1255 ... w = line_writer(out) 

1256 ... w("123 ") 

1257 ... w("") 

1258 ... w(" 456") 

1259 >>> with open(tf, "rt") as inp: 

1260 ... print(list(inp)) 

1261 ['123 \n', '\n', ' 456\n'] 

1262 

1263 >>> with open(tf, "wt") as out: 

1264 ... w = line_writer(out) 

1265 ... w("123 \n") 

1266 ... w("\n") 

1267 ... w(" 456") 

1268 >>> with open(tf, "rt") as inp: 

1269 ... print(list(inp)) 

1270 ['123 \n', '\n', ' 456\n'] 

1271 

1272 >>> try: 

1273 ... with open(tf, "wt") as out: 

1274 ... w = line_writer(out) 

1275 ... w("123 ") 

1276 ... w(None) 

1277 ... except TypeError as te: 

1278 ... print(str(te)[:-10]) 

1279 descriptor 'endswith' for 'str' objects doesn't apply to a 'NoneTy 

1280 

1281 >>> try: 

1282 ... with open(tf, "wt") as out: 

1283 ... w = line_writer(out) 

1284 ... w("123 ") 

1285 ... w(2) 

1286 ... except TypeError as te: 

1287 ... print(te) 

1288 descriptor 'endswith' for 'str' objects doesn't apply to a 'int' object 

1289 

1290 >>> osremove(tf) 

1291 

1292 >>> try: 

1293 ... line_writer(1) 

1294 ... except TypeError as te: 

1295 ... print(te) 

1296 output should be an instance of io.TextIOBase but is int, namely 1. 

1297 

1298 >>> try: 

1299 ... line_writer(None) 

1300 ... except TypeError as te: 

1301 ... print(te) 

1302 output should be an instance of io.TextIOBase but is None. 

1303 """ 

1304 if not isinstance(output, TextIOBase): 

1305 raise type_error(output, "output", TextIOBase) 

1306 

1307 def __call(s: str, __w: Callable[[str], Any] = output.write) -> None: 

1308 b: Final[bool] = __ENDSWITH(s, "\n") 

1309 __w(s) 

1310 if not b: 

1311 __w("\n") 

1312 

1313 return cast("Callable[[str], None]", __call) 

1314 

1315 

1316def __line_iterator(lines: Iterable[str]) -> Generator[str, None, None]: 

1317 r""" 

1318 Iterate over the given lines, adding newlines where needed. 

1319 

1320 :param lines: the lines 

1321 :returns: the generator 

1322 

1323 >>> list(__line_iterator([])) 

1324 [] 

1325 

1326 >>> list(__line_iterator(['a'])) 

1327 ['a', '\n'] 

1328 

1329 >>> list(__line_iterator(['a', 'b'])) 

1330 ['a', '\n', 'b', '\n'] 

1331 

1332 >>> list(__line_iterator(['a\n'])) 

1333 ['a\n'] 

1334 

1335 >>> list(__line_iterator(['a\n', 'b'])) 

1336 ['a\n', 'b', '\n'] 

1337 

1338 >>> list(__line_iterator(['a', 'b\n'])) 

1339 ['a', '\n', 'b\n'] 

1340 

1341 >>> list(__line_iterator(['a\n', 'b\n'])) 

1342 ['a\n', 'b\n'] 

1343 

1344 >>> try: 

1345 ... list(__line_iterator(["a", 1])) 

1346 ... except TypeError as te: 

1347 ... print(te) 

1348 descriptor 'endswith' for 'str' objects doesn't apply to a 'int' object 

1349 """ 

1350 for line in lines: 

1351 b: bool = __ENDSWITH(line, "\n") 

1352 yield line 

1353 if not b: 

1354 yield "\n" 

1355 

1356 

1357def write_lines(lines: Iterable[str], output: TextIO | TextIOBase) -> None: 

1358 r""" 

1359 Write all the lines in the given :class:`typing.Iterable` to the output. 

1360 

1361 This function takes care of properly terminating lines using `"\n"` when 

1362 writing them to an output and also performs type-checking. 

1363 

1364 Notice that :meth:`~io.TextIOBase.write` and 

1365 :meth:`~io.IOBase.writelines` of class :class:`io.TextIOBase` do not 

1366 terminate lines that are written with a `"\n"`. This means that, unless 

1367 you manually make sure that all lines are terminated by `"\n"`, they get 

1368 written as a single line instead of multiple lines. To solve this issue 

1369 conveniently, we provide the functions :func:`line_writer`, which wraps 

1370 the :meth:`~io.TextIOBase.write` into another function, which 

1371 automatically terminates all strings passed to it with `"\n"` unless they 

1372 already end in `"\n"`, and :func:`write_lines`, which iterates over a 

1373 sequence of strings and writes each of them to a given 

1374 :class:`typing.TextIO` and automatically adds the `"\n"` terminator to 

1375 each of them if necessary. 

1376 

1377 :param lines: the lines 

1378 :param output: the output 

1379 :raises TypeError: If anything is of the wrong type. 

1380 

1381 >>> from io import StringIO 

1382 

1383 >>> with StringIO() as sio: 

1384 ... write_lines(("123", "456"), sio) 

1385 ... print(sio.getvalue()) 

1386 123 

1387 456 

1388 <BLANKLINE> 

1389 

1390 >>> from io import StringIO 

1391 >>> with StringIO() as sio: 

1392 ... write_lines(("123\n", "456"), sio) 

1393 ... print(sio.getvalue()) 

1394 123 

1395 456 

1396 <BLANKLINE> 

1397 

1398 >>> from io import StringIO 

1399 >>> with StringIO() as sio: 

1400 ... write_lines(("123\n", "456\n"), sio) 

1401 ... print(sio.getvalue()) 

1402 123 

1403 456 

1404 <BLANKLINE> 

1405 

1406 >>> with StringIO() as sio: 

1407 ... write_lines(["123"], sio) 

1408 ... print(sio.getvalue()) 

1409 123 

1410 <BLANKLINE> 

1411 

1412 >>> with StringIO() as sio: 

1413 ... write_lines(["123\n"], sio) 

1414 ... print(sio.getvalue()) 

1415 123 

1416 <BLANKLINE> 

1417 

1418 >>> with StringIO() as sio: 

1419 ... write_lines("123", sio) 

1420 ... print(sio.getvalue()) 

1421 1 

1422 2 

1423 3 

1424 <BLANKLINE> 

1425 

1426 >>> with StringIO() as sio: 

1427 ... write_lines((sss for sss in ["123", "abc"]), sio) 

1428 ... print(sio.getvalue()) 

1429 123 

1430 abc 

1431 <BLANKLINE> 

1432 

1433 >>> with StringIO() as sio: 

1434 ... write_lines("", sio) 

1435 ... print(sio.getvalue()) 

1436 <BLANKLINE> 

1437 

1438 >>> from tempfile import mkstemp 

1439 >>> from os import close as osclose 

1440 >>> from os import remove as osremove 

1441 >>> (h, tf) = mkstemp() 

1442 >>> osclose(h) 

1443 

1444 >>> with open(tf, "wt") as out: 

1445 ... write_lines(["123"], out) 

1446 >>> with open(tf, "rt") as inp: 

1447 ... print(list(inp)) 

1448 ['123\n'] 

1449 

1450 >>> with open(tf, "wt") as out: 

1451 ... write_lines([""], out) 

1452 >>> with open(tf, "rt") as inp: 

1453 ... print(repr(inp.read())) 

1454 '\n' 

1455 

1456 >>> with open(tf, "wt") as out: 

1457 ... write_lines(["\n"], out) 

1458 >>> with open(tf, "rt") as inp: 

1459 ... print(repr(inp.read())) 

1460 '\n' 

1461 

1462 >>> with open(tf, "wt") as out: 

1463 ... write_lines([" \n"], out) 

1464 >>> with open(tf, "rt") as inp: 

1465 ... print(repr(inp.read())) 

1466 ' \n' 

1467 

1468 >>> osremove(tf) 

1469 

1470 >>> with StringIO() as sio: 

1471 ... write_lines(["\n"], sio) 

1472 ... print(repr(sio.getvalue())) 

1473 '\n' 

1474 

1475 >>> with StringIO() as sio: 

1476 ... write_lines([""], sio) 

1477 ... print(repr(sio.getvalue())) 

1478 '\n' 

1479 

1480 >>> sio = StringIO() 

1481 >>> try: 

1482 ... write_lines(None, sio) 

1483 ... except TypeError as te: 

1484 ... print(te) 

1485 lines should be an instance of typing.Iterable but is None. 

1486 

1487 >>> sio = StringIO() 

1488 >>> try: 

1489 ... write_lines(123, sio) 

1490 ... except TypeError as te: 

1491 ... print(te) 

1492 lines should be an instance of typing.Iterable but is int, namely 123. 

1493 

1494 >>> sio = StringIO() 

1495 >>> try: 

1496 ... write_lines([1, "sdf"], sio) 

1497 ... except TypeError as te: 

1498 ... print(te) 

1499 descriptor 'endswith' for 'str' objects doesn't apply to a 'int' object 

1500 

1501 >>> sio = StringIO() 

1502 >>> try: 

1503 ... write_lines(["sdf", 1], sio) 

1504 ... except TypeError as te: 

1505 ... print(te) 

1506 descriptor 'endswith' for 'str' objects doesn't apply to a 'int' object 

1507 >>> print(repr(sio.getvalue())) 

1508 'sdf\n' 

1509 

1510 >>> try: 

1511 ... write_lines("x", None) 

1512 ... except TypeError as te: 

1513 ... print(te) 

1514 output should be an instance of io.TextIOBase but is None. 

1515 

1516 >>> try: 

1517 ... write_lines("x", 1) 

1518 ... except TypeError as te: 

1519 ... print(te) 

1520 output should be an instance of io.TextIOBase but is int, namely 1. 

1521 

1522 >>> try: 

1523 ... write_lines(2, 1) 

1524 ... except TypeError as te: 

1525 ... print(te) 

1526 lines should be an instance of typing.Iterable but is int, namely 2. 

1527 """ 

1528 if not isinstance(lines, Iterable): 

1529 raise type_error(lines, "lines", Iterable) 

1530 if not isinstance(output, TextIOBase): 

1531 raise type_error(output, "output", TextIOBase) 

1532 output.writelines(__line_iterator(lines)) 

1533 

1534 

1535def delete_path(path: str) -> None: 

1536 """ 

1537 Delete a path, completely, and recursively. 

1538 

1539 This is intentionally inserted as an additional function and not a member 

1540 of the :class:`Path` in order make the deletion more explicit and to avoid 

1541 any form of accidental deleting. This function will not raise an error if 

1542 the file deletion fails. 

1543 

1544 :param path: The path to be deleted 

1545 :raises ValueError: if `path` does not refer to an existing file or 

1546 directory 

1547 :raises TypeError: if `path` is not a string 

1548 

1549 >>> from tempfile import mkstemp, mkdtemp 

1550 >>> from os import close as osxclose 

1551 

1552 >>> (h, tf) = mkstemp() 

1553 >>> isfile(tf) 

1554 True 

1555 >>> delete_path(tf) 

1556 >>> isfile(tf) 

1557 False 

1558 

1559 >>> try: 

1560 ... delete_path(tf) 

1561 ... except ValueError as ve: 

1562 ... print(str(ve).endswith("is neither file nor directory.")) 

1563 True 

1564 

1565 >>> td = mkdtemp() 

1566 >>> isdir(td) 

1567 True 

1568 >>> delete_path(td) 

1569 >>> isdir(td) 

1570 False 

1571 

1572 >>> try: 

1573 ... delete_path(tf) 

1574 ... except ValueError as ve: 

1575 ... print(str(ve).endswith("is neither file nor directory.")) 

1576 True 

1577 """ 

1578 p: Final[Path] = Path(path) 

1579 if isfile(p): 

1580 osremove(p) 

1581 elif isdir(p): 

1582 rmtree(p, ignore_errors=True) 

1583 else: 

1584 raise ValueError(f"{path!r} is neither file nor directory.")