datakit-data 0.6.1__tar.gz → 0.6.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.6.1
3
+ Version: 0.6.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.6.1'
3
+ __version__ = '0.6.2'
4
4
 
5
5
  from .commands.init import Init
6
6
  from .commands.push import Push
@@ -36,7 +36,7 @@ class Pull(ProjectMixin, CommandHelpers, Command):
36
36
  if bucket == "":
37
37
  self.log.info("No bucket specified in config - no data pulled")
38
38
  return
39
- s3 = S3(user_profile, bucket, max_workers=self.project_configs.get('max_workers'))
39
+ s3 = S3(user_profile, bucket)
40
40
  clean_flags = ExtraFlags.convert(parsed_args.args)
41
41
  if getattr(parsed_args, 'force', False) is True and '--force' not in clean_flags:
42
42
  clean_flags.append('--force')
@@ -44,7 +44,7 @@ class Push(ProjectMixin, CommandHelpers, Command):
44
44
  if bucket == "":
45
45
  self.log.info("No bucket specified in config - no data pushed")
46
46
  return
47
- s3 = S3(user_profile, bucket, max_workers=self.project_configs.get('max_workers'))
47
+ s3 = S3(user_profile, bucket)
48
48
  clean_flags = ExtraFlags.convert(parsed_args.args)
49
49
  if getattr(parsed_args, 'force', False) is True and '--force' not in clean_flags:
50
50
  clean_flags.append('--force')
@@ -1,12 +1,9 @@
1
1
  import os
2
2
  import logging
3
3
  from collections import namedtuple
4
- from concurrent.futures import ThreadPoolExecutor, as_completed
5
4
  from logging import NullHandler
6
5
 
7
6
  import boto3
8
- from boto3.s3.transfer import TransferConfig
9
- from botocore.config import Config
10
7
  from botocore.exceptions import BotoCoreError, ClientError
11
8
 
12
9
 
@@ -35,26 +32,9 @@ class S3:
35
32
  # record is the one S3 itself reports, so it stays comparable to a later head/list probe.
36
33
  MULTIPART_THRESHOLD = 8 * 1024 * 1024
37
34
 
38
- # Per-file transfers (push uploads, pull downloads) are network-latency-bound, so we run them
39
- # across a thread pool. boto3 clients are thread-safe for these calls and release the GIL during
40
- # network I/O, so threads give near-linear speedup for the many-small-files case without the
41
- # complexity of asyncio. Callers can override via the max_workers arg (e.g. a config key).
42
- MAX_WORKERS = 16
43
-
44
- # boto3's managed transfers (upload_file/download_file) each spin up their own internal thread
45
- # pool (TransferConfig.max_concurrency, default 10), every thread of which borrows a connection
46
- # from the shared pool. Because we already parallelize across files in our own thread pool, that
47
- # inner concurrency is redundant and multiplies connection demand to max_workers * 10 — far past
48
- # the max_pool_connections=max_workers pool, triggering urllib3 "connection pool is full"
49
- # warnings and pathological TLS-handshake churn. Disabling inner threads makes each managed
50
- # transfer use a single connection, so total demand equals the worker count and the pool matches.
51
- # Large files still upload/download via multipart (parts just go sequentially) for resilience.
52
- _TRANSFER_CONFIG = TransferConfig(use_threads=False)
53
-
54
- def __init__(self, aws_user_profile, s3_bucket, max_workers=None):
35
+ def __init__(self, aws_user_profile, s3_bucket):
55
36
  self.user_profile = aws_user_profile
56
37
  self.bucket = s3_bucket
57
- self.max_workers = max_workers or self.MAX_WORKERS
58
38
 
59
39
  def push(self, data_dir, s3_path='', extra_flags=None, sync_status_dir=None):
60
40
  extra_flags = extra_flags or []
@@ -68,21 +48,20 @@ class S3:
68
48
  client = self._client()
69
49
  failures = 0
70
50
  local_files = {k: v for k, v in self._list_local_files(data_dir).items() if not k.endswith('.synced')}
71
- # Decide what to transfer (cheap, local-only checks) and log intent up front so the log
72
- # stays deterministic; the transfers themselves run concurrently below.
73
- to_upload = []
74
51
  for rel_path, local_path in sorted(local_files.items()):
75
52
  key = prefix + rel_path
76
53
  if not force and self._marker_is_fresh(local_path, rel_path, sync_status_dir):
77
54
  logger.info(f"skipped: {local_path}")
78
55
  continue
79
56
  logger.info(f"upload: {local_path} to s3://{self.bucket}/{key}")
80
- to_upload.append((rel_path, local_path, key))
81
- if not dryrun:
82
- failures += self._run_transfers(
83
- to_upload,
84
- lambda item: self._push_one(client, item, sync_status_dir),
85
- )
57
+ if not dryrun:
58
+ try:
59
+ etag = self._upload(client, local_path, key, sync_status_dir is not None)
60
+ if sync_status_dir is not None:
61
+ self._create_sync_marker(rel_path, sync_status_dir, etag)
62
+ except (ClientError, BotoCoreError) as e:
63
+ failures += 1
64
+ logger.info(f"\n*** Error ***\n{e}\n")
86
65
  if delete:
87
66
  remote_keys = self._list_s3_keys(client, prefix)
88
67
  remote_rel = {k[len(prefix):] for k in remote_keys}
@@ -105,9 +84,6 @@ class S3:
105
84
  client = self._client()
106
85
  failures = 0
107
86
  remote_objects = self._list_s3_objects(client, prefix)
108
- # Decide what to transfer (cheap, local-only checks) and log intent up front so the log
109
- # stays deterministic; the transfers themselves run concurrently below.
110
- to_download = []
111
87
  for rel_path in sorted(remote_objects):
112
88
  key = prefix + rel_path
113
89
  remote_etag = remote_objects[rel_path].etag
@@ -118,12 +94,15 @@ class S3:
118
94
  logger.info(f"skipped: s3://{self.bucket}/{key}")
119
95
  continue
120
96
  logger.info(f"download: s3://{self.bucket}/{key} to {local_path}")
121
- to_download.append((rel_path, key, remote_etag, local_path))
122
- if not dryrun:
123
- failures += self._run_transfers(
124
- to_download,
125
- lambda item: self._pull_one(client, item, sync_status_dir),
126
- )
97
+ if not dryrun:
98
+ os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
99
+ try:
100
+ client.download_file(self.bucket, key, local_path)
101
+ if sync_status_dir is not None:
102
+ self._create_sync_marker(rel_path, sync_status_dir, remote_etag)
103
+ except (ClientError, BotoCoreError) as e:
104
+ failures += 1
105
+ logger.info(f"\n*** Error ***\n{e}\n")
127
106
  if delete:
128
107
  local_files = {k: v for k, v in self._list_local_files(data_dir).items() if not k.endswith('.synced')}
129
108
  remote_rel = set(remote_objects)
@@ -140,10 +119,7 @@ class S3:
140
119
 
141
120
  def _client(self):
142
121
  session = boto3.Session(profile_name=self.user_profile)
143
- # Size the connection pool to the worker count so concurrent transfers don't queue on a
144
- # too-small pool (botocore's default is 10) or emit "connection pool is full" warnings.
145
- config = Config(max_pool_connections=self.max_workers)
146
- return session.client('s3', config=config)
122
+ return session.client('s3')
147
123
 
148
124
  @staticmethod
149
125
  def _normalize_etag(etag):
@@ -164,39 +140,9 @@ class S3:
164
140
  with open(local_path, 'rb') as body:
165
141
  response = client.put_object(Bucket=self.bucket, Key=key, Body=body)
166
142
  return self._normalize_etag(response.get('ETag'))
167
- client.upload_file(local_path, self.bucket, key, Config=self._TRANSFER_CONFIG)
143
+ client.upload_file(local_path, self.bucket, key)
168
144
  return self._object_etag(client, key) if need_etag else None
169
145
 
170
- def _run_transfers(self, items, transfer):
171
- # Run transfer(item) for each item across a thread pool, returning the failure count.
172
- # Per-item ClientError/BotoCoreError are logged and counted (matching the sequential
173
- # behavior); all logging happens here on the main thread, so the workers do pure I/O.
174
- if not items:
175
- return 0
176
- failures = 0
177
- with ThreadPoolExecutor(max_workers=min(self.max_workers, len(items))) as pool:
178
- futures = [pool.submit(transfer, item) for item in items]
179
- for future in as_completed(futures):
180
- try:
181
- future.result()
182
- except (ClientError, BotoCoreError) as e:
183
- failures += 1
184
- logger.info(f"\n*** Error ***\n{e}\n")
185
- return failures
186
-
187
- def _push_one(self, client, item, sync_status_dir):
188
- rel_path, local_path, key = item
189
- etag = self._upload(client, local_path, key, sync_status_dir is not None)
190
- if sync_status_dir is not None:
191
- self._create_sync_marker(rel_path, sync_status_dir, etag)
192
-
193
- def _pull_one(self, client, item, sync_status_dir):
194
- rel_path, key, remote_etag, local_path = item
195
- os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
196
- client.download_file(self.bucket, key, local_path, Config=self._TRANSFER_CONFIG)
197
- if sync_status_dir is not None:
198
- self._create_sync_marker(rel_path, sync_status_dir, remote_etag)
199
-
200
146
  def _normalize_prefix(self, s3_path):
201
147
  # Returns '' for falsy input so callers can concatenate key segments without a leading slash.
202
148
  if not s3_path:
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "datakit-data"
7
- version = "0.6.1"
7
+ version = "0.6.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"},