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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yd-cli
3
- Version: 0.3
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/syf
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.3"
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/syf"
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
- build-backend.exclude-newer = "72 hours"
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 #000020 on #0050aa"
75
+ return "bold #aaccff on #0050aa"
75
76
  case yd.types.Action.COPY:
76
- return "bold white on #006020"
77
+ return "bold #aaffbb on #006020"
77
78
  case yd.types.Action.DELETE:
78
- return "bold #200000 on #aa0000"
79
+ return "bold #ffaaaa on #aa0000"
79
80
  case yd.types.Action.HARDLINK:
80
- return "bold white on #606060"
81
+ return "bold #aaaaaa on #606060"
81
82
  case yd.types.Action.ATTRUPDATE:
82
- return "bold white on #606060"
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": "bold #ffffaa",
93
- "info": "italic #000090",
94
- "warning": "bold #900000",
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 not None:
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.log",
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
- def _description_column_width(min_width: int = 50) -> int:
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 _create_description(path: str, *, max_len: int, prefix: str = "...") -> str:
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
- reversed_chosen_parts = tuple(
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
- it.accumulate(len(s) for s in reversed(path_.parts)),
189
+ # Count separating `/` character.
190
+ it.accumulate(len(s) + 1 for s in reversed(path_.parts)),
167
191
  strict=True,
168
192
  )
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)) :]}"
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
- def _complete_config_name(incomplete: str, /) -> list[str]:
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=_create_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 Callable, Iterable
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: tuple[
165
- tuple[re.Pattern[str], Callable[[re.Match], types.Transaction | str | None]]
166
- ] = (
164
+ _STDOUT_PATTERNS = (
167
165
  (
168
- re.compile(r"created\s(1\s)?directory\s(for\s)?(?P<filepath>.+?)"),
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