stdl 0.6.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
stdl/fs.py ADDED
@@ -0,0 +1,1711 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import json
5
+ import math
6
+ import os
7
+ import pickle
8
+ import platform
9
+ import random
10
+ import re
11
+ import shlex
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from collections.abc import Callable, Generator, Iterable
17
+ from datetime import datetime
18
+ from os import PathLike
19
+ from pathlib import Path
20
+ from queue import Queue
21
+ from typing import IO, Any, Literal
22
+
23
+ if sys.version_info >= (3, 11):
24
+ from typing import Self
25
+ else:
26
+ from typing_extensions import Self
27
+
28
+ from urllib.request import pathname2url
29
+
30
+ import toml
31
+ import yaml
32
+
33
+ from stdl import st
34
+
35
+ stat = os.stat
36
+ link = os.link
37
+ getcwd = os.getcwd
38
+
39
+ SEP = os.sep
40
+ HOME = os.path.expanduser("~")
41
+ abspath = os.path.abspath
42
+ basename = os.path.basename
43
+ dirname = os.path.dirname
44
+ joinpath = os.path.join
45
+ fspath = os.fspath
46
+ splitpath = os.path.split
47
+ isdir = os.path.isdir
48
+ isfile = os.path.isfile
49
+ islink = os.path.islink
50
+ exists = os.path.exists
51
+
52
+ chdir = os.chdir
53
+ chmod = os.chmod
54
+ remove = os.remove
55
+ rename = os.rename
56
+
57
+ copy = shutil.copy2
58
+ move = shutil.move
59
+ chown = shutil.chown
60
+
61
+
62
+ def pickle_load(filepath: str | PathLike) -> Any:
63
+ """Loads a pickled file."""
64
+ with open(filepath, "rb") as f:
65
+ return pickle.load(f)
66
+
67
+
68
+ def pickle_dump(data: Any, filepath: str | PathLike) -> None:
69
+ """Dumps an object to the specified filepath."""
70
+ with open(filepath, "wb") as f:
71
+ pickle.dump(data, f)
72
+
73
+
74
+ def json_load(
75
+ path: str | PathLike, encoding: str = "utf-8"
76
+ ) -> dict[Any, Any] | list[dict[Any, Any]]:
77
+ """
78
+ Load a JSON file from the given path.
79
+
80
+ Args:
81
+ path (str | PathLike): The path of the JSON file to load.
82
+ encoding (str, optional): The encoding of the file. Defaults to "utf-8".
83
+
84
+ Returns:
85
+ dict | list[dict]: The JSON data loaded from the file.
86
+ """
87
+ with open(os.fspath(path), encoding=encoding) as f:
88
+ return json.load(f)
89
+
90
+
91
+ def json_append(
92
+ data: dict[Any, Any] | list[dict[Any, Any]],
93
+ filepath: str | PathLike,
94
+ encoding: str = "utf-8",
95
+ default: Callable[[Any], str] = str,
96
+ indent: int = 4,
97
+ ) -> None:
98
+ """
99
+ Append data to a JSON file.
100
+
101
+ Args:
102
+ data (dict | list[dict]): The data to be appended
103
+ filepath (str | PathLike): The path of the JSON file
104
+ encoding (str, optional): The encoding of the file. Defaults to "utf-8".
105
+ default : A function that gets called for objects that can't otherwise be serialized.
106
+ See json.dump() documentation for more information.
107
+ indent (int, optional): The number of spaces to use for indentation. Defaults to 4.
108
+ """
109
+ file = File(filepath)
110
+ path = os.fspath(filepath)
111
+ if not file.exists or file.size == 0:
112
+ json_dump([data], filepath, encoding=encoding, indent=indent, default=default)
113
+ return
114
+ with open(path, "a+", encoding=encoding) as f:
115
+ f.seek(0)
116
+ first_char = f.read(1)
117
+ if first_char == "[":
118
+ f.seek(0, os.SEEK_END)
119
+ f.seek(f.tell() - 2, os.SEEK_SET)
120
+ f.truncate()
121
+ f.write(",\n")
122
+ json.dump(data, f, indent=indent, default=default)
123
+ f.write("]\n")
124
+ elif first_char == "{":
125
+ file_data = first_char + f.read()
126
+ f.seek(0)
127
+ f.truncate()
128
+ f.seek(0)
129
+ f.write("[\n")
130
+ f.write(file_data)
131
+ f.write(",\n")
132
+ json.dump(data, f, indent=indent, default=default)
133
+ f.write("]\n")
134
+ else:
135
+ raise ValueError(f"Cannot parse '{path}' as JSON.")
136
+
137
+
138
+ def json_dump(
139
+ data: Any,
140
+ path: str | PathLike,
141
+ encoding: str = "utf-8",
142
+ default: Callable[[Any], str] = str,
143
+ indent: int = 4,
144
+ ) -> None:
145
+ """
146
+ Dumps data to a JSON file.
147
+
148
+ Args:
149
+ data: data to be dumped
150
+ path (str | PathLike): path to the output file
151
+ encoding (str): encoding of the output file. Default: 'utf-8'
152
+ default: A function that gets called on objects that cannot be serialized. Default: str
153
+ indent (int): number of spaces to use when indenting the output json. Default: 4
154
+ """
155
+ with open(os.fspath(path), "w", encoding=encoding) as f:
156
+ json.dump(data, f, indent=indent, default=default)
157
+
158
+
159
+ def yaml_load(
160
+ path: str | PathLike, encoding: str = "utf-8"
161
+ ) -> dict[Any, Any] | list[dict[Any, Any]]:
162
+ """
163
+ Load a YAML file from the given path.
164
+
165
+ Args:
166
+ path (str | PathLike): The path of the YAML file to load.
167
+ encoding (str, optional): The encoding of the file. Defaults to "utf-8".
168
+
169
+ Returns:
170
+ dict | list[dict]: The YAML data loaded from the file.
171
+ """
172
+ with open(path, encoding=encoding) as f:
173
+ return yaml.safe_load(f)
174
+
175
+
176
+ def yaml_dump(data: Any, path: str | PathLike, encoding: str = "utf-8") -> None:
177
+ """
178
+ Dumps data to a YAML file
179
+ Args:
180
+ data: data to be dumped
181
+ path (Pathlike): path to the output file
182
+ encoding (str): encoding of the output file. Default: 'utf-8'
183
+ """
184
+ with open(path, "w", encoding=encoding) as f:
185
+ yaml.safe_dump(data, f)
186
+
187
+
188
+ def toml_load(path: str | PathLike, encoding: str = "utf-8") -> dict[str, Any]:
189
+ with open(path, encoding=encoding) as f:
190
+ return toml.load(f)
191
+
192
+
193
+ def toml_dump(data: dict[str, Any], path: str | PathLike, encoding: str = "utf-8") -> None:
194
+ with open(path, "w", encoding=encoding) as f:
195
+ toml.dump(data, f)
196
+
197
+
198
+ def get_dir_size(directory: str | PathLike, *, readable: bool = False) -> str | int:
199
+ """
200
+ Moves files to a specified directory
201
+
202
+ Args:
203
+ directory (str, Path): target directory
204
+ readable (bool, optional): Return the size in human-readable format
205
+ """
206
+ total_size = 0
207
+ for dirpath, _, filenames in os.walk(directory):
208
+ for f in filenames:
209
+ fp = os.path.join(dirpath, f)
210
+ # Skip if it's symbolic link.
211
+ if not os.path.islink(fp):
212
+ total_size += os.path.getsize(fp)
213
+ if readable:
214
+ return bytes_readable(total_size)
215
+ return total_size
216
+
217
+
218
+ def move_files(
219
+ files: list[str | PathLike], directory: str | PathLike, *, mkdir: bool = False
220
+ ) -> None:
221
+ """
222
+ Moves files to a specified directory
223
+
224
+ Args:
225
+ files (list[str | PathLike]): List of files to be moved
226
+ directory (str | PathLike): target directory
227
+ mkdir (bool): whether to create the directory if it doesn't exist. Default: False
228
+
229
+ Raises:
230
+ FileNotFoundError : if the target directory does not exist and mkdir is False.
231
+ """
232
+ directory = os.fspath(directory)
233
+ if exists(directory):
234
+ if mkdir:
235
+ os.makedirs(directory, exist_ok=True)
236
+ else:
237
+ raise FileNotFoundError(f"{directory} is not a directory")
238
+ for file in files:
239
+ os.rename(os.fspath(file), f"{directory}{SEP}{os.path.basename(file)}")
240
+
241
+
242
+ def rand_filename(prefix: str = "file", ext: str = "", include_datetime: bool = False) -> str:
243
+ """
244
+ Generates a random filename with the given prefix and extension.
245
+ Optionally includes current date and time in the filename.
246
+
247
+ Args:
248
+ prefix (str, optional): Filename prefix.
249
+ ext (str, optional): Filename extension.
250
+ include_datetime (bool, optional): Whether to include date and time.
251
+
252
+ Returns:
253
+ str: The generated random filename.
254
+ """
255
+ if ext and not ext.startswith("."):
256
+ ext = f".{ext}"
257
+ num = str(random.randrange(1000000000, 9999999999)).zfill(10)
258
+ if include_datetime:
259
+ creation_time = datetime.now().strftime("%Y-%m-%d.%H-%M-%S-%f")[:-3]
260
+ filename = f"{prefix}.{num}.{creation_time}{ext}"
261
+ else:
262
+ filename = f"{prefix}.{num}{ext}"
263
+ return safe_filename(filename)
264
+
265
+
266
+ def bytes_readable(size_bytes: int) -> str:
267
+ """
268
+ Convert bytes to a human-readable string.
269
+
270
+ Args:
271
+ size_bytes (int): The number of bytes
272
+ Returns:
273
+ str : The number of bytes in a human-readable format
274
+ """
275
+ if size_bytes < 0:
276
+ raise ValueError(size_bytes)
277
+ if size_bytes == 0:
278
+ return "0B"
279
+ i = int(math.floor(math.log(size_bytes, 1024)))
280
+ p = math.pow(1024, i)
281
+ s = round(size_bytes / p, 2)
282
+ size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
283
+ return f"{s} {size_name[i]}"
284
+
285
+
286
+ def readable_size_to_bytes(size: str, kb_size: Literal[1000, 1024] = 1024) -> int:
287
+ """
288
+ Convert human-readable string to bytes.
289
+
290
+ Args:
291
+ size (str): The number of bytes in human-readable format
292
+ kb_size (int, optional): The byte size of a kilobyte (1000 or 1024). Defaults to 1024.
293
+
294
+ Returns:
295
+ int: The number of bytes
296
+ """
297
+ if kb_size not in (1000, 1024):
298
+ raise ValueError(f"Invalid kb_size: {kb_size}. Must be 1000 or 1024.")
299
+
300
+ size = size.upper().replace(" ", "")
301
+ if size.isdigit():
302
+ return int(size)
303
+
304
+ match = re.match(r"^(\d+(\.\d+)?)(B|KB|MB|GB|TB)$", size)
305
+ if not match:
306
+ raise ValueError(f"Invalid size format: {size}")
307
+
308
+ number, unit = float(match.group(1)), match.group(3)
309
+
310
+ units = {"B": 1, "KB": kb_size, "MB": kb_size**2, "GB": kb_size**3, "TB": kb_size**4}
311
+
312
+ if unit not in units:
313
+ raise ValueError(f"Invalid unit: {unit}")
314
+
315
+ return int(number * units[unit])
316
+
317
+
318
+ def windows_has_drive(letter: str) -> bool:
319
+ """
320
+ Check if a drive letter exists on Windows.
321
+ Will always return False if the platform is not Windows.
322
+
323
+ Args:
324
+ letter (str): The letter of the drive.
325
+
326
+ Returns:
327
+ bool: Whether the drive exists.
328
+ """
329
+ if sys.platform != "win32":
330
+ return False
331
+ return os.path.exists(f"{letter}:{SEP}")
332
+
333
+
334
+ def is_wsl() -> bool:
335
+ """Check if the current platform is Windows Subsystem for Linux (WSL)."""
336
+ return sys.platform == "linux" and "microsoft" in platform.platform()
337
+
338
+
339
+ def mkdir(path: str | PathLike, mode: int = 511, exist_ok: bool = True) -> None:
340
+ """
341
+ Creates a directory.
342
+
343
+ Args:
344
+ path (str | Path): The path of the directory to create.
345
+ mode (int, optional): The mode to set for the directory. Defaults to 511 (octal 0777).
346
+ exist_ok (bool, optional): Whether to raise an exception if the directory already exists. Defaults to True.
347
+ """
348
+ os.makedirs(os.fspath(path), exist_ok=exist_ok, mode=mode)
349
+
350
+
351
+ def mkdirs(dest: str | PathLike, names: list[str]) -> None:
352
+ """
353
+ Creates directories inside a destination directory.
354
+
355
+ Args:
356
+ dest (str | Path): The destination directory.
357
+ names (list[str]): A list of directory names to be created in the destination directory.
358
+ """
359
+ if not exists(dest):
360
+ mkdir(dest)
361
+ for i in names:
362
+ if not exists(i):
363
+ path = f"{dest}{SEP}{i}"
364
+ mkdir(path)
365
+
366
+
367
+ def yield_files_in(
368
+ directory: str | PathLike,
369
+ ext: str | tuple[str, ...] | None = None,
370
+ *,
371
+ recursive: bool = True,
372
+ abs: bool = True,
373
+ ) -> Generator[str, None, None]:
374
+ """
375
+ Yields the paths of files in a directory.
376
+
377
+ This function searches for files in a directory and yields their paths.
378
+ If the `ext` parameter is provided, only files with that extension are yielded. The `ext` parameter is case-insensitive.
379
+ If the `recursive` parameter is set to `True`, the function will search for files in subdirectories recursively.
380
+
381
+ Args:
382
+ directory (str | Path): The directory to search.
383
+ ext (str | tuple[str, ...], optional): If provided, only yield files with provided extensions.
384
+ recursive (bool, optional): Whether to search recursively.
385
+ abs (bool, optional): Whether to convert paths to absolute paths.
386
+
387
+ Yields:
388
+ Generator[str, None, None]: The absolute paths of the files in the directory, matching the provided extension.
389
+ """
390
+ if not recursive:
391
+ for entry in os.scandir(directory):
392
+ if not entry.is_file():
393
+ continue
394
+ path = entry.path
395
+ if abs:
396
+ path = os.path.abspath(entry.path)
397
+ if ext is None:
398
+ yield path
399
+ else:
400
+ if path.lower().endswith(ext):
401
+ yield path
402
+ return
403
+
404
+ queue = Queue()
405
+ queue.put(directory)
406
+
407
+ if ext is None:
408
+ while not queue.empty():
409
+ next_dir = queue.get()
410
+ for entry in os.scandir(next_dir):
411
+ if entry.is_dir():
412
+ queue.put(entry.path)
413
+ elif entry.is_file():
414
+ yield os.path.abspath(entry.path) if abs else entry.path
415
+ return
416
+
417
+ while not queue.empty():
418
+ next_dir = queue.get()
419
+ for entry in os.scandir(next_dir):
420
+ if entry.is_dir():
421
+ queue.put(entry.path)
422
+ elif entry.is_file():
423
+ if entry.path.lower().endswith(ext):
424
+ yield os.path.abspath(entry.path) if abs else entry.path
425
+
426
+
427
+ def get_files_in(
428
+ directory: str | PathLike,
429
+ ext: str | tuple[str, ...] | None = None,
430
+ *,
431
+ recursive: bool = True,
432
+ abs: bool = True,
433
+ ) -> list[str]:
434
+ """
435
+ Returns the paths of files in a directory.
436
+
437
+ This function searches for files in a directory and yields their paths.
438
+ If the `ext` parameter is provided, only files with that extension are returned. The `ext` parameter is case-insensitive.
439
+ If the `recursive` parameter is set to `True`, the function will search for files in subdirectories recursively.
440
+
441
+ Args:
442
+ directory (str | Path): The directory to search.
443
+ ext (str | tuple[str, ...], optional): If provided, only yield files with provided extensions. Defaults to None.
444
+ recursive (bool, optional): Whether to search recursively. Defaults to True.
445
+ abs (bool, optional): Whether to convert paths to absolute paths.
446
+
447
+ Returns:
448
+ list[str]: The absolute path of the files in the directory, matching the provided extension.
449
+ """
450
+ return list(yield_files_in(directory, ext, recursive=recursive, abs=abs))
451
+
452
+
453
+ def yield_dirs_in(
454
+ directory: str | PathLike, *, recursive: bool = True, abs: bool = True
455
+ ) -> Generator[str, None, None]:
456
+ """
457
+ Yields paths to all directories in the specified directory.
458
+ Yielded paths are converted to absolute paths.
459
+
460
+ Args:
461
+ directory (str | Path): The directory to search.
462
+ recursive (bool, optional): Whether to search recursively.
463
+ abs (bool, optional): Whether to convert paths to absolute paths.
464
+
465
+
466
+ Yields:
467
+ Generator[str, None, None]: The paths of the directories that are found during travelsal.
468
+ """
469
+ queue = Queue()
470
+ queue.put(directory)
471
+ while not queue.empty():
472
+ next_dir = queue.get()
473
+ for entry in os.scandir(next_dir):
474
+ if entry.is_dir():
475
+ if recursive:
476
+ queue.put(entry.path)
477
+ yield os.path.abspath(entry.path) if abs else entry.path
478
+
479
+
480
+ def get_dirs_in(
481
+ directory: str | PathLike, *, recursive: bool = True, abs: bool = True
482
+ ) -> list[str]:
483
+ """
484
+ Returns all directories in the specified directory.
485
+ Returned paths are converted to absolute paths.
486
+
487
+ Args:
488
+ directory (str | Path): The directory to search.
489
+ recursive (bool, optional): Whether to search recursively.
490
+ abs (bool, optional): Whether to convert paths to absolute paths.
491
+
492
+ Returns:
493
+ list[str]: The paths of the directories that are found during travelsal.
494
+ """
495
+ return list(yield_dirs_in(directory, recursive=recursive, abs=abs))
496
+
497
+
498
+ def ensure_paths_exist(*args: str | PathLike | Iterable[str | PathLike]) -> None:
499
+ """
500
+ Ensures that the specified paths exist.
501
+
502
+ Args:
503
+ *args : one or more strings representing the paths to check. Can also include an Iterable of paths.
504
+
505
+ Raises:
506
+ FileNotFoundError : if one of the provided paths does not exist.
507
+ """
508
+
509
+ def check_path(path: str | PathLike) -> None:
510
+ if not os.path.exists(os.fspath(path)):
511
+ raise FileNotFoundError(f"Path does not exist: '{path}'")
512
+
513
+ for path in args:
514
+ if isinstance(path, (str, bytes, PathLike)):
515
+ check_path(path)
516
+ elif isinstance(path, Iterable):
517
+ for i in path:
518
+ check_path(i)
519
+
520
+
521
+ def ensure_paths_dont_exist(*args: str | PathLike | Iterable[str | PathLike]) -> None:
522
+ """
523
+ Ensures that the specified paths don't exist.
524
+
525
+ Args:
526
+ *args : one or more strings representing the paths to check. Can also include an Iterable of paths.
527
+
528
+ Raises:
529
+ FileNotFoundError : if one of the provided paths exists.
530
+ """
531
+
532
+ def check_path(path: str | PathLike) -> None:
533
+ if os.path.exists(os.fspath(path)):
534
+ raise FileExistsError(f"Path already exists: '{path}'")
535
+
536
+ for path in args:
537
+ if isinstance(path, (str, bytes, PathLike)):
538
+ check_path(path)
539
+ elif isinstance(path, Iterable):
540
+ for i in path:
541
+ check_path(i)
542
+
543
+
544
+ def safe_filename(name: str) -> str:
545
+ newname = name.replace('"', "'")
546
+ newname = st.remove(newname, chars='<>:"/|?*\\“”;')
547
+ return newname
548
+
549
+
550
+ def safe_filepath(path: str) -> str:
551
+ return st.remove(path, chars='<>"|?*“”;')
552
+
553
+
554
+ class CompletedCommand(subprocess.CompletedProcess):
555
+ def __init__(
556
+ self,
557
+ args: str | list[str],
558
+ returncode: int,
559
+ time_taken: float,
560
+ stdout: Any | None = None,
561
+ stderr: Any | None = None,
562
+ ) -> None:
563
+ super().__init__(args, returncode, stdout, stderr)
564
+ self.time_taken = time_taken
565
+
566
+ def __repr__(self) -> str:
567
+ args = [
568
+ f"args={self.args!r}",
569
+ f"returncode={self.returncode!r}",
570
+ f"time_taken={self.time_taken}s",
571
+ ]
572
+ if self.stdout is not None:
573
+ args.append(f"stdout={self.stdout!r}")
574
+ if self.stderr is not None:
575
+ args.append(f"stderr={self.stderr!r}")
576
+ return "{}({})".format(type(self).__name__, ", ".join(args))
577
+
578
+ @property
579
+ def stdout_lines(self) -> list[str]:
580
+ """Returns the stdout as a list of lines."""
581
+ if self.stdout is None:
582
+ return []
583
+ return self.stdout.splitlines()
584
+
585
+ @property
586
+ def stderr_lines(self) -> list[str]:
587
+ """Returns the stderr as a list of lines."""
588
+ if self.stderr is None:
589
+ return []
590
+ return self.stderr.splitlines()
591
+
592
+ def dict(self) -> dict[str, Any]:
593
+ return {
594
+ "args": self.args,
595
+ "returncode": self.returncode,
596
+ "time_taken": self.time_taken,
597
+ "stdout": self.stdout,
598
+ "stderr": self.stderr,
599
+ }
600
+
601
+
602
+ def exec_cmd(
603
+ cmd: list[str] | str,
604
+ timeout: float | None = None, # type:ignore
605
+ shell: bool = False,
606
+ capture_output: bool = True,
607
+ check: bool = False,
608
+ cwd: str | None = None, # type:ignore
609
+ stdin: IO | None = None, # type:ignore
610
+ stdout: IO | None = None, # type:ignore
611
+ stderr: IO | None = None, # type:ignore
612
+ input: str | None | bytes = None, # type:ignore
613
+ env: dict[str, str] | None = None, # type:ignore
614
+ text: bool = True,
615
+ *args: Any,
616
+ **kwargs: Any,
617
+ ) -> CompletedCommand:
618
+ """
619
+ Wrapper for subprocess.run with nicer default arguments.
620
+
621
+ Args:
622
+ cmd (str | list[str]): command to run. A list of strings or a single string.
623
+ timeout (float, optional): the time after which the command is killed.
624
+ shell (bool, optional): whether or not to run the command in a shell.
625
+ capture_output (bool, optional): whether or not to capture the output to stdout and stderr.
626
+ check (bool, optional): whether or not to raise an exception on a non-zero exit code.
627
+ cwd (str, optional): current working directory to run the command in.
628
+ stdin (IO, optional): file object to read stdin from.
629
+ stdout (IO, optional): file object to write stdout to.
630
+ stderr (IO, optional): file object to write stderr to.
631
+ input (str | bytes): input to send to the command.
632
+ env (dict, optional): environment variables to pass to the new process.
633
+ text (bool): whether or not to return output as text or bytes.
634
+ *args : additional arguments to pass to subprocess.run.
635
+ **kwargs : additional keyword arguments to pass to subprocess.run.
636
+
637
+ Returns:
638
+ subprocess.CompletedProcess : the completed process.
639
+ """
640
+ if isinstance(cmd, str):
641
+ cmd = shlex.split(cmd)
642
+
643
+ start_time = time.time()
644
+
645
+ result = subprocess.run(
646
+ cmd,
647
+ timeout=timeout,
648
+ shell=shell,
649
+ check=check,
650
+ stdin=stdin,
651
+ stdout=stdout,
652
+ stderr=stderr,
653
+ capture_output=capture_output,
654
+ text=text,
655
+ input=input,
656
+ env=env,
657
+ cwd=cwd,
658
+ **kwargs,
659
+ )
660
+
661
+ return CompletedCommand(
662
+ result.args,
663
+ result.returncode,
664
+ time.time() - start_time,
665
+ result.stdout,
666
+ result.stderr,
667
+ )
668
+
669
+
670
+ def read_piped() -> str:
671
+ """Reads piped input from stdin."""
672
+ if sys.stdin.isatty():
673
+ return ""
674
+ return sys.stdin.read().strip()
675
+
676
+
677
+ def start_file(path: str | PathLike) -> None:
678
+ """
679
+ Open the file with your OS's default application.
680
+
681
+ This function determines the current operating system and uses the appropriate command
682
+ to open the specified file with the default application. It supports Windows, macOS,
683
+ and Linux, including Windows Subsystem for Linux (WSL).
684
+ """
685
+ path = os.fspath(path)
686
+ if is_wsl():
687
+ exec_cmd(f"cmd.exe /C start '{path}'", check=True)
688
+ elif sys.platform == "win32":
689
+ os.startfile(path)
690
+ elif sys.platform == "darwin":
691
+ exec_cmd(f"open {path}", check=True)
692
+ elif sys.platform == "linux":
693
+ exec_cmd(f"xdg-open '{path}'", check=True)
694
+ else:
695
+ raise NotImplementedError(f"Unsupported platform: {sys.platform}")
696
+
697
+
698
+ class PathBase(PathLike):
699
+ def __init__(
700
+ self,
701
+ path: str | PathLike,
702
+ *,
703
+ abs: bool = False,
704
+ ) -> None:
705
+ """
706
+ Initialize a PathBase object.
707
+
708
+ Args:
709
+ path (str | PathLike): The path.
710
+ abs (bool): Whether to use the absolute path.
711
+ """
712
+ self.path = os.fspath(path).replace("\\", SEP).replace("/", SEP)
713
+ if abs:
714
+ self.path = os.path.abspath(self.path)
715
+
716
+ def __fspath__(self) -> str:
717
+ return self.path
718
+
719
+ def __str__(self) -> str:
720
+ return self.path
721
+
722
+ def resolve(self, strict: bool = False) -> PathBase:
723
+ """
724
+ Make the path absolute, resolving any symlinks.
725
+
726
+ Args:
727
+ strict: If True and path doesn't exist, raises FileNotFoundError. Defaults to False.
728
+
729
+ Returns:
730
+ PathBase: The object with resolved path.
731
+ """
732
+ self.path = os.path.realpath(self.path)
733
+ if strict and not self.exists:
734
+ raise FileNotFoundError(f"No such file: '{self.path}'")
735
+ return self
736
+
737
+ @property
738
+ def nodes(self) -> list[str]:
739
+ """The path as a list of nodes."""
740
+ return self.abspath.split(SEP)
741
+
742
+ @property
743
+ def created(self) -> float:
744
+ """The time when the path was created as a UNIX timestamp."""
745
+ return os.path.getctime(self.path)
746
+
747
+ @property
748
+ def modified(self) -> float:
749
+ """The time when the path was last modified as a UNIX timestamp."""
750
+ return os.path.getmtime(self.path)
751
+
752
+ @property
753
+ def accessed(self) -> float:
754
+ """The time when the path was last accessed as a UNIX timestamp."""
755
+ return os.path.getatime(self.path)
756
+
757
+ ctime = created
758
+ mtime = modified
759
+ atime = accessed
760
+
761
+ @property
762
+ def basename(self) -> str:
763
+ """The base name of the path (without the parent directory)."""
764
+ return os.path.basename(self.path)
765
+
766
+ @property
767
+ def abspath(self) -> str:
768
+ """The absolute path."""
769
+ return os.path.abspath(self.path)
770
+
771
+ @property
772
+ def parents(self) -> tuple[Directory, ...]:
773
+ """All parent directories from immediate parent to root."""
774
+ result: list[Directory] = []
775
+ current = os.path.dirname(os.path.abspath(self.path))
776
+ while current:
777
+ result.append(Directory(current))
778
+ parent = os.path.dirname(current)
779
+ if parent == current:
780
+ break
781
+ current = parent
782
+ return tuple(result)
783
+
784
+ @property
785
+ def is_absolute(self) -> bool:
786
+ """Whether the path is absolute."""
787
+ return os.path.isabs(self.path)
788
+
789
+ @property
790
+ def is_symlink(self) -> bool:
791
+ """Whether the path is a symbolic link."""
792
+ return os.path.islink(self.path)
793
+
794
+ def rename(self, name: str) -> Self:
795
+ """
796
+ Rename the path.
797
+
798
+ Args:
799
+ name (str): The new name.
800
+ """
801
+ new_path = os.path.join(self.parent.path, name)
802
+ os.rename(self.path, new_path)
803
+ self.path = new_path
804
+ return self
805
+
806
+ def chmod(self, mode: int) -> Self:
807
+ """
808
+ Change the path's permissions.
809
+
810
+ Args:
811
+ mode (int): The new permissions mode.
812
+ """
813
+ os.chmod(self.path, mode)
814
+ return self
815
+
816
+ def chown(self, user: str, group: str) -> Self:
817
+ """
818
+ Change the path's owner and group.
819
+
820
+ Args:
821
+ user (str): The new owner.
822
+ group (str): The new group.
823
+ """
824
+ shutil.chown(self.path, user, group)
825
+ return self
826
+
827
+ def should_exist(self) -> Self:
828
+ """Raise FileNotFoundError if the path does not exist."""
829
+ if not self.exists:
830
+ raise FileNotFoundError(f"No such path: '{self.path}'")
831
+ return self
832
+
833
+ def should_not_exist(self) -> Self:
834
+ """Raise FileExistsError if the path exists."""
835
+ if self.exists:
836
+ raise FileExistsError(f"Path already exists: '{self.path}'")
837
+ return self
838
+
839
+ def to_path(self) -> Path:
840
+ """
841
+ Convert to pathlib.Path.
842
+
843
+ Returns:
844
+ Path: The pathlib.Path object.
845
+ """
846
+ return Path(self.path)
847
+
848
+ def to_str(self) -> str:
849
+ """
850
+ Convert to string.
851
+
852
+ Returns:
853
+ str: The path as a string.
854
+ """
855
+ return str(self)
856
+
857
+ def stat(self, *, follow_symlinks: bool = True) -> os.stat_result:
858
+ """
859
+ Return the stat info for this path.
860
+
861
+ Args:
862
+ follow_symlinks: If True, follow symlinks. If False, return info about the link itself.
863
+
864
+ Returns:
865
+ os.stat_result: The stat information.
866
+ """
867
+ return os.stat(self.path, follow_symlinks=follow_symlinks)
868
+
869
+ def relative_to(self, other: str | PathBase) -> str:
870
+ """
871
+ Make this path relative to another path.
872
+
873
+ Args:
874
+ other: The base path to make this path relative to.
875
+
876
+ Returns:
877
+ str: The relative path.
878
+
879
+ Raises:
880
+ ValueError: If this path is not relative to the other path.
881
+ """
882
+ if isinstance(other, PathBase):
883
+ other = other.path
884
+ other = os.path.abspath(other)
885
+ self_abs = os.path.abspath(self.path)
886
+ if not self_abs.startswith(other.rstrip(SEP) + SEP) and self_abs != other:
887
+ raise ValueError(f"'{self.path}' is not relative to '{other}'")
888
+ return os.path.relpath(self_abs, other)
889
+
890
+ def expanduser(self) -> PathBase:
891
+ """
892
+ Expand ~ and ~user constructs in the path.
893
+
894
+ Returns:
895
+ PathBase: The object with the expanded path.
896
+ """
897
+ self.path = os.path.expanduser(self.path)
898
+ return self
899
+
900
+ def as_posix(self) -> str:
901
+ """Return the path with forward slashes."""
902
+ return self.path.replace("\\", "/")
903
+
904
+ def as_uri(self) -> str:
905
+ """
906
+ Return the path as a file URI.
907
+
908
+ Returns:
909
+ str: The file URI (e.g., 'file:///path/to/file').
910
+
911
+ Raises:
912
+ ValueError: If the path is not absolute.
913
+ """
914
+ if not self.is_absolute:
915
+ raise ValueError("relative path can't be expressed as a file URI")
916
+ return "file://" + pathname2url(os.path.abspath(self.path))
917
+
918
+ def match(self, pattern: str) -> bool:
919
+ """
920
+ Check if the path matches the given glob pattern.
921
+
922
+ Args:
923
+ pattern: A glob pattern to match against.
924
+
925
+ Returns:
926
+ bool: True if the path matches the pattern.
927
+ """
928
+ return fnmatch.fnmatch(self.basename, pattern)
929
+
930
+ def with_name(self, name: str) -> PathBase:
931
+ """Return a new path with the name changed."""
932
+ parent = os.path.dirname(self.path)
933
+ if parent:
934
+ return self.__class__(f"{parent}{SEP}{name}")
935
+ return self.__class__(name)
936
+
937
+ @property
938
+ def exists(self) -> bool:
939
+ """Check if the path exists."""
940
+ raise NotImplementedError
941
+
942
+ @property
943
+ def parent(self) -> Directory:
944
+ """The parent directory."""
945
+ raise NotImplementedError
946
+
947
+ @property
948
+ def size(self) -> int:
949
+ raise NotImplementedError
950
+
951
+ @property
952
+ def size_readable(self) -> str:
953
+ raise NotImplementedError
954
+
955
+ def remove(self) -> Self:
956
+ raise NotImplementedError
957
+
958
+ def delete(self) -> Self:
959
+ return self.remove()
960
+
961
+ def create(self) -> Self:
962
+ raise NotImplementedError
963
+
964
+ def clear(self) -> Self:
965
+ raise NotImplementedError
966
+
967
+ def move_to(self, directory: str | Directory | PathLike, *, overwrite: bool = True) -> PathBase:
968
+ """
969
+ Move the path to a new directory.
970
+
971
+ Args:
972
+ directory: The destination directory
973
+ overwrite: Whether to overwrite files if they already exist. Defaults to True.
974
+
975
+ Returns:
976
+ The updated PathBase object
977
+ """
978
+ raise NotImplementedError
979
+
980
+ def copy_to(
981
+ self,
982
+ directory: str | Directory | PathLike,
983
+ *,
984
+ overwrite: bool = True,
985
+ mkdir: bool = False,
986
+ ) -> PathBase:
987
+ """
988
+ Copy the path to a new directory.
989
+
990
+ Args:
991
+ directory: The destination directory
992
+ overwrite: Whether to overwrite files if they already exist. Defaults to True.
993
+ mkdir: Whether to create the destination directory if it doesn't exist. Defaults to False.
994
+
995
+ Returns:
996
+ The updated PathBase object
997
+ """
998
+ raise NotImplementedError
999
+
1000
+ if sys.platform != "win32":
1001
+
1002
+ def get_xattr(self, name: str, group: str = "user") -> str:
1003
+ """
1004
+ Retrieve the value of an extended attribute.
1005
+
1006
+ Args:
1007
+ name: The name of the extended attribute.
1008
+ group: The group of the extended attribute. Defaults to "user".
1009
+
1010
+ Returns:
1011
+ The value of the extended attribute.
1012
+ """
1013
+ return os.getxattr(self.path, f"{group}.{name}").decode()
1014
+
1015
+ def set_xattr(self, value: str | bytes, name: str, group: str = "user") -> PathBase:
1016
+ """
1017
+ Set an extended attribute.
1018
+
1019
+ Args:
1020
+ value: The value of the extended attribute.
1021
+ name: The name of the extended attribute.
1022
+ group: The group of the extended attribute. Defaults to "user".
1023
+ """
1024
+ if isinstance(value, str):
1025
+ value = value.encode()
1026
+ os.setxattr(self.path, f"{group}.{name}", value)
1027
+ return self
1028
+
1029
+ def remove_xattr(self, name: str, group: str = "user") -> PathBase:
1030
+ """
1031
+ Remove an extended attribute.
1032
+
1033
+ Args:
1034
+ name: The name of the extended attribute.
1035
+ group: The group of the extended attribute. Defaults to "user".
1036
+ """
1037
+ os.removexattr(self.path, f"{group}.{name}")
1038
+ return self
1039
+
1040
+
1041
+ class File(PathBase):
1042
+ def __init__(
1043
+ self,
1044
+ path: str | PathLike,
1045
+ encoding: str = "utf-8",
1046
+ *,
1047
+ abs: bool = False,
1048
+ ) -> None:
1049
+ """
1050
+ Initialize a File object.
1051
+
1052
+ Args:
1053
+ path (str | PathLike): File path.
1054
+ encoding (str, optional): The file's encoding.
1055
+ abs (bool): Whether to use the absolute path.
1056
+ """
1057
+ super().__init__(path, abs=abs)
1058
+ self.encoding = encoding
1059
+
1060
+ @property
1061
+ def exists(self) -> bool:
1062
+ return os.path.isfile(self.path)
1063
+
1064
+ @property
1065
+ def parent(self) -> Directory:
1066
+ """The parent directory."""
1067
+ return Directory(os.path.dirname(self.path))
1068
+
1069
+ @property
1070
+ def dirname(self) -> str:
1071
+ """The file's directory name."""
1072
+ return os.path.dirname(self.path)
1073
+
1074
+ @property
1075
+ def ext(self) -> str:
1076
+ """
1077
+ The file's extension (without the dot).
1078
+ Returns empty string if the file has no extension.
1079
+ """
1080
+ if "." in self.basename:
1081
+ return self.basename.split(".")[-1]
1082
+ return ""
1083
+
1084
+ @property
1085
+ def suffix(self) -> str:
1086
+ """The file extension WITH the dot (e.g., '.txt'). Empty string if no extension."""
1087
+ base = os.path.basename(self.path)
1088
+ if "." in base and not base.startswith("."):
1089
+ return "." + base.rsplit(".", 1)[-1]
1090
+ if base.startswith(".") and base.count(".") > 1:
1091
+ return "." + base.rsplit(".", 1)[-1]
1092
+ return ""
1093
+
1094
+ @property
1095
+ def stem(self) -> str:
1096
+ """The file's stem (base name without extension)."""
1097
+ base = self.basename
1098
+ if "." not in base:
1099
+ return base
1100
+ return ".".join(base.split(".")[:-1])
1101
+
1102
+ @property
1103
+ def size(self) -> int:
1104
+ """The file's size in bytes."""
1105
+ return os.path.getsize(self.path)
1106
+
1107
+ @property
1108
+ def size_readable(self) -> str:
1109
+ """The file's size in a human-readable format if readable is set to True."""
1110
+ return bytes_readable(self.size)
1111
+
1112
+ def create(self) -> File:
1113
+ """Create an empty file if it doesn't exist."""
1114
+ if self.exists:
1115
+ return self
1116
+ open(self.path, "a", encoding=self.encoding).close()
1117
+ return self
1118
+
1119
+ def remove(self) -> File:
1120
+ """Remove the file."""
1121
+ if not self.exists:
1122
+ return self
1123
+ os.remove(self.path)
1124
+ return self
1125
+
1126
+ def clear(self) -> File:
1127
+ """Clear the contents of a file if it exists."""
1128
+ if not self.exists:
1129
+ return self
1130
+ open(self.path, "w", encoding=self.encoding).close()
1131
+ return self
1132
+
1133
+ def read(self, mode: Literal["r", "rb"] = "r") -> str | bytes:
1134
+ """
1135
+ Read the contents of a file.
1136
+
1137
+ Args:
1138
+ mode: The mode to open the file in. Use 'r' for text, 'rb' for binary.
1139
+
1140
+ Returns:
1141
+ str | bytes: The file contents as string (text mode) or bytes (binary mode).
1142
+ """
1143
+ if mode == "rb":
1144
+ with open(self.path, mode) as f:
1145
+ return f.read()
1146
+ with open(self.path, mode, encoding=self.encoding) as f:
1147
+ return f.read()
1148
+
1149
+ def _write(self, data: str | bytes, mode: str, *, newline: bool = True) -> None:
1150
+ if "b" in mode:
1151
+ with open(self.path, mode) as f:
1152
+ f.write(data)
1153
+ else:
1154
+ with open(self.path, mode, encoding=self.encoding) as f:
1155
+ f.write(data)
1156
+ if newline:
1157
+ f.write("\n")
1158
+
1159
+ def _write_iter(self, data: Iterable[Any], mode: str, sep: str = "\n") -> None:
1160
+ with open(self.path, mode, encoding=self.encoding) as f:
1161
+ for entry in data:
1162
+ f.write(f"{entry}{sep}")
1163
+
1164
+ def write(
1165
+ self,
1166
+ data: str | bytes,
1167
+ *,
1168
+ mode: Literal["w", "wb"] = "w",
1169
+ newline: bool = True,
1170
+ ) -> File:
1171
+ """
1172
+ Write data to a file, overwriting any existing data.
1173
+
1174
+ Args:
1175
+ data: The data to write.
1176
+ mode: The mode to open the file in. Use 'w' for text, 'wb' for binary.
1177
+ newline: Whether to add a newline at the end. Ignored in binary mode.
1178
+
1179
+ Returns:
1180
+ File: The File object for method chaining.
1181
+ """
1182
+ self._write(data, mode, newline=newline)
1183
+ return self
1184
+
1185
+ def append(
1186
+ self,
1187
+ data: str | bytes,
1188
+ *,
1189
+ mode: Literal["a", "ab"] = "a",
1190
+ newline: bool = True,
1191
+ ) -> File:
1192
+ """
1193
+ Append data to a file.
1194
+
1195
+ Args:
1196
+ data: The data to append.
1197
+ mode: The mode to open the file in. Use 'a' for text, 'ab' for binary.
1198
+ newline: Whether to add a newline at the end. Ignored in binary mode.
1199
+
1200
+ Returns:
1201
+ File: The File object for method chaining.
1202
+ """
1203
+ self._write(data, mode, newline=newline)
1204
+ return self
1205
+
1206
+ def open(self, mode: str = "r", encoding: str | None = None, **kwargs: Any) -> IO:
1207
+ """
1208
+ Open the file and return a file handle.
1209
+
1210
+ Args:
1211
+ mode: The mode to open the file in.
1212
+ encoding: The encoding to use. Defaults to self.encoding for text modes.
1213
+ **kwargs: Additional arguments passed to the built-in open().
1214
+
1215
+ Returns:
1216
+ IO: A file handle.
1217
+ """
1218
+ if "b" in mode:
1219
+ return open(self.path, mode, **kwargs)
1220
+ if encoding is None:
1221
+ encoding = self.encoding
1222
+ return open(self.path, mode, encoding=encoding, **kwargs)
1223
+
1224
+ def write_iter(self, data: Iterable[Any], sep: str = "\n") -> None:
1225
+ """
1226
+ Write data from an iterable to a file, overwriting any existing data.
1227
+
1228
+ Args:
1229
+ data (Iterable): The data to write.
1230
+ sep (str, optional): The separator to use between items.
1231
+ """
1232
+ self._write_iter(data, "w", sep=sep)
1233
+
1234
+ def append_iter(self, data: Iterable[Any], sep: str = "\n") -> None:
1235
+ """
1236
+ Append data from an iterable to a file.
1237
+
1238
+ Args:
1239
+ data (Iterable): The data to append.
1240
+ sep (str, optional): The separator to use between items.
1241
+ """
1242
+ self._write_iter(data, "a", sep=sep)
1243
+
1244
+ def readlines(self) -> list[str]:
1245
+ """Equivalent to TextIOWrapper.readlines()"""
1246
+ with open(self.path, encoding=self.encoding) as f:
1247
+ return f.readlines()
1248
+
1249
+ def splitlines(self) -> list[str]:
1250
+ """Equivalent to File.read().splitlines()"""
1251
+ return self.read(mode="r").splitlines() # type:ignore
1252
+
1253
+ def move_to(
1254
+ self,
1255
+ directory: str | Directory | PathLike,
1256
+ *,
1257
+ mkdir: bool = False,
1258
+ overwrite: bool = True,
1259
+ ) -> File:
1260
+ """
1261
+ Move the file to a new directory.
1262
+
1263
+ Args:
1264
+ directory (str): The destination directory.
1265
+ mkdir (bool, optional): Whether to create the directory if it doesn't exist. Defaults to False.
1266
+ overwrite (bool, optional): Whether to overwrite the file if it already exists in the destination directory. Defaults to True.
1267
+ """
1268
+ directory = os.fspath(directory)
1269
+ if not os.path.isdir(directory):
1270
+ if mkdir:
1271
+ os.mkdir(directory)
1272
+ else:
1273
+ raise FileNotFoundError(f"No such directory: '{directory}'")
1274
+
1275
+ move_path = f"{directory}{SEP}{self.basename}"
1276
+ if os.path.exists(move_path) and not overwrite:
1277
+ raise FileExistsError(move_path)
1278
+ os.rename(self.path, move_path)
1279
+ self.path = move_path
1280
+ return self
1281
+
1282
+ def copy_to(
1283
+ self,
1284
+ directory: str | Directory | PathLike,
1285
+ *,
1286
+ mkdir: bool = False,
1287
+ overwrite: bool = True,
1288
+ ) -> File:
1289
+ """
1290
+ Copy the file to a new directory.
1291
+
1292
+ Args:
1293
+ directory (str): The destination directory.
1294
+ mkdir (bool, optional): Whether to create the directory if it doesn't exist. Defaults to False.
1295
+ overwrite (bool, optional): Whether to overwrite the file if it already exists in the destination directory. Defaults to True.
1296
+ """
1297
+ directory = os.fspath(directory)
1298
+ if not os.path.isdir(directory):
1299
+ if mkdir:
1300
+ os.mkdir(directory)
1301
+ else:
1302
+ raise FileNotFoundError(f"No such directory: '{directory}'")
1303
+
1304
+ copy_path = f"{directory}{SEP}{self.basename}"
1305
+ if os.path.exists(copy_path) and not overwrite:
1306
+ raise FileExistsError(copy_path)
1307
+ self.path = shutil.copy2(self.path, directory)
1308
+ return self
1309
+
1310
+ def with_dir(self, directory: str) -> File:
1311
+ """
1312
+ Change the directory of the file object. This will not move the actual file to that directory.
1313
+ Use File.move_to for that.
1314
+ """
1315
+ self.path = f"{directory}{SEP}{self.basename}"
1316
+ return self
1317
+
1318
+ def with_ext(self, ext: str) -> File:
1319
+ """
1320
+ Change the extension of the file and return the new File object
1321
+
1322
+ Args:
1323
+ ext (str): The new extension of the file.
1324
+
1325
+ Returns:
1326
+ File: The File object with the new extension.
1327
+ """
1328
+ if not ext.startswith("."):
1329
+ ext = f".{ext}"
1330
+ self.path = f"{self.dirname}{SEP}{self.stem}{ext}"
1331
+ return self
1332
+
1333
+ def with_suffix(self, suffix: str) -> File:
1334
+ """Add a suffix to the file's name and return the new File object."""
1335
+ ext = self.ext
1336
+ if ext:
1337
+ ext = f".{ext}"
1338
+ filename = f"{self.stem}{suffix}{ext}"
1339
+ self.path = f"{self.dirname}{SEP}{filename}"
1340
+ return self
1341
+
1342
+ def with_prefix(self, prefix: str) -> File:
1343
+ """Add a prefix to the file's name and return the new File object."""
1344
+ ext = self.ext
1345
+ if ext:
1346
+ ext = f".{ext}"
1347
+ filename = f"{prefix}{self.stem}{ext}"
1348
+ self.path = f"{self.dirname}{SEP}{filename}"
1349
+ return self
1350
+
1351
+ def with_stem(self, stem: str) -> File:
1352
+ """Return a new File with the stem changed, keeping the suffix."""
1353
+ suffix = self.suffix
1354
+ return File(f"{self.dirname}{SEP}{stem}{suffix}", encoding=self.encoding)
1355
+
1356
+ def rename(self, name: str) -> File:
1357
+ """Rename the file and return the new File object."""
1358
+ new_path = f"{self.dirname}{SEP}{name}"
1359
+ os.rename(self.path, new_path)
1360
+ self.path = new_path
1361
+ return self
1362
+
1363
+ def link(self, target: str, follow_symlinks: bool = True) -> File:
1364
+ """Create a hard link to the file."""
1365
+ os.link(self.path, target, follow_symlinks=follow_symlinks)
1366
+ return self
1367
+
1368
+ def symlink(self, target: str) -> File:
1369
+ """Create a symbolic link to the file."""
1370
+ os.symlink(self.path, target)
1371
+ return self
1372
+
1373
+ @classmethod
1374
+ def rand(cls, prefix: str = "file", ext: str = "") -> File:
1375
+ """
1376
+ Create a new random file with a specified prefix and extension.
1377
+
1378
+ Args:
1379
+ prefix (str, optional): The prefix for the file name.
1380
+ ext (str, optional): The extension for the file name.
1381
+
1382
+ Returns:
1383
+ File: A new File object with a random name.
1384
+ """
1385
+ return File(rand_filename(prefix, ext))
1386
+
1387
+
1388
+ class Directory(PathBase):
1389
+ def __init__(
1390
+ self,
1391
+ path: str | PathLike,
1392
+ *,
1393
+ abs: bool = False,
1394
+ ) -> None:
1395
+ """
1396
+ Initialize a Directory object.
1397
+
1398
+ Args:
1399
+ path (str | PathLike): Directory path.
1400
+ abs (bool): Whether to use the absolute path.
1401
+ """
1402
+ super().__init__(path, abs=abs)
1403
+
1404
+ def __truediv__(self, name: str) -> Directory:
1405
+ return self.directory(name)
1406
+
1407
+ def __floordiv__(self, name: str) -> File:
1408
+ return self.file(name)
1409
+
1410
+ @property
1411
+ def size(self) -> int:
1412
+ return get_dir_size(self.path, readable=False) # type:ignore
1413
+
1414
+ @property
1415
+ def exists(self) -> bool:
1416
+ """Check if the directory exists."""
1417
+ return os.path.isdir(self.path)
1418
+
1419
+ def create(self, mode: int = 0o777, exist_ok: bool = True) -> Directory:
1420
+ """Create directory (including parents)."""
1421
+ os.makedirs(self.path, mode=mode, exist_ok=exist_ok)
1422
+ return self
1423
+
1424
+ @property
1425
+ def parent(self) -> Directory:
1426
+ return Directory(os.path.dirname(self.path))
1427
+
1428
+ @classmethod
1429
+ def rand(cls, prefix: str = "dir") -> Directory:
1430
+ return Directory(joinpath(os.getcwd(), safe_filename(rand_filename(prefix))))
1431
+
1432
+ def move_to(
1433
+ self,
1434
+ directory: str | PathLike | Directory,
1435
+ *,
1436
+ overwrite: bool = True,
1437
+ ) -> Directory:
1438
+ """
1439
+ Move this directory to become a child of the target location.
1440
+
1441
+ Args:
1442
+ directory: The destination directory where this directory will be moved
1443
+ overwrite: Whether to overwrite if target path already exists. Defaults to True.
1444
+
1445
+ Returns:
1446
+ The updated Directory object
1447
+ """
1448
+ directory = os.fspath(directory)
1449
+ if not os.path.exists(directory):
1450
+ raise FileNotFoundError(f"Destination directory does not exist: {directory}")
1451
+
1452
+ dest = f"{directory}{SEP}{self.basename}"
1453
+ shutil.move(self.path, dest)
1454
+ self.path = dest
1455
+ return self
1456
+
1457
+ def copy_to(
1458
+ self,
1459
+ directory: str | PathLike | Directory,
1460
+ *,
1461
+ overwrite: bool = True,
1462
+ mkdir: bool = False,
1463
+ ) -> Directory:
1464
+ """
1465
+ Copy this directory to become a child of the target location.
1466
+
1467
+ Args:
1468
+ directory: The destination directory where this directory will be copied
1469
+ overwrite: Whether to overwrite if target path already exists. Defaults to True.
1470
+ mkdir: Whether to create the destination directory if it doesn't exist. Defaults to False.
1471
+
1472
+ Returns:
1473
+ The updated Directory object
1474
+ """
1475
+ directory = os.fspath(directory)
1476
+ if not os.path.exists(directory):
1477
+ if mkdir:
1478
+ os.makedirs(directory)
1479
+ else:
1480
+ raise FileNotFoundError(f"Destination directory does not exist: {directory}")
1481
+
1482
+ dest = f"{directory}{SEP}{self.basename}"
1483
+ shutil.copytree(self.path, dest)
1484
+ self.path = dest
1485
+ return self
1486
+
1487
+ def remove(self) -> Directory:
1488
+ """Remove the directory and all its contents."""
1489
+ if not self.exists:
1490
+ return self
1491
+ shutil.rmtree(self.path)
1492
+ return self
1493
+
1494
+ def yield_files(
1495
+ self,
1496
+ ext: str | tuple[str, ...] | None = None,
1497
+ glob: str | re.Pattern[str] | None = None,
1498
+ regex: str | re.Pattern[str] | None = None,
1499
+ recursive: bool = True,
1500
+ ) -> Generator[File, None, None]:
1501
+ """
1502
+ Yield files in the directory.
1503
+
1504
+ All filter parameters use AND logic - files must match all specified filters.
1505
+
1506
+ Args:
1507
+ ext: If provided, only yield files with provided extensions.
1508
+ glob: Glob pattern as string or compiled regex from fnmatch.translate().
1509
+ regex: Regex pattern as string or compiled re.Pattern.
1510
+ recursive: Whether to search recursively.
1511
+
1512
+ Yields:
1513
+ Generator[File, None, None]: The files in the directory.
1514
+ """
1515
+ glob_pattern = re.compile(fnmatch.translate(glob)) if isinstance(glob, str) else glob
1516
+ regex_pattern = re.compile(regex) if isinstance(regex, str) else regex
1517
+ for file_path in yield_files_in(self.path, ext=ext, recursive=recursive):
1518
+ basename = os.path.basename(file_path)
1519
+ if glob_pattern and not glob_pattern.match(basename):
1520
+ continue
1521
+ if regex_pattern and not regex_pattern.search(basename):
1522
+ continue
1523
+ yield File(file_path)
1524
+
1525
+ def get_files(
1526
+ self,
1527
+ ext: str | tuple[str, ...] | None = None,
1528
+ glob: str | re.Pattern[str] | None = None,
1529
+ regex: str | re.Pattern[str] | None = None,
1530
+ recursive: bool = True,
1531
+ ) -> list[File]:
1532
+ """
1533
+ Get files in the directory.
1534
+
1535
+ All filter parameters use AND logic - files must match all specified filters.
1536
+
1537
+ Args:
1538
+ ext: If provided, only yield files with provided extensions.
1539
+ glob: Glob pattern as string or compiled regex from fnmatch.translate().
1540
+ regex: Regex pattern as string or compiled re.Pattern.
1541
+ recursive: Whether to search recursively.
1542
+
1543
+ Returns:
1544
+ list[File]: The files in the directory.
1545
+ """
1546
+ return list(self.yield_files(ext=ext, glob=glob, regex=regex, recursive=recursive))
1547
+
1548
+ def yield_subdirs(
1549
+ self,
1550
+ glob: str | re.Pattern[str] | None = None,
1551
+ regex: str | re.Pattern[str] | None = None,
1552
+ recursive: bool = True,
1553
+ ) -> Generator[Directory, None, None]:
1554
+ """
1555
+ Yield subdirectories in the directory.
1556
+
1557
+ All filter parameters use AND logic - directories must match all specified filters.
1558
+
1559
+ Args:
1560
+ glob: Glob pattern as string or compiled regex from fnmatch.translate().
1561
+ regex: Regex pattern as string or compiled re.Pattern.
1562
+ recursive: Whether to search recursively.
1563
+
1564
+ Yields:
1565
+ Generator[Directory, None, None]: The subdirectories in the directory.
1566
+ """
1567
+ glob_pattern = re.compile(fnmatch.translate(glob)) if isinstance(glob, str) else glob
1568
+ regex_pattern = re.compile(regex) if isinstance(regex, str) else regex
1569
+ for dir_path in yield_dirs_in(self.path, recursive=recursive):
1570
+ basename = os.path.basename(dir_path)
1571
+ if glob_pattern and not glob_pattern.match(basename):
1572
+ continue
1573
+ if regex_pattern and not regex_pattern.search(basename):
1574
+ continue
1575
+ yield Directory(dir_path)
1576
+
1577
+ def get_subdirs(
1578
+ self,
1579
+ glob: str | re.Pattern[str] | None = None,
1580
+ regex: str | re.Pattern[str] | None = None,
1581
+ recursive: bool = True,
1582
+ ) -> list[Directory]:
1583
+ """
1584
+ Get subdirectories in the directory.
1585
+
1586
+ All filter parameters use AND logic - directories must match all specified filters.
1587
+
1588
+ Args:
1589
+ glob: Glob pattern as string or compiled regex from fnmatch.translate().
1590
+ regex: Regex pattern as string or compiled re.Pattern.
1591
+ recursive: Whether to search recursively.
1592
+
1593
+ Returns:
1594
+ list[Directory]: The subdirectories in the directory.
1595
+ """
1596
+ return list(self.yield_subdirs(glob=glob, regex=regex, recursive=recursive))
1597
+
1598
+ def file(self, name: str) -> File:
1599
+ return File(joinpath(self.path, safe_filename(name)))
1600
+
1601
+ def directory(self, name: str) -> Directory:
1602
+ return Directory(joinpath(self.path, safe_filename(name)))
1603
+
1604
+ @classmethod
1605
+ def home(cls) -> Directory:
1606
+ return Directory(HOME)
1607
+
1608
+ @classmethod
1609
+ def cwd(cls) -> Directory:
1610
+ return Directory(os.getcwd())
1611
+
1612
+
1613
+ class EXT:
1614
+ AUDIO = (
1615
+ ".mp3",
1616
+ ".aac",
1617
+ ".ogg",
1618
+ ".flac",
1619
+ ".wav",
1620
+ ".aiff",
1621
+ ".dsd",
1622
+ ".pcm",
1623
+ )
1624
+ IMAGE = (
1625
+ ".jpg",
1626
+ ".png",
1627
+ ".jpeg",
1628
+ ".webp",
1629
+ ".gif",
1630
+ ".bmp",
1631
+ ".tif",
1632
+ ".tiff",
1633
+ ".jfif",
1634
+ ".heic",
1635
+ ".dib",
1636
+ ".jp2",
1637
+ ".jpx",
1638
+ ".j2k",
1639
+ ".jxl",
1640
+ )
1641
+ VIDEO = (
1642
+ ".mp4",
1643
+ ".mkv",
1644
+ ".avi",
1645
+ ".flv",
1646
+ ".mov",
1647
+ ".webm",
1648
+ ".mpg",
1649
+ ".mpeg",
1650
+ ".mpe",
1651
+ ".mpv",
1652
+ ".ogg",
1653
+ ".m4p",
1654
+ ".m4v",
1655
+ ".wmv",
1656
+ ".f4v",
1657
+ ".swf",
1658
+ )
1659
+
1660
+
1661
+ __all__ = [
1662
+ "EXT",
1663
+ "File",
1664
+ "Directory",
1665
+ "PathLike",
1666
+ "mkdir",
1667
+ "pickle_load",
1668
+ "pickle_dump",
1669
+ "json_load",
1670
+ "json_append",
1671
+ "yaml_load",
1672
+ "json_dump",
1673
+ "yaml_dump",
1674
+ "get_dir_size",
1675
+ "move_files",
1676
+ "rand_filename",
1677
+ "bytes_readable",
1678
+ "readable_size_to_bytes",
1679
+ "windows_has_drive",
1680
+ "is_wsl",
1681
+ "mkdirs",
1682
+ "yield_files_in",
1683
+ "get_files_in",
1684
+ "yield_dirs_in",
1685
+ "get_dirs_in",
1686
+ "ensure_paths_exist",
1687
+ "exec_cmd",
1688
+ "SEP",
1689
+ "HOME",
1690
+ "abspath",
1691
+ "basename",
1692
+ "dirname",
1693
+ "joinpath",
1694
+ "splitpath",
1695
+ "start_file",
1696
+ "read_piped",
1697
+ "isdir",
1698
+ "isfile",
1699
+ "islink",
1700
+ "exists",
1701
+ "stat",
1702
+ "link",
1703
+ "getcwd",
1704
+ "chdir",
1705
+ "chmod",
1706
+ "remove",
1707
+ "rename",
1708
+ "copy",
1709
+ "move",
1710
+ "chown",
1711
+ ]