yd-cli 0.3__tar.gz → 0.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.
- {yd_cli-0.3 → yd_cli-0.5}/PKG-INFO +2 -2
- {yd_cli-0.3 → yd_cli-0.5}/pyproject.toml +3 -3
- {yd_cli-0.3 → yd_cli-0.5}/src/yd/cli.py +51 -26
- {yd_cli-0.3 → yd_cli-0.5}/src/yd/io.py +8 -5
- {yd_cli-0.3 → yd_cli-0.5}/LICENSES/MIT.txt +0 -0
- {yd_cli-0.3 → yd_cli-0.5}/README.md +0 -0
- {yd_cli-0.3 → yd_cli-0.5}/src/yd/__init__.py +0 -0
- {yd_cli-0.3 → yd_cli-0.5}/src/yd/__main__.py +0 -0
- {yd_cli-0.3 → yd_cli-0.5}/src/yd/commands.py +0 -0
- {yd_cli-0.3 → yd_cli-0.5}/src/yd/execution.py +0 -0
- {yd_cli-0.3 → yd_cli-0.5}/src/yd/py.typed +0 -0
- {yd_cli-0.3 → yd_cli-0.5}/src/yd/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: yd-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5
|
|
4
4
|
Summary: CLI tool to synchronize directories using rsync.
|
|
5
5
|
Author: Christian Heinze
|
|
6
6
|
License-Expression: MIT
|
|
@@ -18,7 +18,7 @@ Requires-Dist: msgspec>=0.20
|
|
|
18
18
|
Requires-Dist: rich>=14.3
|
|
19
19
|
Requires-Dist: typer>=0.24
|
|
20
20
|
Requires-Python: >=3.14
|
|
21
|
-
Project-URL: Repository, https://codeberg.org/christianheinze/
|
|
21
|
+
Project-URL: Repository, https://codeberg.org/christianheinze/yd-cli
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
|
|
24
24
|
# Directory synchronization tool `yd`
|
|
@@ -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.5"
|
|
8
8
|
description = "CLI tool to synchronize directories using rsync."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -27,7 +27,7 @@ dependencies = [
|
|
|
27
27
|
"rich>=14.3",
|
|
28
28
|
"typer>=0.24",
|
|
29
29
|
]
|
|
30
|
-
urls.Repository = "https://codeberg.org/christianheinze/
|
|
30
|
+
urls.Repository = "https://codeberg.org/christianheinze/yd-cli"
|
|
31
31
|
scripts.yd = "yd.cli:app"
|
|
32
32
|
|
|
33
33
|
[dependency-groups]
|
|
@@ -57,7 +57,7 @@ lint = [
|
|
|
57
57
|
|
|
58
58
|
[tool.uv]
|
|
59
59
|
# Suggestion from https://mkennedy.codes/posts/python-supply-chain-security-made-easy/
|
|
60
|
-
|
|
60
|
+
exclude-newer = "72 hours"
|
|
61
61
|
build-backend.module-name = "yd"
|
|
62
62
|
|
|
63
63
|
[[tool.uv.index]]
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import dataclasses
|
|
11
11
|
import itertools as it
|
|
12
|
+
import logging
|
|
12
13
|
import re
|
|
13
14
|
import sys
|
|
14
15
|
from typing import TYPE_CHECKING, Annotated
|
|
@@ -71,15 +72,15 @@ class StrEnumHighlighter[T: enum.StrEnum](
|
|
|
71
72
|
def _action_styler(action: yd.types.Action) -> str:
|
|
72
73
|
match action:
|
|
73
74
|
case yd.types.Action.CREATE:
|
|
74
|
-
return "bold #
|
|
75
|
+
return "bold #aaccff on #0050aa"
|
|
75
76
|
case yd.types.Action.COPY:
|
|
76
|
-
return "bold
|
|
77
|
+
return "bold #aaffbb on #006020"
|
|
77
78
|
case yd.types.Action.DELETE:
|
|
78
|
-
return "bold #
|
|
79
|
+
return "bold #ffaaaa on #aa0000"
|
|
79
80
|
case yd.types.Action.HARDLINK:
|
|
80
|
-
return "bold
|
|
81
|
+
return "bold #aaaaaa on #606060"
|
|
81
82
|
case yd.types.Action.ATTRUPDATE:
|
|
82
|
-
return "bold
|
|
83
|
+
return "bold #aaaaaa on #606060"
|
|
83
84
|
|
|
84
85
|
|
|
85
86
|
def _setup_rich_devices() -> tuple[rich.console.Console, StrEnumHighlighter]:
|
|
@@ -89,9 +90,9 @@ def _setup_rich_devices() -> tuple[rich.console.Console, StrEnumHighlighter]:
|
|
|
89
90
|
theme = rich.theme.Theme(
|
|
90
91
|
highlighter.create_theme_addon(styler=_action_styler)
|
|
91
92
|
| {
|
|
92
|
-
"progress.elapsed": "
|
|
93
|
-
"info": "italic #
|
|
94
|
-
"warning": "
|
|
93
|
+
"progress.elapsed": "italic #ffffaa",
|
|
94
|
+
"info": "italic #aaaa00",
|
|
95
|
+
"warning": "italic #cc0000",
|
|
95
96
|
"error": "bold #ff0000",
|
|
96
97
|
}
|
|
97
98
|
)
|
|
@@ -118,7 +119,6 @@ app = typer.Typer(help=_YD_HELP, no_args_is_help=True, rich_markup_mode="markdow
|
|
|
118
119
|
def _setup_io() -> None:
|
|
119
120
|
global env, console, highlighter # noqa: PLW0603
|
|
120
121
|
|
|
121
|
-
import logging
|
|
122
122
|
from pathlib import Path
|
|
123
123
|
|
|
124
124
|
console, highlighter = _setup_rich_devices()
|
|
@@ -128,16 +128,39 @@ def _setup_io() -> None:
|
|
|
128
128
|
console.print("Failed to capture environment.", style="error")
|
|
129
129
|
raise typer.Exit(2) from err
|
|
130
130
|
|
|
131
|
-
if env.log_level is
|
|
131
|
+
if env.log_level is None:
|
|
132
|
+
logging.basicConfig(level=logging.CRITICAL)
|
|
133
|
+
else:
|
|
134
|
+
import msgspec
|
|
135
|
+
|
|
132
136
|
logging.basicConfig(
|
|
133
|
-
filename=Path.cwd() / ".yd.
|
|
137
|
+
filename=Path.cwd() / ".yd.jsonl",
|
|
134
138
|
encoding="utf-8",
|
|
135
139
|
level=env.log_level,
|
|
136
140
|
force=True,
|
|
137
141
|
)
|
|
138
142
|
|
|
143
|
+
_encode_json = msgspec.json.Encoder().encode
|
|
144
|
+
|
|
145
|
+
@dataclasses.dataclass(slots=True)
|
|
146
|
+
class JsonLineFormatter(logging.Formatter):
|
|
147
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
148
|
+
return _encode_json(
|
|
149
|
+
{
|
|
150
|
+
"time": self.formatTime(record),
|
|
151
|
+
"level": record.levelname,
|
|
152
|
+
"logger": record.name,
|
|
153
|
+
"message": record.getMessage(),
|
|
154
|
+
}
|
|
155
|
+
).decode("utf-8")
|
|
156
|
+
|
|
157
|
+
root_logger = logging.getLogger()
|
|
158
|
+
for handler in root_logger.handlers:
|
|
159
|
+
handler.setFormatter(JsonLineFormatter())
|
|
139
160
|
|
|
140
|
-
|
|
161
|
+
|
|
162
|
+
# `min_width` chosen to fit min with in 88 character.
|
|
163
|
+
def _description_column_width(min_width: int = 12) -> int:
|
|
141
164
|
import shutil
|
|
142
165
|
|
|
143
166
|
non_desc_with = (
|
|
@@ -152,30 +175,31 @@ def _description_column_width(min_width: int = 50) -> int:
|
|
|
152
175
|
return max(min_width, shutil.get_terminal_size().columns - non_desc_with)
|
|
153
176
|
|
|
154
177
|
|
|
155
|
-
def
|
|
178
|
+
def _clip_path(path: str, *, max_len: int, prefix: str = ".../") -> str:
|
|
156
179
|
from pathlib import Path
|
|
157
180
|
|
|
158
181
|
if len(label := path) <= max_len:
|
|
159
182
|
return label
|
|
160
183
|
|
|
161
|
-
path_ = Path(path)
|
|
162
|
-
|
|
184
|
+
path_, max_len = Path(path), max_len - len(prefix)
|
|
185
|
+
parts = [
|
|
163
186
|
part
|
|
164
187
|
for part, cum_len in zip(
|
|
165
188
|
reversed(path_.parts),
|
|
166
|
-
|
|
189
|
+
# Count separating `/` character.
|
|
190
|
+
it.accumulate(len(s) + 1 for s in reversed(path_.parts)),
|
|
167
191
|
strict=True,
|
|
168
192
|
)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return base
|
|
174
|
-
return f"{prefix}{base[-(max_len - len(prefix)) :]}"
|
|
193
|
+
# Remove overcounted leading `/` character.
|
|
194
|
+
if cum_len - 1 <= max_len
|
|
195
|
+
]
|
|
196
|
+
return prefix + "/".join(reversed(parts))
|
|
175
197
|
|
|
176
198
|
|
|
177
199
|
# Use `list` as return value since type must be available at runtime for typer to work.
|
|
178
|
-
|
|
200
|
+
# Argument is passed as kwarg by typer.
|
|
201
|
+
def _complete_config_name(incomplete: str) -> list[str]:
|
|
202
|
+
env = yd.io.capture_environment()
|
|
179
203
|
return [
|
|
180
204
|
path.stem
|
|
181
205
|
for path in env.config_dir.glob("*.toml")
|
|
@@ -379,9 +403,7 @@ def run(
|
|
|
379
403
|
for binary, args in commands:
|
|
380
404
|
counter = {str(action): 0 for action in yd.types.Action}
|
|
381
405
|
taskid = progress.add_task(
|
|
382
|
-
description=
|
|
383
|
-
args[-1], max_len=_description_column_width()
|
|
384
|
-
),
|
|
406
|
+
description=_clip_path(args[-1], max_len=_description_column_width()),
|
|
385
407
|
**counter, # type: ignore[ty:invalid-argument-type]
|
|
386
408
|
total=1,
|
|
387
409
|
)
|
|
@@ -400,6 +422,9 @@ def run(
|
|
|
400
422
|
),
|
|
401
423
|
)
|
|
402
424
|
)
|
|
425
|
+
except (KeyboardInterrupt, asyncio.CancelledError) as err:
|
|
426
|
+
console.print("Terminated by user.", style="warning")
|
|
427
|
+
raise typer.Exit(2) from err
|
|
403
428
|
except (yd.types.OutputParseError, yd.types.OutputConsumeError) as err:
|
|
404
429
|
console.print("Failed to handle rsync output.", style="error")
|
|
405
430
|
raise typer.Exit(2) from err
|
|
@@ -18,7 +18,7 @@ from yd import types
|
|
|
18
18
|
|
|
19
19
|
TYPE_CHECKING = False
|
|
20
20
|
if TYPE_CHECKING:
|
|
21
|
-
from collections.abc import
|
|
21
|
+
from collections.abc import Iterable
|
|
22
22
|
from typing import Literal, overload
|
|
23
23
|
|
|
24
24
|
@overload
|
|
@@ -161,15 +161,18 @@ def list_available_configs(config_dir: Path, /) -> Iterable[types.AvailableConfi
|
|
|
161
161
|
yield types.AvailableConfig(name=path.stem, description=None)
|
|
162
162
|
|
|
163
163
|
|
|
164
|
-
_STDOUT_PATTERNS
|
|
165
|
-
tuple[re.Pattern[str], Callable[[re.Match], types.Transaction | str | None]]
|
|
166
|
-
] = (
|
|
164
|
+
_STDOUT_PATTERNS = (
|
|
167
165
|
(
|
|
168
|
-
re.compile(r"created\
|
|
166
|
+
re.compile(r"created\s1\sdirectory\sfor\s(?P<filepath>.+?)"),
|
|
169
167
|
lambda m: types.Transaction(
|
|
170
168
|
filename=m.group("filepath"), transfer_bytes=0, info="c "
|
|
171
169
|
),
|
|
172
170
|
),
|
|
171
|
+
(re.compile(r"created\sdirectory\s.+"), lambda _: None),
|
|
172
|
+
(
|
|
173
|
+
re.compile(r"cannot\sdelete\snon-empty\sdirectory:\s+(?P<filepath>.*?)"),
|
|
174
|
+
lambda m: f"Kept non-empty directory {m.group('filepath')}",
|
|
175
|
+
),
|
|
173
176
|
)
|
|
174
177
|
_decode_transaction = msgspec.json.Decoder(types.Transaction).decode
|
|
175
178
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|