yd-cli 0.6__tar.gz → 0.8__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.
- {yd_cli-0.6 → yd_cli-0.8}/PKG-INFO +1 -1
- {yd_cli-0.6 → yd_cli-0.8}/pyproject.toml +3 -1
- {yd_cli-0.6 → yd_cli-0.8}/src/yd/cli.py +68 -100
- {yd_cli-0.6 → yd_cli-0.8}/src/yd/commands.py +5 -2
- yd_cli-0.8/src/yd/echo.py +12 -0
- {yd_cli-0.6 → yd_cli-0.8}/src/yd/io.py +8 -10
- {yd_cli-0.6 → yd_cli-0.8}/src/yd/types.py +7 -7
- {yd_cli-0.6 → yd_cli-0.8}/LICENSES/MIT.txt +0 -0
- {yd_cli-0.6 → yd_cli-0.8}/README.md +0 -0
- {yd_cli-0.6 → yd_cli-0.8}/src/yd/__init__.py +0 -0
- {yd_cli-0.6 → yd_cli-0.8}/src/yd/__main__.py +0 -0
- {yd_cli-0.6 → yd_cli-0.8}/src/yd/execution.py +0 -0
- {yd_cli-0.6 → yd_cli-0.8}/src/yd/py.typed +0 -0
|
@@ -4,7 +4,7 @@ requires = [ "uv-build>=0.10,<0.12" ]
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "yd-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8"
|
|
8
8
|
description = "CLI tool to synchronize directories using rsync."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -147,6 +147,8 @@ lint.ignore = [
|
|
|
147
147
|
"PLR0913",
|
|
148
148
|
# Comparison with hardcoded literals allowed.
|
|
149
149
|
"PLR2004",
|
|
150
|
+
# open(Path) is ok
|
|
151
|
+
"PTH123",
|
|
150
152
|
"Q000",
|
|
151
153
|
"Q001",
|
|
152
154
|
"Q002",
|
|
@@ -24,7 +24,6 @@ import yd
|
|
|
24
24
|
if TYPE_CHECKING:
|
|
25
25
|
import enum
|
|
26
26
|
from collections.abc import Callable
|
|
27
|
-
from collections.abc import Set as AbstractSet
|
|
28
27
|
|
|
29
28
|
from rich.text import Text
|
|
30
29
|
|
|
@@ -41,7 +40,6 @@ class StrEnumHighlighter[T: enum.StrEnum](
|
|
|
41
40
|
enum_type: type[T]
|
|
42
41
|
|
|
43
42
|
_: dataclasses.KW_ONLY
|
|
44
|
-
ignore: AbstractSet[T] = dataclasses.field(default_factory=set)
|
|
45
43
|
base_style_name: str | None = None
|
|
46
44
|
|
|
47
45
|
@property
|
|
@@ -55,38 +53,36 @@ class StrEnumHighlighter[T: enum.StrEnum](
|
|
|
55
53
|
def highlight(self, text: Text) -> None:
|
|
56
54
|
base_style, hl = self.base_style, text.highlight_regex
|
|
57
55
|
for cat in self.enum_type:
|
|
58
|
-
if cat in self.ignore:
|
|
59
|
-
continue
|
|
60
56
|
# Using `__str__` instead of `cat.name.lower()` for more flexibility.
|
|
61
57
|
hl(rf"(?P<{cat}>{cat})", style_prefix=base_style)
|
|
62
58
|
|
|
63
59
|
def create_theme_addon(self, *, styler: Callable[[T], str]) -> dict[str, str]:
|
|
64
60
|
base_style = self.base_style
|
|
65
|
-
return {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
61
|
+
return {base_style + str(cat): styler(cat) for cat in self.enum_type}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _action_name(action: yd.types.Action) -> str:
|
|
65
|
+
if action is yd.types.Action.HARDLINK:
|
|
66
|
+
return str(yd.types.Action.COPY)
|
|
67
|
+
return str(action)
|
|
70
68
|
|
|
71
69
|
|
|
72
70
|
def _action_styler(action: yd.types.Action) -> str:
|
|
73
71
|
match action:
|
|
74
72
|
case yd.types.Action.CREATE:
|
|
75
|
-
return "bold #
|
|
73
|
+
return "bold #606060 on #0050aa"
|
|
76
74
|
case yd.types.Action.COPY:
|
|
77
|
-
return "bold #
|
|
75
|
+
return "bold #606060 on #008020"
|
|
78
76
|
case yd.types.Action.DELETE:
|
|
79
|
-
return "bold #
|
|
77
|
+
return "bold #606060 on #aa0000"
|
|
80
78
|
case yd.types.Action.HARDLINK:
|
|
81
79
|
return "bold #aaaaaa on #606060"
|
|
82
80
|
case yd.types.Action.ATTRUPDATE:
|
|
83
|
-
return "bold #
|
|
81
|
+
return "bold #606060 on #ffffaa"
|
|
84
82
|
|
|
85
83
|
|
|
86
84
|
def _setup_rich_devices() -> tuple[rich.console.Console, StrEnumHighlighter]:
|
|
87
|
-
highlighter = StrEnumHighlighter(
|
|
88
|
-
yd.types.Action, ignore={yd.types.Action.ATTRUPDATE}
|
|
89
|
-
)
|
|
85
|
+
highlighter = StrEnumHighlighter(yd.types.Action)
|
|
90
86
|
theme = rich.theme.Theme(
|
|
91
87
|
highlighter.create_theme_addon(styler=_action_styler)
|
|
92
88
|
| {
|
|
@@ -115,6 +111,44 @@ available configurations together with their descriptions.
|
|
|
115
111
|
app = typer.Typer(help=_YD_HELP, no_args_is_help=True, rich_markup_mode="markdown")
|
|
116
112
|
|
|
117
113
|
|
|
114
|
+
# Use `list` as return value since type must be available at runtime for typer to work.
|
|
115
|
+
# Argument is passed as kwarg by typer.
|
|
116
|
+
def _complete_config_name(incomplete: str) -> list[str]:
|
|
117
|
+
env = yd.io.capture_environment()
|
|
118
|
+
return [
|
|
119
|
+
path.stem
|
|
120
|
+
for path in env.config_dir.glob("*.toml")
|
|
121
|
+
if re.match(incomplete, path.stem, re.IGNORECASE)
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _require_nonempty(param: typer.CallbackParam, v: str | None) -> str | None:
|
|
126
|
+
if v is None or v:
|
|
127
|
+
return v
|
|
128
|
+
raise typer.BadParameter(f"parameter `{param.name}` must not be an empty string")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_from_stdin(opt: str, /) -> str:
|
|
132
|
+
value = sys.stdin.read().strip()
|
|
133
|
+
if not value:
|
|
134
|
+
raise typer.BadParameter(f"no input via STDIN; `{opt}` cannot be determined")
|
|
135
|
+
return value
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _resolve_home(
|
|
139
|
+
*, src: str | None, target: str | None
|
|
140
|
+
) -> tuple[str | None, str | None]:
|
|
141
|
+
if src == "-" and target == "-":
|
|
142
|
+
raise typer.BadParameter(
|
|
143
|
+
"only one of `src-home` and `target-home` can be read from stdin."
|
|
144
|
+
)
|
|
145
|
+
if src == "-":
|
|
146
|
+
return _read_from_stdin("src-home"), target
|
|
147
|
+
if target == "-":
|
|
148
|
+
return src, _read_from_stdin("target-home")
|
|
149
|
+
return src, target
|
|
150
|
+
|
|
151
|
+
|
|
118
152
|
@app.callback()
|
|
119
153
|
def _setup_io() -> None:
|
|
120
154
|
global env, console, highlighter # noqa: PLW0603
|
|
@@ -159,79 +193,10 @@ def _setup_io() -> None:
|
|
|
159
193
|
handler.setFormatter(JsonLineFormatter())
|
|
160
194
|
|
|
161
195
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
non_desc_with = (
|
|
167
|
-
10 # time elapsed
|
|
168
|
-
+ 2 # dir symbol
|
|
169
|
-
+ sum(
|
|
170
|
-
len(cat.name) + 10
|
|
171
|
-
for cat in highlighter.enum_type
|
|
172
|
-
if cat not in highlighter.ignore
|
|
173
|
-
)
|
|
196
|
+
def _description_table_column(min_width: int = 10) -> rich.table.Column:
|
|
197
|
+
return rich.table.Column(
|
|
198
|
+
min_width=min_width, no_wrap=True, overflow="ellipsis", ratio=1
|
|
174
199
|
)
|
|
175
|
-
return max(min_width, shutil.get_terminal_size().columns - non_desc_with)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def _clip_path(path: str, *, max_len: int, prefix: str = ".../") -> str:
|
|
179
|
-
from pathlib import Path
|
|
180
|
-
|
|
181
|
-
if len(label := path) <= max_len:
|
|
182
|
-
return label
|
|
183
|
-
|
|
184
|
-
path_, max_len = Path(path), max_len - len(prefix)
|
|
185
|
-
parts = [
|
|
186
|
-
part
|
|
187
|
-
for part, cum_len in zip(
|
|
188
|
-
reversed(path_.parts),
|
|
189
|
-
# Count separating `/` character.
|
|
190
|
-
it.accumulate(len(s) + 1 for s in reversed(path_.parts)),
|
|
191
|
-
strict=True,
|
|
192
|
-
)
|
|
193
|
-
# Remove overcounted leading `/` character.
|
|
194
|
-
if cum_len - 1 <= max_len
|
|
195
|
-
]
|
|
196
|
-
return prefix + "/".join(reversed(parts))
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
# Use `list` as return value since type must be available at runtime for typer to work.
|
|
200
|
-
# Argument is passed as kwarg by typer.
|
|
201
|
-
def _complete_config_name(incomplete: str) -> list[str]:
|
|
202
|
-
env = yd.io.capture_environment()
|
|
203
|
-
return [
|
|
204
|
-
path.stem
|
|
205
|
-
for path in env.config_dir.glob("*.toml")
|
|
206
|
-
if re.match(incomplete, path.stem, re.IGNORECASE)
|
|
207
|
-
]
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def _read_from_stdin(opt: str, /) -> str:
|
|
211
|
-
value = sys.stdin.read().strip()
|
|
212
|
-
if not value:
|
|
213
|
-
raise typer.BadParameter(f"no input via STDIN; `{opt}` cannot be determined")
|
|
214
|
-
return value
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def _resolve_home(
|
|
218
|
-
*, src: str | None, target: str | None
|
|
219
|
-
) -> tuple[str | None, str | None]:
|
|
220
|
-
if src == "-" and target == "-":
|
|
221
|
-
raise typer.BadParameter(
|
|
222
|
-
"only one of `src-home` and `target-home` can be read from stdin."
|
|
223
|
-
)
|
|
224
|
-
if src == "-":
|
|
225
|
-
return _read_from_stdin("src-home"), target
|
|
226
|
-
if target == "-":
|
|
227
|
-
return src, _read_from_stdin("target-home")
|
|
228
|
-
return src, target
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def _require_nonempty(param: typer.CallbackParam, v: str | None) -> str | None:
|
|
232
|
-
if v is None or v:
|
|
233
|
-
return v
|
|
234
|
-
raise typer.BadParameter(f"parameter `{param.name}` must not be an empty string")
|
|
235
200
|
|
|
236
201
|
|
|
237
202
|
@app.command()
|
|
@@ -343,7 +308,11 @@ def run(
|
|
|
343
308
|
),
|
|
344
309
|
rich.progress.TextColumn(
|
|
345
310
|
"{task.description}",
|
|
346
|
-
table_column=
|
|
311
|
+
table_column=_description_table_column(),
|
|
312
|
+
),
|
|
313
|
+
rich.progress.TextColumn(
|
|
314
|
+
"",
|
|
315
|
+
table_column=rich.table.Column(width=8),
|
|
347
316
|
),
|
|
348
317
|
*it.chain.from_iterable(
|
|
349
318
|
(
|
|
@@ -359,11 +328,9 @@ def run(
|
|
|
359
328
|
table_column=rich.table.Column(min_width=7),
|
|
360
329
|
),
|
|
361
330
|
)
|
|
362
|
-
for action_name in
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
if cat not in highlighter.ignore
|
|
366
|
-
)
|
|
331
|
+
for action_name in {
|
|
332
|
+
_action_name(cat): None for cat in highlighter.enum_type
|
|
333
|
+
}
|
|
367
334
|
),
|
|
368
335
|
console=console,
|
|
369
336
|
) as progress,
|
|
@@ -382,19 +349,16 @@ def run(
|
|
|
382
349
|
console.print(text, style="info")
|
|
383
350
|
return
|
|
384
351
|
case yd.types.Transaction() as transaction:
|
|
385
|
-
if transaction.action is yd.types.Action.ATTRUPDATE:
|
|
386
|
-
return
|
|
387
|
-
|
|
388
352
|
console.print(
|
|
389
353
|
template.format(
|
|
390
|
-
action=transaction.action,
|
|
354
|
+
action=_action_name(transaction.action),
|
|
391
355
|
filename=target.joinpath(transaction.filename).as_posix()
|
|
392
356
|
if not Path(transaction.filename).is_absolute()
|
|
393
357
|
else transaction.filename,
|
|
394
358
|
transfer_bytes=transaction.transfer_bytes,
|
|
395
359
|
)
|
|
396
360
|
)
|
|
397
|
-
counter[
|
|
361
|
+
counter[_action_name(transaction.action)] += 1
|
|
398
362
|
progress.update(
|
|
399
363
|
taskid,
|
|
400
364
|
**counter, # type: ignore[ty:invalid-argument-type]
|
|
@@ -403,7 +367,7 @@ def run(
|
|
|
403
367
|
for binary, args in commands:
|
|
404
368
|
counter = {str(action): 0 for action in yd.types.Action}
|
|
405
369
|
taskid = progress.add_task(
|
|
406
|
-
description=
|
|
370
|
+
description=args[-1],
|
|
407
371
|
**counter, # type: ignore[ty:invalid-argument-type]
|
|
408
372
|
total=1,
|
|
409
373
|
)
|
|
@@ -423,6 +387,10 @@ def run(
|
|
|
423
387
|
)
|
|
424
388
|
)
|
|
425
389
|
except (KeyboardInterrupt, asyncio.CancelledError) as err:
|
|
390
|
+
# The Runner instance registers a custom signal handler for
|
|
391
|
+
# SIGINT that cancels the main task and raises KeyboardInterrupt if
|
|
392
|
+
# no custom handler is registered. Catching the interrupt here is
|
|
393
|
+
# safer then interfering with the default behavior.
|
|
426
394
|
console.print("Terminated by user.", style="warning")
|
|
427
395
|
raise typer.Exit(2) from err
|
|
428
396
|
except (yd.types.OutputParseError, yd.types.OutputConsumeError) as err:
|
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
import itertools as it
|
|
9
9
|
import logging
|
|
10
|
+
import sys
|
|
10
11
|
import uuid
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
|
|
@@ -48,8 +49,10 @@ def _format_path(path: Path, /) -> str:
|
|
|
48
49
|
|
|
49
50
|
def _create_error_command(
|
|
50
51
|
enc_msg: str, /, environment: types.Environment
|
|
51
|
-
) -> tuple[str, tuple[str]]:
|
|
52
|
-
|
|
52
|
+
) -> tuple[str, tuple[str, ...]]:
|
|
53
|
+
if environment.echo_bin is not None:
|
|
54
|
+
return environment.echo_bin, (enc_msg,)
|
|
55
|
+
return sys.executable, ("-m", "yd.echo", enc_msg)
|
|
53
56
|
|
|
54
57
|
|
|
55
58
|
def create( # noqa: PLR0915
|
|
@@ -59,10 +59,7 @@ def _find_binary(binary: str, /, accept_failure: bool = False) -> str | None:
|
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
def capture_environment() -> types.Environment:
|
|
62
|
-
"""Capture environment.
|
|
63
|
-
|
|
64
|
-
Raises `types.ConfigLoadError` in case of failure.
|
|
65
|
-
"""
|
|
62
|
+
"""Capture environment."""
|
|
66
63
|
home_dir = Path.home()
|
|
67
64
|
if not home_dir.exists():
|
|
68
65
|
raise types.EnvCaptureError(f"home dir `{home_dir}` does not exist")
|
|
@@ -85,11 +82,12 @@ def capture_environment() -> types.Environment:
|
|
|
85
82
|
rsync_bin = _find_binary(_getenv("YD_RSYNC", "rsync"))
|
|
86
83
|
except FileNotFoundError as err:
|
|
87
84
|
raise types.EnvCaptureError("rsync binary not found") from err
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
raise types.EnvCaptureError("echo binary not found") from err
|
|
85
|
+
echo_bin = _find_binary(_getenv("YD_ECHO", "echo"), accept_failure=True)
|
|
86
|
+
if echo_bin is None:
|
|
87
|
+
log.warning("Failed to find echo command. Resorting to builtin alternative.")
|
|
92
88
|
editor_bin = _find_binary(_getenv("EDITOR", "vim"), accept_failure=True)
|
|
89
|
+
if editor_bin is None:
|
|
90
|
+
log.warning("Failed to find editor command.")
|
|
93
91
|
|
|
94
92
|
try:
|
|
95
93
|
log_level: int | None = int(os.getenv("YD_LOGLEVEL", ""))
|
|
@@ -116,7 +114,7 @@ def load_command_group(path: Path, /) -> types.CommandGroup:
|
|
|
116
114
|
Path to TOML file specifying the program.
|
|
117
115
|
"""
|
|
118
116
|
try:
|
|
119
|
-
with
|
|
117
|
+
with open(path, mode="rb") as fh:
|
|
120
118
|
return msgspec.toml.decode(fh.read(), type=types.CommandGroup)
|
|
121
119
|
except FileNotFoundError as err:
|
|
122
120
|
log.exception("missing configuration file `%s`", path)
|
|
@@ -141,7 +139,7 @@ def list_available_configs(config_dir: Path, /) -> Iterable[types.AvailableConfi
|
|
|
141
139
|
if not path.is_file():
|
|
142
140
|
continue
|
|
143
141
|
try:
|
|
144
|
-
with
|
|
142
|
+
with open(path, encoding="utf-8") as fh:
|
|
145
143
|
# Benchmarked variants using one or more of `itertools.takewhile` and
|
|
146
144
|
# `yield` but this had best runtime.
|
|
147
145
|
lines = []
|
|
@@ -16,26 +16,26 @@ if TYPE_CHECKING:
|
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
class
|
|
19
|
+
class YdError(RuntimeError): ...
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
class ConfigLoadError(
|
|
22
|
+
class ConfigLoadError(YdError):
|
|
23
23
|
"""Exception raised when loading a configuration fails."""
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
class EnvCaptureError(
|
|
26
|
+
class EnvCaptureError(YdError):
|
|
27
27
|
"""Exception raised when capturing the environment fails."""
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
class CommandBuildError(
|
|
30
|
+
class CommandBuildError(YdError):
|
|
31
31
|
"""Exception raised when building the rsync command fails."""
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
class OutputParseError(
|
|
34
|
+
class OutputParseError(YdError):
|
|
35
35
|
"""Exception raised when parsing output of other CLI apps fails."""
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
class OutputConsumeError(
|
|
38
|
+
class OutputConsumeError(YdError):
|
|
39
39
|
"""Exception raised when the consumption of parsed output fails."""
|
|
40
40
|
|
|
41
41
|
|
|
@@ -48,7 +48,7 @@ class Environment:
|
|
|
48
48
|
config_dir: Path
|
|
49
49
|
|
|
50
50
|
rsync_bin: str
|
|
51
|
-
echo_bin: str
|
|
51
|
+
echo_bin: str | None
|
|
52
52
|
editor_bin: str | None
|
|
53
53
|
|
|
54
54
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|