88import posixpath
99import sys
1010import traceback
11- from datetime import timedelta
11+ from datetime import datetime , timedelta , timezone
1212from fnmatch import fnmatch
1313from io import StringIO
1414from pathlib import Path
5959
6060from .util import files as file_utils
6161from .util .files import (
62+ MetadataTimeField ,
6263 adjust_regex ,
6364 ensure_mode_int ,
6465 get_timestamp ,
@@ -813,6 +814,56 @@ def get(
813814 host .noop ("file {0} has already been downloaded" .format (dest ))
814815
815816
817+ def _canonicalize_timespec (field : MetadataTimeField , local_file , timespec ):
818+ if isinstance (timespec , datetime ):
819+ if not timespec .tzinfo :
820+ # specify remote host timezone
821+ timespec_with_tz = timespec .replace (tzinfo = host .get_fact (Date ).tzinfo )
822+ return timespec_with_tz
823+ else :
824+ return timespec
825+ elif isinstance (timespec , bool ) and timespec :
826+ lf_ts = (
827+ os .stat (local_file ).st_atime
828+ if field is MetadataTimeField .ATIME
829+ else os .stat (local_file ).st_mtime
830+ )
831+ return datetime .fromtimestamp (lf_ts , tz = timezone .utc )
832+ else :
833+ try :
834+ isodatetime = datetime .fromisoformat (timespec )
835+ if not isodatetime .tzinfo :
836+ return isodatetime .replace (tzinfo = host .get_fact (Date ).tzinfo )
837+ else :
838+ return isodatetime
839+ except ValueError :
840+ try :
841+ timestamp = float (timespec )
842+ return datetime .fromtimestamp (timestamp , tz = timezone .utc )
843+ except ValueError :
844+ # verify there is a remote file matching path in timesrc
845+ ref_file = host .get_fact (File , path = timespec )
846+ if ref_file :
847+ if field is MetadataTimeField .ATIME :
848+ assert ref_file ["atime" ] is not None
849+ return ref_file ["atime" ].replace (tzinfo = timezone .utc )
850+ else :
851+ assert ref_file ["mtime" ] is not None
852+ return ref_file ["mtime" ].replace (tzinfo = timezone .utc )
853+ else :
854+ ValueError ("Bad argument for `timesspec`: {0}" .format (timespec ))
855+
856+
857+ # returns True for a visible difference in the second field between the datetime values
858+ # in the ref's TZ
859+ def _times_differ_in_s (ref , cand ):
860+ assert ref .tzinfo and cand .tzinfo
861+ cand_in_ref_tz = cand .astimezone (ref .tzinfo )
862+ return (abs ((cand_in_ref_tz - ref ).total_seconds ()) >= 1.0 ) or (
863+ ref .second != cand_in_ref_tz .second
864+ )
865+
866+
816867@operation ()
817868def put (
818869 src : str | IO [Any ],
@@ -824,6 +875,8 @@ def put(
824875 create_remote_dir = True ,
825876 force = False ,
826877 assume_exists = False ,
878+ atime : datetime | float | int | str | bool | None = None ,
879+ mtime : datetime | float | int | str | bool | None = None ,
827880):
828881 """
829882 Upload a local file, or file-like object, to the remote system.
@@ -837,6 +890,8 @@ def put(
837890 + create_remote_dir: create the remote directory if it doesn't exist
838891 + force: always upload the file, even if the remote copy matches
839892 + assume_exists: whether to assume the local file exists
893+ + atime: value of atime the file should have, use ``True`` to match the local file
894+ + mtime: value of mtime the file should have, use ``True`` to match the local file
840895
841896 ``dest``:
842897 If this is a directory that already exists on the remote side, the local
@@ -853,7 +908,21 @@ def put(
853908 user & group as passed to ``files.put``. The mode will *not* be copied over,
854909 if this is required call ``files.directory`` separately.
855910
856- Note:
911+ ``atime`` and ``mtime``:
912+ When set to values other than ``False`` or ``None``, the respective metadata
913+ fields on the remote file will updated accordingly. Timestamp values are
914+ considered equivalent if the difference is less than one second and they have
915+ the identical number in the seconds field. If set to ``True`` the local
916+ file is the source of the value. Otherwise, these values can be provided as
917+ ``datetime`` objects, POSIX timestamps, or strings that can be parsed into
918+ either of these date and time specifications. They can also be reference file
919+ paths on the remote host, as with the ``-r`` argument to ``touch``. If a
920+ ``datetime`` argument has no ``tzinfo`` value (i.e., it is naive), it is
921+ assumed to be in the remote host's local timezone. There is no shortcut for
922+ setting both ``atime` and ``mtime`` values with a single time specification,
923+ unlike the native ``touch`` command.
924+
925+ Notes:
857926 This operation is not suitable for large files as it may involve copying
858927 the file before uploading it.
859928
@@ -862,6 +931,12 @@ def put(
862931 behave as if the remote file does not match the specified permissions and
863932 requires a change.
864933
934+ If the ``atime`` argument is set for a given file, unless the remote
935+ filesystem is mounted ``noatime`` or ``relatime``, multiple runs of this
936+ operation will trigger the change detection for that file, since the act of
937+ reading and checksumming the file will cause the host OS to update the file's
938+ ``atime``.
939+
865940 **Examples:**
866941
867942 .. code:: python
@@ -937,7 +1012,22 @@ def put(
9371012 if mode :
9381013 yield file_utils .chmod (dest , mode )
9391014
940- # File exists, check sum and check user/group/mode if supplied
1015+ # do mtime before atime to ensure atime setting isn't undone by mtime setting
1016+ if mtime :
1017+ yield file_utils .touch (
1018+ dest ,
1019+ MetadataTimeField .MTIME ,
1020+ _canonicalize_timespec (MetadataTimeField .MTIME , src , mtime ),
1021+ )
1022+
1023+ if atime :
1024+ yield file_utils .touch (
1025+ dest ,
1026+ MetadataTimeField .ATIME ,
1027+ _canonicalize_timespec (MetadataTimeField .ATIME , src , atime ),
1028+ )
1029+
1030+ # File exists, check sum and check user/group/mode/atime/mtime if supplied
9411031 else :
9421032 if not _file_equal (local_sum_path , dest ):
9431033 yield FileUploadCommand (
@@ -952,6 +1042,20 @@ def put(
9521042 if mode :
9531043 yield file_utils .chmod (dest , mode )
9541044
1045+ if mtime :
1046+ yield file_utils .touch (
1047+ dest ,
1048+ MetadataTimeField .MTIME ,
1049+ _canonicalize_timespec (MetadataTimeField .MTIME , src , mtime ),
1050+ )
1051+
1052+ if atime :
1053+ yield file_utils .touch (
1054+ dest ,
1055+ MetadataTimeField .ATIME ,
1056+ _canonicalize_timespec (MetadataTimeField .ATIME , src , atime ),
1057+ )
1058+
9551059 else :
9561060 changed = False
9571061
@@ -965,6 +1069,26 @@ def put(
9651069 yield file_utils .chown (dest , user , group )
9661070 changed = True
9671071
1072+ # Check mtime
1073+ if mtime :
1074+ canonical_mtime = _canonicalize_timespec (MetadataTimeField .MTIME , src , mtime )
1075+ assert remote_file ["mtime" ] is not None
1076+ if _times_differ_in_s (
1077+ canonical_mtime , remote_file ["mtime" ].replace (tzinfo = timezone .utc )
1078+ ):
1079+ yield file_utils .touch (dest , MetadataTimeField .MTIME , canonical_mtime )
1080+ changed = True
1081+
1082+ # Check atime
1083+ if atime :
1084+ canonical_atime = _canonicalize_timespec (MetadataTimeField .ATIME , src , atime )
1085+ assert remote_file ["atime" ] is not None
1086+ if _times_differ_in_s (
1087+ canonical_atime , remote_file ["atime" ].replace (tzinfo = timezone .utc )
1088+ ):
1089+ yield file_utils .touch (dest , MetadataTimeField .ATIME , canonical_atime )
1090+ changed = True
1091+
9681092 if not changed :
9691093 host .noop ("file {0} is already uploaded" .format (dest ))
9701094
0 commit comments