datakit-data 0.6.1__tar.gz → 0.6.3__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,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: datakit-data
3
- Version: 0.6.1
3
+ Version: 0.6.3
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>
@@ -1,6 +1,6 @@
1
1
  __author__ = """Larry Fenn"""
2
2
  __email__ = 'lfenn@ap.org'
3
- __version__ = '0.6.1'
3
+ __version__ = '0.6.3'
4
4
 
5
5
  from .commands.init import Init
6
6
  from .commands.push import Push
@@ -36,7 +36,7 @@ class Pull(ProjectMixin, CommandHelpers, Command):
36
36
  if bucket == "":
37
37
  self.log.info("No bucket specified in config - no data pulled")
38
38
  return
39
- s3 = S3(user_profile, bucket, max_workers=self.project_configs.get('max_workers'))
39
+ s3 = S3(user_profile, bucket)
40
40
  clean_flags = ExtraFlags.convert(parsed_args.args)
41
41
  if getattr(parsed_args, 'force', False) is True and '--force' not in clean_flags:
42
42
  clean_flags.append('--force')
@@ -44,7 +44,7 @@ class Push(ProjectMixin, CommandHelpers, Command):
44
44
  if bucket == "":
45
45
  self.log.info("No bucket specified in config - no data pushed")
46
46
  return
47
- s3 = S3(user_profile, bucket, max_workers=self.project_configs.get('max_workers'))
47
+ s3 = S3(user_profile, bucket)
48
48
  clean_flags = ExtraFlags.convert(parsed_args.args)
49
49
  if getattr(parsed_args, 'force', False) is True and '--force' not in clean_flags:
50
50
  clean_flags.append('--force')
@@ -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,14 +1,14 @@
1
1
  import os
2
2
  import logging
3
+ import mimetypes
3
4
  from collections import namedtuple
4
- from concurrent.futures import ThreadPoolExecutor, as_completed
5
5
  from logging import NullHandler
6
6
 
7
7
  import boto3
8
- from boto3.s3.transfer import TransferConfig
9
- from botocore.config import Config
10
8
  from botocore.exceptions import BotoCoreError, ClientError
11
9
 
10
+ from .sync_markers import SyncMarkers
11
+
12
12
 
13
13
  logger = logging.getLogger(__name__)
14
14
  logger.addHandler(NullHandler())
@@ -18,12 +18,38 @@ logger.addHandler(NullHandler())
18
18
  # the object changed on S3 since our last sync.
19
19
  S3ObjectInfo = namedtuple('S3ObjectInfo', ['etag'])
20
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
+
21
28
  EMPTY_PATH_DELETE_MSG = (
22
29
  "\n*** Refusing --delete with an empty s3_path: this would scan and delete "
23
30
  "across the entire bucket. Set s3_path in the project config. ***\n"
24
31
  )
25
32
 
26
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
+
27
53
  class S3:
28
54
  """A limited, human-friendly interface to S3."""
29
55
 
@@ -35,26 +61,9 @@ class S3:
35
61
  # record is the one S3 itself reports, so it stays comparable to a later head/list probe.
36
62
  MULTIPART_THRESHOLD = 8 * 1024 * 1024
37
63
 
38
- # Per-file transfers (push uploads, pull downloads) are network-latency-bound, so we run them
39
- # across a thread pool. boto3 clients are thread-safe for these calls and release the GIL during
40
- # network I/O, so threads give near-linear speedup for the many-small-files case without the
41
- # complexity of asyncio. Callers can override via the max_workers arg (e.g. a config key).
42
- MAX_WORKERS = 16
43
-
44
- # boto3's managed transfers (upload_file/download_file) each spin up their own internal thread
45
- # pool (TransferConfig.max_concurrency, default 10), every thread of which borrows a connection
46
- # from the shared pool. Because we already parallelize across files in our own thread pool, that
47
- # inner concurrency is redundant and multiplies connection demand to max_workers * 10 — far past
48
- # the max_pool_connections=max_workers pool, triggering urllib3 "connection pool is full"
49
- # warnings and pathological TLS-handshake churn. Disabling inner threads makes each managed
50
- # transfer use a single connection, so total demand equals the worker count and the pool matches.
51
- # Large files still upload/download via multipart (parts just go sequentially) for resilience.
52
- _TRANSFER_CONFIG = TransferConfig(use_threads=False)
53
-
54
- def __init__(self, aws_user_profile, s3_bucket, max_workers=None):
64
+ def __init__(self, aws_user_profile, s3_bucket):
55
65
  self.user_profile = aws_user_profile
56
66
  self.bucket = s3_bucket
57
- self.max_workers = max_workers or self.MAX_WORKERS
58
67
 
59
68
  def push(self, data_dir, s3_path='', extra_flags=None, sync_status_dir=None):
60
69
  extra_flags = extra_flags or []
@@ -65,24 +74,24 @@ class S3:
65
74
  if delete and not prefix:
66
75
  logger.info(EMPTY_PATH_DELETE_MSG)
67
76
  return 1
77
+ markers = SyncMarkers(sync_status_dir)
68
78
  client = self._client()
69
79
  failures = 0
70
- local_files = {k: v for k, v in self._list_local_files(data_dir).items() if not k.endswith('.synced')}
71
- # Decide what to transfer (cheap, local-only checks) and log intent up front so the log
72
- # stays deterministic; the transfers themselves run concurrently below.
73
- to_upload = []
80
+ local_files = list_local_files(data_dir)
74
81
  for rel_path, local_path in sorted(local_files.items()):
75
82
  key = prefix + rel_path
76
- 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):
77
84
  logger.info(f"skipped: {local_path}")
78
85
  continue
79
86
  logger.info(f"upload: {local_path} to s3://{self.bucket}/{key}")
80
- to_upload.append((rel_path, local_path, key))
81
- if not dryrun:
82
- failures += self._run_transfers(
83
- to_upload,
84
- lambda item: self._push_one(client, item, sync_status_dir),
85
- )
87
+ if not dryrun:
88
+ try:
89
+ etag = self._upload(client, local_path, key, markers.enabled)
90
+ if markers.enabled:
91
+ markers.write(rel_path, etag)
92
+ except (ClientError, BotoCoreError) as e:
93
+ failures += 1
94
+ logger.info(f"\n*** Error ***\n{e}\n")
86
95
  if delete:
87
96
  remote_keys = self._list_s3_keys(client, prefix)
88
97
  remote_rel = {k[len(prefix):] for k in remote_keys}
@@ -102,30 +111,31 @@ class S3:
102
111
  if delete and not prefix:
103
112
  logger.info(EMPTY_PATH_DELETE_MSG)
104
113
  return 1
114
+ markers = SyncMarkers(sync_status_dir)
105
115
  client = self._client()
106
116
  failures = 0
107
117
  remote_objects = self._list_s3_objects(client, prefix)
108
- # Decide what to transfer (cheap, local-only checks) and log intent up front so the log
109
- # stays deterministic; the transfers themselves run concurrently below.
110
- to_download = []
111
118
  for rel_path in sorted(remote_objects):
112
119
  key = prefix + rel_path
113
120
  remote_etag = remote_objects[rel_path].etag
114
121
  local_path = os.path.join(data_dir, rel_path)
115
122
  if not force:
116
- marker_etag = self._marker_etag(rel_path, sync_status_dir)
123
+ marker_etag = markers.etag(rel_path)
117
124
  if marker_etag is not None and marker_etag == remote_etag and os.path.exists(local_path):
118
125
  logger.info(f"skipped: s3://{self.bucket}/{key}")
119
126
  continue
120
127
  logger.info(f"download: s3://{self.bucket}/{key} to {local_path}")
121
- to_download.append((rel_path, key, remote_etag, local_path))
122
- if not dryrun:
123
- failures += self._run_transfers(
124
- to_download,
125
- lambda item: self._pull_one(client, item, sync_status_dir),
126
- )
128
+ if not dryrun:
129
+ os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
130
+ try:
131
+ client.download_file(self.bucket, key, local_path)
132
+ if markers.enabled:
133
+ markers.write(rel_path, remote_etag)
134
+ except (ClientError, BotoCoreError) as e:
135
+ failures += 1
136
+ logger.info(f"\n*** Error ***\n{e}\n")
127
137
  if delete:
128
- 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)
129
139
  remote_rel = set(remote_objects)
130
140
  for rel_path, local_path in sorted(local_files.items()):
131
141
  if rel_path not in remote_rel:
@@ -138,12 +148,46 @@ class S3:
138
148
  logger.info(f"\n*** Error ***\n{e}\n")
139
149
  return failures
140
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
+
141
188
  def _client(self):
142
189
  session = boto3.Session(profile_name=self.user_profile)
143
- # Size the connection pool to the worker count so concurrent transfers don't queue on a
144
- # too-small pool (botocore's default is 10) or emit "connection pool is full" warnings.
145
- config = Config(max_pool_connections=self.max_workers)
146
- return session.client('s3', config=config)
190
+ return session.client('s3')
147
191
 
148
192
  @staticmethod
149
193
  def _normalize_etag(etag):
@@ -160,94 +204,22 @@ class S3:
160
204
  # files go via put_object, whose response carries the ETag, so no extra head_object is
161
205
  # needed; larger files keep upload_file's managed multipart transfer and we read the ETag
162
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'
163
210
  if os.path.getsize(local_path) < self.MULTIPART_THRESHOLD:
164
211
  with open(local_path, 'rb') as body:
165
- 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)
166
213
  return self._normalize_etag(response.get('ETag'))
167
- client.upload_file(local_path, self.bucket, key, Config=self._TRANSFER_CONFIG)
214
+ client.upload_file(local_path, self.bucket, key, ExtraArgs={'ContentType': content_type})
168
215
  return self._object_etag(client, key) if need_etag else None
169
216
 
170
- def _run_transfers(self, items, transfer):
171
- # Run transfer(item) for each item across a thread pool, returning the failure count.
172
- # Per-item ClientError/BotoCoreError are logged and counted (matching the sequential
173
- # behavior); all logging happens here on the main thread, so the workers do pure I/O.
174
- if not items:
175
- return 0
176
- failures = 0
177
- with ThreadPoolExecutor(max_workers=min(self.max_workers, len(items))) as pool:
178
- futures = [pool.submit(transfer, item) for item in items]
179
- for future in as_completed(futures):
180
- try:
181
- future.result()
182
- except (ClientError, BotoCoreError) as e:
183
- failures += 1
184
- logger.info(f"\n*** Error ***\n{e}\n")
185
- return failures
186
-
187
- def _push_one(self, client, item, sync_status_dir):
188
- rel_path, local_path, key = item
189
- etag = self._upload(client, local_path, key, sync_status_dir is not None)
190
- if sync_status_dir is not None:
191
- self._create_sync_marker(rel_path, sync_status_dir, etag)
192
-
193
- def _pull_one(self, client, item, sync_status_dir):
194
- rel_path, key, remote_etag, local_path = item
195
- os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
196
- client.download_file(self.bucket, key, local_path, Config=self._TRANSFER_CONFIG)
197
- if sync_status_dir is not None:
198
- self._create_sync_marker(rel_path, sync_status_dir, remote_etag)
199
-
200
217
  def _normalize_prefix(self, s3_path):
201
218
  # Returns '' for falsy input so callers can concatenate key segments without a leading slash.
202
219
  if not s3_path:
203
220
  return ''
204
221
  return s3_path.strip('/') + '/'
205
222
 
206
- def _marker_is_fresh(self, local_path, rel_path, sync_status_dir):
207
- # True when a .synced marker exists and is at least as new as the data file, i.e. the
208
- # file has not been modified on disk since our last push. Mirrors the staleness test in
209
- # `data status` (a file is stale when its mtime is strictly newer than its marker's).
210
- if not sync_status_dir:
211
- return False
212
- marker_path = os.path.join(sync_status_dir, rel_path + '.synced')
213
- if not os.path.exists(marker_path):
214
- return False
215
- return os.path.getmtime(marker_path) >= os.path.getmtime(local_path)
216
-
217
- def _marker_etag(self, rel_path, sync_status_dir):
218
- # Return the ETag recorded in the file's .synced marker, or None when there is no
219
- # usable record (no location configured, no marker, or a legacy/empty marker).
220
- if not sync_status_dir:
221
- return None
222
- marker_path = os.path.join(sync_status_dir, rel_path + '.synced')
223
- if not os.path.exists(marker_path):
224
- return None
225
- with open(marker_path) as f:
226
- etag = f.read().strip()
227
- return etag or None
228
-
229
- def _create_sync_marker(self, rel_path, sync_status_dir, etag):
230
- # The marker's content is the object's S3 ETag at sync time (the basis for detecting
231
- # remote changes); its mtime is the sync time (the basis for detecting local changes).
232
- marker_path = os.path.join(sync_status_dir, rel_path + '.synced')
233
- os.makedirs(os.path.dirname(os.path.abspath(marker_path)), exist_ok=True)
234
- with open(marker_path, 'w') as f:
235
- f.write(etag or '')
236
-
237
- def _list_local_files(self, data_dir):
238
- files = {}
239
- if not os.path.isdir(data_dir):
240
- return files
241
- for root, _, filenames in os.walk(data_dir):
242
- for filename in filenames:
243
- full_path = os.path.join(root, filename)
244
- # The key is used to build/compare S3 keys, which always use '/'. Normalize
245
- # the OS separator so keys generated on Windows match remote keys; the value
246
- # stays OS-native for filesystem operations.
247
- rel_path = os.path.relpath(full_path, data_dir).replace(os.sep, '/')
248
- files[rel_path] = full_path
249
- return files
250
-
251
223
  def _delete_keys(self, client, keys):
252
224
  # delete_objects removes up to 1000 keys per request; batch accordingly.
253
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.1"
7
+ version = "0.6.3"
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"},
@@ -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)