datakit-data 0.5.0__tar.gz → 0.5.2__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.0 → datakit_data-0.5.2}/PKG-INFO +1 -1
- {datakit_data-0.5.0 → datakit_data-0.5.2}/datakit_data/__init__.py +1 -1
- {datakit_data-0.5.0 → datakit_data-0.5.2}/datakit_data/commands/init.py +7 -0
- {datakit_data-0.5.0 → datakit_data-0.5.2}/datakit_data/commands/pull.py +5 -2
- {datakit_data-0.5.0 → datakit_data-0.5.2}/datakit_data/commands/push.py +4 -1
- {datakit_data-0.5.0 → datakit_data-0.5.2}/datakit_data/commands/status.py +7 -0
- {datakit_data-0.5.0 → datakit_data-0.5.2}/datakit_data/s3.py +47 -8
- {datakit_data-0.5.0 → datakit_data-0.5.2}/pyproject.toml +1 -1
- {datakit_data-0.5.0 → datakit_data-0.5.2}/datakit_data/commands/__init__.py +0 -0
- {datakit_data-0.5.0 → datakit_data-0.5.2}/datakit_data/extra_flags.py +0 -0
- {datakit_data-0.5.0 → datakit_data-0.5.2}/datakit_data/project_mixin.py +0 -0
|
@@ -40,6 +40,13 @@ class Init(ProjectMixin, CommandHelpers, Command):
|
|
|
40
40
|
print(f"\nThere is an issue with {self.plugin_config_path}: `s3_bucket` is missing or empty.")
|
|
41
41
|
print("Please review and update the file, then re-run `data init`.")
|
|
42
42
|
raise SystemExit(1)
|
|
43
|
+
if not plugin_configs.get('sync_status_location'):
|
|
44
|
+
print(f"\n`sync_status_location` is not configured in {self.plugin_config_path}.")
|
|
45
|
+
print("Without it, the `status` command will be non-functional for newly created projects.")
|
|
46
|
+
answer = input("Add sync_status_location = '.sync_status/' to system config? [Y/n]: ").strip().lower()
|
|
47
|
+
if answer in ('', 'y', 'yes'):
|
|
48
|
+
plugin_configs['sync_status_location'] = '.sync_status/'
|
|
49
|
+
self.write_configs(plugin_configs)
|
|
43
50
|
else:
|
|
44
51
|
print(f"\nNo system configuration for datakit-data exists at {self.plugin_config_path}.")
|
|
45
52
|
plugin_configs = self._prompt_for_plugin_configs()
|
|
@@ -28,12 +28,15 @@ class Pull(ProjectMixin, CommandHelpers, Command):
|
|
|
28
28
|
self.log.info("No config file found - have you run `datakit data init`?")
|
|
29
29
|
return
|
|
30
30
|
if bucket == "":
|
|
31
|
-
self.log.info("No bucket specified in config - no data
|
|
31
|
+
self.log.info("No bucket specified in config - no data pulled")
|
|
32
32
|
return
|
|
33
33
|
s3 = S3(user_profile, bucket)
|
|
34
34
|
clean_flags = ExtraFlags.convert(parsed_args.args)
|
|
35
|
-
s3.pull(
|
|
35
|
+
failures = s3.pull(
|
|
36
36
|
'data/',
|
|
37
37
|
self.project_configs['s3_path'],
|
|
38
38
|
extra_flags=clean_flags
|
|
39
39
|
)
|
|
40
|
+
if failures:
|
|
41
|
+
self.log.info(f"{failures} file(s) failed to transfer")
|
|
42
|
+
return 1
|
|
@@ -47,9 +47,12 @@ class Push(ProjectMixin, CommandHelpers, Command):
|
|
|
47
47
|
write_json(self.project_config_path, configs)
|
|
48
48
|
else:
|
|
49
49
|
sync_status_dir = self.project_configs.get('sync_status_location')
|
|
50
|
-
s3.push(
|
|
50
|
+
failures = s3.push(
|
|
51
51
|
'data/',
|
|
52
52
|
self.project_configs['s3_path'],
|
|
53
53
|
extra_flags=clean_flags,
|
|
54
54
|
sync_status_dir=sync_status_dir
|
|
55
55
|
)
|
|
56
|
+
if failures:
|
|
57
|
+
self.log.info(f"{failures} file(s) failed to transfer")
|
|
58
|
+
return 1
|
|
@@ -2,6 +2,7 @@ import os
|
|
|
2
2
|
from datetime import datetime, timezone
|
|
3
3
|
from cliff.command import Command
|
|
4
4
|
from datakit import CommandHelpers
|
|
5
|
+
from datakit.utils import read_json, write_json
|
|
5
6
|
|
|
6
7
|
from ..project_mixin import ProjectMixin
|
|
7
8
|
from ..s3 import S3
|
|
@@ -51,6 +52,12 @@ class Status(ProjectMixin, CommandHelpers, Command):
|
|
|
51
52
|
return
|
|
52
53
|
if not sync_status_dir:
|
|
53
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")
|
|
54
61
|
return
|
|
55
62
|
missing, stale = self._find_unsynced('data/', sync_status_dir)
|
|
56
63
|
self._log_group("file(s) not yet pushed to S3", missing, parsed_args.filepaths)
|
|
@@ -3,12 +3,17 @@ import logging
|
|
|
3
3
|
from logging import NullHandler
|
|
4
4
|
|
|
5
5
|
import boto3
|
|
6
|
-
from botocore.exceptions import ClientError
|
|
6
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
logger = logging.getLogger(__name__)
|
|
10
10
|
logger.addHandler(NullHandler())
|
|
11
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
|
+
|
|
12
17
|
|
|
13
18
|
class S3:
|
|
14
19
|
"""A limited, human-friendly interface to S3."""
|
|
@@ -22,7 +27,11 @@ class S3:
|
|
|
22
27
|
dryrun = '--dryrun' in extra_flags or '--dry-run' in extra_flags
|
|
23
28
|
delete = '--delete' in extra_flags
|
|
24
29
|
prefix = self._normalize_prefix(s3_path)
|
|
30
|
+
if delete and not prefix:
|
|
31
|
+
logger.info(EMPTY_PATH_DELETE_MSG)
|
|
32
|
+
return 1
|
|
25
33
|
client = self._client()
|
|
34
|
+
failures = 0
|
|
26
35
|
local_files = {k: v for k, v in self._list_local_files(data_dir).items() if not k.endswith('.synced')}
|
|
27
36
|
for rel_path, local_path in sorted(local_files.items()):
|
|
28
37
|
key = prefix + rel_path
|
|
@@ -32,23 +41,29 @@ class S3:
|
|
|
32
41
|
client.upload_file(local_path, self.bucket, key)
|
|
33
42
|
if sync_status_dir is not None:
|
|
34
43
|
self._create_sync_marker(rel_path, sync_status_dir)
|
|
35
|
-
except ClientError as e:
|
|
44
|
+
except (ClientError, BotoCoreError) as e:
|
|
45
|
+
failures += 1
|
|
36
46
|
logger.info(f"\n*** Error ***\n{e}\n")
|
|
37
47
|
if delete:
|
|
38
48
|
remote_keys = self._list_s3_keys(client, prefix)
|
|
39
49
|
remote_rel = {k[len(prefix):] for k in remote_keys}
|
|
40
|
-
for rel_path in sorted(remote_rel - set(local_files.keys()))
|
|
41
|
-
|
|
50
|
+
to_delete = [prefix + rel_path for rel_path in sorted(remote_rel - set(local_files.keys()))]
|
|
51
|
+
for key in to_delete:
|
|
42
52
|
logger.info(f"delete: s3://{self.bucket}/{key}")
|
|
43
|
-
|
|
44
|
-
|
|
53
|
+
if not dryrun:
|
|
54
|
+
failures += self._delete_keys(client, to_delete)
|
|
55
|
+
return failures
|
|
45
56
|
|
|
46
57
|
def pull(self, data_dir, s3_path='', extra_flags=None):
|
|
47
58
|
extra_flags = extra_flags or []
|
|
48
59
|
dryrun = '--dryrun' in extra_flags or '--dry-run' in extra_flags
|
|
49
60
|
delete = '--delete' in extra_flags
|
|
50
61
|
prefix = self._normalize_prefix(s3_path)
|
|
62
|
+
if delete and not prefix:
|
|
63
|
+
logger.info(EMPTY_PATH_DELETE_MSG)
|
|
64
|
+
return 1
|
|
51
65
|
client = self._client()
|
|
66
|
+
failures = 0
|
|
52
67
|
remote_keys = self._list_s3_keys(client, prefix)
|
|
53
68
|
for key in remote_keys:
|
|
54
69
|
rel_path = key[len(prefix):]
|
|
@@ -58,7 +73,8 @@ class S3:
|
|
|
58
73
|
os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
|
|
59
74
|
try:
|
|
60
75
|
client.download_file(self.bucket, key, local_path)
|
|
61
|
-
except ClientError as e:
|
|
76
|
+
except (ClientError, BotoCoreError) as e:
|
|
77
|
+
failures += 1
|
|
62
78
|
logger.info(f"\n*** Error ***\n{e}\n")
|
|
63
79
|
if delete:
|
|
64
80
|
local_files = self._list_local_files(data_dir)
|
|
@@ -68,6 +84,7 @@ class S3:
|
|
|
68
84
|
logger.info(f"delete: {local_path}")
|
|
69
85
|
if not dryrun:
|
|
70
86
|
os.remove(local_path)
|
|
87
|
+
return failures
|
|
71
88
|
|
|
72
89
|
def _client(self):
|
|
73
90
|
session = boto3.Session(profile_name=self.user_profile)
|
|
@@ -91,10 +108,32 @@ class S3:
|
|
|
91
108
|
for root, _, filenames in os.walk(data_dir):
|
|
92
109
|
for filename in filenames:
|
|
93
110
|
full_path = os.path.join(root, filename)
|
|
94
|
-
|
|
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, '/')
|
|
95
115
|
files[rel_path] = full_path
|
|
96
116
|
return files
|
|
97
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
|
+
|
|
98
137
|
def _list_s3_keys(self, client, prefix):
|
|
99
138
|
keys = []
|
|
100
139
|
paginator = client.get_paginator('list_objects_v2')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|