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.
- {datakit_data-0.5.2 → datakit_data-0.6.0}/PKG-INFO +1 -1
- {datakit_data-0.5.2 → datakit_data-0.6.0}/datakit_data/__init__.py +1 -1
- {datakit_data-0.5.2 → datakit_data-0.6.0}/datakit_data/commands/init.py +6 -3
- {datakit_data-0.5.2 → datakit_data-0.6.0}/datakit_data/commands/pull.py +16 -3
- {datakit_data-0.5.2 → datakit_data-0.6.0}/datakit_data/commands/push.py +18 -5
- {datakit_data-0.5.2 → datakit_data-0.6.0}/datakit_data/commands/status.py +43 -13
- datakit_data-0.6.0/datakit_data/extra_flags.py +16 -0
- datakit_data-0.6.0/datakit_data/s3.py +274 -0
- {datakit_data-0.5.2 → datakit_data-0.6.0}/pyproject.toml +3 -3
- datakit_data-0.5.2/datakit_data/extra_flags.py +0 -5
- datakit_data-0.5.2/datakit_data/s3.py +0 -152
- {datakit_data-0.5.2 → datakit_data-0.6.0}/datakit_data/commands/__init__.py +0 -0
- {datakit_data-0.5.2 → datakit_data-0.6.0}/datakit_data/project_mixin.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
78
|
+
changed_local = []
|
|
79
|
+
changed_s3 = []
|
|
80
|
+
conflict = []
|
|
81
|
+
differ = []
|
|
79
82
|
for rel_path in local_keys & s3_keys:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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)
|
|
88
|
-
self._log_group("file(s)
|
|
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.
|
|
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,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
|
|
File without changes
|
|
File without changes
|