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

180 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-02 06:36 +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 

19import codecs 

20from io import TextIOBase 

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

22from os import close as osclose 

23from os import open as osopen 

24from os import remove as osremove 

25from os.path import ( 

26 abspath, 

27 commonpath, 

28 dirname, 

29 expanduser, 

30 expandvars, 

31 isdir, 

32 isfile, 

33 join, 

34 normcase, 

35 realpath, 

36 relpath, 

37) 

38from os.path import basename as osbasename 

39from os.path import exists as osexists 

40from shutil import rmtree 

41from typing import ( 

42 Any, 

43 Callable, 

44 Final, 

45 Generator, 

46 Iterable, 

47 Iterator, 

48 TextIO, 

49 cast, 

50) 

51 

52from pycommons.types import check_int_range, type_error 

53 

54#: the UTF-8 encoding 

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

56 

57#: The list of possible text encodings 

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

59 (((codecs.BOM_UTF8,), UTF8), 

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

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

62 

63 

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

65 r""" 

66 Get the text encoding from a BOM if present. 

67 

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

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

70 

71 :param filename: the filename 

72 :returns: the encoding 

73 

74 >>> from tempfile import mkstemp 

75 >>> from os import close as osxclose 

76 >>> from os import remove as osremove 

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

78 >>> osxclose(h) 

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

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

81 3 

82 >>> _get_text_encoding(tf) 

83 'utf-8-sig' 

84 

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

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

87 4 

88 >>> _get_text_encoding(tf) 

89 'utf-32' 

90 

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

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

93 4 

94 >>> _get_text_encoding(tf) 

95 'utf-32' 

96 

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

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

99 2 

100 >>> _get_text_encoding(tf) 

101 'utf-16' 

102 

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

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

105 2 

106 >>> _get_text_encoding(tf) 

107 'utf-16' 

108 

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

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

111 2 

112 >>> _get_text_encoding(tf) 

113 'utf-8-sig' 

114 

115 >>> osremove(tf) 

116 """ 

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

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

119 for boms, encoding in __ENCODINGS: 

120 for bom in boms: 

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

122 return encoding 

123 return UTF8 

124 

125 

126class Path(str): 

127 """ 

128 An immutable representation of a canonical path. 

129 

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

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

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

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

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

135 instead. 

136 

137 >>> try: 

138 ... Path(1) 

139 ... except TypeError as te: 

140 ... print(te) 

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

142 

143 >>> try: 

144 ... Path(None) 

145 ... except TypeError as te: 

146 ... print(te) 

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

148 

149 >>> try: 

150 ... Path("") 

151 ... except ValueError as ve: 

152 ... print(ve) 

153 Path must not be empty. 

154 

155 >>> try: 

156 ... Path(" ") 

157 ... except ValueError as ve: 

158 ... print(ve) 

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

160 

161 >>> from os.path import dirname 

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

163dirname(dirname(realpath(__file__))) 

164 True 

165 

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

167dirname(realpath(__file__)) 

168 True 

169 

170 >>> Path(__file__) == realpath(__file__) 

171 True 

172 

173 >>> from os import getcwd 

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

175 True 

176 

177 >>> from os import getcwd 

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

179 True 

180 

181 >>> from os import getcwd 

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

183 True 

184 

185 >>> from os import getcwd 

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

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

188 True 

189 

190 >>> from os import getcwd 

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

192 True 

193 

194 >>> from os.path import isabs 

195 >>> isabs(Path("..")) 

196 True 

197 """ 

198 

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

200 __slots__ = () 

201 

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

203 """ 

204 Construct the path object by normalizing the path string. 

205 

206 :param value: the string value 

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

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

209 

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

211 True 

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

213 True 

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

215 False 

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

217 True 

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

219 False 

220 

221 >>> isinstance(__file__, Path) 

222 False 

223 >>> isinstance(Path(__file__), Path) 

224 True 

225 >>> p = Path(__file__) 

226 >>> Path(p) is p 

227 True 

228 

229 >>> try: 

230 ... Path(None) 

231 ... except TypeError as te: 

232 ... print(te) 

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

234 

235 >>> try: 

236 ... Path(1) 

237 ... except TypeError as te: 

238 ... print(te) 

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

240 

241 >>> try: 

242 ... Path("") 

243 ... except ValueError as ve: 

244 ... print(ve) 

245 Path must not be empty. 

246 """ 

247 if isinstance(value, Path): 

248 return cast("Path", value) 

249 

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

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

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

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

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

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

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

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

258 

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

260 

261 def exists(self) -> bool: 

262 """ 

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

264 

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

266 

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

268 otherwise. 

269 

270 >>> Path(__file__).exists() 

271 True 

272 >>> from os.path import dirname 

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

274 True 

275 >>> from tempfile import mkstemp 

276 >>> from os import close as osxclose 

277 >>> from os import remove as osremove 

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

279 >>> osxclose(h) 

280 >>> p = Path(tf) 

281 >>> p.exists() 

282 True 

283 >>> osremove(p) 

284 >>> p.exists() 

285 False 

286 """ 

287 return osexists(self) 

288 

289 def is_file(self) -> bool: 

290 """ 

291 Check if this path identifies an existing file. 

292 

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

294 is not `True`. 

295 

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

297 otherwise. 

298 

299 >>> Path(__file__).is_file() 

300 True 

301 >>> from os.path import dirname 

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

303 False 

304 """ 

305 return isfile(self) 

306 

307 def enforce_file(self) -> None: 

308 """ 

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

310 

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

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

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

314 not identify a file. 

315 

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

317 

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

319 >>> from os import getcwd 

320 >>> try: 

321 ... Path(getcwd()).enforce_file() 

322 ... except ValueError as ve: 

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

324 does not identify a file. 

325 """ 

326 if not self.is_file(): 

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

328 

329 def is_dir(self) -> bool: 

330 """ 

331 Check if this path identifies an existing directory. 

332 

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

334 exception if it is not `True`. 

335 

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

337 `False` otherwise. 

338 

339 >>> Path(__file__).is_dir() 

340 False 

341 >>> from os.path import dirname 

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

343 True 

344 """ 

345 return isdir(self) 

346 

347 def enforce_dir(self) -> None: 

348 """ 

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

350 

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

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

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

354 not identify a directory. 

355 

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

357 directory 

358 

359 >>> try: 

360 ... Path(__file__).enforce_dir() 

361 ... except ValueError as ve: 

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

363 does not identify a directory. 

364 

365 >>> from os import getcwd 

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

367 """ 

368 if not self.is_dir(): 

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

370 

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

372 r""" 

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

374 

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

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

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

378 path does not contain `other`. 

379 

380 :param other: the other path 

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

382 

383 >>> from os.path import dirname 

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

385 True 

386 >>> Path(__file__).contains(__file__) 

387 False 

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

389 True 

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

391 False 

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

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

394 False 

395 

396 >>> try: 

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

398 ... except TypeError as te: 

399 ... print(te) 

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

401 

402 >>> try: 

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

404 ... except TypeError as te: 

405 ... print(te) 

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

407 

408 >>> try: 

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

410 ... except ValueError as ve: 

411 ... print(ve) 

412 Path must not be empty. 

413 

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

415 False 

416 """ 

417 if not self.is_dir(): 

418 return False 

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

420 try: 

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

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

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

424 

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

426 """ 

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

428 

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

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

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

432 

433 :param other: the other path 

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

435 

436 >>> try: 

437 ... Path(__file__).enforce_contains(__file__) 

438 ... except ValueError as ve: 

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

440 not identify a directory. 

441 

442 >>> from os.path import dirname 

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

444 >>> try: 

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

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

447 ... except ValueError as ve: 

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

449 not identify a directory. 

450 

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

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

453 >>> try: 

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

455dirname(__file__))) 

456 ... except ValueError as ve: 

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

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

459 Path 

460 True 

461 """ 

462 self.enforce_dir() 

463 if not self.contains(other): 

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

465 

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

467 """ 

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

469 

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

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

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

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

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

475 

476 :param relative_path: the path to resolve 

477 :returns: the resolved child path 

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

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

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

481 

482 >>> from os.path import dirname 

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

484 'a.txt' 

485 

486 >>> from os.path import basename 

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

488== Path(__file__) 

489 True 

490 

491 >>> try: 

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

493 ... except ValueError as ve: 

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

495 True 

496 

497 >>> try: 

498 ... Path(__file__).resolve_inside("..") 

499 ... except ValueError as ve: 

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

501 True 

502 

503 >>> try: 

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

505 ... except TypeError as te: 

506 ... print(te) 

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

508 

509 >>> try: 

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

511 ... except TypeError as te: 

512 ... print(te) 

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

514 

515 >>> try: 

516 ... Path(__file__).resolve_inside("") 

517 ... except ValueError as ve: 

518 ... print(ve) 

519 Relative path must not be empty. 

520 

521 >>> try: 

522 ... Path(__file__).resolve_inside(" ") 

523 ... except ValueError as ve: 

524 ... print(ve) 

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

526 """ 

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

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

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

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

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

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

533 self.enforce_contains(opath) 

534 return opath 

535 

536 def ensure_file_exists(self) -> bool: 

537 """ 

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

539 

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

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

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

543 the file if the directory already exists. 

544 

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

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

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

548 

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

550 True 

551 

552 >>> from os.path import dirname 

553 >>> try: 

554 ... Path(dirname(__file__)).ensure_file_exists() 

555 ... print("??") 

556 ... except ValueError as ve: 

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

558 True 

559 

560 >>> try: 

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

562.ensure_file_exists() 

563 ... print("??") 

564 ... except ValueError as ve: 

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

566 True 

567 """ 

568 existed: bool = False 

569 try: 

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

571 except FileExistsError: 

572 existed = True 

573 except Exception as err: 

574 raise ValueError( 

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

576 self.enforce_file() 

577 return existed 

578 

579 def create_file_or_truncate(self) -> None: 

580 """ 

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

582 

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

584 

585 >>> from tempfile import mkstemp 

586 >>> from os import close as osxclose 

587 >>> from os import remove as osremove 

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

589 >>> osxclose(h) 

590 

591 >>> pth = Path(tf) 

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

593 >>> print(pth.read_all_str()) 

594 test 

595 <BLANKLINE> 

596 

597 >>> pth.create_file_or_truncate() 

598 >>> pth.is_file() 

599 True 

600 

601 >>> try: 

602 ... pth.read_all_str() 

603 ... except ValueError as ve: 

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

605 contains no text. 

606 

607 >>> osremove(pth) 

608 >>> pth.is_file() 

609 False 

610 

611 >>> pth.create_file_or_truncate() 

612 >>> pth.is_file() 

613 True 

614 

615 >>> osremove(pth) 

616 

617 >>> from os import makedirs as osmkdir 

618 >>> from os import rmdir as osrmdir 

619 >>> osmkdir(pth) 

620 

621 >>> try: 

622 ... pth.create_file_or_truncate() 

623 ... except ValueError as ve: 

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

625 Error when truncating/creating file 

626 

627 >>> osrmdir(pth) 

628 """ 

629 try: 

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

631 except BaseException as err: # noqa 

632 raise ValueError( 

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

634 self.enforce_file() 

635 

636 def ensure_dir_exists(self) -> None: 

637 """ 

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

639 

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

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

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

643 

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

645 

646 >>> from os.path import dirname 

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

648 

649 >>> try: 

650 ... Path(__file__).ensure_dir_exists() 

651 ... except ValueError as ve: 

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

653 True 

654 

655 >>> try: 

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

657 ... except ValueError as ve: 

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

659 True 

660 

661 >>> from tempfile import mkdtemp 

662 >>> from os import rmdir as osrmdirx 

663 >>> td = mkdtemp() 

664 >>> Path(td).ensure_dir_exists() 

665 >>> osrmdirx(td) 

666 >>> Path(td).ensure_dir_exists() 

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

668 >>> p.ensure_dir_exists() 

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

670 >>> p2.ensure_dir_exists() 

671 >>> osrmdirx(p2) 

672 >>> osrmdirx(p) 

673 >>> osrmdirx(td) 

674 >>> p2.ensure_dir_exists() 

675 >>> osrmdirx(p2) 

676 >>> osrmdirx(p) 

677 >>> osrmdirx(td) 

678 """ 

679 try: 

680 makedirs(name=self, exist_ok=True) 

681 except FileExistsError: 

682 pass 

683 except Exception as err: 

684 raise ValueError( 

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

686 self.enforce_dir() 

687 

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

689 """ 

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

691 

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

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

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

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

696 :class:`ValueError` is raised. 

697 

698 :returns: the parent dir 

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

700 

701 >>> from os.path import dirname 

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

703 

704 >>> try: 

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

706 ... except ValueError as ve: 

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

708 True 

709 

710 >>> from tempfile import mkdtemp 

711 >>> from os import rmdir as osrmdirx 

712 >>> td = mkdtemp() 

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

714 >>> _ = tf.ensure_parent_dir_exists() 

715 >>> osrmdirx(td) 

716 >>> isdir(dirname(tf)) 

717 False 

718 >>> _ = tf.ensure_parent_dir_exists() 

719 >>> isdir(dirname(tf)) 

720 True 

721 >>> osrmdirx(td) 

722 

723 >>> td = mkdtemp() 

724 >>> isdir(td) 

725 True 

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

727 >>> isdir(td2) 

728 False 

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

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

731 >>> isdir(td2) 

732 True 

733 >>> osrmdirx(td2) 

734 >>> osrmdirx(td) 

735 

736 >>> td = mkdtemp() 

737 >>> isdir(td) 

738 True 

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

740 >>> isdir(td2) 

741 False 

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

743 >>> isdir(td3) 

744 False 

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

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

747 >>> isdir(td3) 

748 True 

749 >>> isdir(td2) 

750 True 

751 >>> osrmdirx(td3) 

752 >>> osrmdirx(td2) 

753 >>> osrmdirx(td) 

754 """ 

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

756 Path.ensure_dir_exists(pd) 

757 return pd 

758 

759 def open_for_read(self) -> TextIOBase: 

760 r""" 

761 Open this file for reading text. 

762 

763 The resulting text stream will automatically use the right encoding 

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

765 existing file, an exception is thrown. 

766 

767 :returns: the file open for reading 

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

769 

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

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

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

773 4 

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

775 

776 >>> from os.path import dirname 

777 >>> try: 

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

779 ... pass 

780 ... except ValueError as ve: 

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

782 does not identify a file. 

783 """ 

784 self.enforce_file() 

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

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

787 

788 def read_all_str(self) -> str: 

789 r""" 

790 Read a file as a single string. 

791 

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

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

794 the text that is read. 

795 

796 :returns: the single string of text 

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

798 file it identifies is empty 

799 

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

801 'The class `Path` for handl' 

802 

803 >>> from os.path import dirname 

804 >>> try: 

805 ... Path(dirname(__file__)).read_all_str() 

806 ... except ValueError as ve: 

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

808 does not identify a file. 

809 

810 >>> from tempfile import mkstemp 

811 >>> from os import remove as osremovex 

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

813 >>> osclose(h) 

814 >>> try: 

815 ... Path(p).read_all_str() 

816 ... except ValueError as ve: 

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

818 ' contains no text. 

819 

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

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

822 ... tx.write(" bb ") 

823 3 

824 6 

825 >>> Path(p).read_all_str() 

826 'aa\n bb ' 

827 >>> osremovex(p) 

828 """ 

829 with self.open_for_read() as reader: 

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

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

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

833 return res 

834 

835 def open_for_write(self) -> TextIOBase: 

836 """ 

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

838 

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

840 

841 :returns: the text io wrapper for writing 

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

843 file cannot be created 

844 

845 >>> from tempfile import mkstemp 

846 >>> from os import remove as osremovex 

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

848 >>> osclose(h) 

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

850 ... wd.write("1234") 

851 4 

852 >>> Path(p).read_all_str() 

853 '1234' 

854 >>> osremovex(p) 

855 

856 >>> from os.path import dirname 

857 >>> try: 

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

859 ... pass 

860 ... except ValueError as ve: 

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

862 True 

863 """ 

864 self.ensure_file_exists() 

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

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

867 

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

869 r""" 

870 Write the given string to the file. 

871 

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

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

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

875 be empty. 

876 

877 :param contents: the contents to write 

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

879 of strings 

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

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

882 

883 >>> from tempfile import mkstemp 

884 >>> from os import remove as osremovex 

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

886 >>> osclose(h) 

887 

888 >>> try: 

889 ... Path(p).write_all_str(None) 

890 ... except TypeError as te: 

891 ... print(str(te)) 

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

893 

894 >>> try: 

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

896 ... except TypeError as te: 

897 ... print(str(te)) 

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

899 

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

901 >>> Path(p).read_all_str() 

902 '\na\nb\n' 

903 

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

905 >>> Path(p).read_all_str() 

906 ' \na\n b \n' 

907 

908 >>> try: 

909 ... Path(p).write_all_str("") 

910 ... except ValueError as ve: 

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

912 Cannot write empty content to file 

913 

914 >>> osremovex(p) 

915 >>> from os.path import dirname 

916 >>> try: 

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

918 ... except ValueError as ve: 

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

920 True 

921 """ 

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

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

924 with self.open_for_write() as writer: 

925 writer.write(contents) 

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

927 writer.write("\n") 

928 

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

930 """ 

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

932 

933 :param base_path: the string 

934 :returns: a relative path 

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

936 relativization result is otherwise invalid 

937 

938 >>> from os.path import dirname 

939 >>> f = file_path(__file__) 

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

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

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

943 >>> f.relative_to(d1) 

944 'path.py' 

945 >>> f.relative_to(d2) 

946 'io/path.py' 

947 >>> f.relative_to(d3) 

948 'pycommons/io/path.py' 

949 >>> d1.relative_to(d3) 

950 'pycommons/io' 

951 >>> d1.relative_to(d1) 

952 '.' 

953 

954 >>> try: 

955 ... d1.relative_to(f) 

956 ... except ValueError as ve: 

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

958 does not identify a directory. 

959 

960 >>> try: 

961 ... d2.relative_to(d1) 

962 ... except ValueError as ve: 

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

964 pycommons/pycommons'. 

965 """ 

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

967 opath.enforce_contains(self) 

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

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

970 raise ValueError( # close to impossible 

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

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

973 return rv 

974 

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

976 """ 

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

978 

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

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

981 

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

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

984 and so on. 

985 :returns: the resulting path 

986 

987 >>> f = file_path(__file__) 

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

989 /pycommons/io 

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

991 /pycommons/io 

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

993 /pycommons 

994 

995 >>> try: 

996 ... f.up(0) 

997 ... except ValueError as ve: 

998 ... print(ve) 

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

1000 

1001 >>> try: 

1002 ... f.up(None) 

1003 ... except TypeError as te: 

1004 ... print(te) 

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

1006 

1007 >>> try: 

1008 ... f.up('x') 

1009 ... except TypeError as te: 

1010 ... print(te) 

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

1012 

1013 >>> try: 

1014 ... f.up(255) 

1015 ... except ValueError as ve: 

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

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

1018 """ 

1019 s: str = self 

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

1021 old: str = s 

1022 s = dirname(s) 

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

1024 raise ValueError( 

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

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

1027 return directory_path(s) 

1028 

1029 def basename(self) -> str: 

1030 """ 

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

1032 

1033 :returns: the name of the file or directory 

1034 

1035 >>> file_path(__file__).basename() 

1036 'path.py' 

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

1038 'pycommons' 

1039 

1040 >>> try: 

1041 ... Path("/").basename() 

1042 ... except ValueError as ve: 

1043 ... print(ve) 

1044 Invalid basename '' of path '/'. 

1045 """ 

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

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

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

1049 return s 

1050 

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

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

1053 """ 

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

1055 

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

1057 

1058 >>> from tempfile import mkstemp, mkdtemp 

1059 >>> from os import close as osxclose 

1060 

1061 >>> dir1 = Path(mkdtemp()) 

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

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

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

1065 >>> osclose(h) 

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

1067 >>> osclose(h) 

1068 >>> file1 = Path(tf1) 

1069 >>> file2 = Path(tf2) 

1070 

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

1072 True 

1073 

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

1075 True 

1076 

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

1078 True 

1079 

1080 >>> try: 

1081 ... dir1.list_dir(None) 

1082 ... except TypeError as te: 

1083 ... print(te) 

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

1085 

1086 >>> try: 

1087 ... dir1.list_dir(1) 

1088 ... except TypeError as te: 

1089 ... print(te) 

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

1091 

1092 >>> try: 

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

1094 ... except TypeError as te: 

1095 ... print(te) 

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

1097 

1098 >>> try: 

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

1100 ... except TypeError as te: 

1101 ... print(te) 

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

1103 

1104 >>> try: 

1105 ... dir1.list_dir(False, False) 

1106 ... except ValueError as ve: 

1107 ... print(ve) 

1108 files and directories cannot both be False. 

1109 

1110 >>> delete_path(dir1) 

1111 """ 

1112 if not isinstance(files, bool): 

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

1114 if not isinstance(directories, bool): 

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

1116 if not (files or directories): 

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

1118 self.enforce_dir() 

1119 return map(self.resolve_inside, ( 

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

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

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

1123 

1124 

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

1126 """ 

1127 Get a path identifying an existing file. 

1128 

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

1130 :meth:`~Path.enforce_file`. 

1131 

1132 :param pathstr: the path 

1133 :returns: the file 

1134 

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

1136 'pycommons/io/path.py' 

1137 

1138 >>> from os.path import dirname 

1139 >>> try: 

1140 ... file_path(dirname(__file__)) 

1141 ... except ValueError as ve: 

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

1143 True 

1144 """ 

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

1146 fi.enforce_file() 

1147 return fi 

1148 

1149 

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

1151 """ 

1152 Get a path identifying an existing directory. 

1153 

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

1155 :meth:`~Path.enforce_dir`. 

1156 

1157 :param pathstr: the path 

1158 :returns: the file 

1159 

1160 >>> from os.path import dirname 

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

1162 'pycommons/io' 

1163 

1164 >>> try: 

1165 ... directory_path(__file__) 

1166 ... except ValueError as ve: 

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

1168 True 

1169 """ 

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

1171 fi.enforce_dir() 

1172 return fi 

1173 

1174 

1175#: the ends-with check 

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

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

1178 

1179 

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

1181 r""" 

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

1183 

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

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

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

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

1188 

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

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

1191 terminate lines that are written 

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

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

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

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

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

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

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

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

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

1201 

1202 :param output: the output stream 

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

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

1205 stream. 

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

1207 :class:`io.TextIOBase`. 

1208 

1209 >>> from tempfile import mkstemp 

1210 >>> from os import close as osclose 

1211 >>> from os import remove as osremove 

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

1213 >>> osclose(h) 

1214 

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

1216 ... w = line_writer(out) 

1217 ... w("123") 

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

1219 ... print(list(inp)) 

1220 ['123\n'] 

1221 

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

1223 ... w = line_writer(out) 

1224 ... w("") 

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

1226 ... print(list(inp)) 

1227 ['\n'] 

1228 

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

1230 ... w = line_writer(out) 

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

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

1233 ... print(list(inp)) 

1234 ['123\n'] 

1235 

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

1237 ... w = line_writer(out) 

1238 ... w("\n") 

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

1240 ... print(list(inp)) 

1241 ['\n'] 

1242 

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

1244 ... w = line_writer(out) 

1245 ... w("123") 

1246 ... w("456") 

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

1248 ... print(list(inp)) 

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

1250 

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

1252 ... w = line_writer(out) 

1253 ... w("123 ") 

1254 ... w("") 

1255 ... w(" 456") 

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

1257 ... print(list(inp)) 

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

1259 

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

1261 ... w = line_writer(out) 

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

1263 ... w("\n") 

1264 ... w(" 456") 

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

1266 ... print(list(inp)) 

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

1268 

1269 >>> try: 

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

1271 ... w = line_writer(out) 

1272 ... w("123 ") 

1273 ... w(None) 

1274 ... except TypeError as te: 

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

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

1277 

1278 >>> try: 

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

1280 ... w = line_writer(out) 

1281 ... w("123 ") 

1282 ... w(2) 

1283 ... except TypeError as te: 

1284 ... print(te) 

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

1286 

1287 >>> osremove(tf) 

1288 

1289 >>> try: 

1290 ... line_writer(1) 

1291 ... except TypeError as te: 

1292 ... print(te) 

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

1294 

1295 >>> try: 

1296 ... line_writer(None) 

1297 ... except TypeError as te: 

1298 ... print(te) 

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

1300 """ 

1301 if not isinstance(output, TextIOBase): 

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

1303 

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

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

1306 __w(s) 

1307 if not b: 

1308 __w("\n") 

1309 

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

1311 

1312 

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

1314 r""" 

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

1316 

1317 :param lines: the lines 

1318 :returns: the generator 

1319 

1320 >>> list(__line_iterator([])) 

1321 [] 

1322 

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

1324 ['a', '\n'] 

1325 

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

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

1328 

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

1330 ['a\n'] 

1331 

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

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

1334 

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

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

1337 

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

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

1340 

1341 >>> try: 

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

1343 ... except TypeError as te: 

1344 ... print(te) 

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

1346 """ 

1347 for line in lines: 

1348 b: bool = __ENDSWITH(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.")