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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yd-cli
3
- Version: 0.6
3
+ Version: 0.8
4
4
  Summary: CLI tool to synchronize directories using rsync.
5
5
  Author: Christian Heinze
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ requires = [ "uv-build>=0.10,<0.12" ]
4
4
 
5
5
  [project]
6
6
  name = "yd-cli"
7
- version = "0.6"
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
- base_style + str(cat): styler(cat)
67
- for cat in self.enum_type
68
- if cat not in self.ignore
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 #aaccff on #0050aa"
73
+ return "bold #606060 on #0050aa"
76
74
  case yd.types.Action.COPY:
77
- return "bold #aaffbb on #006020"
75
+ return "bold #606060 on #008020"
78
76
  case yd.types.Action.DELETE:
79
- return "bold #ffaaaa on #aa0000"
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 #aaaaaa on #606060"
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
- # `min_width` chosen to fit min with in 88 character.
163
- def _description_column_width(min_width: int = 12) -> int:
164
- import shutil
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=rich.table.Column(width=_description_column_width()),
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
- str(cat)
364
- for cat in highlighter.enum_type
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[str(transaction.action)] += 1
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=_clip_path(args[-1], max_len=_description_column_width()),
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
- return environment.echo_bin, (enc_msg,)
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
@@ -0,0 +1,12 @@
1
+ """Echo command replacement.
2
+
3
+ This is called instead of `echo` if the `echo` command is not available.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+
10
+ if __name__ == "__main__":
11
+ if len(sys.argv) > 1:
12
+ print(sys.argv[1])
@@ -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
- try:
89
- echo_bin = _find_binary(_getenv("YD_ECHO", "echo"))
90
- except FileNotFoundError as err:
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 path.open(mode="rb") as fh:
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 path.open(encoding="utf-8") as fh:
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 SyfError(RuntimeError): ...
19
+ class YdError(RuntimeError): ...
20
20
 
21
21
 
22
- class ConfigLoadError(SyfError):
22
+ class ConfigLoadError(YdError):
23
23
  """Exception raised when loading a configuration fails."""
24
24
 
25
25
 
26
- class EnvCaptureError(SyfError):
26
+ class EnvCaptureError(YdError):
27
27
  """Exception raised when capturing the environment fails."""
28
28
 
29
29
 
30
- class CommandBuildError(SyfError):
30
+ class CommandBuildError(YdError):
31
31
  """Exception raised when building the rsync command fails."""
32
32
 
33
33
 
34
- class OutputParseError(SyfError):
34
+ class OutputParseError(YdError):
35
35
  """Exception raised when parsing output of other CLI apps fails."""
36
36
 
37
37
 
38
- class OutputConsumeError(SyfError):
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