Skip to content

Add atime and mtime support for files ops, start with files.put #1372

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 6 commits into
base: 3.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyinfra/facts/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

STAT_REGEX = (
r"user=(.*) group=(.*) mode=(.*) "
r"atime=([0-9]*) mtime=([0-9]*) ctime=([0-9]*) "
r"atime=(-?[0-9]*) mtime=(-?[0-9]*) ctime=(-?[0-9]*) "
r"size=([0-9]*) (.*)"
)

Expand Down
115 changes: 112 additions & 3 deletions pyinfra/operations/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import posixpath
import sys
import traceback
from datetime import timedelta
from datetime import datetime, timedelta, timezone
from fnmatch import fnmatch
from io import StringIO
from pathlib import Path
Expand Down Expand Up @@ -778,6 +778,56 @@ def get(
yield FileDownloadCommand(src, dest, remote_temp_filename=host.get_temp_filename(dest))


def _canonicalize_timespec(field, local_file, timespec):
assert field == "atime" or field == "mtime"
if isinstance(timespec, datetime):
if not timespec.tzinfo:
# specify remote host timezone
timespec_with_tz = timespec.replace(tzinfo=host.get_fact(Date).tzinfo)
return timespec_with_tz
else:
return timespec
elif isinstance(timespec, bool) and timespec:
lf_stat = os.stat(local_file)
if field == "atime":
return datetime.fromtimestamp(lf_stat.st_atime, tz=timezone.utc)
else:
return datetime.fromtimestamp(lf_stat.st_mtime, tz=timezone.utc)
else:
try:
isodatetime = datetime.fromisoformat(timespec)
if not isodatetime.tzinfo:
return isodatetime.replace(tzinfo=host.get_fact(Date).tzinfo)
else:
return isodatetime
except ValueError:
try:
timestamp = float(timespec)
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
except ValueError:
# verify there is a remote file matching path in timesrc
ref_file = host.get_fact(File, path=timespec)
if ref_file:
if field == "atime":
assert ref_file["atime"] is not None
return ref_file["atime"].replace(tzinfo=timezone.utc)
else:
assert ref_file["mtime"] is not None
return ref_file["mtime"].replace(tzinfo=timezone.utc)
else:
ValueError("Bad argument for `timesspec`: {0}".format(timespec))


# returns True for a visible difference in the second field between the datetime values
# in the ref's TZ
def _times_differ_in_s(ref, cand):
assert ref.tzinfo and cand.tzinfo
cand_in_ref_tz = cand.astimezone(ref.tzinfo)
return (abs((cand_in_ref_tz - ref).total_seconds()) >= 1.0) or (
ref.second != cand_in_ref_tz.second
)


@operation()
def put(
src: str | IO[Any],
Expand All @@ -789,6 +839,8 @@ def put(
create_remote_dir=True,
force=False,
assume_exists=False,
atime: datetime | float | int | str | bool | None = None,
mtime: datetime | float | int | str | bool | None = None,
):
"""
Upload a local file, or file-like object, to the remote system.
Expand All @@ -802,6 +854,9 @@ def put(
+ create_remote_dir: create the remote directory if it doesn't exist
+ force: always upload the file, even if the remote copy matches
+ assume_exists: whether to assume the local file exists
+ atime: value of atime the file should have, use ``True`` to match the local file
+ mtime: value of atime the file should have, use ``True`` to match the local file
+ timesrc: the source of the time value if atime or mtime are ``True``

``dest``:
If this is a directory that already exists on the remote side, the local
Expand All @@ -818,7 +873,21 @@ def put(
user & group as passed to ``files.put``. The mode will *not* be copied over,
if this is required call ``files.directory`` separately.

Note:
``atime`` and ``mtime``:
When set to values other than ``False`` or ``None``, the respective metadata
fields on the remote file will updated accordingly. Timestamp values are
considered equivalent if the difference is less than one second and they have
the identical number in the seconds field. If set to ``True`` the local
file is the source of the value. Otherwise, these values can be provided as
``datetime`` objects, POSIX timestamps, or strings that can be parsed into
either of these date and time specifications. They can also be reference file
paths on the remote host, as with the ``-r`` argument to ``touch``. If a
``datetime`` argument has no ``tzinfo`` value (i.e., it is naive), it is
assumed to be in the remote host's local timezone. There is no shortcut for
setting both ``atime` and ``mtime`` values with a single time specification,
unlike the native ``touch`` command.

Notes:
This operation is not suitable for large files as it may involve copying
the file before uploading it.

Expand All @@ -827,6 +896,12 @@ def put(
behave as if the remote file does not match the specified permissions and
requires a change.

If the ``atime`` argument is set for a given file, unless the remote
filesystem is mounted ``noatime`` or ``relatime``, multiple runs of this
operation will trigger the change detection for that file, since the act of
reading and checksumming the file will cause the host OS to update the file's
``atime``.

**Examples:**

.. code:: python
Expand Down Expand Up @@ -902,12 +977,20 @@ def put(
if mode:
yield file_utils.chmod(dest, mode)

# File exists, check sum and check user/group/mode if supplied
# do mtime before atime to ensure atime setting isn't undone by mtime setting
if mtime:
yield file_utils.touch(dest, "mtime", _canonicalize_timespec("mtime", src, mtime))

if atime:
yield file_utils.touch(dest, "atime", _canonicalize_timespec("atime", src, atime))

# File exists, check sum and check user/group/mode/atime/mtime if supplied
else:
remote_sum = host.get_fact(Sha1File, path=dest)

# Check sha1sum, upload if needed
if local_sum != remote_sum:

yield FileUploadCommand(
local_file,
dest,
Expand All @@ -920,6 +1003,12 @@ def put(
if mode:
yield file_utils.chmod(dest, mode)

if mtime:
yield file_utils.touch(dest, "mtime", _canonicalize_timespec("mtime", src, mtime))

if atime:
yield file_utils.touch(dest, "atime", _canonicalize_timespec("atime", src, atime))

else:
changed = False

Expand All @@ -933,6 +1022,26 @@ def put(
yield file_utils.chown(dest, user, group)
changed = True

# Check mtime
if mtime:
canonical_mtime = _canonicalize_timespec("mtime", src, mtime)
assert remote_file["mtime"] is not None
if _times_differ_in_s(
canonical_mtime, remote_file["mtime"].replace(tzinfo=timezone.utc)
):
yield file_utils.touch(dest, "mtime", canonical_mtime)
changed = True

# Check atime
if atime:
canonical_atime = _canonicalize_timespec("atime", src, atime)
assert remote_file["atime"] is not None
if _times_differ_in_s(
canonical_atime, remote_file["atime"].replace(tzinfo=timezone.utc)
):
yield file_utils.touch(dest, "atime", canonical_atime)
changed = True

if not changed:
host.noop("file {0} is already uploaded".format(dest))

Expand Down
32 changes: 31 additions & 1 deletion pyinfra/operations/util/files.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import re
from datetime import datetime
from datetime import datetime, timezone

from pyinfra.api import QuoteString, StringCommand

Expand Down Expand Up @@ -115,6 +115,36 @@ def chown(
return StringCommand(" ".join(args), user_group, QuoteString(target))


# like the touch command, but only supports setting one field at a time, and expects any
# reference times to have been read from the reference file metadata and turned into
# aware datetimes
def touch(
target: str,
atime_or_mtime: str,
timesrc: datetime,
dereference=True,
) -> StringCommand:
args = ["touch"]

if atime_or_mtime == "atime":
args.append("-a")
elif atime_or_mtime == "mtime":
args.append("-m")
else:
ValueError("Bad argument `atime_or_mtime`: {0}".format(atime_or_mtime))

if not dereference:
args.append("-h")

# don't reinvent the wheel; use isoformat()
timestr = timesrc.astimezone(timezone.utc).isoformat()
# but replace the ISO format TZ offset with "Z" for BSD
timestr = timestr.replace("+00:00", "Z")
args.extend(["-d", timestr])

return StringCommand(" ".join(args), QuoteString(target))


def adjust_regex(line: str, escape_regex_characters: bool) -> str:
"""
Ensure the regex starts with '^' and ends with '$' and escape regex characters if requested
Expand Down
31 changes: 31 additions & 0 deletions tests/operations/files.put/atime_mtime_sub_second.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"args": ["somefile.txt", "/home/somefile.txt"],
"kwargs": {
"atime": "datetime:2002-09-15T10:11:12Z",
"mtime": "datetime:2002-09-15T10:11:11.888888Z",
},
"local_files": {
"files": {
"somefile.txt": null
},
"dirs": {}
},
"facts": {
"files.File": {
"path=/home/somefile.txt": {
"mode": 640,
"atime": "datetime:2002-09-15T10:11:12",
"mtime": "datetime:2002-09-15T10:11:12"
},
},
"files.Directory": {
"path=/home": true,
},
"files.Sha1File": {
"path=/home/somefile.txt": "ac2cd59a622114712b5b21081763c54bf0caacb8"
}
},
"commands": [
"touch -m -d 2002-09-15T10:11:11.888888Z /home/somefile.txt",
]
}
37 changes: 37 additions & 0 deletions tests/operations/files.put/change_atime_eq_mtime_ref_remote.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"args": ["somefile.txt", "/home/somefile.txt"],
"kwargs": {
"atime": "/var/timeref.blob",
"mtime": "/var/timeref.blob",
},
"local_files": {
"files": {
"somefile.txt": null
},
"dirs": {}
},
"facts": {
"files.File": {
"path=/home/somefile.txt": {
"mode": 500,
"atime": "datetime:2020-07-15T09:19:27",
"mtime": "datetime:2002-09-15T10:11:12"
},
"path=/var/timeref.blob": {
"mode": 644,
"atime": "datetime:1991-04-15T18:18:27",
"mtime": "datetime:2002-09-15T10:11:12"
},
},
"files.Directory": {
"path=/home": true,
"path=/var": true
},
"files.Sha1File": {
"path=/home/somefile.txt": "ac2cd59a622114712b5b21081763c54bf0caacb8"
}
},
"commands": [
"touch -a -d 1991-04-15T18:18:27Z /home/somefile.txt",
]
}
37 changes: 37 additions & 0 deletions tests/operations/files.put/change_mtime_arg_no_tz.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"args": ["somefile.txt", "/home/somefile.txt"],
"kwargs": {
"atime": false,
"mtime": "2022-11-17T20:45:00",
},
"local_files": {
"files": {
"somefile.txt": {
"mode": 644,
"ctime": "datetime:1997-04-21T18:06:55.982Z",
"atime": "datetime:2020-06-20T22:09:17",
"mtime": "datetime:2000-05-01T00:01:00Z"
},
},
"dirs": {}
},
"facts": {
"files.File": {
"path=/home/somefile.txt": {
"mode": 500,
"atime": "datetime:2020-07-15T09:19:27",
"mtime": "datetime:2020-07-15T09:19:27"
}
},
"files.Directory": {
"path=/home": true
},
"files.Sha1File": {
"path=/home/somefile.txt": "ac2cd59a622114712b5b21081763c54bf0caacb8"
},
"server.Date": "datetime:2017-03-03T10:07:37-06:00"
},
"commands": [
"touch -m -d 2022-11-18T02:45:00Z /home/somefile.txt",
]
}
37 changes: 37 additions & 0 deletions tests/operations/files.put/change_mtime_atime_ref_local file.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"args": ["somefile.txt", "/home/somefile.txt"],
"kwargs": {
"atime": true,
"mtime": true,
},
"local_files": {
"files": {
"somefile.txt": {
"mode": 644,
"ctime": "datetime:1997-04-21T18:06:55.982Z",
"atime": "datetime:2020-06-20T22:09:17",
"mtime": "datetime:2000-05-01T00:01:00Z"
},
},
"dirs": {}
},
"facts": {
"files.File": {
"path=/home/somefile.txt": {
"mode": 500,
"atime": "datetime:2020-07-15T09:19:27",
"mtime": "datetime:2020-07-15T09:19:27"
}
},
"files.Directory": {
"path=/home": true
},
"files.Sha1File": {
"path=/home/somefile.txt": "ac2cd59a622114712b5b21081763c54bf0caacb8"
}
},
"commands": [
"touch -m -d 2000-05-01T00:01:00Z /home/somefile.txt",
"touch -a -d 2020-06-20T22:09:17Z /home/somefile.txt",
]
}
Loading
Loading