Skip to content

Commit 95edc84

Browse files
vram0gh2Fizzadar
andauthored
operations/files: add atime and mtime arguments to files.put operation
Co-authored-by: Nick Mills-Barrett <[email protected]>
1 parent c7d5415 commit 95edc84

File tree

9 files changed

+447
-10
lines changed

9 files changed

+447
-10
lines changed

pyinfra/facts/files.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828

2929
STAT_REGEX = (
3030
r"user=(.*) group=(.*) mode=(.*) "
31-
r"atime=([0-9]*) mtime=([0-9]*) ctime=([0-9]*) "
31+
r"atime=(-?[0-9]*) mtime=(-?[0-9]*) ctime=(-?[0-9]*) "
3232
r"size=([0-9]*) (.*)"
3333
)
3434

pyinfra/operations/files.py

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import posixpath
99
import sys
1010
import traceback
11-
from datetime import timedelta
11+
from datetime import datetime, timedelta, timezone
1212
from fnmatch import fnmatch
1313
from io import StringIO
1414
from pathlib import Path
@@ -59,6 +59,7 @@
5959

6060
from .util import files as file_utils
6161
from .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()
817868
def 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

pyinfra/operations/util/files.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
from __future__ import annotations
22

33
import re
4-
from datetime import datetime
4+
from datetime import datetime, timezone
5+
from enum import Enum
56
from typing import Callable
67

78
from pyinfra.api import QuoteString, StringCommand
89

910

11+
class MetadataTimeField(Enum):
12+
ATIME = "atime"
13+
MTIME = "mtime"
14+
15+
1016
def unix_path_join(*parts) -> str:
1117
part_list = list(parts)
1218
part_list[0:-1] = [part.rstrip("/") for part in part_list[0:-1]]
@@ -156,6 +162,34 @@ def chown(
156162
return StringCommand(" ".join(args), user_group, QuoteString(target))
157163

158164

165+
# like the touch command, but only supports setting one field at a time, and expects any
166+
# reference times to have been read from the reference file metadata and turned into
167+
# aware datetimes
168+
def touch(
169+
target: str,
170+
timefield: MetadataTimeField,
171+
timesrc: datetime,
172+
dereference=True,
173+
) -> StringCommand:
174+
args = ["touch"]
175+
176+
if timefield is MetadataTimeField.ATIME:
177+
args.append("-a")
178+
else:
179+
args.append("-m")
180+
181+
if not dereference:
182+
args.append("-h")
183+
184+
# don't reinvent the wheel; use isoformat()
185+
timestr = timesrc.astimezone(timezone.utc).isoformat()
186+
# but replace the ISO format TZ offset with "Z" for BSD
187+
timestr = timestr.replace("+00:00", "Z")
188+
args.extend(["-d", timestr])
189+
190+
return StringCommand(" ".join(args), QuoteString(target))
191+
192+
159193
def adjust_regex(line: str, escape_regex_characters: bool) -> str:
160194
"""
161195
Ensure the regex starts with '^' and ends with '$' and escape regex characters if requested
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"args": ["somefile.txt", "/home/somefile.txt"],
3+
"kwargs": {
4+
"atime": "datetime:2002-09-15T10:11:12+00:00",
5+
"mtime": "datetime:2002-09-15T10:11:11.888888+00:00"
6+
},
7+
"local_files": {
8+
"files": {
9+
"somefile.txt": null
10+
},
11+
"dirs": {}
12+
},
13+
"facts": {
14+
"files.File": {
15+
"path=/home/somefile.txt": {
16+
"mode": 640,
17+
"atime": "datetime:2002-09-15T10:11:12",
18+
"mtime": "datetime:2002-09-15T10:11:12"
19+
}
20+
},
21+
"files.Directory": {
22+
"path=/home": true
23+
},
24+
"files.Sha1File": {
25+
"path=/home/somefile.txt": "ac2cd59a622114712b5b21081763c54bf0caacb8"
26+
}
27+
},
28+
"commands": [
29+
"touch -m -d 2002-09-15T10:11:11.888888Z /home/somefile.txt"
30+
]
31+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"args": ["somefile.txt", "/home/somefile.txt"],
3+
"kwargs": {
4+
"atime": "/var/timeref.blob",
5+
"mtime": "/var/timeref.blob",
6+
},
7+
"local_files": {
8+
"files": {
9+
"somefile.txt": null
10+
},
11+
"dirs": {}
12+
},
13+
"facts": {
14+
"files.File": {
15+
"path=/home/somefile.txt": {
16+
"mode": 500,
17+
"atime": "datetime:2020-07-15T09:19:27",
18+
"mtime": "datetime:2002-09-15T10:11:12"
19+
},
20+
"path=/var/timeref.blob": {
21+
"mode": 644,
22+
"atime": "datetime:1991-04-15T18:18:27",
23+
"mtime": "datetime:2002-09-15T10:11:12"
24+
},
25+
},
26+
"files.Directory": {
27+
"path=/home": true,
28+
"path=/var": true
29+
},
30+
"files.Sha1File": {
31+
"path=/home/somefile.txt": "ac2cd59a622114712b5b21081763c54bf0caacb8"
32+
}
33+
},
34+
"commands": [
35+
"touch -a -d 1991-04-15T18:18:27Z /home/somefile.txt",
36+
]
37+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"args": ["somefile.txt", "/home/somefile.txt"],
3+
"kwargs": {
4+
"atime": false,
5+
"mtime": "2022-11-17T20:45:00",
6+
},
7+
"local_files": {
8+
"files": {
9+
"somefile.txt": {
10+
"mode": 644,
11+
"ctime": "datetime:1997-04-21T18:06:55.982Z",
12+
"atime": "datetime:2020-06-20T22:09:17",
13+
"mtime": "datetime:2000-05-01T00:01:00Z"
14+
},
15+
},
16+
"dirs": {}
17+
},
18+
"facts": {
19+
"files.File": {
20+
"path=/home/somefile.txt": {
21+
"mode": 500,
22+
"atime": "datetime:2020-07-15T09:19:27",
23+
"mtime": "datetime:2020-07-15T09:19:27"
24+
}
25+
},
26+
"files.Directory": {
27+
"path=/home": true
28+
},
29+
"files.Sha1File": {
30+
"path=/home/somefile.txt": "ac2cd59a622114712b5b21081763c54bf0caacb8"
31+
},
32+
"server.Date": "datetime:2017-03-03T10:07:37-06:00"
33+
},
34+
"commands": [
35+
"touch -m -d 2022-11-18T02:45:00Z /home/somefile.txt",
36+
]
37+
}

0 commit comments

Comments
 (0)