datakit-data 0.6.2__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: datakit-data
3
- Version: 0.6.2
3
+ Version: 0.7.0
4
4
  Summary: A datakit plugin to manage data assets on AWS S3.
5
5
  Author: Larry Fenn
6
6
  Author-email: Larry Fenn <lfenn@ap.org>
7
7
  License: ISC
8
8
  Requires-Dist: boto3
9
9
  Requires-Dist: cliff
10
- Requires-Dist: datakit-core>=0.2.1
10
+ Requires-Dist: datakit-core>=0.5.0
11
11
  Requires-Python: >=3.10
12
12
  Project-URL: Homepage, https://github.com/associatedpress/datakit-data
@@ -1,6 +1,6 @@
1
1
  __author__ = """Larry Fenn"""
2
2
  __email__ = 'lfenn@ap.org'
3
- __version__ = '0.6.2'
3
+ __version__ = '0.7.0'
4
4
 
5
5
  from .commands.init import Init
6
6
  from .commands.push import Push
@@ -0,0 +1,90 @@
1
+ import os
2
+ from datetime import datetime
3
+ from cliff.command import Command
4
+ from datakit import CommandHelpers
5
+ from datakit.utils import read_json, write_json
6
+
7
+ from ..project_mixin import ProjectMixin
8
+ from ..s3 import S3, list_local_files
9
+ from ..sync_markers import SyncMarkers
10
+
11
+ class Status(ProjectMixin, CommandHelpers, Command):
12
+
13
+ "Show sync status of local data files"
14
+
15
+ def get_parser(self, prog_name):
16
+ parser = super(Status, self).get_parser(prog_name)
17
+ parser.add_argument(
18
+ '--filepaths',
19
+ action='store_true',
20
+ default=False,
21
+ help="List individual file paths instead of just counts"
22
+ )
23
+ parser.add_argument(
24
+ '--all',
25
+ action='store_true',
26
+ default=False,
27
+ help="Query S3 directly for a full comparison of local and remote files"
28
+ )
29
+ return parser
30
+
31
+ def take_action(self, parsed_args):
32
+ if not os.path.exists("config/datakit-data.json"):
33
+ self.log.info("No config file found - have you run `datakit data init`?")
34
+ return
35
+ sync_status_dir = self.project_configs.get('sync_status_location')
36
+ markers = SyncMarkers(sync_status_dir)
37
+ last_push = markers.latest_mtime()
38
+ if last_push:
39
+ stamp = datetime.fromtimestamp(last_push).astimezone()
40
+ self.log.info(f"Last pushed: {stamp.strftime('%Y-%m-%d %H:%M:%S %Z')}")
41
+ else:
42
+ self.log.info("Last pushed: never")
43
+ if getattr(parsed_args, 'all', False):
44
+ self._report_s3_comparison(sync_status_dir, parsed_args.filepaths)
45
+ return
46
+ if not markers.enabled:
47
+ self.log.info("No sync_status_location configured")
48
+ answer = input("\nAdd sync_status_location = '.sync_status/' to project config? [Y/n]: ").strip().lower()
49
+ if answer in ('', 'y', 'yes'):
50
+ configs = read_json(self.project_config_path)
51
+ configs['sync_status_location'] = '.sync_status/'
52
+ write_json(self.project_config_path, configs)
53
+ self.log.info("Added sync_status_location to config/datakit-data.json")
54
+ return
55
+ missing, stale = self._find_unsynced('data/', markers)
56
+
57
+ self._log_group("file(s) missing a .synced file", missing, parsed_args.filepaths)
58
+ self._log_group("file(s) modified since last sync", stale, parsed_args.filepaths)
59
+
60
+ def _report_s3_comparison(self, sync_status_dir, filepaths):
61
+ bucket = self.project_configs['s3_bucket']
62
+ if bucket == "":
63
+ self.log.info("No bucket specified in config")
64
+ return
65
+ s3 = S3(self.project_configs['aws_user_profile'], bucket)
66
+ comparison = s3.compare('data/', self.project_configs['s3_path'], sync_status_dir)
67
+ self._log_group("file(s) local but not on S3", comparison.only_local, filepaths)
68
+ self._log_group("file(s) on S3 but not local", comparison.only_s3, filepaths)
69
+ self._log_group("file(s) changed locally since last sync", comparison.changed_local, filepaths)
70
+ self._log_group("file(s) changed on S3 since last sync", comparison.changed_s3, filepaths)
71
+ self._log_group("file(s) changed both locally and on S3 (conflict)", comparison.conflict, filepaths)
72
+ self._log_group("file(s) differing from S3 (no sync record)", comparison.differ, filepaths)
73
+
74
+ def _find_unsynced(self, data_dir, markers):
75
+ # Local-only staleness check against the recorded markers; no S3 round-trips.
76
+ missing = []
77
+ stale = []
78
+ for rel_path, local_path in list_local_files(data_dir).items():
79
+ _, marker_mtime = markers.read(rel_path)
80
+ if marker_mtime is None:
81
+ missing.append(rel_path)
82
+ elif os.path.getmtime(local_path) > marker_mtime:
83
+ stale.append(rel_path)
84
+ return sorted(missing), sorted(stale)
85
+
86
+ def _log_group(self, label, paths, filepaths):
87
+ self.log.info(f"{len(paths)} {label}")
88
+ if filepaths:
89
+ for path in paths:
90
+ self.log.info(f" {path}")
@@ -1,5 +1,6 @@
1
1
  import os
2
2
 
3
+ from datakit import ConfigField
3
4
  from datakit.utils import read_json
4
5
 
5
6
 
@@ -7,6 +8,19 @@ class ProjectMixin:
7
8
 
8
9
  plugin_slug = 'datakit-data'
9
10
 
11
+ config_spec = [
12
+ ConfigField('s3_bucket', required=True,
13
+ help='S3 bucket name where project data is archived'),
14
+ ConfigField('aws_user_profile', default='default',
15
+ help='AWS credentials profile to use'),
16
+ ConfigField('sync_status_location', default='.sync_status/',
17
+ help='S3 key prefix where sync status is recorded'),
18
+ ConfigField('s3_path_prefix',
19
+ help='Optional prefix prepended to the project S3 path'),
20
+ ConfigField('s3_path_suffix',
21
+ help='Optional suffix appended to the project S3 path'),
22
+ ]
23
+
10
24
  @property
11
25
  def default_configs(self):
12
26
  return {
@@ -1,11 +1,14 @@
1
1
  import os
2
2
  import logging
3
+ import mimetypes
3
4
  from collections import namedtuple
4
5
  from logging import NullHandler
5
6
 
6
7
  import boto3
7
8
  from botocore.exceptions import BotoCoreError, ClientError
8
9
 
10
+ from .sync_markers import SyncMarkers
11
+
9
12
 
10
13
  logger = logging.getLogger(__name__)
11
14
  logger.addHandler(NullHandler())
@@ -15,12 +18,38 @@ logger.addHandler(NullHandler())
15
18
  # the object changed on S3 since our last sync.
16
19
  S3ObjectInfo = namedtuple('S3ObjectInfo', ['etag'])
17
20
 
21
+ # The result of S3.compare: sorted lists of rel_paths bucketed by how the local copy, the live
22
+ # remote object, and the recorded .synced marker disagree. 'differ' holds files present on both
23
+ # sides whose difference cannot be attributed for lack of a usable sync record.
24
+ SyncComparison = namedtuple(
25
+ 'SyncComparison', ['only_local', 'only_s3', 'changed_local', 'changed_s3', 'conflict', 'differ']
26
+ )
27
+
18
28
  EMPTY_PATH_DELETE_MSG = (
19
29
  "\n*** Refusing --delete with an empty s3_path: this would scan and delete "
20
30
  "across the entire bucket. Set s3_path in the project config. ***\n"
21
31
  )
22
32
 
23
33
 
34
+ def list_local_files(data_dir):
35
+ # Map of rel_path -> full path for every data file under data_dir, excluding .synced
36
+ # markers (which live alongside the data when sync_status_location is data/). The key is
37
+ # used to build/compare S3 keys, which always use '/'; normalize the OS separator so keys
38
+ # generated on Windows match remote keys, while the value stays OS-native for filesystem
39
+ # operations.
40
+ files = {}
41
+ if not os.path.isdir(data_dir):
42
+ return files
43
+ for root, _, filenames in os.walk(data_dir):
44
+ for filename in filenames:
45
+ if filename.endswith(SyncMarkers.SUFFIX):
46
+ continue
47
+ full_path = os.path.join(root, filename)
48
+ rel_path = os.path.relpath(full_path, data_dir).replace(os.sep, '/')
49
+ files[rel_path] = full_path
50
+ return files
51
+
52
+
24
53
  class S3:
25
54
  """A limited, human-friendly interface to S3."""
26
55
 
@@ -45,20 +74,21 @@ class S3:
45
74
  if delete and not prefix:
46
75
  logger.info(EMPTY_PATH_DELETE_MSG)
47
76
  return 1
77
+ markers = SyncMarkers(sync_status_dir)
48
78
  client = self._client()
49
79
  failures = 0
50
- local_files = {k: v for k, v in self._list_local_files(data_dir).items() if not k.endswith('.synced')}
80
+ local_files = list_local_files(data_dir)
51
81
  for rel_path, local_path in sorted(local_files.items()):
52
82
  key = prefix + rel_path
53
- if not force and self._marker_is_fresh(local_path, rel_path, sync_status_dir):
83
+ if not force and markers.is_fresh(rel_path, local_path):
54
84
  logger.info(f"skipped: {local_path}")
55
85
  continue
56
86
  logger.info(f"upload: {local_path} to s3://{self.bucket}/{key}")
57
87
  if not dryrun:
58
88
  try:
59
- etag = self._upload(client, local_path, key, sync_status_dir is not None)
60
- if sync_status_dir is not None:
61
- self._create_sync_marker(rel_path, sync_status_dir, etag)
89
+ etag = self._upload(client, local_path, key, markers.enabled)
90
+ if markers.enabled:
91
+ markers.write(rel_path, etag)
62
92
  except (ClientError, BotoCoreError) as e:
63
93
  failures += 1
64
94
  logger.info(f"\n*** Error ***\n{e}\n")
@@ -81,6 +111,7 @@ class S3:
81
111
  if delete and not prefix:
82
112
  logger.info(EMPTY_PATH_DELETE_MSG)
83
113
  return 1
114
+ markers = SyncMarkers(sync_status_dir)
84
115
  client = self._client()
85
116
  failures = 0
86
117
  remote_objects = self._list_s3_objects(client, prefix)
@@ -89,7 +120,7 @@ class S3:
89
120
  remote_etag = remote_objects[rel_path].etag
90
121
  local_path = os.path.join(data_dir, rel_path)
91
122
  if not force:
92
- marker_etag = self._marker_etag(rel_path, sync_status_dir)
123
+ marker_etag = markers.etag(rel_path)
93
124
  if marker_etag is not None and marker_etag == remote_etag and os.path.exists(local_path):
94
125
  logger.info(f"skipped: s3://{self.bucket}/{key}")
95
126
  continue
@@ -98,13 +129,13 @@ class S3:
98
129
  os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
99
130
  try:
100
131
  client.download_file(self.bucket, key, local_path)
101
- if sync_status_dir is not None:
102
- self._create_sync_marker(rel_path, sync_status_dir, remote_etag)
132
+ if markers.enabled:
133
+ markers.write(rel_path, remote_etag)
103
134
  except (ClientError, BotoCoreError) as e:
104
135
  failures += 1
105
136
  logger.info(f"\n*** Error ***\n{e}\n")
106
137
  if delete:
107
- local_files = {k: v for k, v in self._list_local_files(data_dir).items() if not k.endswith('.synced')}
138
+ local_files = list_local_files(data_dir)
108
139
  remote_rel = set(remote_objects)
109
140
  for rel_path, local_path in sorted(local_files.items()):
110
141
  if rel_path not in remote_rel:
@@ -117,6 +148,43 @@ class S3:
117
148
  logger.info(f"\n*** Error ***\n{e}\n")
118
149
  return failures
119
150
 
151
+ def compare(self, data_dir, s3_path='', sync_status_dir=None):
152
+ """Compare local data files against the bucket's live listing.
153
+
154
+ Returns a SyncComparison bucketing each rel_path by what changed since the sync
155
+ recorded in its .synced marker: present on only one side, changed locally, changed
156
+ on S3, changed on both (conflict), or present on both sides with no usable sync
157
+ record to attribute the difference (differ). In-sync files are not reported.
158
+ """
159
+ markers = SyncMarkers(sync_status_dir)
160
+ client = self._client()
161
+ prefix = self._normalize_prefix(s3_path)
162
+ local_files = list_local_files(data_dir)
163
+ remote_objects = self._list_s3_objects(client, prefix)
164
+ changed_local, changed_s3, conflict, differ = [], [], [], []
165
+ for rel_path in sorted(set(local_files) & set(remote_objects)):
166
+ marker_etag, marker_mtime = markers.read(rel_path)
167
+ if marker_etag is None:
168
+ differ.append(rel_path)
169
+ continue
170
+ s3_changed = remote_objects[rel_path].etag != marker_etag
171
+ local_changed = os.path.getmtime(local_files[rel_path]) > marker_mtime
172
+ if local_changed and s3_changed:
173
+ conflict.append(rel_path)
174
+ elif local_changed:
175
+ changed_local.append(rel_path)
176
+ elif s3_changed:
177
+ changed_s3.append(rel_path)
178
+ # else: neither side changed since the last sync -> in sync, nothing to report
179
+ return SyncComparison(
180
+ only_local=sorted(set(local_files) - set(remote_objects)),
181
+ only_s3=sorted(set(remote_objects) - set(local_files)),
182
+ changed_local=changed_local,
183
+ changed_s3=changed_s3,
184
+ conflict=conflict,
185
+ differ=differ,
186
+ )
187
+
120
188
  def _client(self):
121
189
  session = boto3.Session(profile_name=self.user_profile)
122
190
  return session.client('s3')
@@ -136,11 +204,14 @@ class S3:
136
204
  # files go via put_object, whose response carries the ETag, so no extra head_object is
137
205
  # needed; larger files keep upload_file's managed multipart transfer and we read the ETag
138
206
  # back with head_object only when a sync marker needs it (need_etag), else return None.
207
+ content_type, _ = mimetypes.guess_type(local_path)
208
+ if content_type is None:
209
+ content_type = 'application/octet-stream'
139
210
  if os.path.getsize(local_path) < self.MULTIPART_THRESHOLD:
140
211
  with open(local_path, 'rb') as body:
141
- response = client.put_object(Bucket=self.bucket, Key=key, Body=body)
212
+ response = client.put_object(Bucket=self.bucket, Key=key, Body=body, ContentType=content_type)
142
213
  return self._normalize_etag(response.get('ETag'))
143
- client.upload_file(local_path, self.bucket, key)
214
+ client.upload_file(local_path, self.bucket, key, ExtraArgs={'ContentType': content_type})
144
215
  return self._object_etag(client, key) if need_etag else None
145
216
 
146
217
  def _normalize_prefix(self, s3_path):
@@ -149,51 +220,6 @@ class S3:
149
220
  return ''
150
221
  return s3_path.strip('/') + '/'
151
222
 
152
- def _marker_is_fresh(self, local_path, rel_path, sync_status_dir):
153
- # True when a .synced marker exists and is at least as new as the data file, i.e. the
154
- # file has not been modified on disk since our last push. Mirrors the staleness test in
155
- # `data status` (a file is stale when its mtime is strictly newer than its marker's).
156
- if not sync_status_dir:
157
- return False
158
- marker_path = os.path.join(sync_status_dir, rel_path + '.synced')
159
- if not os.path.exists(marker_path):
160
- return False
161
- return os.path.getmtime(marker_path) >= os.path.getmtime(local_path)
162
-
163
- def _marker_etag(self, rel_path, sync_status_dir):
164
- # Return the ETag recorded in the file's .synced marker, or None when there is no
165
- # usable record (no location configured, no marker, or a legacy/empty marker).
166
- if not sync_status_dir:
167
- return None
168
- marker_path = os.path.join(sync_status_dir, rel_path + '.synced')
169
- if not os.path.exists(marker_path):
170
- return None
171
- with open(marker_path) as f:
172
- etag = f.read().strip()
173
- return etag or None
174
-
175
- def _create_sync_marker(self, rel_path, sync_status_dir, etag):
176
- # The marker's content is the object's S3 ETag at sync time (the basis for detecting
177
- # remote changes); its mtime is the sync time (the basis for detecting local changes).
178
- marker_path = os.path.join(sync_status_dir, rel_path + '.synced')
179
- os.makedirs(os.path.dirname(os.path.abspath(marker_path)), exist_ok=True)
180
- with open(marker_path, 'w') as f:
181
- f.write(etag or '')
182
-
183
- def _list_local_files(self, data_dir):
184
- files = {}
185
- if not os.path.isdir(data_dir):
186
- return files
187
- for root, _, filenames in os.walk(data_dir):
188
- for filename in filenames:
189
- full_path = os.path.join(root, filename)
190
- # The key is used to build/compare S3 keys, which always use '/'. Normalize
191
- # the OS separator so keys generated on Windows match remote keys; the value
192
- # stays OS-native for filesystem operations.
193
- rel_path = os.path.relpath(full_path, data_dir).replace(os.sep, '/')
194
- files[rel_path] = full_path
195
- return files
196
-
197
223
  def _delete_keys(self, client, keys):
198
224
  # delete_objects removes up to 1000 keys per request; batch accordingly.
199
225
  failures = 0
@@ -0,0 +1,67 @@
1
+ import os
2
+
3
+
4
+ class SyncMarkers:
5
+ """Bookkeeping for per-file sync markers under a sync status directory.
6
+
7
+ For each data file `foo.csv`, a marker `foo.csv.synced` mirrors its path under the
8
+ sync status directory. The marker's content is the object's S3 ETag at sync time
9
+ (the basis for detecting remote changes); its mtime is the sync time (the basis for
10
+ detecting local changes — a file is stale when its mtime is strictly newer than its
11
+ marker's).
12
+
13
+ A falsy sync_status_dir disables bookkeeping: reads report no record and `enabled`
14
+ is False. Callers must check `enabled` before calling `write`.
15
+ """
16
+
17
+ SUFFIX = '.synced'
18
+
19
+ def __init__(self, sync_status_dir):
20
+ self.sync_status_dir = sync_status_dir
21
+
22
+ @property
23
+ def enabled(self):
24
+ return bool(self.sync_status_dir)
25
+
26
+ def read(self, rel_path):
27
+ """Return the (etag, mtime) recorded for rel_path. The etag is None when there is
28
+ no usable record (no location configured, no marker, or a legacy/empty marker);
29
+ the mtime is None only when the marker itself is missing."""
30
+ marker_path = self._path(rel_path)
31
+ if marker_path is None or not os.path.exists(marker_path):
32
+ return None, None
33
+ with open(marker_path) as f:
34
+ etag = f.read().strip()
35
+ return etag or None, os.path.getmtime(marker_path)
36
+
37
+ def etag(self, rel_path):
38
+ return self.read(rel_path)[0]
39
+
40
+ def is_fresh(self, rel_path, local_path):
41
+ """True when a marker exists and is at least as new as the data file, i.e. the
42
+ file has not been modified on disk since the last sync."""
43
+ _, marker_mtime = self.read(rel_path)
44
+ return marker_mtime is not None and marker_mtime >= os.path.getmtime(local_path)
45
+
46
+ def write(self, rel_path, etag):
47
+ marker_path = self._path(rel_path)
48
+ os.makedirs(os.path.dirname(os.path.abspath(marker_path)), exist_ok=True)
49
+ with open(marker_path, 'w') as f:
50
+ f.write(etag or '')
51
+
52
+ def latest_mtime(self):
53
+ """The mtime of the most recently written marker, or None when there are none."""
54
+ if not self.enabled or not os.path.isdir(self.sync_status_dir):
55
+ return None
56
+ mtimes = [
57
+ os.path.getmtime(os.path.join(root, filename))
58
+ for root, _, filenames in os.walk(self.sync_status_dir)
59
+ for filename in filenames
60
+ if filename.endswith(self.SUFFIX)
61
+ ]
62
+ return max(mtimes, default=None)
63
+
64
+ def _path(self, rel_path):
65
+ if not self.enabled:
66
+ return None
67
+ return os.path.join(self.sync_status_dir, rel_path + self.SUFFIX)
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "datakit-data"
7
- version = "0.6.2"
7
+ version = "0.7.0"
8
8
  description = "A datakit plugin to manage data assets on AWS S3."
9
9
  authors = [
10
10
  {name = "Larry Fenn", email = "lfenn@ap.org"},
@@ -14,7 +14,7 @@ requires-python = ">=3.10"
14
14
  dependencies = [
15
15
  "boto3",
16
16
  "cliff",
17
- "datakit-core>=0.2.1",
17
+ "datakit-core>=0.5.0",
18
18
  ]
19
19
 
20
20
  [project.urls]
@@ -1,154 +0,0 @@
1
- import os
2
- from datetime import datetime, timezone
3
- from cliff.command import Command
4
- from datakit import CommandHelpers
5
- from datakit.utils import read_json, write_json
6
-
7
- from ..project_mixin import ProjectMixin
8
- from ..s3 import S3
9
-
10
- class Status(ProjectMixin, CommandHelpers, Command):
11
-
12
- "Show sync status of local data files"
13
-
14
- def get_parser(self, prog_name):
15
- parser = super(Status, self).get_parser(prog_name)
16
- parser.add_argument(
17
- '--filepaths',
18
- action='store_true',
19
- default=False,
20
- help="List individual file paths instead of just counts"
21
- )
22
- parser.add_argument(
23
- '--all',
24
- action='store_true',
25
- default=False,
26
- help="Query S3 directly for a full comparison of local and remote files"
27
- )
28
- return parser
29
-
30
- def take_action(self, parsed_args):
31
- if not os.path.exists("config/datakit-data.json"):
32
- self.log.info("No config file found - have you run `datakit data init`?")
33
- return
34
- sync_status_dir = self.project_configs.get('sync_status_location')
35
- last_push = self._last_push_time(sync_status_dir) if sync_status_dir else None
36
- if last_push:
37
- self.log.info(f"Last pushed: {last_push.strftime('%Y-%m-%d %H:%M:%S %Z')}")
38
- else:
39
- self.log.info("Last pushed: never")
40
- if getattr(parsed_args, 'all', False):
41
- bucket = self.project_configs['s3_bucket']
42
- if bucket == "":
43
- self.log.info("No bucket specified in config")
44
- return
45
- self._report_s3_comparison(
46
- self.project_configs['aws_user_profile'],
47
- bucket,
48
- self.project_configs['s3_path'],
49
- sync_status_dir,
50
- filepaths=parsed_args.filepaths,
51
- )
52
- return
53
- if not sync_status_dir:
54
- self.log.info("No sync_status_location configured")
55
- answer = input("\nAdd sync_status_location = '.sync_status/' to project config? [Y/n]: ").strip().lower()
56
- if answer in ('', 'y', 'yes'):
57
- configs = read_json(self.project_config_path)
58
- configs['sync_status_location'] = '.sync_status/'
59
- write_json(self.project_config_path, configs)
60
- self.log.info("Added sync_status_location to config/datakit-data.json")
61
- return
62
- missing, stale = self._find_unsynced('data/', sync_status_dir)
63
-
64
- self._log_group("file(s) missing a .synced file", missing, parsed_args.filepaths)
65
- self._log_group("file(s) modified since last sync", stale, parsed_args.filepaths)
66
-
67
- def _report_s3_comparison(self, user_profile, bucket, s3_path, sync_status_dir, filepaths=False):
68
- s3 = S3(user_profile, bucket)
69
- client = s3._client()
70
- prefix = s3._normalize_prefix(s3_path)
71
- local_files = {k: v for k, v in s3._list_local_files('data/').items()
72
- if not k.endswith('.synced')}
73
- s3_objects = s3._list_s3_objects(client, prefix)
74
- local_keys = set(local_files)
75
- s3_keys = set(s3_objects)
76
- only_local = sorted(local_keys - s3_keys)
77
- only_s3 = sorted(s3_keys - local_keys)
78
- changed_local = []
79
- changed_s3 = []
80
- conflict = []
81
- differ = []
82
- for rel_path in local_keys & s3_keys:
83
- local_path = local_files[rel_path]
84
- marker_etag, marker_mtime = self._read_marker(rel_path, sync_status_dir)
85
- if marker_etag is None:
86
- differ.append(rel_path)
87
- continue
88
- s3_changed = s3_objects[rel_path].etag != marker_etag
89
- local_mtime = datetime.fromtimestamp(os.path.getmtime(local_path), tz=timezone.utc)
90
- local_changed = local_mtime > marker_mtime
91
- if local_changed and s3_changed:
92
- conflict.append(rel_path)
93
- elif local_changed:
94
- changed_local.append(rel_path)
95
- elif s3_changed:
96
- changed_s3.append(rel_path)
97
- # else: neither side changed since the last sync -> in sync, nothing to report
98
- self._log_group("file(s) local but not on S3", only_local, filepaths)
99
- self._log_group("file(s) on S3 but not local", only_s3, filepaths)
100
- self._log_group("file(s) changed locally since last sync", sorted(changed_local), filepaths)
101
- self._log_group("file(s) changed on S3 since last sync", sorted(changed_s3), filepaths)
102
- self._log_group("file(s) changed both locally and on S3 (conflict)", sorted(conflict), filepaths)
103
- self._log_group("file(s) differing from S3 (no sync record)", sorted(differ), filepaths)
104
-
105
- def _read_marker(self, rel_path, sync_status_dir):
106
- """Return the (etag, mtime) recorded in the .synced marker, or (None, None) when there
107
- is no usable record (no location configured, no marker, or a legacy/empty marker)."""
108
- if not sync_status_dir:
109
- return None, None
110
- marker_path = os.path.join(sync_status_dir, rel_path + '.synced')
111
- if not os.path.exists(marker_path):
112
- return None, None
113
- with open(marker_path) as f:
114
- etag = f.read().strip()
115
- if not etag:
116
- return None, None
117
- mtime = datetime.fromtimestamp(os.path.getmtime(marker_path), tz=timezone.utc)
118
- return etag, mtime
119
-
120
- def _log_group(self, label, paths, filepaths):
121
- self.log.info(f"{len(paths)} {label}")
122
- if filepaths:
123
- for path in paths:
124
- self.log.info(f" {path}")
125
-
126
- def _last_push_time(self, sync_status_dir):
127
- if not os.path.isdir(sync_status_dir):
128
- return None
129
- latest = None
130
- for root, _, filenames in os.walk(sync_status_dir):
131
- for filename in filenames:
132
- if filename.endswith('.synced'):
133
- mtime = os.path.getmtime(os.path.join(root, filename))
134
- if latest is None or mtime > latest:
135
- latest = mtime
136
- return datetime.fromtimestamp(latest).astimezone() if latest is not None else None
137
-
138
- def _find_unsynced(self, data_dir, sync_status_dir):
139
- missing = []
140
- stale = []
141
- if not os.path.isdir(data_dir):
142
- return missing, stale
143
- for root, _, filenames in os.walk(data_dir):
144
- for filename in filenames:
145
- full_path = os.path.join(root, filename)
146
- rel_path = os.path.relpath(full_path, data_dir)
147
- if rel_path.endswith('.synced'):
148
- continue
149
- marker_path = os.path.join(sync_status_dir, rel_path + '.synced')
150
- if not os.path.exists(marker_path):
151
- missing.append(rel_path)
152
- elif os.path.getmtime(full_path) > os.path.getmtime(marker_path):
153
- stale.append(rel_path)
154
- return sorted(missing), sorted(stale)