ytclone 0.1.5__py3-none-any.whl

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.
ytclone/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Resumable YTsaurus / S3 copying."""
2
+
3
+
4
+ def main() -> int:
5
+ """Load and run the command-line entry point."""
6
+ from .cli import main as cli_main
7
+
8
+ return cli_main()
ytclone/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
ytclone/cli.py ADDED
@@ -0,0 +1,277 @@
1
+ """Copy YTsaurus static tables, files and directories to or from an S3 archive."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import math
6
+ import sys
7
+ import time
8
+ from contextlib import nullcontext, suppress
9
+
10
+ import aiohttp
11
+ import aiomisc
12
+ import argclass
13
+ from aiohttp_s3_client import S3Client
14
+ from aiomisc.log import LogFormat
15
+
16
+ from .engine import CopyOptions, export_tree, restore_tree
17
+ from .format import MIB, parse_s3_url, path_components
18
+ from .logging import file_logging
19
+ from .memory import estimate_memory_mib
20
+ from .progress import Progress, ProgressReporter
21
+ from .s3 import S3Storage
22
+ from .yt import YtAdapter
23
+
24
+ log = logging.getLogger(__name__)
25
+ LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S %z"
26
+
27
+
28
+ class Arguments(argclass.Parser):
29
+ source: str = argclass.Argument(
30
+ "source", help="Source //YT/path or s3://bucket/prefix"
31
+ )
32
+ destination: str = argclass.Argument(
33
+ "destination", help="Destination in the other storage"
34
+ )
35
+ yt_proxy: str = argclass.Argument(env_var="YT_PROXY", required=True)
36
+ no_proxy_discovery: bool = argclass.Argument(
37
+ default=False, help="Use only the supplied YT proxy (for local Docker)"
38
+ )
39
+ endpoint: str = argclass.Argument(
40
+ env_var="S3_ENDPOINT", default="https://s3.amazonaws.com"
41
+ )
42
+ region: str = argclass.Argument(env_var="AWS_DEFAULT_REGION", default="us-east-1")
43
+ concurrency: int = argclass.Argument(
44
+ default=4, help="Maximum concurrent table/file transfers"
45
+ )
46
+ queue_size: int = argclass.Argument(default=16, help="Maximum queued node paths")
47
+ part_size_mib: int = argclass.Argument(
48
+ default=256,
49
+ help="Target uncompressed size per archive object; approximate for tables",
50
+ )
51
+ buffer_size_mib: int = argclass.Argument(
52
+ default=32, help="S3 multipart buffer per transfer, 5..512 MiB"
53
+ )
54
+ compression_level: int = argclass.Argument(
55
+ default=3, help="Zstandard level, -7..22"
56
+ )
57
+ log_file: str | None = argclass.Argument(
58
+ default=None,
59
+ type=str,
60
+ help="Append detailed logs to this file and show progress on stdout",
61
+ )
62
+ progress_interval: float = argclass.Argument(
63
+ default=5.0,
64
+ help="Seconds between console summaries with --log-file",
65
+ )
66
+ log_level: str = argclass.Argument(
67
+ default="info", choices=("debug", "info", "warning", "error")
68
+ )
69
+ log_format: str = argclass.Argument(
70
+ default=LogFormat.default(),
71
+ choices=LogFormat.choices(),
72
+ env_var="AIOMISC_LOG_FORMAT",
73
+ )
74
+
75
+
76
+ def direction(*, source: str, destination: str) -> tuple[bool, str, str, str]:
77
+ """Validate paths and resolve direction, YT path, bucket and prefix."""
78
+ export = source.startswith("//") and destination.startswith("s3://")
79
+ restore = source.startswith("s3://") and destination.startswith("//")
80
+ if not (export or restore):
81
+ raise ValueError("Specify one YT path and one S3 URL; source comes first")
82
+ yt_path = source if export else destination
83
+ path_components(yt_path)
84
+ if any(c in yt_path for c in "\\[]{}<>&*@"):
85
+ raise ValueError(
86
+ "YT selectors, links and escaped/special node names are not supported"
87
+ )
88
+ bucket, prefix = parse_s3_url(destination if export else source)
89
+ return export, yt_path, bucket, prefix
90
+
91
+
92
+ async def run(*, arguments: Arguments, options: CopyOptions) -> int:
93
+ """Create adapters, run the copy and return its exit code."""
94
+ export, yt_path, bucket, prefix = direction(
95
+ source=arguments.source, destination=arguments.destination
96
+ )
97
+
98
+ memory_budget = estimate_memory_mib(
99
+ concurrency=options.concurrency,
100
+ chunk_size=options.chunk_size,
101
+ compression_level=options.compression_level,
102
+ export=export,
103
+ )
104
+ log.info(
105
+ "copy_started direction=%s concurrency=%d buffer_size_mib=%d "
106
+ "memory_estimate_mib=%d",
107
+ "yt-to-s3" if export else "s3-to-yt",
108
+ options.concurrency,
109
+ options.chunk_size // MIB,
110
+ memory_budget,
111
+ )
112
+ log.info(
113
+ "Memory estimate is a planning budget; large rows, metadata and "
114
+ "allocator overhead can exceed it."
115
+ )
116
+
117
+ progress = Progress() if arguments.log_file else None
118
+ if progress is not None:
119
+ print(
120
+ f"Detailed log: {arguments.log_file}; "
121
+ f"RAM estimate: {memory_budget} MiB (planning budget, not a limit)",
122
+ flush=True,
123
+ )
124
+ reporter = (
125
+ ProgressReporter(
126
+ progress=progress,
127
+ concurrency=options.concurrency,
128
+ interval=arguments.progress_interval,
129
+ export=export,
130
+ stream=sys.stdout,
131
+ )
132
+ if progress is not None
133
+ else nullcontext()
134
+ )
135
+ started = time.monotonic()
136
+
137
+ async with reporter:
138
+ yt = YtAdapter(
139
+ proxy=arguments.yt_proxy,
140
+ proxy_discovery=not arguments.no_proxy_discovery,
141
+ progress=progress,
142
+ )
143
+ estimate_task = (
144
+ asyncio.create_task(estimate_total(yt=yt, path=yt_path, progress=progress))
145
+ if export and progress is not None
146
+ else None
147
+ )
148
+ try:
149
+ async with aiohttp.ClientSession(
150
+ timeout=aiohttp.ClientTimeout(
151
+ total=None, sock_connect=30, sock_read=300
152
+ )
153
+ ) as session:
154
+ client = S3Client(
155
+ session=session,
156
+ url=arguments.endpoint.rstrip("/") + "/" + bucket,
157
+ region=arguments.region,
158
+ )
159
+ storage = S3Storage(
160
+ client=client,
161
+ chunk_size=options.chunk_size,
162
+ progress=progress,
163
+ )
164
+ if export:
165
+ result = await export_tree(
166
+ yt=yt,
167
+ storage=storage,
168
+ source=yt_path,
169
+ prefix=prefix,
170
+ cluster=arguments.yt_proxy.rstrip("/"),
171
+ options=options,
172
+ progress=progress,
173
+ )
174
+ else:
175
+ result = await restore_tree(
176
+ yt=yt,
177
+ storage=storage,
178
+ source_prefix=prefix,
179
+ destination=yt_path,
180
+ options=options,
181
+ progress=progress,
182
+ )
183
+ finally:
184
+ if estimate_task is not None:
185
+ estimate_task.cancel()
186
+ with suppress(asyncio.CancelledError):
187
+ await estimate_task
188
+ log.info(
189
+ "copy_finished completed=%d skipped=%d failed=%d seconds=%.3f",
190
+ result.completed,
191
+ result.skipped,
192
+ result.failed,
193
+ time.monotonic() - started,
194
+ )
195
+ return 1 if result.failed else 0
196
+
197
+
198
+ async def estimate_total(*, yt: YtAdapter, path: str, progress: Progress) -> None:
199
+ """Fetch optional subtree counts and storage size alongside discovery."""
200
+ try:
201
+ async with yt.worker() as worker:
202
+ estimate = await worker.estimate_source(path=path)
203
+ progress.estimated_total = estimate.node_count
204
+ progress.estimated_source_bytes = estimate.disk_size
205
+ log.info(
206
+ "source_estimate path=%s nodes=%s disk_space=%s",
207
+ path,
208
+ estimate.node_count,
209
+ estimate.disk_size,
210
+ )
211
+ except Exception:
212
+ log.warning("source_estimate_unavailable path=%s", path, exc_info=True)
213
+
214
+
215
+ def main() -> int:
216
+ """Parse arguments and run the CLI with configured logging."""
217
+ arguments = Arguments(description=__doc__)
218
+ arguments.parse_args()
219
+ try:
220
+ if (
221
+ not math.isfinite(arguments.progress_interval)
222
+ or arguments.progress_interval <= 0
223
+ ):
224
+ raise ValueError("Progress interval must be positive and finite")
225
+ direction(source=arguments.source, destination=arguments.destination)
226
+ options = CopyOptions(
227
+ concurrency=arguments.concurrency,
228
+ queue_size=arguments.queue_size,
229
+ part_size=arguments.part_size_mib * MIB,
230
+ chunk_size=arguments.buffer_size_mib * MIB,
231
+ compression_level=arguments.compression_level,
232
+ )
233
+ except ValueError as exc:
234
+ arguments.create_parser().error(str(exc))
235
+
236
+ # aiomisc's plain formatter ignores dates; stream supports timestamps.
237
+ log_format = arguments.log_format
238
+ if log_format == LogFormat.plain.name:
239
+ log_format = LogFormat.stream.name
240
+
241
+ # System log collectors supply their own timestamps.
242
+ log_date_format = (
243
+ None
244
+ if log_format in (LogFormat.syslog.name, LogFormat.journald.name)
245
+ else LOG_DATE_FORMAT
246
+ )
247
+
248
+ log_context = (
249
+ file_logging(
250
+ filename=arguments.log_file,
251
+ level=arguments.log_level,
252
+ log_format=log_format,
253
+ date_format=LOG_DATE_FORMAT,
254
+ )
255
+ if arguments.log_file
256
+ else nullcontext()
257
+ )
258
+ try:
259
+ with log_context:
260
+ try:
261
+ return aiomisc.run(
262
+ run(arguments=arguments, options=options),
263
+ pool_size=max(4, arguments.concurrency + 1),
264
+ log_level=arguments.log_level,
265
+ log_format=log_format,
266
+ log_date_format=log_date_format,
267
+ log_config=not bool(arguments.log_file),
268
+ )
269
+ except (KeyboardInterrupt, asyncio.CancelledError):
270
+ log.warning("copy_cancelled")
271
+ return 130
272
+ except Exception:
273
+ log.exception("copy_failed")
274
+ return 1
275
+ except OSError as exc:
276
+ print(f"Cannot write log file {arguments.log_file}: {exc}", file=sys.stderr)
277
+ return 1
ytclone/codec.py ADDED
@@ -0,0 +1,79 @@
1
+ """Small, bounded bridges between async storage and synchronous codecs/SDKs."""
2
+
3
+ import asyncio
4
+ import concurrent.futures
5
+ import io
6
+ import threading
7
+ from collections.abc import AsyncIterator
8
+
9
+
10
+ class AsyncReader(io.RawIOBase):
11
+ """Bridge an async byte stream to a worker with one chunk buffered."""
12
+
13
+ def __init__(self, *, chunks: AsyncIterator[bytes], loop):
14
+ """Bind an async byte source to a synchronous reader."""
15
+ super().__init__()
16
+ self._chunks = chunks.__aiter__()
17
+ self._loop = loop
18
+ self._buffer = memoryview(b"")
19
+ self._pending = None
20
+ self._lock = threading.Lock()
21
+ self._cancelled = False
22
+ self.exhausted = False
23
+
24
+ def readable(self):
25
+ """Advertise support for reading bytes."""
26
+ return True
27
+
28
+ def cancel(self):
29
+ """Interrupt pending input and prevent further reads."""
30
+ with self._lock:
31
+ self._cancelled = True
32
+ if self._pending is not None:
33
+ self._pending.cancel()
34
+
35
+ def _next(self):
36
+ """Pull one async chunk from the event loop into the worker."""
37
+ with self._lock:
38
+ if self._cancelled:
39
+ raise InterruptedError("Restore input cancelled")
40
+ self._pending = asyncio.run_coroutine_threadsafe(
41
+ coro=self._chunks.__anext__(), loop=self._loop
42
+ )
43
+ pending = self._pending
44
+
45
+ try:
46
+ return pending.result()
47
+ except StopAsyncIteration:
48
+ self.exhausted = True
49
+ return b""
50
+ except concurrent.futures.CancelledError as error:
51
+ raise InterruptedError("Restore input cancelled") from error
52
+ finally:
53
+ with self._lock:
54
+ self._pending = None
55
+
56
+ def read(self, size=-1):
57
+ """Return a bounded slice of the buffered input."""
58
+ if size < 0:
59
+ raise ValueError("Unbounded reads are not supported")
60
+ if size == 0:
61
+ return b""
62
+
63
+ while not self._buffer and not self.exhausted:
64
+ self._buffer = memoryview(self._next())
65
+
66
+ result = bytes(self._buffer[:size])
67
+ self._buffer = self._buffer[len(result) :]
68
+ return result
69
+
70
+ def readinto(self, buffer):
71
+ """Fill the supplied buffer with available bytes."""
72
+ data = self.read(size=len(buffer))
73
+ buffer[: len(data)] = data
74
+ return len(data)
75
+
76
+ def finish(self):
77
+ """Reach source EOF so size/checksum validation cannot be bypassed."""
78
+ if self._buffer or self.read(size=1):
79
+ raise ValueError("Compressed input was not fully consumed")