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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: datakit-data
3
- Version: 0.5.0
3
+ Version: 0.5.2
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.0'
3
+ __version__ = '0.5.2'
4
4
 
5
5
  from .commands.init import Init
6
6
  from .commands.push import Push
@@ -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 pushed")
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
- key = prefix + rel_path
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
- if not dryrun:
44
- client.delete_object(Bucket=self.bucket, Key=key)
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
- rel_path = os.path.relpath(full_path, data_dir)
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')
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "datakit-data"
7
- version = "0.5.0"
7
+ version = "0.5.2"
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"},