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

180 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-06-03 00:58 +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 

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

1179 r""" 

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

1181 

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

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

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

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

1186 

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

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

1189 terminate lines that are written 

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

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

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

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

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

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

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

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

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

1199 

1200 :param output: the output stream 

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

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

1203 stream. 

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

1205 :class:`io.TextIOBase`. 

1206 

1207 >>> from tempfile import mkstemp 

1208 >>> from os import close as osclose 

1209 >>> from os import remove as osremove 

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

1211 >>> osclose(h) 

1212 

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

1214 ... w = line_writer(out) 

1215 ... w("123") 

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

1217 ... print(list(inp)) 

1218 ['123\n'] 

1219 

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

1221 ... w = line_writer(out) 

1222 ... w("") 

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

1224 ... print(list(inp)) 

1225 ['\n'] 

1226 

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

1228 ... w = line_writer(out) 

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

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

1231 ... print(list(inp)) 

1232 ['123\n'] 

1233 

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

1235 ... w = line_writer(out) 

1236 ... w("\n") 

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

1238 ... print(list(inp)) 

1239 ['\n'] 

1240 

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

1242 ... w = line_writer(out) 

1243 ... w("123") 

1244 ... w("456") 

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

1246 ... print(list(inp)) 

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

1248 

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

1250 ... w = line_writer(out) 

1251 ... w("123 ") 

1252 ... w("") 

1253 ... w(" 456") 

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

1255 ... print(list(inp)) 

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

1257 

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

1259 ... w = line_writer(out) 

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

1261 ... w("\n") 

1262 ... w(" 456") 

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

1264 ... print(list(inp)) 

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

1266 

1267 >>> try: 

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

1269 ... w = line_writer(out) 

1270 ... w("123 ") 

1271 ... w(None) 

1272 ... except TypeError as te: 

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

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

1275 

1276 >>> try: 

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

1278 ... w = line_writer(out) 

1279 ... w("123 ") 

1280 ... w(2) 

1281 ... except TypeError as te: 

1282 ... print(te) 

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

1284 

1285 >>> osremove(tf) 

1286 

1287 >>> try: 

1288 ... line_writer(1) 

1289 ... except TypeError as te: 

1290 ... print(te) 

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

1292 

1293 >>> try: 

1294 ... line_writer(None) 

1295 ... except TypeError as te: 

1296 ... print(te) 

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

1298 """ 

1299 if not isinstance(output, TextIOBase): 

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

1301 

1302 def __call(s: str, w: Callable[[str], Any] = output.write, 

1303 ew: Callable[[str, str], bool] = str.endswith) -> None: 

1304 b: Final[bool] = ew(s, "\n") # Force error if s is not a string 

1305 w(s) 

1306 if not b: 

1307 w("\n") 

1308 

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

1310 

1311 

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

1313 r""" 

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

1315 

1316 :param lines: the lines 

1317 :returns: the generator 

1318 

1319 >>> list(__line_iterator([])) 

1320 [] 

1321 

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

1323 ['a', '\n'] 

1324 

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

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

1327 

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

1329 ['a\n'] 

1330 

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

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

1333 

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

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

1336 

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

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

1339 

1340 >>> try: 

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

1342 ... except TypeError as te: 

1343 ... print(te) 

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

1345 """ 

1346 ew: Final[Callable[[str, str], bool]] = str.endswith 

1347 for line in lines: 

1348 b: bool = ew(line, "\n") 

1349 yield line 

1350 if not b: 

1351 yield "\n" 

1352 

1353 

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

1355 r""" 

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

1357 

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

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

1360 

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

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

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

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

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

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

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

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

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

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

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

1372 each of them if necessary. 

1373 

1374 :param lines: the lines 

1375 :param output: the output 

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

1377 

1378 >>> from io import StringIO 

1379 

1380 >>> with StringIO() as sio: 

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

1382 ... print(sio.getvalue()) 

1383 123 

1384 456 

1385 <BLANKLINE> 

1386 

1387 >>> from io import StringIO 

1388 >>> with StringIO() as sio: 

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

1390 ... print(sio.getvalue()) 

1391 123 

1392 456 

1393 <BLANKLINE> 

1394 

1395 >>> from io import StringIO 

1396 >>> with StringIO() as sio: 

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

1398 ... print(sio.getvalue()) 

1399 123 

1400 456 

1401 <BLANKLINE> 

1402 

1403 >>> with StringIO() as sio: 

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

1405 ... print(sio.getvalue()) 

1406 123 

1407 <BLANKLINE> 

1408 

1409 >>> with StringIO() as sio: 

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

1411 ... print(sio.getvalue()) 

1412 123 

1413 <BLANKLINE> 

1414 

1415 >>> with StringIO() as sio: 

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

1417 ... print(sio.getvalue()) 

1418 1 

1419 2 

1420 3 

1421 <BLANKLINE> 

1422 

1423 >>> with StringIO() as sio: 

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

1425 ... print(sio.getvalue()) 

1426 123 

1427 abc 

1428 <BLANKLINE> 

1429 

1430 >>> with StringIO() as sio: 

1431 ... write_lines("", sio) 

1432 ... print(sio.getvalue()) 

1433 <BLANKLINE> 

1434 

1435 >>> from tempfile import mkstemp 

1436 >>> from os import close as osclose 

1437 >>> from os import remove as osremove 

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

1439 >>> osclose(h) 

1440 

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

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

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

1444 ... print(list(inp)) 

1445 ['123\n'] 

1446 

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

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

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

1450 ... print(repr(inp.read())) 

1451 '\n' 

1452 

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

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

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

1456 ... print(repr(inp.read())) 

1457 '\n' 

1458 

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

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

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

1462 ... print(repr(inp.read())) 

1463 ' \n' 

1464 

1465 >>> osremove(tf) 

1466 

1467 >>> with StringIO() as sio: 

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

1469 ... print(repr(sio.getvalue())) 

1470 '\n' 

1471 

1472 >>> with StringIO() as sio: 

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

1474 ... print(repr(sio.getvalue())) 

1475 '\n' 

1476 

1477 >>> sio = StringIO() 

1478 >>> try: 

1479 ... write_lines(None, sio) 

1480 ... except TypeError as te: 

1481 ... print(te) 

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

1483 

1484 >>> sio = StringIO() 

1485 >>> try: 

1486 ... write_lines(123, sio) 

1487 ... except TypeError as te: 

1488 ... print(te) 

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

1490 

1491 >>> sio = StringIO() 

1492 >>> try: 

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

1494 ... except TypeError as te: 

1495 ... print(te) 

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

1497 

1498 >>> sio = StringIO() 

1499 >>> try: 

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

1501 ... except TypeError as te: 

1502 ... print(te) 

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

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

1505 'sdf\n' 

1506 

1507 >>> try: 

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

1509 ... except TypeError as te: 

1510 ... print(te) 

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

1512 

1513 >>> try: 

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

1515 ... except TypeError as te: 

1516 ... print(te) 

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

1518 

1519 >>> try: 

1520 ... write_lines(2, 1) 

1521 ... except TypeError as te: 

1522 ... print(te) 

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

1524 """ 

1525 if not isinstance(lines, Iterable): 

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

1527 if not isinstance(output, TextIOBase): 

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

1529 output.writelines(__line_iterator(lines)) 

1530 

1531 

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

1533 """ 

1534 Delete a path, completely, and recursively. 

1535 

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

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

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

1539 the file deletion fails. 

1540 

1541 :param path: The path to be deleted 

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

1543 directory 

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

1545 

1546 >>> from tempfile import mkstemp, mkdtemp 

1547 >>> from os import close as osxclose 

1548 

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

1550 >>> isfile(tf) 

1551 True 

1552 >>> delete_path(tf) 

1553 >>> isfile(tf) 

1554 False 

1555 

1556 >>> try: 

1557 ... delete_path(tf) 

1558 ... except ValueError as ve: 

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

1560 True 

1561 

1562 >>> td = mkdtemp() 

1563 >>> isdir(td) 

1564 True 

1565 >>> delete_path(td) 

1566 >>> isdir(td) 

1567 False 

1568 

1569 >>> try: 

1570 ... delete_path(tf) 

1571 ... except ValueError as ve: 

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

1573 True 

1574 """ 

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

1576 if isfile(p): 

1577 osremove(p) 

1578 elif isdir(p): 

1579 rmtree(p, ignore_errors=True) 

1580 else: 

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