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