Repository: borgbackup/borg

Issue:
--timestamp-aware {now}/{utcnow} placeholders
## Have you checked borgbackup docs, FAQ, and open Github issues?

Yes

## Is this a BUG / ISSUE report or a QUESTION?

Feature request

## System information. For client/server mode post info for both machines.

#### Your borg version (borg -V).

```
martin@martin ~ % borg -V
borg 1.1.5
```

#### Operating system (distribution) and version.

```
martin@martin ~ % lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 18.04.4 LTS
Release:        18.04
Codename:       bionic
```

#### Hardware / network configuration, and filesystems used.

shouldn't matter

#### How much data is handled by borg?

shouldn't matter

#### Full borg commandline that lead to the problem (leave away excludes and passwords)

```
borg create -s --timestamp /path/to/old/backup -C auto,zstd --exclude-caches ./::"{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}" /path/to/old/backup
```

## Describe the problem you're observing.

`{now}`/`{utcnow}` currently refer to the current date and time. This is fine when creating normal backups, but it's not really useful when using the `--timestamp` option to manually set the archive creation date, e.g. when importing old backups:

```
--timestamp TIMESTAMP
       manually specify the archive creation date/time
       (UTC, yyyy-mm-ddThh:mm:ss format).
       Alternatively, give a reference file/directory.
```

For `--timestamp`, it would be useful if `{now}`/`{utcnow}` would either refer to the timestamp as specified by `--timestamp`, or if new placeholders like `{timestamp}` and `{utctimestamp}` were introduced. The new placeholders could then refer the the current time if `--timestamp` wasn't used, and to the referred timestamp otherwise.

Bounty: https://www.bountysource.com/issues/91451358-timestamp-aware-now-utcnow-placeholders


Discussion:
My current workaround is:

```
cd /path/to/old
for i in *; do echo "$i"; borg create --progress -s --timestamp "$i" -C auto,zstd --exclude-caches /path/to/repo/::"import-$i-$(date -r "$i" +"%Y-%m-%dT%H:%M:%S")" "$i"; done
```
Guess it would make sense to assign `now` and/or `utcnow` from `--timestamp`.

If we want to do both, guess we'ld need the timezone from the current system.
If someone works on this, please make sure timezone offset is correct and timestamps work as expected / consistent.
> If we want to do both, guess we'd need the timezone from the current system.

`--timezone` only accepts timezones in UTC. Are you saying to "work backwards" from the UTC timestamp given and make `{now}` what that UTC time corresponds to in local time?

Resolving patch:
diff --git a/src/borg/archiver.py b/src/borg/archiver.py
--- a/src/borg/archiver.py
+++ b/src/borg/archiver.py
@@ -4457,6 +4457,8 @@ def parse_args(self, args=None):
                               self.do_list, self.do_mount, self.do_umount}
             if func not in bypass_allowed:
                 raise Error('Not allowed to bypass locking mechanism for chosen command')
+        if getattr(args, 'timestamp', None):
+            args.location = args.location.with_timestamp(args.timestamp)
         return args
 
     def prerun_checks(self, logger, is_serve):
diff --git a/src/borg/helpers.py b/src/borg/helpers.py
--- a/src/borg/helpers.py
+++ b/src/borg/helpers.py
@@ -606,7 +606,7 @@ def timestamp(s):
     try:
         # is it pointing to a file / directory?
         ts = safe_s(os.stat(s).st_mtime)
-        return datetime.utcfromtimestamp(ts)
+        return datetime.fromtimestamp(ts, tz=timezone.utc)
     except OSError:
         # didn't work, try parsing as timestamp. UTC, no TZ, no microsecs support.
         for format in ('%Y-%m-%dT%H:%M:%SZ', '%Y-%m-%dT%H:%M:%S+00:00',
@@ -615,7 +615,7 @@ def timestamp(s):
                        '%Y-%m-%d', '%Y-%j',
                        ):
             try:
-                return datetime.strptime(s, format)
+                return datetime.strptime(s, format).replace(tzinfo=timezone.utc)
             except ValueError:
                 continue
         raise ValueError
@@ -718,7 +718,7 @@ def format_line(format, data):
         raise PlaceholderError(format, data, e.__class__.__name__, str(e))
 
 
-def replace_placeholders(text):
+def replace_placeholders(text, overrides={}):
     """Replace placeholders in text with their values."""
     from .platform import fqdn, hostname
     current_time = datetime.now(timezone.utc)
@@ -735,6 +735,7 @@ def replace_placeholders(text):
         'borgmajor': '%d' % borg_version_tuple[:1],
         'borgminor': '%d.%d' % borg_version_tuple[:2],
         'borgpatch': '%d.%d.%d' % borg_version_tuple[:3],
+        **overrides,
     }
     return format_line(text, data)
 
@@ -1103,13 +1104,13 @@ class Location:
         |                                                   # or
         """ + optional_archive_re, re.VERBOSE)              # archive name (optional, may be empty)
 
-    def __init__(self, text=''):
-        if not self.parse(text):
+    def __init__(self, text='', overrides={}):
+        if not self.parse(text, overrides):
             raise ValueError('Invalid location format: "%s"' % self.orig)
 
-    def parse(self, text):
+    def parse(self, text, overrides={}):
         self.orig = text
-        text = replace_placeholders(text)
+        text = replace_placeholders(text, overrides)
         valid = self._parse(text)
         if valid:
             return True
@@ -1202,6 +1203,12 @@ def canonical_path(self):
                                            ':{}'.format(self.port) if self.port else '',
                                            path)
 
+    def with_timestamp(self, timestamp):
+        return Location(self.orig, overrides={
+            'now': DatetimeWrapper(timestamp.astimezone(None)),
+            'utcnow': DatetimeWrapper(timestamp),
+        })
+
 
 def location_validator(archive=None, proto=None):
     def validator(text):
diff --git a/src/borg/testsuite/helpers.py b/src/borg/testsuite/helpers.py
--- a/src/borg/testsuite/helpers.py
+++ b/src/borg/testsuite/helpers.py
@@ -189,6 +189,10 @@ def test_user_parsing(self):
         assert repr(Location('ssh://host/path::2016-12-31@23:59:59')) == \
             "Location(proto='ssh', user=None, host='host', port=None, path='/path', archive='2016-12-31@23:59:59')"
 
+    def test_with_timestamp(self):
+        assert repr(Location('path::archive-{utcnow}').with_timestamp(datetime(2002, 9, 19, tzinfo=timezone.utc))) == \
+            "Location(proto='file', user=None, host=None, port=None, path='path', archive='archive-2002-09-19T00:00:00')"
+
     def test_underspecified(self, monkeypatch):
         monkeypatch.delenv('BORG_REPO', raising=False)
         with pytest.raises(ValueError):
@@ -894,6 +898,10 @@ def test_replace_placeholders():
     assert int(replace_placeholders('{now:%Y}')) == now.year
 
 
+def test_override_placeholders():
+    assert replace_placeholders('{uuid4}', overrides={'uuid4': "overridden"}) == "overridden"
+
+
 def working_swidth():
     return platform.swidth('선') == 2
 


Test patch: