ytclone 0.1.5__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.
ytclone-0.1.5/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Vasin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ytclone-0.1.5/PKG-INFO ADDED
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: ytclone
3
+ Version: 0.1.5
4
+ Summary: Resumable copying of YTsaurus tables, files and directories to and from S3
5
+ Author: Alexander Vasin
6
+ Author-email: Alexander Vasin <hi@alvass.in>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Operating System :: MacOS :: MacOS X
11
+ Requires-Dist: aiochannel>=1.3,<2
12
+ Requires-Dist: aiohttp>=3.14.3,<4
13
+ Requires-Dist: aiohttp-s3-client>=1.0.14,<2
14
+ Requires-Dist: aiomisc>=17.10.3,<19
15
+ Requires-Dist: argclass>=1.11.1,<2
16
+ Requires-Dist: ytsaurus-client>=0.13.54,<0.14
17
+ Requires-Dist: ytsaurus-yson>=0.4.10,<0.5
18
+ Requires-Dist: zstandard>=0.25.0,<1
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+
22
+ # ytclone
23
+
24
+ Copy static tables, files, and directories between YTsaurus and S3, preserving
25
+ schemas and portable attributes. Repeat a command to skip completed, unchanged
26
+ nodes and retry unfinished copies.
27
+
28
+ Restoring from S3 requires an archive created by `ytclone`.
29
+
30
+ ## Installation
31
+
32
+ Install from PyPI in your Python virtual environment:
33
+
34
+ ```bash
35
+ pip install ytclone
36
+ ```
37
+
38
+ Requires **CPython 3.10+** on **Linux x86_64 with glibc** or **macOS ARM64**
39
+ (Apple Silicon). Other platforms are currently unsupported because the required
40
+ [`ytsaurus-yson`](https://pypi.org/project/ytsaurus-yson/) dependency has no wheels
41
+ for them.
42
+
43
+ ## Quick start
44
+
45
+ Configure access to YTsaurus and S3:
46
+
47
+ ```bash
48
+ export YT_PROXY="https://yt.example.com"
49
+ export YT_TOKEN="..."
50
+ export AWS_ACCESS_KEY_ID="..."
51
+ export AWS_SECRET_ACCESS_KEY="..."
52
+ export AWS_DEFAULT_REGION="us-east-1"
53
+ ```
54
+
55
+ The S3 bucket must already exist. For an S3-compatible service, also set
56
+ `S3_ENDPOINT` to its URL. Use `AWS_SESSION_TOKEN` for temporary credentials.
57
+
58
+ **Copy to S3:**
59
+
60
+ ```bash
61
+ ytclone //home/analytics/events s3://my-bucket/export --log-file export.log
62
+ ```
63
+
64
+ The full YT path is appended to the S3 prefix: this command stores `events` at
65
+ `s3://my-bucket/export/home/analytics/events/`.
66
+
67
+ **Restore to YTsaurus:**
68
+
69
+ ```bash
70
+ ytclone s3://my-bucket/export/home/analytics/events \
71
+ //home/analytics/events_copy --log-file restore.log
72
+ ```
73
+
74
+ The restore destination is used exactly as given: the children of `events` go
75
+ directly under `//home/analytics/events_copy`. The same commands work for a single
76
+ table or file. With `--log-file`, progress appears on the console and detailed
77
+ logs are appended to the file.
78
+
79
+ ## Repeated runs
80
+
81
+ Run the same command again after an interruption or a source update. Completed,
82
+ unchanged nodes are skipped. New, changed, or incomplete nodes are copied again;
83
+ an interrupted table or file starts from the beginning, not from its last part.
84
+
85
+ Source data is never deleted. Destination nodes absent from the source are kept.
86
+ Use a separate S3 prefix for each source cluster.
87
+
88
+ ## Parallel export
89
+
90
+ For trees with many tables or files, use multiple copy processes:
91
+
92
+ ```bash
93
+ ytclone-parallel //home/analytics/events s3://my-bucket/export \
94
+ --processes 2 --concurrency 2 --log-file export.log
95
+ ```
96
+
97
+ This runs two processes with two transfers each, prioritizing directories and
98
+ small nodes. One table still occupies one transfer slot. Parallel mode supports
99
+ YT → S3; restore its archives with `ytclone` as above.
100
+
101
+ ## Before you copy
102
+
103
+ - **Supported node types:** static tables, files, and directories. Dynamic tables
104
+ are skipped and reported; links are unsupported.
105
+ - **One current S3 copy.** There is no archive version history. Replacing a node
106
+ makes its archive unavailable until completion; a failed replacement can lose
107
+ the previous complete copy.
108
+ - **Permissions are not copied.** ACLs, ownership, and account settings are
109
+ excluded. Replacing an existing YT table or file recreates the node, so its
110
+ previous settings are not preserved either.
111
+ - **Consistency is per node.** A directory tree is not a single point-in-time
112
+ snapshot.
113
+ - **Avoid overlapping writers.** Do not run commands that write to the same
114
+ destination nodes concurrently, or restore an archive while it is being replaced.
115
+
116
+ ## Documentation
117
+
118
+ Run `ytclone --help` or `ytclone-parallel --help` for all command-line options.
119
+
120
+ - [User guide](https://github.com/alvassin/ytclone/blob/main/USAGE.md): paths,
121
+ configuration, progress, retries, and parallel export.
122
+ - [Architecture](https://github.com/alvassin/ytclone/blob/main/ARCHITECTURE.md):
123
+ archive format, change detection, and execution diagrams.
124
+ - [Contributing](https://github.com/alvassin/ytclone/blob/main/CONTRIBUTING.md):
125
+ installation from source, development, and tests.
126
+
127
+ ## License
128
+
129
+ [MIT](https://github.com/alvassin/ytclone/blob/main/LICENSE).
@@ -0,0 +1,108 @@
1
+ # ytclone
2
+
3
+ Copy static tables, files, and directories between YTsaurus and S3, preserving
4
+ schemas and portable attributes. Repeat a command to skip completed, unchanged
5
+ nodes and retry unfinished copies.
6
+
7
+ Restoring from S3 requires an archive created by `ytclone`.
8
+
9
+ ## Installation
10
+
11
+ Install from PyPI in your Python virtual environment:
12
+
13
+ ```bash
14
+ pip install ytclone
15
+ ```
16
+
17
+ Requires **CPython 3.10+** on **Linux x86_64 with glibc** or **macOS ARM64**
18
+ (Apple Silicon). Other platforms are currently unsupported because the required
19
+ [`ytsaurus-yson`](https://pypi.org/project/ytsaurus-yson/) dependency has no wheels
20
+ for them.
21
+
22
+ ## Quick start
23
+
24
+ Configure access to YTsaurus and S3:
25
+
26
+ ```bash
27
+ export YT_PROXY="https://yt.example.com"
28
+ export YT_TOKEN="..."
29
+ export AWS_ACCESS_KEY_ID="..."
30
+ export AWS_SECRET_ACCESS_KEY="..."
31
+ export AWS_DEFAULT_REGION="us-east-1"
32
+ ```
33
+
34
+ The S3 bucket must already exist. For an S3-compatible service, also set
35
+ `S3_ENDPOINT` to its URL. Use `AWS_SESSION_TOKEN` for temporary credentials.
36
+
37
+ **Copy to S3:**
38
+
39
+ ```bash
40
+ ytclone //home/analytics/events s3://my-bucket/export --log-file export.log
41
+ ```
42
+
43
+ The full YT path is appended to the S3 prefix: this command stores `events` at
44
+ `s3://my-bucket/export/home/analytics/events/`.
45
+
46
+ **Restore to YTsaurus:**
47
+
48
+ ```bash
49
+ ytclone s3://my-bucket/export/home/analytics/events \
50
+ //home/analytics/events_copy --log-file restore.log
51
+ ```
52
+
53
+ The restore destination is used exactly as given: the children of `events` go
54
+ directly under `//home/analytics/events_copy`. The same commands work for a single
55
+ table or file. With `--log-file`, progress appears on the console and detailed
56
+ logs are appended to the file.
57
+
58
+ ## Repeated runs
59
+
60
+ Run the same command again after an interruption or a source update. Completed,
61
+ unchanged nodes are skipped. New, changed, or incomplete nodes are copied again;
62
+ an interrupted table or file starts from the beginning, not from its last part.
63
+
64
+ Source data is never deleted. Destination nodes absent from the source are kept.
65
+ Use a separate S3 prefix for each source cluster.
66
+
67
+ ## Parallel export
68
+
69
+ For trees with many tables or files, use multiple copy processes:
70
+
71
+ ```bash
72
+ ytclone-parallel //home/analytics/events s3://my-bucket/export \
73
+ --processes 2 --concurrency 2 --log-file export.log
74
+ ```
75
+
76
+ This runs two processes with two transfers each, prioritizing directories and
77
+ small nodes. One table still occupies one transfer slot. Parallel mode supports
78
+ YT → S3; restore its archives with `ytclone` as above.
79
+
80
+ ## Before you copy
81
+
82
+ - **Supported node types:** static tables, files, and directories. Dynamic tables
83
+ are skipped and reported; links are unsupported.
84
+ - **One current S3 copy.** There is no archive version history. Replacing a node
85
+ makes its archive unavailable until completion; a failed replacement can lose
86
+ the previous complete copy.
87
+ - **Permissions are not copied.** ACLs, ownership, and account settings are
88
+ excluded. Replacing an existing YT table or file recreates the node, so its
89
+ previous settings are not preserved either.
90
+ - **Consistency is per node.** A directory tree is not a single point-in-time
91
+ snapshot.
92
+ - **Avoid overlapping writers.** Do not run commands that write to the same
93
+ destination nodes concurrently, or restore an archive while it is being replaced.
94
+
95
+ ## Documentation
96
+
97
+ Run `ytclone --help` or `ytclone-parallel --help` for all command-line options.
98
+
99
+ - [User guide](https://github.com/alvassin/ytclone/blob/main/USAGE.md): paths,
100
+ configuration, progress, retries, and parallel export.
101
+ - [Architecture](https://github.com/alvassin/ytclone/blob/main/ARCHITECTURE.md):
102
+ archive format, change detection, and execution diagrams.
103
+ - [Contributing](https://github.com/alvassin/ytclone/blob/main/CONTRIBUTING.md):
104
+ installation from source, development, and tests.
105
+
106
+ ## License
107
+
108
+ [MIT](https://github.com/alvassin/ytclone/blob/main/LICENSE).
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "ytclone"
3
+ version = "0.1.5"
4
+ description = "Resumable copying of YTsaurus tables, files and directories to and from S3"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ classifiers = [
10
+ "Operating System :: POSIX :: Linux",
11
+ "Operating System :: MacOS :: MacOS X",
12
+ ]
13
+ dependencies = [
14
+ "aiochannel>=1.3,<2",
15
+ "aiohttp>=3.14.3,<4",
16
+ "aiohttp-s3-client>=1.0.14,<2",
17
+ "aiomisc>=17.10.3,<19",
18
+ "argclass>=1.11.1,<2",
19
+ "ytsaurus-client>=0.13.54,<0.14",
20
+ "ytsaurus-yson>=0.4.10,<0.5",
21
+ "zstandard>=0.25.0,<1",
22
+ ]
23
+
24
+ [[project.authors]]
25
+ name = "Alexander Vasin"
26
+ email = "hi@alvass.in"
27
+
28
+ [project.scripts]
29
+ ytclone = "ytclone:main"
30
+ ytclone-parallel = "ytclone.parallel.cli:main"
31
+
32
+ [build-system]
33
+ requires = ["uv_build>=0.12.19,<0.13.0"]
34
+ build-backend = "uv_build"
35
+
36
+ [dependency-groups]
37
+ dev = [
38
+ "pytest>=8",
39
+ "ruff>=0.16",
40
+ ]
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "ytclone"
3
+ version = "0.1.5"
4
+ description = "Resumable copying of YTsaurus tables, files and directories to and from S3"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Alexander Vasin", email = "hi@alvass.in" }
10
+ ]
11
+ requires-python = ">=3.10"
12
+ # Native ytsaurus-yson wheels limit support to Linux x86_64 (glibc)
13
+ # and macOS ARM64. See README.md for installation requirements.
14
+ classifiers = [
15
+ "Operating System :: POSIX :: Linux",
16
+ "Operating System :: MacOS :: MacOS X",
17
+ ]
18
+ dependencies = [
19
+ "aiochannel>=1.3,<2",
20
+ "aiohttp>=3.14.3,<4",
21
+ "aiohttp-s3-client>=1.0.14,<2",
22
+ "aiomisc>=17.10.3,<19",
23
+ "argclass>=1.11.1,<2",
24
+ "ytsaurus-client>=0.13.54,<0.14",
25
+ "ytsaurus-yson>=0.4.10,<0.5",
26
+ "zstandard>=0.25.0,<1",
27
+ ]
28
+
29
+ [project.scripts]
30
+ ytclone = "ytclone:main"
31
+ ytclone-parallel = "ytclone.parallel.cli:main"
32
+
33
+ [build-system]
34
+ requires = ["uv_build>=0.12.19,<0.13.0"]
35
+ build-backend = "uv_build"
36
+
37
+ [dependency-groups]
38
+ dev = ["pytest>=8", "ruff>=0.16"]
@@ -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()
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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