datakit-data 0.6.2__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.
- {datakit_data-0.6.2 → datakit_data-0.6.3}/PKG-INFO +1 -1
- {datakit_data-0.6.2 → datakit_data-0.6.3}/datakit_data/__init__.py +1 -1
- datakit_data-0.6.3/datakit_data/commands/status.py +90 -0
- {datakit_data-0.6.2 → datakit_data-0.6.3}/datakit_data/s3.py +82 -56
- datakit_data-0.6.3/datakit_data/sync_markers.py +67 -0
- {datakit_data-0.6.2 → datakit_data-0.6.3}/pyproject.toml +1 -1
- datakit_data-0.6.2/datakit_data/commands/status.py +0 -154
- {datakit_data-0.6.2 → datakit_data-0.6.3}/datakit_data/commands/__init__.py +0 -0
- {datakit_data-0.6.2 → datakit_data-0.6.3}/datakit_data/commands/init.py +0 -0
- {datakit_data-0.6.2 → datakit_data-0.6.3}/datakit_data/commands/pull.py +0 -0
- {datakit_data-0.6.2 → datakit_data-0.6.3}/datakit_data/commands/push.py +0 -0
- {datakit_data-0.6.2 → datakit_data-0.6.3}/datakit_data/extra_flags.py +0 -0
- {datakit_data-0.6.2 → datakit_data-0.6.3}/datakit_data/project_mixin.py +0 -0
|
@@ -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,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 =
|
|
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
|
|
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,
|
|
60
|
-
if
|
|
61
|
-
|
|
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 =
|
|
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
|
|
102
|
-
|
|
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 =
|
|
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)
|
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|