datakit-data 0.5.2__tar.gz → 0.5.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: datakit-data
3
- Version: 0.5.2
3
+ Version: 0.5.3
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.5.3'
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,7 @@ 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"
21
21
  )
22
22
  return parser
23
23
 
@@ -32,6 +32,9 @@ class Pull(ProjectMixin, CommandHelpers, Command):
32
32
  return
33
33
  s3 = S3(user_profile, bucket)
34
34
  clean_flags = ExtraFlags.convert(parsed_args.args)
35
+ unsupported = ExtraFlags.unsupported(parsed_args.args)
36
+ if unsupported:
37
+ self.log.info(f"Ignoring unsupported flag(s): {', '.join(unsupported)}")
35
38
  failures = s3.pull(
36
39
  'data/',
37
40
  self.project_configs['s3_path'],
@@ -19,7 +19,7 @@ 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"
23
23
  )
24
24
  parser.add_argument(
25
25
  '--sync-status-in-data',
@@ -40,11 +40,16 @@ class Push(ProjectMixin, CommandHelpers, Command):
40
40
  return
41
41
  s3 = S3(user_profile, bucket)
42
42
  clean_flags = ExtraFlags.convert(parsed_args.args)
43
+ unsupported = ExtraFlags.unsupported(parsed_args.args)
44
+ if unsupported:
45
+ self.log.info(f"Ignoring unsupported flag(s): {', '.join(unsupported)}")
46
+ dryrun = '--dryrun' in clean_flags or '--dry-run' in clean_flags
43
47
  if parsed_args.sync_status_in_data:
44
48
  sync_status_dir = 'data/'
45
- configs = self.project_configs.copy()
46
- configs['sync_status_location'] = 'data/'
47
- write_json(self.project_config_path, configs)
49
+ if not dryrun:
50
+ configs = self.project_configs.copy()
51
+ configs['sync_status_location'] = 'data/'
52
+ write_json(self.project_config_path, configs)
48
53
  else:
49
54
  sync_status_dir = self.project_configs.get('sync_status_location')
50
55
  failures = s3.push(
@@ -1,5 +1,5 @@
1
1
  import os
2
- from datetime import datetime, timezone
2
+ from datetime import datetime, timedelta, timezone
3
3
  from cliff.command import Command
4
4
  from datakit import CommandHelpers
5
5
  from datakit.utils import read_json, write_json
@@ -7,6 +7,10 @@ from datakit.utils import read_json, write_json
7
7
  from ..project_mixin import ProjectMixin
8
8
  from ..s3 import S3
9
9
 
10
+ # Tolerance for clock skew when comparing a local .synced marker (our last-push time)
11
+ # against an object's S3 LastModified, which is stamped by S3's clock.
12
+ SYNC_WINDOW = timedelta(seconds=2)
13
+
10
14
 
11
15
  class Status(ProjectMixin, CommandHelpers, Command):
12
16
 
@@ -47,6 +51,7 @@ class Status(ProjectMixin, CommandHelpers, Command):
47
51
  self.project_configs['aws_user_profile'],
48
52
  bucket,
49
53
  self.project_configs['s3_path'],
54
+ sync_status_dir,
50
55
  filepaths=parsed_args.filepaths,
51
56
  )
52
57
  return
@@ -63,7 +68,7 @@ class Status(ProjectMixin, CommandHelpers, Command):
63
68
  self._log_group("file(s) not yet pushed to S3", missing, parsed_args.filepaths)
64
69
  self._log_group("file(s) modified since last push", stale, parsed_args.filepaths)
65
70
 
66
- def _report_s3_comparison(self, user_profile, bucket, s3_path, filepaths=False):
71
+ def _report_s3_comparison(self, user_profile, bucket, s3_path, sync_status_dir, filepaths=False):
67
72
  s3 = S3(user_profile, bucket)
68
73
  client = s3._client()
69
74
  prefix = s3._normalize_prefix(s3_path)
@@ -74,18 +79,45 @@ class Status(ProjectMixin, CommandHelpers, Command):
74
79
  s3_keys = set(s3_objects)
75
80
  only_local = sorted(local_keys - s3_keys)
76
81
  only_s3 = sorted(s3_keys - local_keys)
77
- newer_local = []
78
- newer_s3 = []
82
+ changed_local = []
83
+ changed_s3 = []
84
+ conflict = []
85
+ differ = []
79
86
  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)
87
+ local_path = local_files[rel_path]
88
+ if os.path.getsize(local_path) == s3_objects[rel_path].size:
89
+ continue # same byte size: treat as in sync
90
+ # Sizes differ. Attribute direction using the .synced marker (our last-push time)
91
+ # rather than comparing local mtime to S3 LastModified, which measure different things.
92
+ marker_mtime = self._marker_mtime(rel_path, sync_status_dir)
93
+ if marker_mtime is None:
94
+ differ.append(rel_path)
95
+ continue
96
+ local_mtime = datetime.fromtimestamp(os.path.getmtime(local_path), tz=timezone.utc)
97
+ local_changed = local_mtime > marker_mtime + SYNC_WINDOW
98
+ s3_changed = s3_objects[rel_path].last_modified > marker_mtime + SYNC_WINDOW
99
+ if local_changed and s3_changed:
100
+ conflict.append(rel_path)
101
+ elif local_changed:
102
+ changed_local.append(rel_path)
103
+ elif s3_changed:
104
+ changed_s3.append(rel_path)
105
+ else:
106
+ differ.append(rel_path)
85
107
  self._log_group("file(s) local but not on S3", only_local, filepaths)
86
108
  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)
109
+ self._log_group("file(s) changed locally since last push", sorted(changed_local), filepaths)
110
+ self._log_group("file(s) changed on S3 since last push", sorted(changed_s3), filepaths)
111
+ self._log_group("file(s) changed both locally and on S3 (conflict)", sorted(conflict), filepaths)
112
+ self._log_group("file(s) differing from S3 (no sync record)", sorted(differ), filepaths)
113
+
114
+ def _marker_mtime(self, rel_path, sync_status_dir):
115
+ if not sync_status_dir:
116
+ return None
117
+ marker_path = os.path.join(sync_status_dir, rel_path + '.synced')
118
+ if not os.path.exists(marker_path):
119
+ return None
120
+ return datetime.fromtimestamp(os.path.getmtime(marker_path), tz=timezone.utc)
89
121
 
90
122
  def _log_group(self, label, paths, filepaths):
91
123
  self.log.info(f"{len(paths)} {label}")
@@ -0,0 +1,12 @@
1
+ class ExtraFlags:
2
+
3
+ # Flags the plugin actually acts on (boto3 push/pull only honor these).
4
+ SUPPORTED = ('dryrun', 'dry-run', 'delete')
5
+
6
+ @classmethod
7
+ def convert(kls, raw_flags):
8
+ return [f"--{flag}" for flag in raw_flags]
9
+
10
+ @classmethod
11
+ def unsupported(kls, raw_flags):
12
+ return [flag for flag in raw_flags if flag not in kls.SUPPORTED]
@@ -1,5 +1,6 @@
1
1
  import os
2
2
  import logging
3
+ from collections import namedtuple
3
4
  from logging import NullHandler
4
5
 
5
6
  import boto3
@@ -9,6 +10,9 @@ from botocore.exceptions import BotoCoreError, ClientError
9
10
  logger = logging.getLogger(__name__)
10
11
  logger.addHandler(NullHandler())
11
12
 
13
+ # Metadata captured per remote object from a list_objects_v2 listing.
14
+ S3ObjectInfo = namedtuple('S3ObjectInfo', ['size', 'last_modified'])
15
+
12
16
  EMPTY_PATH_DELETE_MSG = (
13
17
  "\n*** Refusing --delete with an empty s3_path: this would scan and delete "
14
18
  "across the entire bucket. Set s3_path in the project config. ***\n"
@@ -77,13 +81,17 @@ class S3:
77
81
  failures += 1
78
82
  logger.info(f"\n*** Error ***\n{e}\n")
79
83
  if delete:
80
- local_files = self._list_local_files(data_dir)
84
+ local_files = {k: v for k, v in self._list_local_files(data_dir).items() if not k.endswith('.synced')}
81
85
  remote_rel = {k[len(prefix):] for k in remote_keys}
82
86
  for rel_path, local_path in sorted(local_files.items()):
83
87
  if rel_path not in remote_rel:
84
88
  logger.info(f"delete: {local_path}")
85
89
  if not dryrun:
86
- os.remove(local_path)
90
+ try:
91
+ os.remove(local_path)
92
+ except OSError as e:
93
+ failures += 1
94
+ logger.info(f"\n*** Error ***\n{e}\n")
87
95
  return failures
88
96
 
89
97
  def _client(self):
@@ -148,5 +156,5 @@ class S3:
148
156
  for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix):
149
157
  for obj in page.get('Contents', []):
150
158
  rel_path = obj['Key'][len(prefix):]
151
- objects[rel_path] = obj['LastModified']
159
+ objects[rel_path] = S3ObjectInfo(size=obj['Size'], last_modified=obj['LastModified'])
152
160
  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.5.3"
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]