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.
- {datakit_data-0.6.1 → datakit_data-0.6.3}/PKG-INFO +1 -1
- {datakit_data-0.6.1 → datakit_data-0.6.3}/datakit_data/__init__.py +1 -1
- {datakit_data-0.6.1 → datakit_data-0.6.3}/datakit_data/commands/pull.py +1 -1
- {datakit_data-0.6.1 → datakit_data-0.6.3}/datakit_data/commands/push.py +1 -1
- datakit_data-0.6.3/datakit_data/commands/status.py +90 -0
- {datakit_data-0.6.1 → datakit_data-0.6.3}/datakit_data/s3.py +96 -124
- datakit_data-0.6.3/datakit_data/sync_markers.py +67 -0
- {datakit_data-0.6.1 → datakit_data-0.6.3}/pyproject.toml +1 -1
- datakit_data-0.6.1/datakit_data/commands/status.py +0 -154
- {datakit_data-0.6.1 → datakit_data-0.6.3}/datakit_data/commands/__init__.py +0 -0
- {datakit_data-0.6.1 → datakit_data-0.6.3}/datakit_data/commands/init.py +0 -0
- {datakit_data-0.6.1 → datakit_data-0.6.3}/datakit_data/extra_flags.py +0 -0
- {datakit_data-0.6.1 → datakit_data-0.6.3}/datakit_data/project_mixin.py +0 -0
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 =
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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)
|
|
@@ -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
|