Fix logger.exception() calls.
[pyutils.git] / src / pyutils / files / file_utils.py
1 #!/usr/bin/env python3
2
3 # © Copyright 2021-2022, Scott Gasch
4
5 """
6 This is a grab bag of file-related utilities.  It has code to, for example,
7 read files transforming the text as its read, normalize pathnames, strip
8 extensions, read and manipulate atimes/mtimes/ctimes, compute a signature
9 based on a file's contents, traverse the file system recursively, etc...
10 """
11
12 import contextlib
13 import datetime
14 import errno
15 import fnmatch
16 import glob
17 import hashlib
18 import logging
19 import os
20 import pathlib
21 import re
22 import time
23 from os.path import exists, isfile, join
24 from typing import Callable, List, Literal, Optional, TextIO
25 from uuid import uuid4
26
27 logger = logging.getLogger(__name__)
28
29
30 def remove_newlines(x: str) -> str:
31     """Trivial function to be used as a line_transformer in
32     :meth:`slurp_file` for no newlines in file contents"""
33     return x.replace("\n", "")
34
35
36 def strip_whitespace(x: str) -> str:
37     """Trivial function to be used as a line_transformer in
38     :meth:`slurp_file` for no leading / trailing whitespace in
39     file contents"""
40     return x.strip()
41
42
43 def remove_hash_comments(x: str) -> str:
44     """Trivial function to be used as a line_transformer in
45     :meth:`slurp_file` for no # comments in file contents"""
46     return re.sub(r"#.*$", "", x)
47
48
49 def slurp_file(
50     filename: str,
51     *,
52     skip_blank_lines=False,
53     line_transformers: Optional[List[Callable[[str], str]]] = None,
54 ):
55     """Reads in a file's contents line-by-line to a memory buffer applying
56     each line transformation in turn.
57
58     Args:
59         filename: file to be read
60         skip_blank_lines: should reading skip blank lines?
61         line_transformers: little string->string transformations
62
63     Returns:
64         A list of lines from the read and transformed file contents.
65     """
66
67     ret = []
68     xforms = []
69     if line_transformers is not None:
70         for x in line_transformers:
71             xforms.append(x)
72     if not is_readable(filename):
73         raise Exception(f"{filename} can't be read.")
74     with open(filename) as rf:
75         for line in rf:
76             for transformation in xforms:
77                 line = transformation(line)
78             if skip_blank_lines and line == "":
79                 continue
80             ret.append(line)
81     return ret
82
83
84 def remove(path: str) -> None:
85     """Deletes a file.  Raises if path refers to a directory or a file
86     that doesn't exist.
87
88     Args:
89         path: the path of the file to delete
90
91     >>> import os
92     >>> filename = '/tmp/file_utils_test_file'
93     >>> os.system(f'touch {filename}')
94     0
95     >>> does_file_exist(filename)
96     True
97     >>> remove(filename)
98     >>> does_file_exist(filename)
99     False
100     """
101     os.remove(path)
102
103
104 def fix_multiple_slashes(path: str) -> str:
105     """Fixes multi-slashes in paths or path-like strings
106
107     Args:
108         path: the path in which to remove multiple slashes
109
110     >>> p = '/usr/local//etc/rc.d///file.txt'
111     >>> fix_multiple_slashes(p)
112     '/usr/local/etc/rc.d/file.txt'
113
114     >>> p = 'this is a test'
115     >>> fix_multiple_slashes(p) == p
116     True
117     """
118     return re.sub(r"/+", "/", path)
119
120
121 def delete(path: str) -> None:
122     """This is a convenience for my dumb ass who can't remember os.remove
123     sometimes.
124     """
125     os.remove(path)
126
127
128 def without_extension(path: str) -> str:
129     """Remove one (the last) extension from a file or path.
130
131     Args:
132         path: the path from which to remove an extension
133
134     Returns:
135         the path with one extension removed.
136
137     See also :meth:`without_all_extensions`.
138
139     >>> without_extension('foobar.txt')
140     'foobar'
141
142     >>> without_extension('/home/scott/frapp.py')
143     '/home/scott/frapp'
144
145     >>> f = 'a.b.c.tar.gz'
146     >>> while('.' in f):
147     ...     f = without_extension(f)
148     ...     print(f)
149     a.b.c.tar
150     a.b.c
151     a.b
152     a
153
154     >>> without_extension('foobar')
155     'foobar'
156
157     """
158     return os.path.splitext(path)[0]
159
160
161 def without_all_extensions(path: str) -> str:
162     """Removes all extensions from a path; handles multiple extensions
163     like foobar.tar.gz -> foobar.
164
165     Args:
166         path: the path from which to remove all extensions
167
168     Returns:
169         the path with all extensions removed.
170
171     See also :meth:`without_extension`
172
173     >>> without_all_extensions('/home/scott/foobar.1.tar.gz')
174     '/home/scott/foobar'
175
176     """
177     while "." in path:
178         path = without_extension(path)
179     return path
180
181
182 def get_extension(path: str) -> str:
183     """Extract and return one (the last) extension from a file or path.
184
185     Args:
186         path: the path from which to extract an extension
187
188     Returns:
189         The last extension from the file path.
190
191     See also :meth:`without_extension`, :meth:`without_all_extensions`,
192     :meth:`get_all_extensions`.
193
194     >>> get_extension('this_is_a_test.txt')
195     '.txt'
196
197     >>> get_extension('/home/scott/test.py')
198     '.py'
199
200     >>> get_extension('foobar')
201     ''
202
203     """
204     return os.path.splitext(path)[1]
205
206
207 def get_all_extensions(path: str) -> List[str]:
208     """Return the extensions of a file or path in order.
209
210     Args:
211         path: the path from which to extract all extensions.
212
213     Returns:
214         a list containing each extension which may be empty.
215
216     See also :meth:`without_extension`, :meth:`without_all_extensions`,
217     :meth:`get_extension`.
218
219     >>> get_all_extensions('/home/scott/foo.tar.gz.1')
220     ['.tar', '.gz', '.1']
221
222     >>> get_all_extensions('/home/scott/foobar')
223     []
224
225     """
226     ret = []
227     while True:
228         ext = get_extension(path)
229         path = without_extension(path)
230         if ext:
231             ret.append(ext)
232         else:
233             ret.reverse()
234             return ret
235
236
237 def without_path(filespec: str) -> str:
238     """Returns the base filename without any leading path.
239
240     Args:
241         filespec: path to remove leading directories from
242
243     Returns:
244         filespec without leading dir components.
245
246     See also :meth:`get_path`, :meth:`get_canonical_path`.
247
248     >>> without_path('/home/scott/foo.py')
249     'foo.py'
250
251     >>> without_path('foo.py')
252     'foo.py'
253
254     """
255     return os.path.split(filespec)[1]
256
257
258 def get_path(filespec: str) -> str:
259     """Returns just the path of the filespec by removing the filename and
260     extension.
261
262     Args:
263         filespec: path to remove filename / extension(s) from
264
265     Returns:
266         filespec with just the leading directory components and no
267             filename or extension(s)
268
269     See also :meth:`without_path`, :meth:`get_canonical_path`.
270
271     >>> get_path('/home/scott/foobar.py')
272     '/home/scott'
273
274     >>> get_path('/home/scott/test.1.2.3.gz')
275     '/home/scott'
276
277     >>> get_path('~scott/frapp.txt')
278     '~scott'
279
280     """
281     return os.path.split(filespec)[0]
282
283
284 def get_canonical_path(filespec: str) -> str:
285     """Returns a canonicalized absolute path.
286
287     Args:
288         filespec: the path to canonicalize
289
290     Returns:
291         the canonicalized path
292
293     See also :meth:`get_path`, :meth:`without_path`.
294
295     >>> get_canonical_path('/home/scott/../../home/lynn/../scott/foo.txt')
296     '/usr/home/scott/foo.txt'
297
298     """
299     return os.path.realpath(filespec)
300
301
302 def create_path_if_not_exist(path, on_error=None) -> None:
303     """
304     Attempts to create path if it does not exist already.
305
306     Args:
307         path: the path to attempt to create
308         on_error: If True, it's invoked on error conditions.  Otherwise
309             any exceptions are raised.
310
311     See also :meth:`does_file_exist`.
312
313     .. warning::
314
315         Files are created with mode 0o0777 (i.e. world read/writeable).
316
317     >>> import uuid
318     >>> import os
319     >>> path = os.path.join("/tmp", str(uuid.uuid4()), str(uuid.uuid4()))
320     >>> os.path.exists(path)
321     False
322     >>> create_path_if_not_exist(path)
323     >>> os.path.exists(path)
324     True
325     """
326     logger.debug("Creating path %s", path)
327     previous_umask = os.umask(0)
328     try:
329         os.makedirs(path)
330         os.chmod(path, 0o777)
331     except OSError as ex:
332         if ex.errno != errno.EEXIST and not os.path.isdir(path):
333             if on_error is not None:
334                 on_error(path, ex)
335             else:
336                 raise
337     finally:
338         os.umask(previous_umask)
339
340
341 def does_file_exist(filename: str) -> bool:
342     """Returns True if a file exists and is a normal file.
343
344     Args:
345         filename: filename to check
346
347     Returns:
348         True if filename exists and is a normal file.
349
350     See also :meth:`create_path_if_not_exist`, :meth:`is_readable`.
351
352     >>> does_file_exist(__file__)
353     True
354     >>> does_file_exist('/tmp/2492043r9203r9230r9230r49230r42390r4230')
355     False
356     """
357     return os.path.exists(filename) and os.path.isfile(filename)
358
359
360 def is_readable(filename: str) -> bool:
361     """Is the file readable?
362
363     Args:
364         filename: the filename to check for read access
365
366     Returns:
367         True if the file exists, is a normal file, and is readable
368         by the current process.  False otherwise.
369
370     See also :meth:`does_file_exist`, :meth:`is_writable`,
371     :meth:`is_executable`.
372     """
373     return os.access(filename, os.R_OK)
374
375
376 def is_writable(filename: str) -> bool:
377     """Is the file writable?
378
379     Args:
380         filename: the file to check for write access.
381
382     Returns:
383         True if file exists, is a normal file and is writable by the
384         current process.  False otherwise.
385
386     See also :meth:`is_readable`, :meth:`does_file_exist`.
387     """
388     return os.access(filename, os.W_OK)
389
390
391 def is_executable(filename: str) -> bool:
392     """Is the file executable?
393
394     Args:
395         filename: the file to check for execute access.
396
397     Returns:
398         True if file exists, is a normal file and is executable by the
399         current process.  False otherwise.
400
401     See also :meth:`does_file_exist`, :meth:`is_readable`,
402     :meth:`is_writable`.
403     """
404     return os.access(filename, os.X_OK)
405
406
407 def does_directory_exist(dirname: str) -> bool:
408     """Does the given directory exist?
409
410     Args:
411         dirname: the name of the directory to check
412
413     Returns:
414         True if a path exists and is a directory, not a regular file.
415
416     See also :meth:`does_file_exist`.
417
418     >>> does_directory_exist('/tmp')
419     True
420     >>> does_directory_exist('/xyzq/21341')
421     False
422     """
423     return os.path.exists(dirname) and os.path.isdir(dirname)
424
425
426 def does_path_exist(pathname: str) -> bool:
427     """Just a more verbose wrapper around os.path.exists."""
428     return os.path.exists(pathname)
429
430
431 def get_file_size(filename: str) -> int:
432     """Returns the size of a file in bytes.
433
434     Args:
435         filename: the filename to size
436
437     Returns:
438         size of filename in bytes
439     """
440     return os.path.getsize(filename)
441
442
443 def is_normal_file(filename: str) -> bool:
444     """Is that file normal (not a directory or some special file?)
445
446     Args:
447         filename: the path of the file to check
448
449     Returns:
450         True if filename is a normal file.
451
452     See also :meth:`is_directory`, :meth:`does_file_exist`, :meth:`is_symlink`.
453
454     >>> is_normal_file(__file__)
455     True
456     """
457     return os.path.isfile(filename)
458
459
460 def is_directory(filename: str) -> bool:
461     """Is that path a directory (not a normal file?)
462
463     Args:
464         filename: the path of the file to check
465
466     Returns:
467         True if filename is a directory
468
469     See also :meth:`does_directory_exist`, :meth:`is_normal_file`,
470     :meth:`is_symlink`.
471
472     >>> is_directory('/tmp')
473     True
474     """
475     return os.path.isdir(filename)
476
477
478 def is_symlink(filename: str) -> bool:
479     """Is that path a symlink?
480
481     Args:
482         filename: the path of the file to check
483
484     Returns:
485         True if filename is a symlink, False otherwise.
486
487     See also :meth:`is_directory`, :meth:`is_normal_file`.
488
489     >>> is_symlink('/tmp')
490     False
491
492     >>> is_symlink('/home')
493     True
494     """
495     return os.path.islink(filename)
496
497
498 def is_same_file(file1: str, file2: str) -> bool:
499     """Determine if two paths reference the same inode.
500
501     Args:
502         file1: the first file
503         file2: the second file
504
505     Returns:
506         True if the two files are the same file.
507
508     See also :meth:`is_symlink`, :meth:`is_normal_file`.
509
510     >>> is_same_file('/tmp', '/tmp/../tmp')
511     True
512
513     >>> is_same_file('/tmp', '/home')
514     False
515     """
516     return os.path.samefile(file1, file2)
517
518
519 def get_file_raw_timestamps(filename: str) -> Optional[os.stat_result]:
520     """Stats the file and returns an `os.stat_result` or None on error.
521
522     Args:
523         filename: the file whose timestamps to fetch
524
525     Returns:
526         the os.stat_result or None to indicate an error occurred
527
528     See also
529     :meth:`get_file_raw_atime`,
530     :meth:`get_file_raw_ctime`,
531     :meth:`get_file_raw_mtime`,
532     :meth:`get_file_raw_timestamp`
533     """
534     try:
535         return os.stat(filename)
536     except Exception:
537         logger.exception("Failed to stat path %s; returning None", filename)
538         return None
539
540
541 def get_file_raw_timestamp(
542     filename: str, extractor: Callable[[os.stat_result], Optional[float]]
543 ) -> Optional[float]:
544     """Stat a file and, if successful, use extractor to fetch some
545     subset of the information in the `os.stat_result`.
546
547     Args:
548         filename: the filename to stat
549         extractor: Callable that takes a os.stat_result and produces
550             something useful(?) with it.
551
552     Returns:
553         whatever the extractor produced or None on error.
554
555     See also
556     :meth:`get_file_raw_atime`,
557     :meth:`get_file_raw_ctime`,
558     :meth:`get_file_raw_mtime`,
559     :meth:`get_file_raw_timestamps`
560     """
561     tss = get_file_raw_timestamps(filename)
562     if tss is not None:
563         return extractor(tss)
564     return None
565
566
567 def get_file_raw_atime(filename: str) -> Optional[float]:
568     """Get a file's raw access time.
569
570     Args:
571         filename: the path to the file to stat
572
573     Returns:
574         The file's raw atime (seconds since the Epoch) or
575         None on error.
576
577     See also
578     :meth:`get_file_atime_age_seconds`,
579     :meth:`get_file_atime_as_datetime`,
580     :meth:`get_file_atime_timedelta`,
581     :meth:`get_file_raw_ctime`,
582     :meth:`get_file_raw_mtime`,
583     :meth:`get_file_raw_timestamps`
584     """
585     return get_file_raw_timestamp(filename, lambda x: x.st_atime)
586
587
588 def get_file_raw_mtime(filename: str) -> Optional[float]:
589     """Get a file's raw modification time.
590
591     Args:
592         filename: the path to the file to stat
593
594     Returns:
595         The file's raw mtime (seconds since the Epoch) or
596         None on error.
597
598     See also
599     :meth:`get_file_raw_atime`,
600     :meth:`get_file_raw_ctime`,
601     :meth:`get_file_mtime_age_seconds`,
602     :meth:`get_file_mtime_as_datetime`,
603     :meth:`get_file_mtime_timedelta`,
604     :meth:`get_file_raw_timestamps`
605     """
606     return get_file_raw_timestamp(filename, lambda x: x.st_mtime)
607
608
609 def get_file_raw_ctime(filename: str) -> Optional[float]:
610     """Get a file's raw creation time.
611
612     Args:
613         filename: the path to the file to stat
614
615     Returns:
616         The file's raw ctime (seconds since the Epoch) or
617         None on error.
618
619     See also
620     :meth:`get_file_raw_atime`,
621     :meth:`get_file_ctime_age_seconds`,
622     :meth:`get_file_ctime_as_datetime`,
623     :meth:`get_file_ctime_timedelta`,
624     :meth:`get_file_raw_mtime`,
625     :meth:`get_file_raw_timestamps`
626     """
627     return get_file_raw_timestamp(filename, lambda x: x.st_ctime)
628
629
630 def get_file_md5(filename: str) -> str:
631     """Hashes filename's disk contents and returns the MD5 digest.
632
633     Args:
634         filename: the file whose contents to hash
635
636     Returns:
637         the MD5 digest of the file's contents.  Raises on error.
638     """
639     file_hash = hashlib.md5()
640     with open(filename, "rb") as f:
641         chunk = f.read(8192)
642         while chunk:
643             file_hash.update(chunk)
644             chunk = f.read(8192)
645     return file_hash.hexdigest()
646
647
648 def set_file_raw_atime(filename: str, atime: float) -> None:
649     """Sets a file's raw access time.
650
651     Args:
652         filename: the file whose atime should be set
653         atime: raw atime as number of seconds since the Epoch to set
654
655     See also
656     :meth:`get_file_raw_atime`,
657     :meth:`get_file_atime_age_seconds`,
658     :meth:`get_file_atime_as_datetime`,
659     :meth:`get_file_atime_timedelta`,
660     :meth:`get_file_raw_timestamps`,
661     :meth:`set_file_raw_mtime`,
662     :meth:`set_file_raw_atime_and_mtime`,
663     :meth:`touch_file`
664     """
665     mtime = get_file_raw_mtime(filename)
666     assert mtime is not None
667     os.utime(filename, (atime, mtime))
668
669
670 def set_file_raw_mtime(filename: str, mtime: float):
671     """Sets a file's raw modification time.
672
673     Args:
674         filename: the file whose mtime should be set
675         mtime: the raw mtime as number of seconds since the Epoch to set
676
677     See also
678     :meth:`get_file_raw_mtime`,
679     :meth:`get_file_mtime_age_seconds`,
680     :meth:`get_file_mtime_as_datetime`,
681     :meth:`get_file_mtime_timedelta`,
682     :meth:`get_file_raw_timestamps`,
683     :meth:`set_file_raw_atime`,
684     :meth:`set_file_raw_atime_and_mtime`,
685     :meth:`touch_file`
686     """
687     atime = get_file_raw_atime(filename)
688     assert atime is not None
689     os.utime(filename, (atime, mtime))
690
691
692 def set_file_raw_atime_and_mtime(filename: str, ts: float = None):
693     """Sets both a file's raw modification and access times.
694
695     Args:
696         filename: the file whose times to set
697         ts: the raw time to set or None to indicate time should be
698             set to the current time.
699
700     See also
701     :meth:`get_file_raw_atime`,
702     :meth:`get_file_raw_mtime`,
703     :meth:`get_file_raw_timestamps`,
704     :meth:`set_file_raw_atime`,
705     :meth:`set_file_raw_mtime`
706     """
707     if ts is not None:
708         os.utime(filename, (ts, ts))
709     else:
710         os.utime(filename, None)
711
712
713 def _convert_file_timestamp_to_datetime(
714     filename: str, producer
715 ) -> Optional[datetime.datetime]:
716     """
717     Converts a raw file timestamp into a Python datetime.
718
719     Args:
720         filename: file whose timestamps should be converted.
721         producer: source of the timestamp.
722
723     Returns:
724         The datetime.
725     """
726     ts = producer(filename)
727     if ts is not None:
728         return datetime.datetime.fromtimestamp(ts)
729     return None
730
731
732 def get_file_atime_as_datetime(filename: str) -> Optional[datetime.datetime]:
733     """Fetch a file's access time as a Python datetime.
734
735     Args:
736         filename: the file whose atime should be fetched.
737
738     Returns:
739         The file's atime as a Python :class:`datetime.datetime`.
740
741     See also
742     :meth:`get_file_raw_atime`,
743     :meth:`get_file_atime_age_seconds`,
744     :meth:`get_file_atime_timedelta`,
745     :meth:`get_file_raw_ctime`,
746     :meth:`get_file_raw_mtime`,
747     :meth:`get_file_raw_timestamps`,
748     :meth:`set_file_raw_atime`,
749     :meth:`set_file_raw_atime_and_mtime`
750     """
751     return _convert_file_timestamp_to_datetime(filename, get_file_raw_atime)
752
753
754 def get_file_mtime_as_datetime(filename: str) -> Optional[datetime.datetime]:
755     """Fetch a file's modification time as a Python datetime.
756
757     Args:
758         filename: the file whose mtime should be fetched.
759
760     Returns:
761         The file's mtime as a Python :class:`datetime.datetime`.
762
763     See also
764     :meth:`get_file_raw_mtime`,
765     :meth:`get_file_mtime_age_seconds`,
766     :meth:`get_file_mtime_timedelta`,
767     :meth:`get_file_raw_ctime`,
768     :meth:`get_file_raw_atime`,
769     :meth:`get_file_raw_timestamps`,
770     :meth:`set_file_raw_atime`,
771     :meth:`set_file_raw_atime_and_mtime`
772     """
773     return _convert_file_timestamp_to_datetime(filename, get_file_raw_mtime)
774
775
776 def get_file_ctime_as_datetime(filename: str) -> Optional[datetime.datetime]:
777     """Fetches a file's creation time as a Python datetime.
778
779     Args:
780         filename: the file whose ctime should be fetched.
781
782     Returns:
783         The file's ctime as a Python :class:`datetime.datetime`.
784
785     See also
786     :meth:`get_file_raw_ctime`,
787     :meth:`get_file_ctime_age_seconds`,
788     :meth:`get_file_ctime_timedelta`,
789     :meth:`get_file_raw_atime`,
790     :meth:`get_file_raw_mtime`,
791     :meth:`get_file_raw_timestamps`
792     """
793     return _convert_file_timestamp_to_datetime(filename, get_file_raw_ctime)
794
795
796 def _get_file_timestamp_age_seconds(filename: str, extractor) -> Optional[int]:
797     """~Internal helper"""
798     now = time.time()
799     ts = get_file_raw_timestamps(filename)
800     if ts is None:
801         return None
802     result = extractor(ts)
803     return now - result
804
805
806 def get_file_atime_age_seconds(filename: str) -> Optional[int]:
807     """Gets a file's access time as an age in seconds (ago).
808
809     Args:
810         filename: file whose atime should be checked.
811
812     Returns:
813         The number of seconds ago that filename was last accessed.
814
815     See also
816     :meth:`get_file_raw_atime`,
817     :meth:`get_file_atime_as_datetime`,
818     :meth:`get_file_atime_timedelta`,
819     :meth:`get_file_raw_ctime`,
820     :meth:`get_file_raw_mtime`,
821     :meth:`get_file_raw_timestamps`,
822     :meth:`set_file_raw_atime`,
823     :meth:`set_file_raw_atime_and_mtime`
824     """
825     return _get_file_timestamp_age_seconds(filename, lambda x: x.st_atime)
826
827
828 def get_file_ctime_age_seconds(filename: str) -> Optional[int]:
829     """Gets a file's creation time as an age in seconds (ago).
830
831     Args:
832         filename: file whose ctime should be checked.
833
834     Returns:
835         The number of seconds ago that filename was created.
836
837     See also
838     :meth:`get_file_raw_ctime`,
839     :meth:`get_file_ctime_age_seconds`,
840     :meth:`get_file_ctime_as_datetime`,
841     :meth:`get_file_ctime_timedelta`,
842     :meth:`get_file_raw_mtime`,
843     :meth:`get_file_raw_atime`,
844     :meth:`get_file_raw_timestamps`
845     """
846     return _get_file_timestamp_age_seconds(filename, lambda x: x.st_ctime)
847
848
849 def get_file_mtime_age_seconds(filename: str) -> Optional[int]:
850     """Gets a file's modification time as seconds (ago).
851
852     Args:
853         filename: file whose mtime should be checked.
854
855     Returns:
856         The number of seconds ago that filename was last modified.
857
858     See also
859     :meth:`get_file_raw_atime`,
860     :meth:`get_file_raw_ctime`,
861     :meth:`get_file_raw_mtime`,
862     :meth:`get_file_mtime_as_datetime`,
863     :meth:`get_file_mtime_timedelta`,
864     :meth:`get_file_raw_timestamps`,
865     :meth:`set_file_raw_atime`,
866     :meth:`set_file_raw_atime_and_mtime`
867     """
868     return _get_file_timestamp_age_seconds(filename, lambda x: x.st_mtime)
869
870
871 def _get_file_timestamp_timedelta(
872     filename: str, extractor
873 ) -> Optional[datetime.timedelta]:
874     """~Internal helper"""
875     age = _get_file_timestamp_age_seconds(filename, extractor)
876     if age is not None:
877         return datetime.timedelta(seconds=float(age))
878     return None
879
880
881 def get_file_atime_timedelta(filename: str) -> Optional[datetime.timedelta]:
882     """How long ago was a file accessed as a timedelta?
883
884     Args:
885         filename: the file whose atime should be checked.
886
887     Returns:
888         A Python :class:`datetime.timedelta` representing how long
889         ago filename was last accessed.
890
891     See also
892     :meth:`get_file_raw_atime`,
893     :meth:`get_file_atime_age_seconds`,
894     :meth:`get_file_atime_as_datetime`,
895     :meth:`get_file_raw_ctime`,
896     :meth:`get_file_raw_mtime`,
897     :meth:`get_file_raw_timestamps`,
898     :meth:`set_file_raw_atime`,
899     :meth:`set_file_raw_atime_and_mtime`
900     """
901     return _get_file_timestamp_timedelta(filename, lambda x: x.st_atime)
902
903
904 def get_file_ctime_timedelta(filename: str) -> Optional[datetime.timedelta]:
905     """How long ago was a file created as a timedelta?
906
907     Args:
908         filename: the file whose ctime should be checked.
909
910     Returns:
911         A Python :class:`datetime.timedelta` representing how long
912         ago filename was created.
913
914     See also
915     :meth:`get_file_raw_atime`,
916     :meth:`get_file_raw_ctime`,
917     :meth:`get_file_ctime_age_seconds`,
918     :meth:`get_file_ctime_as_datetime`,
919     :meth:`get_file_raw_mtime`,
920     :meth:`get_file_raw_timestamps`
921     """
922     return _get_file_timestamp_timedelta(filename, lambda x: x.st_ctime)
923
924
925 def get_file_mtime_timedelta(filename: str) -> Optional[datetime.timedelta]:
926     """
927     Gets a file's modification time as a Python timedelta.
928
929     Args:
930         filename: the file whose mtime should be checked.
931
932     Returns:
933         A Python :class:`datetime.timedelta` representing how long
934         ago filename was last modified.
935
936     See also
937     :meth:`get_file_raw_atime`,
938     :meth:`get_file_raw_ctime`,
939     :meth:`get_file_raw_mtime`,
940     :meth:`get_file_mtime_age_seconds`,
941     :meth:`get_file_mtime_as_datetime`,
942     :meth:`get_file_raw_timestamps`,
943     :meth:`set_file_raw_atime`,
944     :meth:`set_file_raw_atime_and_mtime`
945     """
946     return _get_file_timestamp_timedelta(filename, lambda x: x.st_mtime)
947
948
949 def describe_file_timestamp(filename: str, extractor, *, brief=False) -> Optional[str]:
950     """~Internal helper"""
951     from pyutils.datetimes.datetime_utils import (
952         describe_duration,
953         describe_duration_briefly,
954     )
955
956     age = _get_file_timestamp_age_seconds(filename, extractor)
957     if age is None:
958         return None
959     if brief:
960         return describe_duration_briefly(age)
961     else:
962         return describe_duration(age)
963
964
965 def describe_file_atime(filename: str, *, brief=False) -> Optional[str]:
966     """
967     Describe how long ago a file was accessed.
968
969     Args:
970         filename: the file whose atime should be described.
971         brief: if True, describe atime briefly.
972
973     Returns:
974         A string that represents how long ago filename was last
975         accessed.  The description will be verbose or brief depending
976         on the brief argument.
977
978     See also
979     :meth:`get_file_raw_atime`,
980     :meth:`get_file_atime_age_seconds`,
981     :meth:`get_file_atime_as_datetime`,
982     :meth:`get_file_atime_timedelta`,
983     :meth:`get_file_raw_ctime`,
984     :meth:`get_file_raw_mtime`,
985     :meth:`get_file_raw_timestamps`
986     :meth:`set_file_raw_atime`,
987     :meth:`set_file_raw_atime_and_mtime`
988     """
989     return describe_file_timestamp(filename, lambda x: x.st_atime, brief=brief)
990
991
992 def describe_file_ctime(filename: str, *, brief=False) -> Optional[str]:
993     """Describes a file's creation time.
994
995     Args:
996         filename: the file whose ctime should be described.
997         brief: if True, describe ctime briefly.
998
999     Returns:
1000         A string that represents how long ago filename was created.
1001         The description will be verbose or brief depending
1002         on the brief argument.
1003
1004     See also
1005     :meth:`get_file_raw_atime`,
1006     :meth:`get_file_raw_ctime`,
1007     :meth:`get_file_ctime_age_seconds`,
1008     :meth:`get_file_ctime_as_datetime`,
1009     :meth:`get_file_ctime_timedelta`,
1010     :meth:`get_file_raw_mtime`,
1011     :meth:`get_file_raw_timestamps`
1012     """
1013     return describe_file_timestamp(filename, lambda x: x.st_ctime, brief=brief)
1014
1015
1016 def describe_file_mtime(filename: str, *, brief=False) -> Optional[str]:
1017     """Describes how long ago a file was modified.
1018
1019     Args:
1020         filename: the file whose mtime should be described.
1021         brief: if True, describe mtime briefly.
1022
1023     Returns:
1024         A string that represents how long ago filename was last
1025         modified.  The description will be verbose or brief depending
1026         on the brief argument.
1027
1028     See also
1029     :meth:`get_file_raw_atime`,
1030     :meth:`get_file_raw_ctime`,
1031     :meth:`get_file_raw_mtime`,
1032     :meth:`get_file_mtime_age_seconds`,
1033     :meth:`get_file_mtime_as_datetime`,
1034     :meth:`get_file_mtime_timedelta`,
1035     :meth:`get_file_raw_timestamps`,
1036     :meth:`set_file_raw_atime`,
1037     :meth:`set_file_raw_atime_and_mtime`
1038     """
1039     return describe_file_timestamp(filename, lambda x: x.st_mtime, brief=brief)
1040
1041
1042 def touch_file(filename: str, *, mode: Optional[int] = 0o666):
1043     """Like unix "touch" command's semantics: update the timestamp
1044     of a file to the current time if the file exists.  Create the
1045     file if it doesn't exist.
1046
1047     Args:
1048         filename: the filename
1049         mode: the mode to create the file with
1050
1051     .. warning::
1052
1053         The default creation mode is 0o666 which is world readable
1054         and writable.  Override this by passing in your own mode
1055         parameter if desired.
1056
1057     See also :meth:`set_file_raw_atime`, :meth:`set_file_raw_atime_and_mtime`,
1058     :meth:`set_file_raw_mtime`, :meth:`create_path_if_not_exist`
1059     """
1060     pathlib.Path(filename, mode=mode).touch()
1061
1062
1063 def expand_globs(in_filename: str):
1064     """
1065     Expands shell globs (* and ? wildcards) to the matching files.
1066
1067     Args:
1068         in_filename: the filepath to be expanded.  May contain '*' and '?'
1069             globbing characters.
1070
1071     Returns:
1072         A Generator that yields filenames that match the input pattern.
1073
1074     See also :meth:`get_files`, :meth:`get_files_recursive`.
1075     """
1076     for filename in glob.glob(in_filename):
1077         yield filename
1078
1079
1080 def get_files(directory: str):
1081     """Returns the files in a directory as a generator.
1082
1083     Args:
1084         directory: the directory to list files under.
1085
1086     Returns:
1087         A generator that yields all files in the input directory.
1088
1089     See also :meth:`expand_globs`, :meth:`get_files_recursive`,
1090     :meth:`get_matching_files`.
1091     """
1092     for filename in os.listdir(directory):
1093         full_path = join(directory, filename)
1094         if isfile(full_path) and exists(full_path):
1095             yield full_path
1096
1097
1098 def get_matching_files(directory: str, glob: str):
1099     """
1100     Returns the subset of files whose name matches a glob.
1101
1102     Args:
1103         directory: the directory to match files within.
1104         glob: the globbing pattern (may include '*' and '?') to
1105             use when matching files.
1106
1107     Returns:
1108         A generator that yields filenames in directory that match
1109         the given glob pattern.
1110
1111     See also :meth:`get_files`, :meth:`expand_globs`.
1112     """
1113     for filename in get_files(directory):
1114         if fnmatch.fnmatch(filename, glob):
1115             yield filename
1116
1117
1118 def get_directories(directory: str):
1119     """
1120     Returns the subdirectories in a directory as a generator.
1121
1122     Args:
1123         directory: the directory to list subdirectories within.
1124
1125     Returns:
1126         A generator that yields all subdirectories within the given
1127         input directory.
1128
1129     See also :meth:`get_files`, :meth:`get_files_recursive`.
1130     """
1131     for d in os.listdir(directory):
1132         full_path = join(directory, d)
1133         if not isfile(full_path) and exists(full_path):
1134             yield full_path
1135
1136
1137 def get_files_recursive(directory: str):
1138     """
1139     Find the files and directories under a root recursively.
1140
1141     Args:
1142         directory: the root directory under which to list subdirectories
1143             and file contents.
1144
1145     Returns:
1146         A generator that yields all directories and files beneath the input
1147         root directory.
1148
1149     See also :meth:`get_files`, :meth:`get_matching_files`,
1150     :meth:`get_matching_files_recursive`
1151     """
1152     for filename in get_files(directory):
1153         yield filename
1154     for subdir in get_directories(directory):
1155         for file_or_directory in get_files_recursive(subdir):
1156             yield file_or_directory
1157
1158
1159 def get_matching_files_recursive(directory: str, glob: str):
1160     """
1161     Returns the subset of files whose name matches a glob under a root recursively.
1162
1163     Args:
1164         directory: the root under which to search
1165         glob: a globbing pattern that describes the subset of files and directories
1166             to return.  May contain '?' and '*'.
1167
1168     Returns:
1169         A generator that yields all files and directories under the given root
1170         directory that match the given globbing pattern.
1171
1172     See also :meth:`get_files_recursive`.
1173     """
1174     for filename in get_files_recursive(directory):
1175         if fnmatch.fnmatch(filename, glob):
1176             yield filename
1177
1178
1179 class FileWriter(contextlib.AbstractContextManager):
1180     """A helper that writes a file to a temporary location and then
1181     moves it atomically to its ultimate destination on close.
1182
1183     Example usage.  Creates a temporary file that is populated by the
1184     print statements within the context.  Until the context is exited,
1185     the true destination file does not exist so no reader of it can
1186     see partial writes due to buffering or code timing.  Once the
1187     context is exited, the file is moved from its temporary location
1188     to its permanent location by a call to `/bin/mv` which should be
1189     atomic::
1190
1191         with FileWriter('/home/bob/foobar.txt') as w:
1192             print("This is a test!", file=w)
1193             time.sleep(2)
1194             print("This is only a test...", file=w)
1195     """
1196
1197     def __init__(self, filename: str) -> None:
1198         """
1199         Args:
1200             filename: the ultimate destination file we want to populate.
1201                 On exit, the file will be atomically created.
1202         """
1203         self.filename = filename
1204         uuid = uuid4()
1205         self.tempfile = f"{filename}-{uuid}.tmp"
1206         self.handle: Optional[TextIO] = None
1207
1208     def __enter__(self) -> TextIO:
1209         assert not does_path_exist(self.tempfile)
1210         self.handle = open(self.tempfile, mode="w")
1211         return self.handle
1212
1213     def __exit__(self, exc_type, exc_val, exc_tb) -> Literal[False]:
1214         if self.handle is not None:
1215             self.handle.close()
1216             cmd = f"/bin/mv -f {self.tempfile} {self.filename}"
1217             ret = os.system(cmd)
1218             if (ret >> 8) != 0:
1219                 raise Exception(f"{cmd} failed, exit value {ret>>8}!")
1220         return False
1221
1222
1223 class CreateFileWithMode(contextlib.AbstractContextManager):
1224     """This helper context manager can be used instead of the typical
1225     pattern for creating a file if you want to ensure that the file
1226     created is a particular permission mode upon creation.
1227
1228     Python's open doesn't support this; you need to set the os.umask
1229     and then create a descriptor to open via os.open, see below.
1230
1231         >>> import os
1232         >>> filename = f'/tmp/CreateFileWithModeTest.{os.getpid()}'
1233         >>> with CreateFileWithMode(filename, mode=0o600) as wf:
1234         ...     print('This is a test', file=wf)
1235         >>> result = os.stat(filename)
1236
1237         Note: there is a high order bit set in this that is S_IFREG indicating
1238         that the file is a "normal file".  Clear it with the mask.
1239
1240         >>> print(f'{result.st_mode & 0o7777:o}')
1241         600
1242         >>> with open(filename, 'r') as rf:
1243         ...     contents = rf.read()
1244         >>> contents
1245         'This is a test\\n'
1246         >>> remove(filename)
1247     """
1248
1249     def __init__(self, filename: str, mode: Optional[int] = 0o600) -> None:
1250         """
1251         Args:
1252             filename: path of the file to create.
1253             mode: the UNIX-style octal mode with which to create the
1254                 filename.  Defaults to 0o600.
1255
1256         .. warning::
1257
1258             If the file already exists it will be overwritten!
1259
1260         """
1261         self.filename = filename
1262         if mode is not None:
1263             self.mode = mode & 0o7777
1264         else:
1265             self.mode = 0o666
1266         self.handle: Optional[TextIO] = None
1267         self.old_umask = os.umask(0)
1268
1269     def __enter__(self) -> TextIO:
1270         descriptor = os.open(
1271             path=self.filename,
1272             flags=(os.O_WRONLY | os.O_CREAT | os.O_TRUNC),
1273             mode=self.mode,
1274         )
1275         self.handle = open(descriptor, "w")
1276         return self.handle
1277
1278     def __exit__(self, exc_type, exc_val, exc_tb) -> Literal[False]:
1279         os.umask(self.old_umask)
1280         if self.handle is not None:
1281             self.handle.close()
1282         return False
1283
1284
1285 if __name__ == "__main__":
1286     import doctest
1287
1288     doctest.testmod()