yd-cli 0.3__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.
yd/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ # SPDX-FileCopyrightText: Christian Heinze
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """The yd synchronization tool.
5
+
6
+ Do not directly import items from this module (e.g. `from yd import io`) which
7
+ triggers `__getattr__` and thereby eager importing of the module. Instead, import
8
+ `yd` and use attribute access.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib
14
+ import importlib.metadata
15
+
16
+ TYPE_CHECKING = False
17
+ if TYPE_CHECKING:
18
+ from types import ModuleType
19
+
20
+ from yd import commands, execution, io, types
21
+ else:
22
+
23
+ def __getattr__(key: str, /) -> ModuleType:
24
+ if key in __all__:
25
+ return importlib.import_module(f"{__package__}.{key}", package=__package__)
26
+ raise AttributeError(f"module `{__name__}` has not attribute `{key}`")
27
+
28
+
29
+ __version__ = importlib.metadata.version("yd-cli")
30
+ __all__ = ["__version__", "commands", "io", "execution", "types"]
yd/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ # SPDX-FileCopyrightText: Christian Heinze
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """CLI entry point."""
5
+
6
+ from __future__ import annotations
7
+
8
+ if __name__ == "__main__":
9
+ from yd import cli
10
+
11
+ cli.app()
yd/cli.py ADDED
@@ -0,0 +1,484 @@
1
+ # SPDX-FileCopyrightText: Christian Heinze
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """CLI."""
5
+
6
+ # ruff: noqa: PLC0415
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import itertools as it
12
+ import re
13
+ import sys
14
+ from typing import TYPE_CHECKING, Annotated
15
+
16
+ import rich.console
17
+ import rich.highlighter
18
+ import rich.theme
19
+ import typer
20
+
21
+ import yd
22
+
23
+ if TYPE_CHECKING:
24
+ import enum
25
+ from collections.abc import Callable
26
+ from collections.abc import Set as AbstractSet
27
+
28
+ from rich.text import Text
29
+
30
+ env: yd.types.Environment
31
+ console: rich.console.Console
32
+ highlighter: StrEnumHighlighter
33
+
34
+
35
+ @dataclasses.dataclass(slots=True)
36
+ class StrEnumHighlighter[T: enum.StrEnum](
37
+ # Solely defines a __call__ method wrapping the abstract method `highlight`.
38
+ rich.highlighter.Highlighter
39
+ ):
40
+ enum_type: type[T]
41
+
42
+ _: dataclasses.KW_ONLY
43
+ ignore: AbstractSet[T] = dataclasses.field(default_factory=set)
44
+ base_style_name: str | None = None
45
+
46
+ @property
47
+ def base_style(self) -> str:
48
+ return (
49
+ self.enum_type.__qualname__.lower()
50
+ if self.base_style_name is None
51
+ else self.base_style_name
52
+ )
53
+
54
+ def highlight(self, text: Text) -> None:
55
+ base_style, hl = self.base_style, text.highlight_regex
56
+ for cat in self.enum_type:
57
+ if cat in self.ignore:
58
+ continue
59
+ # Using `__str__` instead of `cat.name.lower()` for more flexibility.
60
+ hl(rf"(?P<{cat}>{cat})", style_prefix=base_style)
61
+
62
+ def create_theme_addon(self, *, styler: Callable[[T], str]) -> dict[str, str]:
63
+ base_style = self.base_style
64
+ return {
65
+ base_style + str(cat): styler(cat)
66
+ for cat in self.enum_type
67
+ if cat not in self.ignore
68
+ }
69
+
70
+
71
+ def _action_styler(action: yd.types.Action) -> str:
72
+ match action:
73
+ case yd.types.Action.CREATE:
74
+ return "bold #000020 on #0050aa"
75
+ case yd.types.Action.COPY:
76
+ return "bold white on #006020"
77
+ case yd.types.Action.DELETE:
78
+ return "bold #200000 on #aa0000"
79
+ case yd.types.Action.HARDLINK:
80
+ return "bold white on #606060"
81
+ case yd.types.Action.ATTRUPDATE:
82
+ return "bold white on #606060"
83
+
84
+
85
+ def _setup_rich_devices() -> tuple[rich.console.Console, StrEnumHighlighter]:
86
+ highlighter = StrEnumHighlighter(
87
+ yd.types.Action, ignore={yd.types.Action.ATTRUPDATE}
88
+ )
89
+ theme = rich.theme.Theme(
90
+ highlighter.create_theme_addon(styler=_action_styler)
91
+ | {
92
+ "progress.elapsed": "bold #ffffaa",
93
+ "info": "italic #000090",
94
+ "warning": "bold #900000",
95
+ "error": "bold #ff0000",
96
+ }
97
+ )
98
+ console = rich.console.Console(highlighter=highlighter, theme=theme)
99
+ return console, highlighter
100
+
101
+
102
+ _YD_HELP = """
103
+ Build and execute `rsync` commands from TOML configuration files.
104
+
105
+ Configuration files live in `~/.config/yd/`. If `XDG_CONFIG_HOME` is set, it
106
+ replaces only the `~/.config` part, so configurations are loaded from
107
+ `$XDG_CONFIG_HOME/yd/`. Relative `src_home` and `target_home` paths are
108
+ resolved from your home directory. Use `yd edit NAME` to edit an existing
109
+ configuration, or `yd edit --new NAME` to create one; `EDITOR` must be set for
110
+ this command. Use `yd run NAME` to execute a configuration and `yd ls` to show
111
+ available configurations together with their descriptions.
112
+ """
113
+
114
+ app = typer.Typer(help=_YD_HELP, no_args_is_help=True, rich_markup_mode="markdown")
115
+
116
+
117
+ @app.callback()
118
+ def _setup_io() -> None:
119
+ global env, console, highlighter # noqa: PLW0603
120
+
121
+ import logging
122
+ from pathlib import Path
123
+
124
+ console, highlighter = _setup_rich_devices()
125
+ try:
126
+ env = yd.io.capture_environment()
127
+ except yd.types.EnvCaptureError as err:
128
+ console.print("Failed to capture environment.", style="error")
129
+ raise typer.Exit(2) from err
130
+
131
+ if env.log_level is not None:
132
+ logging.basicConfig(
133
+ filename=Path.cwd() / ".yd.log",
134
+ encoding="utf-8",
135
+ level=env.log_level,
136
+ force=True,
137
+ )
138
+
139
+
140
+ def _description_column_width(min_width: int = 50) -> int:
141
+ import shutil
142
+
143
+ non_desc_with = (
144
+ 10 # time elapsed
145
+ + 2 # dir symbol
146
+ + sum(
147
+ len(cat.name) + 10
148
+ for cat in highlighter.enum_type
149
+ if cat not in highlighter.ignore
150
+ )
151
+ )
152
+ return max(min_width, shutil.get_terminal_size().columns - non_desc_with)
153
+
154
+
155
+ def _create_description(path: str, *, max_len: int, prefix: str = "...") -> str:
156
+ from pathlib import Path
157
+
158
+ if len(label := path) <= max_len:
159
+ return label
160
+
161
+ path_ = Path(path)
162
+ reversed_chosen_parts = tuple(
163
+ part
164
+ for part, cum_len in zip(
165
+ reversed(path_.parts),
166
+ it.accumulate(len(s) for s in reversed(path_.parts)),
167
+ strict=True,
168
+ )
169
+ if cum_len <= max_len
170
+ )
171
+ base = "/".join(reversed(reversed_chosen_parts))
172
+ if len(base) <= max_len:
173
+ return base
174
+ return f"{prefix}{base[-(max_len - len(prefix)) :]}"
175
+
176
+
177
+ # Use `list` as return value since type must be available at runtime for typer to work.
178
+ def _complete_config_name(incomplete: str, /) -> list[str]:
179
+ return [
180
+ path.stem
181
+ for path in env.config_dir.glob("*.toml")
182
+ if re.match(incomplete, path.stem, re.IGNORECASE)
183
+ ]
184
+
185
+
186
+ def _read_from_stdin(opt: str, /) -> str:
187
+ value = sys.stdin.read().strip()
188
+ if not value:
189
+ raise typer.BadParameter(f"no input via STDIN; `{opt}` cannot be determined")
190
+ return value
191
+
192
+
193
+ def _resolve_home(
194
+ *, src: str | None, target: str | None
195
+ ) -> tuple[str | None, str | None]:
196
+ if src == "-" and target == "-":
197
+ raise typer.BadParameter(
198
+ "only one of `src-home` and `target-home` can be read from stdin."
199
+ )
200
+ if src == "-":
201
+ return _read_from_stdin("src-home"), target
202
+ if target == "-":
203
+ return src, _read_from_stdin("target-home")
204
+ return src, target
205
+
206
+
207
+ def _require_nonempty(param: typer.CallbackParam, v: str | None) -> str | None:
208
+ if v is None or v:
209
+ return v
210
+ raise typer.BadParameter(f"parameter `{param.name}` must not be an empty string")
211
+
212
+
213
+ @app.command()
214
+ def run(
215
+ # Considered allowing absolute path here (to skip lookup logic). However, can be
216
+ # handled via setting an environment variable; not extra handling needed.
217
+ cfg_name: Annotated[
218
+ str,
219
+ typer.Argument(
220
+ help="Configuration name. Must match the TOML file name without `.toml`.",
221
+ autocompletion=_complete_config_name,
222
+ callback=_require_nonempty,
223
+ ),
224
+ ],
225
+ *,
226
+ no_backup: Annotated[
227
+ bool,
228
+ typer.Option(
229
+ "--no-backup",
230
+ help="Disable backup handling for this run.",
231
+ rich_help_panel="Sync options",
232
+ ),
233
+ ] = False,
234
+ keep_newer: Annotated[
235
+ bool,
236
+ typer.Option(
237
+ "--keep-newer",
238
+ help="Skip updates when the target file is newer.",
239
+ rich_help_panel="Sync options",
240
+ ),
241
+ ] = False,
242
+ rename_speedup: Annotated[
243
+ bool,
244
+ typer.Option(
245
+ "--rename-speedup",
246
+ help="Enable rsync options tuned for rename-heavy targets."
247
+ " This may require more disk space on the target.",
248
+ rich_help_panel="Sync options",
249
+ ),
250
+ ] = False,
251
+ dry_run: Annotated[
252
+ bool,
253
+ typer.Option(
254
+ "--dry-run",
255
+ help="Show what would happen without changing files.",
256
+ ),
257
+ ] = False,
258
+ src_home: Annotated[
259
+ str | None,
260
+ typer.Option(
261
+ "--src-home",
262
+ rich_help_panel="Location options",
263
+ help="Override `src_home` from the configuration."
264
+ " Use '-' to read the value from stdin.",
265
+ callback=_require_nonempty,
266
+ ),
267
+ ] = None,
268
+ target_home: Annotated[
269
+ str | None,
270
+ typer.Option(
271
+ "--target-home",
272
+ rich_help_panel="Location options",
273
+ help="Override `target_home` from the configuration."
274
+ " Use '-' to read the value from stdin.",
275
+ callback=_require_nonempty,
276
+ ),
277
+ ] = None,
278
+ ) -> int:
279
+ """Run a saved configuration by name."""
280
+ import asyncio
281
+ import functools
282
+ from pathlib import Path
283
+
284
+ import rich.progress
285
+ import rich.table
286
+
287
+ try:
288
+ cfg = yd.io.load_command_group(env.config_dir / f"{cfg_name}.toml")
289
+ except yd.types.ConfigLoadError as err:
290
+ console.print(str(err), style="error")
291
+ raise typer.Exit(2) from err
292
+
293
+ res_src_home, res_target_home = _resolve_home(src=src_home, target=target_home)
294
+ try:
295
+ commands = list(
296
+ yd.commands.create(
297
+ cfg,
298
+ env=env,
299
+ src_home=res_src_home,
300
+ target_home=res_target_home,
301
+ dry_run=dry_run,
302
+ keep_newer=keep_newer,
303
+ rename_speedup=rename_speedup,
304
+ no_backup=no_backup,
305
+ error_encoder=yd.io.encode_error,
306
+ )
307
+ )
308
+ except yd.types.CommandBuildError as err:
309
+ console.print(str(err), style="error")
310
+ raise typer.Exit(2) from err
311
+
312
+ with (
313
+ asyncio.Runner() as runner,
314
+ rich.progress.Progress(
315
+ rich.progress.TimeElapsedColumn(table_column=rich.table.Column(width=8)),
316
+ rich.progress.TextColumn(
317
+ ":file_folder:",
318
+ table_column=rich.table.Column(width=2),
319
+ ),
320
+ rich.progress.TextColumn(
321
+ "{task.description}",
322
+ table_column=rich.table.Column(width=_description_column_width()),
323
+ ),
324
+ *it.chain.from_iterable(
325
+ (
326
+ rich.progress.TextColumn(
327
+ action_name,
328
+ justify="right",
329
+ highlighter=highlighter,
330
+ table_column=rich.table.Column(width=len(action_name)),
331
+ ),
332
+ rich.progress.TextColumn(
333
+ f"{{task.fields[{action_name}]}}",
334
+ justify="right",
335
+ table_column=rich.table.Column(min_width=7),
336
+ ),
337
+ )
338
+ for action_name in (
339
+ str(cat)
340
+ for cat in highlighter.enum_type
341
+ if cat not in highlighter.ignore
342
+ )
343
+ ),
344
+ console=console,
345
+ ) as progress,
346
+ ):
347
+ template = "{action} {filename} ({transfer_bytes} bytes)"
348
+
349
+ async def show_progress(
350
+ item: str | yd.types.Transaction | None,
351
+ *,
352
+ counter: dict[str, int],
353
+ taskid: rich.progress.TaskID,
354
+ target: Path,
355
+ ) -> None:
356
+ match item:
357
+ case str() as text:
358
+ console.print(text, style="info")
359
+ return
360
+ case yd.types.Transaction() as transaction:
361
+ if transaction.action is yd.types.Action.ATTRUPDATE:
362
+ return
363
+
364
+ console.print(
365
+ template.format(
366
+ action=transaction.action,
367
+ filename=target.joinpath(transaction.filename).as_posix()
368
+ if not Path(transaction.filename).is_absolute()
369
+ else transaction.filename,
370
+ transfer_bytes=transaction.transfer_bytes,
371
+ )
372
+ )
373
+ counter[str(transaction.action)] += 1
374
+ progress.update(
375
+ taskid,
376
+ **counter, # type: ignore[ty:invalid-argument-type]
377
+ )
378
+
379
+ for binary, args in commands:
380
+ counter = {str(action): 0 for action in yd.types.Action}
381
+ taskid = progress.add_task(
382
+ description=_create_description(
383
+ args[-1], max_len=_description_column_width()
384
+ ),
385
+ **counter, # type: ignore[ty:invalid-argument-type]
386
+ total=1,
387
+ )
388
+ try:
389
+ runner.run(
390
+ yd.execution.run(
391
+ binary,
392
+ *args,
393
+ parser_stdout=yd.io.decode_from_stdout,
394
+ parser_stderr=yd.io.decode_from_stderr,
395
+ consumer=functools.partial(
396
+ show_progress,
397
+ counter=counter,
398
+ taskid=taskid,
399
+ target=Path(args[-1]),
400
+ ),
401
+ )
402
+ )
403
+ except (yd.types.OutputParseError, yd.types.OutputConsumeError) as err:
404
+ console.print("Failed to handle rsync output.", style="error")
405
+ raise typer.Exit(2) from err
406
+ progress.update(taskid, completed=1)
407
+
408
+ return 0
409
+
410
+
411
+ @app.command(name="ls")
412
+ def list_configs() -> int:
413
+ """List available configurations and their descriptions."""
414
+ import rich.table
415
+
416
+ configs = list(yd.io.list_available_configs(env.config_dir))
417
+ if not configs:
418
+ console.print("No configurations found.", style="warning")
419
+ return 0
420
+
421
+ table = rich.table.Table()
422
+ table.add_column("Configuration", style="bold")
423
+ table.add_column("Description", style="bold")
424
+ for config in configs:
425
+ table.add_row(config.name, config.description or "")
426
+ console.print(table)
427
+ return 0
428
+
429
+
430
+ @app.command()
431
+ def edit(
432
+ cfg_name: Annotated[
433
+ str | None,
434
+ typer.Argument(
435
+ help="Configuration name. Must match the TOML file name without `.toml`.",
436
+ autocompletion=_complete_config_name,
437
+ callback=_require_nonempty,
438
+ ),
439
+ ] = None,
440
+ *,
441
+ new: Annotated[
442
+ bool,
443
+ typer.Option("--new", help="Create a new configuration before opening it."),
444
+ ] = False,
445
+ ) -> int:
446
+ """Open an existing configuration or create a new one in the editor."""
447
+ import contextlib
448
+ import subprocess
449
+
450
+ if (editor_bin := env.editor_bin) is None:
451
+ raise typer.BadParameter("environment variable `EDITOR` is not set")
452
+
453
+ is_nvim = False
454
+ with contextlib.suppress(subprocess.CalledProcessError):
455
+ vers = subprocess.run(
456
+ [editor_bin, "--version"], encoding="utf-8", capture_output=True, check=True
457
+ ).stdout
458
+ is_nvim = re.match(r"\s*NVIM", vers) is not None
459
+
460
+ config_path = env.config_dir / f"{cfg_name}.toml"
461
+ if config_path.exists():
462
+ if new:
463
+ raise typer.BadParameter(f"Configuration '{cfg_name}' already exists.")
464
+ args: tuple[str, ...] = (config_path.as_posix(),)
465
+ input_: str | None = None
466
+ elif new and is_nvim:
467
+ args = (
468
+ "-",
469
+ "+setlocal filetype=toml",
470
+ f"+file {config_path.as_posix()}",
471
+ )
472
+ input_ = yd.commands.CONFIG_TEMPLATE.lstrip()
473
+ elif new:
474
+ config_path.write_text(yd.commands.CONFIG_TEMPLATE.lstrip(), encoding="utf-8")
475
+ args, input_ = (config_path.as_posix(),), None
476
+ else:
477
+ raise typer.BadParameter(
478
+ f"Configuration '{cfg_name}' does not exist. Use '--new' to create it."
479
+ )
480
+
481
+ completed = subprocess.run(
482
+ (editor_bin, *args), input=input_, encoding="utf-8", check=False
483
+ )
484
+ return completed.returncode