datakit-data 0.5.2__tar.gz → 0.6.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,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: datakit-data
3
- Version: 0.5.2
3
+ Version: 0.6.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>
@@ -1,6 +1,6 @@
1
1
  __author__ = """Larry Fenn"""
2
2
  __email__ = 'lfenn@ap.org'
3
- __version__ = '0.5.2'
3
+ __version__ = '0.6.0'
4
4
 
5
5
  from .commands.init import Init
6
6
  from .commands.push import Push
@@ -28,12 +28,14 @@ class Init(ProjectMixin, CommandHelpers, Command):
28
28
  mkdir_p(directory)
29
29
  open('data/.gitkeep', 'w').close()
30
30
  self.log.info("Created data/ directory")
31
- self.create_project_config()
32
- self.log.info("Created config/datakit-data.json")
31
+ if self.create_project_config():
32
+ self.log.info("Created config/datakit-data.json")
33
+ else:
34
+ self.log.info("config/datakit-data.json already exists - leaving it unchanged")
33
35
 
34
36
  def create_project_config(self):
35
37
  if os.path.exists(self.project_config_path):
36
- return
38
+ return False
37
39
  if os.path.exists(self.plugin_config_path):
38
40
  plugin_configs = read_json(self.plugin_config_path)
39
41
  if not plugin_configs.get('s3_bucket'):
@@ -56,6 +58,7 @@ class Init(ProjectMixin, CommandHelpers, Command):
56
58
  self._expand_vars(to_write)
57
59
  self.finalize_configs(to_write)
58
60
  write_json(self.project_config_path, to_write)
61
+ return True
59
62
 
60
63
  def _prompt_for_plugin_configs(self):
61
64
  print("Please provide the following configuration values (press Enter to use the default):\n")
@@ -17,7 +17,13 @@ class Pull(ProjectMixin, CommandHelpers, Command):
17
17
  parser.add_argument(
18
18
  'args',
19
19
  nargs=argparse.REMAINDER,
20
- help="One or more boolean S3 sync flags without leading dashes, e.g. delete or dryrun"
20
+ help="One or more boolean flags without leading dashes: delete, dryrun, force"
21
+ )
22
+ parser.add_argument(
23
+ '--force',
24
+ action='store_true',
25
+ default=False,
26
+ help="Pull every S3 object, ignoring sync status checks"
21
27
  )
22
28
  return parser
23
29
 
@@ -30,12 +36,19 @@ class Pull(ProjectMixin, CommandHelpers, Command):
30
36
  if bucket == "":
31
37
  self.log.info("No bucket specified in config - no data pulled")
32
38
  return
33
- s3 = S3(user_profile, bucket)
39
+ s3 = S3(user_profile, bucket, max_workers=self.project_configs.get('max_workers'))
34
40
  clean_flags = ExtraFlags.convert(parsed_args.args)
41
+ if getattr(parsed_args, 'force', False) is True and '--force' not in clean_flags:
42
+ clean_flags.append('--force')
43
+ unsupported = ExtraFlags.unsupported(parsed_args.args)
44
+ if unsupported:
45
+ self.log.info(f"Ignoring unsupported flag(s): {', '.join(unsupported)}")
46
+ sync_status_dir = self.project_configs.get('sync_status_location')
35
47
  failures = s3.pull(
36
48
  'data/',
37
49
  self.project_configs['s3_path'],
38
- extra_flags=clean_flags
50
+ extra_flags=clean_flags,
51
+ sync_status_dir=sync_status_dir
39
52
  )
40
53
  if failures:
41
54
  self.log.info(f"{failures} file(s) failed to transfer")
@@ -19,7 +19,13 @@ class Push(ProjectMixin, CommandHelpers, Command):
19
19
  parser.add_argument(
20
20
  'args',
21
21
  nargs=argparse.REMAINDER,
22
- help="One or more boolean S3 sync flags without leading dashes, e.g. delete or dryrun"
22
+ help="One or more boolean flags without leading dashes: delete, dryrun, force"
23
+ )
24
+ parser.add_argument(
25
+ '--force',
26
+ action='store_true',
27
+ default=False,
28
+ help="Push every file, ignoring sync status checks"
23
29
  )
24
30
  parser.add_argument(
25
31
  '--sync-status-in-data',
@@ -38,13 +44,20 @@ class Push(ProjectMixin, CommandHelpers, Command):
38
44
  if bucket == "":
39
45
  self.log.info("No bucket specified in config - no data pushed")
40
46
  return
41
- s3 = S3(user_profile, bucket)
47
+ s3 = S3(user_profile, bucket, max_workers=self.project_configs.get('max_workers'))
42
48
  clean_flags = ExtraFlags.convert(parsed_args.args)
49
+ if getattr(parsed_args, 'force', False) is True and '--force' not in clean_flags:
50
+ clean_flags.append('--force')
51
+ unsupported = ExtraFlags.unsupported(parsed_args.args)
52
+ if unsupported:
53
+ self.log.info(f"Ignoring unsupported flag(s): {', '.join(unsupported)}")
54
+ dryrun = '--dryrun' in clean_flags or '--dry-run' in clean_flags
43
55
  if parsed_args.sync_status_in_data:
44
56
  sync_status_dir = 'data/'
45
- configs = self.project_configs.copy()
46
- configs['sync_status_location'] = 'data/'
47
- write_json(self.project_config_path, configs)
57
+ if not dryrun:
58
+ configs = self.project_configs.copy()
59
+ configs['sync_status_location'] = 'data/'
60
+ write_json(self.project_config_path, configs)
48
61
  else:
49
62
  sync_status_dir = self.project_configs.get('sync_status_location')
50
63
  failures = s3.push(
@@ -7,7 +7,6 @@ from datakit.utils import read_json, write_json
7
7
  from ..project_mixin import ProjectMixin
8
8
  from ..s3 import S3
9
9
 
10
-
11
10
  class Status(ProjectMixin, CommandHelpers, Command):
12
11
 
13
12
  "Show sync status of local data files"
@@ -47,6 +46,7 @@ class Status(ProjectMixin, CommandHelpers, Command):
47
46
  self.project_configs['aws_user_profile'],
48
47
  bucket,
49
48
  self.project_configs['s3_path'],
49
+ sync_status_dir,
50
50
  filepaths=parsed_args.filepaths,
51
51
  )
52
52
  return
@@ -60,10 +60,11 @@ class Status(ProjectMixin, CommandHelpers, Command):
60
60
  self.log.info("Added sync_status_location to config/datakit-data.json")
61
61
  return
62
62
  missing, stale = self._find_unsynced('data/', sync_status_dir)
63
- self._log_group("file(s) not yet pushed to S3", missing, parsed_args.filepaths)
64
- self._log_group("file(s) modified since last push", stale, parsed_args.filepaths)
65
63
 
66
- def _report_s3_comparison(self, user_profile, bucket, s3_path, filepaths=False):
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):
67
68
  s3 = S3(user_profile, bucket)
68
69
  client = s3._client()
69
70
  prefix = s3._normalize_prefix(s3_path)
@@ -74,18 +75,47 @@ class Status(ProjectMixin, CommandHelpers, Command):
74
75
  s3_keys = set(s3_objects)
75
76
  only_local = sorted(local_keys - s3_keys)
76
77
  only_s3 = sorted(s3_keys - local_keys)
77
- newer_local = []
78
- newer_s3 = []
78
+ changed_local = []
79
+ changed_s3 = []
80
+ conflict = []
81
+ differ = []
79
82
  for rel_path in local_keys & s3_keys:
80
- local_dt = datetime.fromtimestamp(os.path.getmtime(local_files[rel_path]), tz=timezone.utc)
81
- if local_dt > s3_objects[rel_path]:
82
- newer_local.append(rel_path)
83
- elif s3_objects[rel_path] > local_dt:
84
- newer_s3.append(rel_path)
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
85
98
  self._log_group("file(s) local but not on S3", only_local, filepaths)
86
99
  self._log_group("file(s) on S3 but not local", only_s3, filepaths)
87
- self._log_group("file(s) newer locally than on S3", sorted(newer_local), filepaths)
88
- self._log_group("file(s) newer on S3 than locally", sorted(newer_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
89
119
 
90
120
  def _log_group(self, label, paths, filepaths):
91
121
  self.log.info(f"{len(paths)} {label}")
@@ -0,0 +1,16 @@
1
+ class ExtraFlags:
2
+
3
+ # Flags the plugin actually acts on (boto3 push/pull only honor these).
4
+ SUPPORTED = ('dryrun', 'dry-run', 'delete', 'force')
5
+
6
+ @staticmethod
7
+ def _normalize(flag):
8
+ return flag[2:] if flag.startswith('--') else flag
9
+
10
+ @classmethod
11
+ def convert(kls, raw_flags):
12
+ return [f"--{kls._normalize(flag)}" for flag in raw_flags]
13
+
14
+ @classmethod
15
+ def unsupported(kls, raw_flags):
16
+ return [flag for flag in raw_flags if kls._normalize(flag) not in kls.SUPPORTED]
@@ -0,0 +1,274 @@
1
+ import os
2
+ import logging
3
+ from collections import namedtuple
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
+ from logging import NullHandler
6
+
7
+ import boto3
8
+ from botocore.config import Config
9
+ from botocore.exceptions import BotoCoreError, ClientError
10
+
11
+
12
+ logger = logging.getLogger(__name__)
13
+ logger.addHandler(NullHandler())
14
+
15
+ # Metadata captured per remote object from a list_objects_v2 listing. The ETag identifies the
16
+ # object's content; it is what we compare against the recorded .synced marker to detect whether
17
+ # the object changed on S3 since our last sync.
18
+ S3ObjectInfo = namedtuple('S3ObjectInfo', ['etag'])
19
+
20
+ EMPTY_PATH_DELETE_MSG = (
21
+ "\n*** Refusing --delete with an empty s3_path: this would scan and delete "
22
+ "across the entire bucket. Set s3_path in the project config. ***\n"
23
+ )
24
+
25
+
26
+ class S3:
27
+ """A limited, human-friendly interface to S3."""
28
+
29
+ # boto3's managed upload_file transparently switches from a single PutObject to a multipart
30
+ # upload above this many bytes (its TransferConfig.multipart_threshold default). We mirror it:
31
+ # files below the threshold take the put_object fast path, whose response carries the ETag, so
32
+ # we avoid a follow-up head_object; larger files keep upload_file's multipart transfer (and its
33
+ # part-level resilience) and we read the ETag back with head_object. Either way the ETag we
34
+ # record is the one S3 itself reports, so it stays comparable to a later head/list probe.
35
+ MULTIPART_THRESHOLD = 8 * 1024 * 1024
36
+
37
+ # Per-file transfers (push uploads, pull downloads) are network-latency-bound, so we run them
38
+ # across a thread pool. boto3 clients are thread-safe for these calls and release the GIL during
39
+ # network I/O, so threads give near-linear speedup for the many-small-files case without the
40
+ # complexity of asyncio. Callers can override via the max_workers arg (e.g. a config key).
41
+ MAX_WORKERS = 16
42
+
43
+ def __init__(self, aws_user_profile, s3_bucket, max_workers=None):
44
+ self.user_profile = aws_user_profile
45
+ self.bucket = s3_bucket
46
+ self.max_workers = max_workers or self.MAX_WORKERS
47
+
48
+ def push(self, data_dir, s3_path='', extra_flags=None, sync_status_dir=None):
49
+ extra_flags = extra_flags or []
50
+ dryrun = '--dryrun' in extra_flags or '--dry-run' in extra_flags
51
+ delete = '--delete' in extra_flags
52
+ force = '--force' in extra_flags
53
+ prefix = self._normalize_prefix(s3_path)
54
+ if delete and not prefix:
55
+ logger.info(EMPTY_PATH_DELETE_MSG)
56
+ return 1
57
+ client = self._client()
58
+ failures = 0
59
+ local_files = {k: v for k, v in self._list_local_files(data_dir).items() if not k.endswith('.synced')}
60
+ # Decide what to transfer (cheap, local-only checks) and log intent up front so the log
61
+ # stays deterministic; the transfers themselves run concurrently below.
62
+ to_upload = []
63
+ for rel_path, local_path in sorted(local_files.items()):
64
+ key = prefix + rel_path
65
+ if not force and self._marker_is_fresh(local_path, rel_path, sync_status_dir):
66
+ logger.info(f"skipped: {local_path}")
67
+ continue
68
+ logger.info(f"upload: {local_path} to s3://{self.bucket}/{key}")
69
+ to_upload.append((rel_path, local_path, key))
70
+ if not dryrun:
71
+ failures += self._run_transfers(
72
+ to_upload,
73
+ lambda item: self._push_one(client, item, sync_status_dir),
74
+ )
75
+ if delete:
76
+ remote_keys = self._list_s3_keys(client, prefix)
77
+ remote_rel = {k[len(prefix):] for k in remote_keys}
78
+ to_delete = [prefix + rel_path for rel_path in sorted(remote_rel - set(local_files.keys()))]
79
+ for key in to_delete:
80
+ logger.info(f"delete: s3://{self.bucket}/{key}")
81
+ if not dryrun:
82
+ failures += self._delete_keys(client, to_delete)
83
+ return failures
84
+
85
+ def pull(self, data_dir, s3_path='', extra_flags=None, sync_status_dir=None):
86
+ extra_flags = extra_flags or []
87
+ dryrun = '--dryrun' in extra_flags or '--dry-run' in extra_flags
88
+ delete = '--delete' in extra_flags
89
+ force = '--force' in extra_flags
90
+ prefix = self._normalize_prefix(s3_path)
91
+ if delete and not prefix:
92
+ logger.info(EMPTY_PATH_DELETE_MSG)
93
+ return 1
94
+ client = self._client()
95
+ failures = 0
96
+ remote_objects = self._list_s3_objects(client, prefix)
97
+ # Decide what to transfer (cheap, local-only checks) and log intent up front so the log
98
+ # stays deterministic; the transfers themselves run concurrently below.
99
+ to_download = []
100
+ for rel_path in sorted(remote_objects):
101
+ key = prefix + rel_path
102
+ remote_etag = remote_objects[rel_path].etag
103
+ local_path = os.path.join(data_dir, rel_path)
104
+ if not force:
105
+ marker_etag = self._marker_etag(rel_path, sync_status_dir)
106
+ if marker_etag is not None and marker_etag == remote_etag and os.path.exists(local_path):
107
+ logger.info(f"skipped: s3://{self.bucket}/{key}")
108
+ continue
109
+ logger.info(f"download: s3://{self.bucket}/{key} to {local_path}")
110
+ to_download.append((rel_path, key, remote_etag, local_path))
111
+ if not dryrun:
112
+ failures += self._run_transfers(
113
+ to_download,
114
+ lambda item: self._pull_one(client, item, sync_status_dir),
115
+ )
116
+ if delete:
117
+ local_files = {k: v for k, v in self._list_local_files(data_dir).items() if not k.endswith('.synced')}
118
+ remote_rel = set(remote_objects)
119
+ for rel_path, local_path in sorted(local_files.items()):
120
+ if rel_path not in remote_rel:
121
+ logger.info(f"delete: {local_path}")
122
+ if not dryrun:
123
+ try:
124
+ os.remove(local_path)
125
+ except OSError as e:
126
+ failures += 1
127
+ logger.info(f"\n*** Error ***\n{e}\n")
128
+ return failures
129
+
130
+ def _client(self):
131
+ session = boto3.Session(profile_name=self.user_profile)
132
+ # Size the connection pool to the worker count so concurrent transfers don't queue on a
133
+ # too-small pool (botocore's default is 10) or emit "connection pool is full" warnings.
134
+ config = Config(max_pool_connections=self.max_workers)
135
+ return session.client('s3', config=config)
136
+
137
+ @staticmethod
138
+ def _normalize_etag(etag):
139
+ # boto3 surfaces the ETag wrapped in literal double quotes; strip them so the value we
140
+ # store and compare is the bare hash.
141
+ return etag.strip('"') if etag else etag
142
+
143
+ def _object_etag(self, client, key):
144
+ response = client.head_object(Bucket=self.bucket, Key=key)
145
+ return self._normalize_etag(response.get('ETag'))
146
+
147
+ def _upload(self, client, local_path, key, need_etag):
148
+ # Upload a local file to `key` and return the object's S3 ETag (quotes stripped). Small
149
+ # files go via put_object, whose response carries the ETag, so no extra head_object is
150
+ # needed; larger files keep upload_file's managed multipart transfer and we read the ETag
151
+ # back with head_object only when a sync marker needs it (need_etag), else return None.
152
+ if os.path.getsize(local_path) < self.MULTIPART_THRESHOLD:
153
+ with open(local_path, 'rb') as body:
154
+ response = client.put_object(Bucket=self.bucket, Key=key, Body=body)
155
+ return self._normalize_etag(response.get('ETag'))
156
+ client.upload_file(local_path, self.bucket, key)
157
+ return self._object_etag(client, key) if need_etag else None
158
+
159
+ def _run_transfers(self, items, transfer):
160
+ # Run transfer(item) for each item across a thread pool, returning the failure count.
161
+ # Per-item ClientError/BotoCoreError are logged and counted (matching the sequential
162
+ # behavior); all logging happens here on the main thread, so the workers do pure I/O.
163
+ if not items:
164
+ return 0
165
+ failures = 0
166
+ with ThreadPoolExecutor(max_workers=min(self.max_workers, len(items))) as pool:
167
+ futures = [pool.submit(transfer, item) for item in items]
168
+ for future in as_completed(futures):
169
+ try:
170
+ future.result()
171
+ except (ClientError, BotoCoreError) as e:
172
+ failures += 1
173
+ logger.info(f"\n*** Error ***\n{e}\n")
174
+ return failures
175
+
176
+ def _push_one(self, client, item, sync_status_dir):
177
+ rel_path, local_path, key = item
178
+ etag = self._upload(client, local_path, key, sync_status_dir is not None)
179
+ if sync_status_dir is not None:
180
+ self._create_sync_marker(rel_path, sync_status_dir, etag)
181
+
182
+ def _pull_one(self, client, item, sync_status_dir):
183
+ rel_path, key, remote_etag, local_path = item
184
+ os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
185
+ client.download_file(self.bucket, key, local_path)
186
+ if sync_status_dir is not None:
187
+ self._create_sync_marker(rel_path, sync_status_dir, remote_etag)
188
+
189
+ def _normalize_prefix(self, s3_path):
190
+ # Returns '' for falsy input so callers can concatenate key segments without a leading slash.
191
+ if not s3_path:
192
+ return ''
193
+ return s3_path.strip('/') + '/'
194
+
195
+ def _marker_is_fresh(self, local_path, rel_path, sync_status_dir):
196
+ # True when a .synced marker exists and is at least as new as the data file, i.e. the
197
+ # file has not been modified on disk since our last push. Mirrors the staleness test in
198
+ # `data status` (a file is stale when its mtime is strictly newer than its marker's).
199
+ if not sync_status_dir:
200
+ return False
201
+ marker_path = os.path.join(sync_status_dir, rel_path + '.synced')
202
+ if not os.path.exists(marker_path):
203
+ return False
204
+ return os.path.getmtime(marker_path) >= os.path.getmtime(local_path)
205
+
206
+ def _marker_etag(self, rel_path, sync_status_dir):
207
+ # Return the ETag recorded in the file's .synced marker, or None when there is no
208
+ # usable record (no location configured, no marker, or a legacy/empty marker).
209
+ if not sync_status_dir:
210
+ return None
211
+ marker_path = os.path.join(sync_status_dir, rel_path + '.synced')
212
+ if not os.path.exists(marker_path):
213
+ return None
214
+ with open(marker_path) as f:
215
+ etag = f.read().strip()
216
+ return etag or None
217
+
218
+ def _create_sync_marker(self, rel_path, sync_status_dir, etag):
219
+ # The marker's content is the object's S3 ETag at sync time (the basis for detecting
220
+ # remote changes); its mtime is the sync time (the basis for detecting local changes).
221
+ marker_path = os.path.join(sync_status_dir, rel_path + '.synced')
222
+ os.makedirs(os.path.dirname(os.path.abspath(marker_path)), exist_ok=True)
223
+ with open(marker_path, 'w') as f:
224
+ f.write(etag or '')
225
+
226
+ def _list_local_files(self, data_dir):
227
+ files = {}
228
+ if not os.path.isdir(data_dir):
229
+ return files
230
+ for root, _, filenames in os.walk(data_dir):
231
+ for filename in filenames:
232
+ full_path = os.path.join(root, filename)
233
+ # The key is used to build/compare S3 keys, which always use '/'. Normalize
234
+ # the OS separator so keys generated on Windows match remote keys; the value
235
+ # stays OS-native for filesystem operations.
236
+ rel_path = os.path.relpath(full_path, data_dir).replace(os.sep, '/')
237
+ files[rel_path] = full_path
238
+ return files
239
+
240
+ def _delete_keys(self, client, keys):
241
+ # delete_objects removes up to 1000 keys per request; batch accordingly.
242
+ failures = 0
243
+ for start in range(0, len(keys), 1000):
244
+ batch = keys[start:start + 1000]
245
+ try:
246
+ response = client.delete_objects(
247
+ Bucket=self.bucket,
248
+ Delete={'Objects': [{'Key': key} for key in batch]},
249
+ )
250
+ except (ClientError, BotoCoreError) as e:
251
+ failures += len(batch)
252
+ logger.info(f"\n*** Error ***\n{e}\n")
253
+ continue
254
+ for error in response.get('Errors', []):
255
+ failures += 1
256
+ logger.info(f"\n*** Error ***\n{error.get('Key')}: {error.get('Message')}\n")
257
+ return failures
258
+
259
+ def _list_s3_keys(self, client, prefix):
260
+ keys = []
261
+ paginator = client.get_paginator('list_objects_v2')
262
+ for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix):
263
+ for obj in page.get('Contents', []):
264
+ keys.append(obj['Key'])
265
+ return keys
266
+
267
+ def _list_s3_objects(self, client, prefix):
268
+ objects = {}
269
+ paginator = client.get_paginator('list_objects_v2')
270
+ for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix):
271
+ for obj in page.get('Contents', []):
272
+ rel_path = obj['Key'][len(prefix):]
273
+ objects[rel_path] = S3ObjectInfo(etag=self._normalize_etag(obj.get('ETag')))
274
+ return objects
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["uv-build"]
2
+ requires = ["uv-build>=0.8.18,<0.9.0"]
3
3
  build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "datakit-data"
7
- version = "0.5.2"
7
+ version = "0.6.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"},
@@ -44,7 +44,7 @@ dev = [
44
44
  module-root = ""
45
45
 
46
46
  [tool.tox]
47
- env_list = ["py311", "py312", "py313"]
47
+ env_list = ["py310", "py311", "py312", "py313"]
48
48
 
49
49
  [tool.tox.env_run_base]
50
50
  dependency_groups = ["dev"]
@@ -1,5 +0,0 @@
1
- class ExtraFlags:
2
-
3
- @classmethod
4
- def convert(kls, raw_flags):
5
- return [f"--{flag}" for flag in raw_flags]
@@ -1,152 +0,0 @@
1
- import os
2
- import logging
3
- from logging import NullHandler
4
-
5
- import boto3
6
- from botocore.exceptions import BotoCoreError, ClientError
7
-
8
-
9
- logger = logging.getLogger(__name__)
10
- logger.addHandler(NullHandler())
11
-
12
- EMPTY_PATH_DELETE_MSG = (
13
- "\n*** Refusing --delete with an empty s3_path: this would scan and delete "
14
- "across the entire bucket. Set s3_path in the project config. ***\n"
15
- )
16
-
17
-
18
- class S3:
19
- """A limited, human-friendly interface to S3."""
20
-
21
- def __init__(self, aws_user_profile, s3_bucket):
22
- self.user_profile = aws_user_profile
23
- self.bucket = s3_bucket
24
-
25
- def push(self, data_dir, s3_path='', extra_flags=None, sync_status_dir=None):
26
- extra_flags = extra_flags or []
27
- dryrun = '--dryrun' in extra_flags or '--dry-run' in extra_flags
28
- delete = '--delete' in extra_flags
29
- prefix = self._normalize_prefix(s3_path)
30
- if delete and not prefix:
31
- logger.info(EMPTY_PATH_DELETE_MSG)
32
- return 1
33
- client = self._client()
34
- failures = 0
35
- local_files = {k: v for k, v in self._list_local_files(data_dir).items() if not k.endswith('.synced')}
36
- for rel_path, local_path in sorted(local_files.items()):
37
- key = prefix + rel_path
38
- logger.info(f"upload: {local_path} to s3://{self.bucket}/{key}")
39
- if not dryrun:
40
- try:
41
- client.upload_file(local_path, self.bucket, key)
42
- if sync_status_dir is not None:
43
- self._create_sync_marker(rel_path, sync_status_dir)
44
- except (ClientError, BotoCoreError) as e:
45
- failures += 1
46
- logger.info(f"\n*** Error ***\n{e}\n")
47
- if delete:
48
- remote_keys = self._list_s3_keys(client, prefix)
49
- remote_rel = {k[len(prefix):] for k in remote_keys}
50
- to_delete = [prefix + rel_path for rel_path in sorted(remote_rel - set(local_files.keys()))]
51
- for key in to_delete:
52
- logger.info(f"delete: s3://{self.bucket}/{key}")
53
- if not dryrun:
54
- failures += self._delete_keys(client, to_delete)
55
- return failures
56
-
57
- def pull(self, data_dir, s3_path='', extra_flags=None):
58
- extra_flags = extra_flags or []
59
- dryrun = '--dryrun' in extra_flags or '--dry-run' in extra_flags
60
- delete = '--delete' in extra_flags
61
- prefix = self._normalize_prefix(s3_path)
62
- if delete and not prefix:
63
- logger.info(EMPTY_PATH_DELETE_MSG)
64
- return 1
65
- client = self._client()
66
- failures = 0
67
- remote_keys = self._list_s3_keys(client, prefix)
68
- for key in remote_keys:
69
- rel_path = key[len(prefix):]
70
- local_path = os.path.join(data_dir, rel_path)
71
- logger.info(f"download: s3://{self.bucket}/{key} to {local_path}")
72
- if not dryrun:
73
- os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
74
- try:
75
- client.download_file(self.bucket, key, local_path)
76
- except (ClientError, BotoCoreError) as e:
77
- failures += 1
78
- logger.info(f"\n*** Error ***\n{e}\n")
79
- if delete:
80
- local_files = self._list_local_files(data_dir)
81
- remote_rel = {k[len(prefix):] for k in remote_keys}
82
- for rel_path, local_path in sorted(local_files.items()):
83
- if rel_path not in remote_rel:
84
- logger.info(f"delete: {local_path}")
85
- if not dryrun:
86
- os.remove(local_path)
87
- return failures
88
-
89
- def _client(self):
90
- session = boto3.Session(profile_name=self.user_profile)
91
- return session.client('s3')
92
-
93
- def _normalize_prefix(self, s3_path):
94
- # Returns '' for falsy input so callers can concatenate key segments without a leading slash.
95
- if not s3_path:
96
- return ''
97
- return s3_path.strip('/') + '/'
98
-
99
- def _create_sync_marker(self, rel_path, sync_status_dir):
100
- marker_path = os.path.join(sync_status_dir, rel_path + '.synced')
101
- os.makedirs(os.path.dirname(os.path.abspath(marker_path)), exist_ok=True)
102
- open(marker_path, 'w').close()
103
-
104
- def _list_local_files(self, data_dir):
105
- files = {}
106
- if not os.path.isdir(data_dir):
107
- return files
108
- for root, _, filenames in os.walk(data_dir):
109
- for filename in filenames:
110
- full_path = os.path.join(root, filename)
111
- # The key is used to build/compare S3 keys, which always use '/'. Normalize
112
- # the OS separator so keys generated on Windows match remote keys; the value
113
- # stays OS-native for filesystem operations.
114
- rel_path = os.path.relpath(full_path, data_dir).replace(os.sep, '/')
115
- files[rel_path] = full_path
116
- return files
117
-
118
- def _delete_keys(self, client, keys):
119
- # delete_objects removes up to 1000 keys per request; batch accordingly.
120
- failures = 0
121
- for start in range(0, len(keys), 1000):
122
- batch = keys[start:start + 1000]
123
- try:
124
- response = client.delete_objects(
125
- Bucket=self.bucket,
126
- Delete={'Objects': [{'Key': key} for key in batch]},
127
- )
128
- except (ClientError, BotoCoreError) as e:
129
- failures += len(batch)
130
- logger.info(f"\n*** Error ***\n{e}\n")
131
- continue
132
- for error in response.get('Errors', []):
133
- failures += 1
134
- logger.info(f"\n*** Error ***\n{error.get('Key')}: {error.get('Message')}\n")
135
- return failures
136
-
137
- def _list_s3_keys(self, client, prefix):
138
- keys = []
139
- paginator = client.get_paginator('list_objects_v2')
140
- for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix):
141
- for obj in page.get('Contents', []):
142
- keys.append(obj['Key'])
143
- return keys
144
-
145
- def _list_s3_objects(self, client, prefix):
146
- objects = {}
147
- paginator = client.get_paginator('list_objects_v2')
148
- for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix):
149
- for obj in page.get('Contents', []):
150
- rel_path = obj['Key'][len(prefix):]
151
- objects[rel_path] = obj['LastModified']
152
- return objects