yd-cli 0.9__tar.gz → 0.11__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.9
3
+ Version: 0.11
4
4
  Summary: CLI tool to synchronize directories using rsync.
5
5
  Author: Christian Heinze
6
6
  License-Expression: MIT
@@ -25,6 +25,26 @@ Description-Content-Type: text/markdown
25
25
 
26
26
  Build and execute `rsync` commands from *TOML* configuration files.
27
27
 
28
+ ## (Un)Install
29
+
30
+ To install, run
31
+
32
+ ```bash
33
+ uv tool install yd-cli
34
+ ```
35
+
36
+ Then run
37
+
38
+ ```bash
39
+ yd --install-completion
40
+ ```
41
+
42
+ to install auto-completion in your shell.
43
+
44
+ In `bash`, the completion code is stored in `~/.bash_completions/yd.sh` and that file is sourced from `~/.bashrc`.
45
+ Remove both and call `uv tool uninstall yd-cli` to remove this tool.
46
+ The below mentioned configuration files need to be removed separately.
47
+
28
48
  ## Create a config
29
49
 
30
50
  Create a new configuration called `photos` with:
@@ -72,7 +92,7 @@ Leading comment lines directly at the top of the file are treated as the configu
72
92
  | Key | Meaning |
73
93
  | --- | --- |
74
94
  | `src_home` | Base directory for all `src` paths. Relative paths are resolved from your home directory. |
75
- | `target_home` | Base directory for all target paths. Relative paths are resolved from your home directory. `strftime` placeholders such as `%Y-%m-%d` are supported. |
95
+ | `target_home` | Base directory for all target paths. Relative paths are resolved from your home directory. |
76
96
  | `mtp_target` | Use in-place syncing for MTP targets. |
77
97
  | `backup` | Backup directory for replaced or deleted files. Relative paths are resolved from `target_home`. `strftime` placeholders are supported. |
78
98
  | `exclude` | Exclude patterns applied to every command. |
@@ -84,7 +104,7 @@ Leading comment lines directly at the top of the file are treated as the configu
84
104
  | Key | Meaning |
85
105
  | --- | --- |
86
106
  | `src` | Relative source directory below `src_home`. |
87
- | `target` | Relative target directory below `target_home`; defaults to `src`. `strftime` placeholders are supported. |
107
+ | `target` | Relative target directory below `target_home`; defaults to `src`. `strftime` placeholders (`%Y`, `%m`, and `%d` only!) are supported. |
88
108
  | `delete_extra` | Delete files in the target that do not exist in the source. Defaults to `true`. |
89
109
  | `exclude` | Extra exclude patterns for this command only. |
90
110
 
@@ -95,6 +115,8 @@ Leading comment lines directly at the top of the file are treated as the configu
95
115
  - Exactly one of `--src-home -` and `--target-home -` may read from standard input in a single run.
96
116
  - If `backup` is omitted, `yd` creates a timestamped backup directory automatically unless `--no-backup` is specified.
97
117
  - If a configured source directory exists but is empty, `yd` reports that no synchronization was performed for that command. E.g., forgetting to mount an external drive does not delete all corresponding copies on harddrive).
118
+ - If `strftime` placeholders are included in the `target`, then the `target_home` is scanned for directories that may have been created by this rule in the past. All matches are included as additional references in the synchronization: if a file in `src` is included in one of those reference directories, then it will not be copied to `target`.
119
+ - If the `target` directory did not exist before and no files are transferred into it, then it will be removed again.
98
120
 
99
121
  ## Run a config
100
122
 
@@ -2,6 +2,26 @@
2
2
 
3
3
  Build and execute `rsync` commands from *TOML* configuration files.
4
4
 
5
+ ## (Un)Install
6
+
7
+ To install, run
8
+
9
+ ```bash
10
+ uv tool install yd-cli
11
+ ```
12
+
13
+ Then run
14
+
15
+ ```bash
16
+ yd --install-completion
17
+ ```
18
+
19
+ to install auto-completion in your shell.
20
+
21
+ In `bash`, the completion code is stored in `~/.bash_completions/yd.sh` and that file is sourced from `~/.bashrc`.
22
+ Remove both and call `uv tool uninstall yd-cli` to remove this tool.
23
+ The below mentioned configuration files need to be removed separately.
24
+
5
25
  ## Create a config
6
26
 
7
27
  Create a new configuration called `photos` with:
@@ -49,7 +69,7 @@ Leading comment lines directly at the top of the file are treated as the configu
49
69
  | Key | Meaning |
50
70
  | --- | --- |
51
71
  | `src_home` | Base directory for all `src` paths. Relative paths are resolved from your home directory. |
52
- | `target_home` | Base directory for all target paths. Relative paths are resolved from your home directory. `strftime` placeholders such as `%Y-%m-%d` are supported. |
72
+ | `target_home` | Base directory for all target paths. Relative paths are resolved from your home directory. |
53
73
  | `mtp_target` | Use in-place syncing for MTP targets. |
54
74
  | `backup` | Backup directory for replaced or deleted files. Relative paths are resolved from `target_home`. `strftime` placeholders are supported. |
55
75
  | `exclude` | Exclude patterns applied to every command. |
@@ -61,7 +81,7 @@ Leading comment lines directly at the top of the file are treated as the configu
61
81
  | Key | Meaning |
62
82
  | --- | --- |
63
83
  | `src` | Relative source directory below `src_home`. |
64
- | `target` | Relative target directory below `target_home`; defaults to `src`. `strftime` placeholders are supported. |
84
+ | `target` | Relative target directory below `target_home`; defaults to `src`. `strftime` placeholders (`%Y`, `%m`, and `%d` only!) are supported. |
65
85
  | `delete_extra` | Delete files in the target that do not exist in the source. Defaults to `true`. |
66
86
  | `exclude` | Extra exclude patterns for this command only. |
67
87
 
@@ -72,6 +92,8 @@ Leading comment lines directly at the top of the file are treated as the configu
72
92
  - Exactly one of `--src-home -` and `--target-home -` may read from standard input in a single run.
73
93
  - If `backup` is omitted, `yd` creates a timestamped backup directory automatically unless `--no-backup` is specified.
74
94
  - If a configured source directory exists but is empty, `yd` reports that no synchronization was performed for that command. E.g., forgetting to mount an external drive does not delete all corresponding copies on harddrive).
95
+ - If `strftime` placeholders are included in the `target`, then the `target_home` is scanned for directories that may have been created by this rule in the past. All matches are included as additional references in the synchronization: if a file in `src` is included in one of those reference directories, then it will not be copied to `target`.
96
+ - If the `target` directory did not exist before and no files are transferred into it, then it will be removed again.
75
97
 
76
98
  ## Run a config
77
99
 
@@ -4,7 +4,7 @@ requires = [ "uv-build>=0.10,<0.12" ]
4
4
 
5
5
  [project]
6
6
  name = "yd-cli"
7
- version = "0.9"
7
+ version = "0.11"
8
8
  description = "CLI tool to synchronize directories using rsync."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -27,6 +27,8 @@ if TYPE_CHECKING:
27
27
 
28
28
  from rich.text import Text
29
29
 
30
+ log = logging.getLogger(__name__)
31
+
30
32
  env: yd.types.Environment
31
33
  console: rich.console.Console
32
34
  highlighter: StrEnumHighlighter
@@ -176,7 +178,6 @@ def _setup_io() -> None:
176
178
 
177
179
  _encode_json = msgspec.json.Encoder().encode
178
180
 
179
- @dataclasses.dataclass(slots=True)
180
181
  class JsonLineFormatter(logging.Formatter):
181
182
  def format(self, record: logging.LogRecord) -> str:
182
183
  return _encode_json(
@@ -267,7 +268,9 @@ def run(
267
268
  ) -> int:
268
269
  """Run a saved configuration by name."""
269
270
  import asyncio
270
- import functools
271
+ import contextlib
272
+ import functools as ft
273
+ import shutil
271
274
  from pathlib import Path
272
275
 
273
276
  import rich.progress
@@ -291,7 +294,6 @@ def run(
291
294
  keep_newer=keep_newer,
292
295
  rename_speedup=rename_speedup,
293
296
  no_backup=no_backup,
294
- error_encoder=yd.io.encode_error,
295
297
  )
296
298
  )
297
299
  except yd.types.CommandBuildError as err:
@@ -312,7 +314,7 @@ def run(
312
314
  ),
313
315
  rich.progress.TextColumn(
314
316
  "",
315
- table_column=rich.table.Column(width=8),
317
+ table_column=rich.table.Column(min_width=8),
316
318
  ),
317
319
  *it.chain.from_iterable(
318
320
  (
@@ -333,6 +335,8 @@ def run(
333
335
  }
334
336
  ),
335
337
  console=console,
338
+ expand=True,
339
+ refresh_per_second=10,
336
340
  ) as progress,
337
341
  ):
338
342
  template = "{action} {filename} ({transfer_bytes} bytes)"
@@ -371,6 +375,13 @@ def run(
371
375
  **counter, # type: ignore[ty:invalid-argument-type]
372
376
  total=1,
373
377
  )
378
+
379
+ target = Path(args[-1])
380
+ target_existed = target.is_dir()
381
+ progress_updater = ft.partial(
382
+ show_progress, counter=counter, taskid=taskid, target=target
383
+ )
384
+
374
385
  try:
375
386
  runner.run(
376
387
  yd.execution.run(
@@ -378,12 +389,7 @@ def run(
378
389
  *args,
379
390
  parser_stdout=yd.io.decode_from_stdout,
380
391
  parser_stderr=yd.io.decode_from_stderr,
381
- consumer=functools.partial(
382
- show_progress,
383
- counter=counter,
384
- taskid=taskid,
385
- target=Path(args[-1]),
386
- ),
392
+ consumer=progress_updater,
387
393
  )
388
394
  )
389
395
  except (KeyboardInterrupt, asyncio.CancelledError) as err:
@@ -396,6 +402,28 @@ def run(
396
402
  except (yd.types.OutputParseError, yd.types.OutputConsumeError) as err:
397
403
  console.print("Failed to handle rsync output.", style="error")
398
404
  raise typer.Exit(2) from err
405
+
406
+ # If target was created but no files were copied, then remove it.
407
+ if (
408
+ not target_existed
409
+ and target.is_dir() # Ensure rglob can be used.
410
+ and not any(
411
+ path.is_file() or path.is_symlink() for path in target.rglob("*")
412
+ )
413
+ ):
414
+ with contextlib.suppress(OSError):
415
+ shutil.rmtree(target)
416
+ log.info("Removed empty but newly created target `%s`.", target)
417
+ runner.run(
418
+ progress_updater(
419
+ yd.types.Transaction(
420
+ filename=target.as_posix(),
421
+ transfer_bytes=0,
422
+ info="*deleting",
423
+ )
424
+ )
425
+ )
426
+
399
427
  progress.update(taskid, completed=1)
400
428
 
401
429
  return 0
@@ -437,17 +465,18 @@ def edit(
437
465
  ] = False,
438
466
  ) -> int:
439
467
  """Open an existing configuration or create a new one in the editor."""
440
- import contextlib
441
468
  import subprocess
442
469
 
443
470
  if (editor_bin := env.editor_bin) is None:
444
471
  raise typer.BadParameter("environment variable `EDITOR` is not set")
445
472
 
446
- is_nvim = False
447
- with contextlib.suppress(subprocess.CalledProcessError):
473
+ try:
448
474
  vers = subprocess.run(
449
475
  [editor_bin, "--version"], encoding="utf-8", capture_output=True, check=True
450
476
  ).stdout
477
+ except subprocess.CalledProcessError:
478
+ is_nvim = False
479
+ else:
451
480
  is_nvim = re.match(r"\s*NVIM", vers) is not None
452
481
 
453
482
  config_path = env.config_dir / f"{cfg_name}.toml"
@@ -5,17 +5,20 @@
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import datetime as dt
8
9
  import itertools as it
9
10
  import logging
11
+ import re
10
12
  import sys
11
13
  import uuid
14
+ from dataclasses import dataclass
12
15
  from pathlib import Path
13
16
 
14
17
  from yd import types
15
18
 
16
19
  TYPE_CHECKING = False
17
20
  if TYPE_CHECKING:
18
- from collections.abc import Callable, Iterable, Sequence
21
+ from collections.abc import Iterator, Sequence
19
22
 
20
23
 
21
24
  log = logging.getLogger(__name__)
@@ -47,7 +50,57 @@ def _format_path(path: Path, /) -> str:
47
50
  return f"{path.as_posix()}/"
48
51
 
49
52
 
50
- def _create_error_command(
53
+ @dataclass(frozen=True, slots=True)
54
+ class _DatePathMatch:
55
+ path: Path
56
+ relative_path: str
57
+ datetime: dt.datetime | None
58
+
59
+
60
+ _PLACEHOLDER = re.compile("%Y|%m|%d")
61
+
62
+
63
+ def _match_dirs(
64
+ *, rel_path_pattern: str, home: Path, env: types.Environment
65
+ ) -> Iterator[_DatePathMatch]:
66
+ """Find subdirectories matching a target pattern."""
67
+ if "%" not in rel_path_pattern:
68
+ log.debug("No date placeholder found in `%s`.", rel_path_pattern)
69
+ return
70
+
71
+ # Crude filter. More careful checking below.
72
+ glob_pattern = re.sub(
73
+ "(?P<f>[^/])$", r"\g<f>/", _PLACEHOLDER.sub("*", rel_path_pattern)
74
+ )
75
+ if "%" in glob_pattern:
76
+ raise ValueError("pattern contains unsupported date placeholders")
77
+ rel_path_pattern = re.sub("/$", "", rel_path_pattern)
78
+
79
+ log.debug("Searching for sub-directories of the form `%s`.", home / glob_pattern)
80
+ for path in home.glob(glob_pattern):
81
+ rel_path = path.relative_to(home).as_posix()
82
+ log.debug("Scanning for date in `%s`.", rel_path)
83
+ try:
84
+ parsed = dt.datetime.strptime(rel_path, rel_path_pattern)
85
+ except ValueError:
86
+ log.debug("Found no date in `%s`.", rel_path)
87
+ continue
88
+
89
+ log.info("Found date `%s` in `%s`.", parsed.strftime("%Y-%m-%d"), rel_path)
90
+ if (rendered := parsed.strftime(rel_path_pattern)) != rel_path:
91
+ log.debug("Path pattern rendered to `%s` != `%s`.", rendered, rel_path)
92
+ continue
93
+ if parsed.date() >= env.current_time.date():
94
+ log.debug(
95
+ "Date `%s` not before current date `%s`.",
96
+ parsed.strftime("%Y-%m-%d"),
97
+ env.current_time.strftime("%Y-%m-%d"),
98
+ )
99
+ continue
100
+ yield _DatePathMatch(path=path, relative_path=rel_path, datetime=parsed)
101
+
102
+
103
+ def _create_echo_command(
51
104
  enc_msg: str, /, environment: types.Environment
52
105
  ) -> tuple[str, tuple[str, ...]]:
53
106
  if environment.echo_bin is not None:
@@ -67,8 +120,7 @@ def create( # noqa: PLR0915
67
120
  rename_speedup: bool,
68
121
  no_backup: bool,
69
122
  out_format: str = '{"filename": "%n", "transfer_bytes": %b, "info": "%i"}',
70
- error_encoder: Callable[[str], str],
71
- ) -> Iterable[tuple[str, Sequence[str]]]:
123
+ ) -> Iterator[tuple[str, Sequence[str]]]:
72
124
  """Build CLI commands.
73
125
 
74
126
  If the source directory is empty, then an `echo` command will be created. If
@@ -91,7 +143,6 @@ def create( # noqa: PLR0915
91
143
  if not (target_home := cg.target_home if target_home is None else target_home):
92
144
  raise types.CommandBuildError("missing target home specification")
93
145
 
94
- target_home = env.current_time.strftime(target_home)
95
146
  src_hpath, target_hpath = (
96
147
  _complete_path(Path(src_home), home=env.home_dir),
97
148
  _complete_path(Path(target_home), home=env.home_dir),
@@ -160,9 +211,9 @@ def create( # noqa: PLR0915
160
211
  msg = f"Source directory `{src}` is empty. No synchronization will be done."
161
212
  if cmd.delete_extra:
162
213
  msg += " Delete the folder contents manually to synchronize."
163
-
164
214
  log.warning(msg)
165
- yield _create_error_command(error_encoder(msg), environment=env)
215
+
216
+ yield _create_echo_command(msg, environment=env)
166
217
  continue
167
218
 
168
219
  target_spec = (
@@ -211,6 +262,29 @@ def create( # noqa: PLR0915
211
262
  # A relative path will be considered relative to the destination directory.
212
263
  args.extend(("--backup", "--backup-dir", _format_path(target_backup_path)))
213
264
 
265
+ if cmd.target is not None and target_spec != cmd.target:
266
+ try:
267
+ cmp_dests = sorted(
268
+ _match_dirs(
269
+ rel_path_pattern=cmd.target, home=target_hpath, env=env
270
+ ),
271
+ key=lambda d: d.datetime,
272
+ reverse=True,
273
+ )
274
+ except ValueError as err:
275
+ raise types.CommandBuildError(
276
+ "unsupported date specifiers in target"
277
+ ) from err
278
+ # rsync restricts the number of `--compare-dest` specs to 20.
279
+ cmp_dests = [d.path.as_posix() for i, d in enumerate(cmp_dests) if i < 20]
280
+ log.info(
281
+ "Found reference targets `%s`.",
282
+ "`, `".join(d for d in cmp_dests),
283
+ )
284
+ args.extend(
285
+ it.chain.from_iterable(("--compare-dest", d) for d in cmp_dests)
286
+ )
287
+
214
288
  args.extend(
215
289
  f"--exclude={pattern}" for pattern in it.chain(cmd.exclude, cg.exclude)
216
290
  )
@@ -116,6 +116,7 @@ async def run[T](
116
116
  consumer: Consumer[T],
117
117
  ) -> int:
118
118
  proc: asyncio.subprocess.Process | None = None
119
+ log.info("Running `%s`.", (binary, *args))
119
120
  try:
120
121
  proc = await asyncio.create_subprocess_exec(
121
122
  binary,
@@ -35,7 +35,6 @@ __all__ = [
35
35
  "list_available_configs",
36
36
  "decode_from_stdout",
37
37
  "decode_from_stderr",
38
- "encode_error",
39
38
  ]
40
39
 
41
40
 
@@ -195,7 +194,3 @@ def decode_from_stderr(text: str, /) -> types.Transaction | str | None:
195
194
  if not (stripped_text := text.strip()):
196
195
  return None
197
196
  return stripped_text
198
-
199
-
200
- def encode_error(text: str, /) -> str:
201
- return text
@@ -70,10 +70,6 @@ class CommandGroup(msgspec.Struct, kw_only=True, forbid_unknown_fields=True):
70
70
  src_home/target_home
71
71
  Absolute path used to turn relative src/target path in individual commands into
72
72
  absolute paths.
73
-
74
- The `target_home` is passed to `datetime.strftime` via the `format` parameter,
75
- that is, placeholders for date/time components like `%Y`, `%m`, etc., are
76
- replaced with the actual values at runtime.
77
73
  mtp_target
78
74
  Set True if communication with the target works with MTP. Due to
79
75
  technical limitations of that protocol, synchronization needs to be done
@@ -84,7 +80,7 @@ class CommandGroup(msgspec.Struct, kw_only=True, forbid_unknown_fields=True):
84
80
  Path to backup directory. Added to each command and if relative, then taken
85
81
  as relative to target home. If None, a default relative path is used.
86
82
 
87
- The `target` is passed to `datetime.strftime` via the `format` parameter, that
83
+ The `backup` is passed to `datetime.strftime` via the `format` parameter, that
88
84
  is, placeholders for date/time components like `%Y`, `%m`, etc., are replaced
89
85
  with the actual values at runtime.
90
86
  exclude
File without changes
File without changes
File without changes
File without changes
File without changes