path-sync 0.4.1__py3-none-any.whl → 0.6.0__py3-none-any.whl

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.
path_sync/__init__.py CHANGED
@@ -1,12 +1,14 @@
1
1
  # Generated by pkg-ext
2
2
  # flake8: noqa
3
- from path_sync import config
4
3
  from path_sync import copy
5
4
  from path_sync import dep_update
5
+ from path_sync import validate_no_changes
6
+ from path_sync import config
6
7
 
7
- VERSION = "0.4.1"
8
+ VERSION = "0.6.0"
8
9
  __all__ = [
9
- "config",
10
10
  "copy",
11
11
  "dep_update",
12
+ "validate_no_changes",
13
+ "config",
12
14
  ]
@@ -2,8 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import glob
4
4
  import logging
5
- import tempfile
6
- from contextlib import contextmanager
5
+ from collections.abc import Callable
7
6
  from dataclasses import dataclass, field
8
7
  from pathlib import Path
9
8
 
@@ -11,10 +10,10 @@ import typer
11
10
  from pydantic import BaseModel
12
11
 
13
12
  from path_sync import sections
14
- from path_sync._internal import git_ops, header
13
+ from path_sync._internal import cmd_options, git_ops, header, prompt_utils, verify
15
14
  from path_sync._internal.file_utils import ensure_parents_write_text
15
+ from path_sync._internal.log_capture import capture_log
16
16
  from path_sync._internal.models import (
17
- LOG_FORMAT,
18
17
  Destination,
19
18
  PathMapping,
20
19
  SrcConfig,
@@ -23,6 +22,7 @@ from path_sync._internal.models import (
23
22
  resolve_config_path,
24
23
  )
25
24
  from path_sync._internal.typer_app import app
25
+ from path_sync._internal.verify import StepFailure, VerifyResult, VerifyStatus
26
26
  from path_sync._internal.yaml_utils import load_yaml_model
27
27
 
28
28
  logger = logging.getLogger(__name__)
@@ -32,16 +32,6 @@ EXIT_CHANGES = 1
32
32
  EXIT_ERROR = 2
33
33
 
34
34
 
35
- def _prompt(message: str, no_prompt: bool) -> bool:
36
- if no_prompt:
37
- return True
38
- try:
39
- response = input(f"{message} [y/n]: ").strip().lower()
40
- return response == "y"
41
- except (EOFError, KeyboardInterrupt):
42
- return False
43
-
44
-
45
35
  @dataclass
46
36
  class SyncResult:
47
37
  content_changes: int = 0
@@ -53,35 +43,20 @@ class SyncResult:
53
43
  return self.content_changes + self.orphans_deleted
54
44
 
55
45
 
56
- @contextmanager
57
- def capture_sync_log(dest_name: str):
58
- with tempfile.TemporaryDirectory(prefix="path-sync-") as tmpdir:
59
- log_path = Path(tmpdir) / f"{dest_name}.log"
60
- file_handler = logging.FileHandler(log_path, mode="w")
61
- file_handler.setLevel(logging.INFO)
62
- file_handler.setFormatter(logging.Formatter(LOG_FORMAT))
63
- root_logger = logging.getLogger("path_sync")
64
- root_logger.addHandler(file_handler)
65
- try:
66
- yield log_path
67
- finally:
68
- file_handler.close()
69
- root_logger.removeHandler(file_handler)
70
-
71
-
72
46
  class CopyOptions(BaseModel):
73
47
  dry_run: bool = False
74
48
  force_overwrite: bool = False
75
49
  no_checkout: bool = False
76
50
  checkout_from_default: bool = False
77
- local: bool = False
51
+ skip_commit: bool = False
78
52
  no_prompt: bool = False
79
53
  no_pr: bool = False
80
54
  skip_orphan_cleanup: bool = False
55
+ skip_verify: bool = False
81
56
  pr_title: str = ""
82
- pr_labels: str = ""
83
- pr_reviewers: str = ""
84
- pr_assignees: str = ""
57
+ labels: list[str] | None = None
58
+ reviewers: list[str] | None = None
59
+ assignees: list[str] | None = None
85
60
 
86
61
 
87
62
  @app.command()
@@ -120,8 +95,9 @@ def copy(
120
95
  "--checkout-from-default",
121
96
  help="Reset to origin/default before sync (for CI)",
122
97
  ),
123
- local: bool = typer.Option(
98
+ skip_commit: bool = typer.Option(
124
99
  False,
100
+ "--skip-commit",
125
101
  "--local",
126
102
  help="No git operations after sync (no commit/push/PR)",
127
103
  ),
@@ -141,26 +117,19 @@ def copy(
141
117
  "--pr-title",
142
118
  help="Override PR title (supports {name}, {dest_name})",
143
119
  ),
144
- pr_labels: str = typer.Option(
145
- "",
146
- "--pr-labels",
147
- help="Comma-separated PR labels",
148
- ),
149
- pr_reviewers: str = typer.Option(
150
- "",
151
- "--pr-reviewers",
152
- help="Comma-separated PR reviewers",
153
- ),
154
- pr_assignees: str = typer.Option(
155
- "",
156
- "--pr-assignees",
157
- help="Comma-separated PR assignees",
158
- ),
120
+ pr_labels: str = cmd_options.pr_labels_option(),
121
+ pr_reviewers: str = cmd_options.pr_reviewers_option(),
122
+ pr_assignees: str = cmd_options.pr_assignees_option(),
159
123
  skip_orphan_cleanup: bool = typer.Option(
160
124
  False,
161
125
  "--skip-orphan-cleanup",
162
126
  help="Skip deletion of orphaned synced files",
163
127
  ),
128
+ skip_verify: bool = typer.Option(
129
+ False,
130
+ "--skip-verify",
131
+ help="Skip verification steps after syncing",
132
+ ),
164
133
  ) -> None:
165
134
  """Copy files from SRC to DEST repositories."""
166
135
  if name and config_path_opt:
@@ -187,14 +156,15 @@ def copy(
187
156
  force_overwrite=force_overwrite,
188
157
  no_checkout=no_checkout,
189
158
  checkout_from_default=checkout_from_default,
190
- local=local,
159
+ skip_commit=skip_commit,
191
160
  no_prompt=no_prompt,
192
161
  no_pr=no_pr,
193
162
  skip_orphan_cleanup=skip_orphan_cleanup,
163
+ skip_verify=skip_verify,
194
164
  pr_title=pr_title or config.pr_defaults.title,
195
- pr_labels=pr_labels or ",".join(config.pr_defaults.labels),
196
- pr_reviewers=pr_reviewers or ",".join(config.pr_defaults.reviewers),
197
- pr_assignees=pr_assignees or ",".join(config.pr_defaults.assignees),
165
+ labels=cmd_options.split_csv(pr_labels) or config.pr_defaults.labels,
166
+ reviewers=cmd_options.split_csv(pr_reviewers) or config.pr_defaults.reviewers,
167
+ assignees=cmd_options.split_csv(pr_assignees) or config.pr_defaults.assignees,
198
168
  )
199
169
 
200
170
  destinations = config.destinations
@@ -205,8 +175,8 @@ def copy(
205
175
  total_changes = 0
206
176
  for dest in destinations:
207
177
  try:
208
- with capture_sync_log(dest.name) as log_path:
209
- changes = _sync_destination(config, dest, src_root, current_sha, src_repo_url, opts, log_path)
178
+ with capture_log(dest.name) as read_log:
179
+ changes = _sync_destination(config, dest, src_root, current_sha, src_repo_url, opts, read_log)
210
180
  total_changes += changes
211
181
  except Exception as e:
212
182
  logger.error(f"Failed to sync {dest.name}: {e}")
@@ -225,7 +195,7 @@ def _sync_destination(
225
195
  current_sha: str,
226
196
  src_repo_url: str,
227
197
  opts: CopyOptions,
228
- log_path: Path,
198
+ read_log: Callable[[], str],
229
199
  ) -> int:
230
200
  dest_root = (src_root / dest.dest_path_relative).resolve()
231
201
 
@@ -235,23 +205,14 @@ def _sync_destination(
235
205
  dest_repo = _ensure_dest_repo(dest, dest_root, opts.dry_run)
236
206
  copy_branch = dest.resolved_copy_branch(config.name)
237
207
 
238
- # --no-checkout means "I'm already on the right branch"
239
- # Prompt decline means "skip git operations for this run"
240
- if opts.dry_run:
241
- skip_git_ops = True
242
- elif opts.no_checkout:
243
- skip_git_ops = False
244
- elif _prompt(f"Switch {dest.name} to {copy_branch}?", opts.no_prompt):
208
+ # --no-checkout skips branch switching (assumes already on correct branch)
209
+ if not opts.no_checkout and prompt_utils.prompt_confirm(f"Switch {dest.name} to {copy_branch}?", opts.no_prompt):
245
210
  git_ops.prepare_copy_branch(
246
211
  repo=dest_repo,
247
212
  default_branch=dest.default_branch,
248
213
  copy_branch=copy_branch,
249
214
  from_default=opts.checkout_from_default,
250
215
  )
251
- skip_git_ops = False
252
- else:
253
- skip_git_ops = True
254
-
255
216
  result = _sync_paths(config, dest, src_root, dest_root, opts)
256
217
  _print_sync_summary(dest, result)
257
218
 
@@ -259,10 +220,29 @@ def _sync_destination(
259
220
  logger.info(f"{dest.name}: No changes")
260
221
  return 0
261
222
 
262
- if skip_git_ops:
263
- return result.total
223
+ # --skip-commit and --dry-run skip commit; otherwise prompt
224
+ should_skip_commit = opts.skip_commit or opts.dry_run
225
+ if not should_skip_commit and prompt_utils.prompt_confirm(f"Commit changes to {dest.name}?", opts.no_prompt):
226
+ sync_commit_msg = f"chore: sync {config.name} from {current_sha[:8]}"
227
+ git_ops.commit_changes(dest_repo, sync_commit_msg)
228
+
229
+ verify_result = VerifyResult()
230
+ effective_verify = dest.resolve_verify(config.verify)
231
+ if not opts.skip_verify and effective_verify.steps:
232
+ verify_result = verify.run_verify_steps(
233
+ dest_repo, dest_root, effective_verify, dry_run=opts.dry_run, skip_commit=opts.skip_commit
234
+ )
235
+ verify.log_verify_summary(dest.name, verify_result)
236
+
237
+ if verify_result.status == VerifyStatus.FAILED:
238
+ logger.error(f"{dest.name}: Verification failed, stopping")
239
+ raise typer.Exit(EXIT_ERROR)
240
+
241
+ if verify_result.status == VerifyStatus.SKIPPED:
242
+ logger.warning(f"{dest.name}: Verification skipped due to failure")
243
+ return result.total
264
244
 
265
- _commit_and_pr(config, dest_repo, dest_root, dest, current_sha, src_repo_url, opts, log_path)
245
+ _push_and_pr(config, dest_repo, dest_root, dest, current_sha, src_repo_url, opts, read_log, verify_result)
266
246
  return result.total
267
247
 
268
248
 
@@ -303,6 +283,7 @@ def _sync_paths(
303
283
  config.name,
304
284
  opts.dry_run,
305
285
  opts.force_overwrite,
286
+ config.wrap_synced_files,
306
287
  )
307
288
  result.content_changes += changes
308
289
  result.synced_paths.update(paths)
@@ -353,10 +334,12 @@ def _sync_path(
353
334
  config_name: str,
354
335
  dry_run: bool,
355
336
  force_overwrite: bool,
337
+ wrap_synced_files: bool = False,
356
338
  ) -> tuple[int, set[Path]]:
357
339
  changes = 0
358
340
  synced: set[Path] = set()
359
341
 
342
+ should_wrap = mapping.should_wrap(wrap_synced_files)
360
343
  for src_path, dest_key, dest_path in _iter_sync_files(mapping, src_root, dest_root):
361
344
  if dest.is_skipped(dest_key):
362
345
  continue
@@ -369,6 +352,7 @@ def _sync_path(
369
352
  mapping.sync_mode,
370
353
  dry_run,
371
354
  force_overwrite,
355
+ should_wrap,
372
356
  )
373
357
  synced.add(dest_path)
374
358
 
@@ -384,6 +368,7 @@ def _copy_file(
384
368
  sync_mode: SyncMode,
385
369
  dry_run: bool,
386
370
  force_overwrite: bool = False,
371
+ should_wrap: bool = False,
387
372
  ) -> int:
388
373
  try:
389
374
  src_content = header.remove_header(src.read_text())
@@ -397,7 +382,7 @@ def _copy_file(
397
382
  return _handle_replace(src_content, dest_path, dry_run)
398
383
  case SyncMode.SYNC:
399
384
  skip_list = dest.skip_sections.get(dest_key, [])
400
- return _handle_sync(src_content, dest_path, skip_list, config_name, dry_run, force_overwrite)
385
+ return _handle_sync(src_content, dest_path, skip_list, config_name, dry_run, force_overwrite, should_wrap)
401
386
 
402
387
 
403
388
  def _copy_binary_file(src: Path, dest_path: Path, sync_mode: SyncMode, dry_run: bool) -> int:
@@ -441,10 +426,15 @@ def _handle_sync(
441
426
  config_name: str,
442
427
  dry_run: bool,
443
428
  force_overwrite: bool,
429
+ should_wrap: bool = False,
444
430
  ) -> int:
445
431
  if sections.has_sections(src_content, dest_path):
446
432
  return _handle_sync_sections(src_content, dest_path, skip_list, config_name, dry_run, force_overwrite)
447
433
 
434
+ if should_wrap:
435
+ wrapped = sections.wrap_in_synced_section(src_content, dest_path)
436
+ return _handle_sync_sections(wrapped, dest_path, skip_list, config_name, dry_run, force_overwrite)
437
+
448
438
  if dest_path.exists():
449
439
  existing = dest_path.read_text()
450
440
  has_hdr = header.has_header(existing)
@@ -475,7 +465,7 @@ def _handle_sync_sections(
475
465
  dry_run: bool,
476
466
  force_overwrite: bool,
477
467
  ) -> int:
478
- src_sections = sections.extract_sections(src_content, dest_path)
468
+ src_sections = sections.parse_sections(src_content, dest_path)
479
469
 
480
470
  if dest_path.exists():
481
471
  existing = dest_path.read_text()
@@ -484,6 +474,9 @@ def _handle_sync_sections(
484
474
  return 0
485
475
  dest_body = header.remove_header(existing)
486
476
  new_body = sections.replace_sections(dest_body, src_sections, dest_path, skip_list)
477
+ elif skip_list:
478
+ filtered = [s for s in src_sections if s.id not in skip_list]
479
+ new_body = sections.build_sections_content(filtered, dest_path)
487
480
  else:
488
481
  new_body = src_content
489
482
 
@@ -523,7 +516,7 @@ def _find_files_with_config(dest_root: Path, config_name: str) -> list[Path]:
523
516
  return result
524
517
 
525
518
 
526
- def _commit_and_pr(
519
+ def _push_and_pr(
527
520
  config: SrcConfig,
528
521
  repo,
529
522
  dest_root: Path,
@@ -531,31 +524,33 @@ def _commit_and_pr(
531
524
  sha: str,
532
525
  src_repo_url: str,
533
526
  opts: CopyOptions,
534
- log_path: Path,
527
+ read_log: Callable[[], str],
528
+ verify_result: VerifyResult,
535
529
  ) -> None:
536
- if opts.local:
537
- logger.info("Local mode: skipping commit/push/PR")
530
+ if opts.skip_commit or opts.dry_run:
531
+ logger.info("Skipping push/PR (--skip-commit or --dry-run)")
538
532
  return
539
533
 
540
534
  copy_branch = dest.resolved_copy_branch(config.name)
541
535
 
542
- if not _prompt(f"Commit changes to {dest.name}?", opts.no_prompt):
543
- return
544
-
545
- commit_msg = f"chore: sync {config.name} from {sha[:8]}"
546
- git_ops.commit_changes(repo, commit_msg)
547
- typer.echo(f" Committed: {commit_msg}", err=True)
536
+ # Commit any remaining changes from verify steps without their own commit config
537
+ if git_ops.has_changes(repo):
538
+ if not prompt_utils.prompt_confirm(f"Commit remaining changes to {dest.name}?", opts.no_prompt):
539
+ return
540
+ commit_msg = f"chore: post-sync changes for {config.name}"
541
+ git_ops.commit_changes(repo, commit_msg)
542
+ typer.echo(f" Committed: {commit_msg}", err=True)
548
543
 
549
- if not _prompt(f"Push {dest.name} to origin?", opts.no_prompt):
544
+ if not prompt_utils.prompt_confirm(f"Push {dest.name} to origin?", opts.no_prompt):
550
545
  return
551
546
 
552
547
  git_ops.push_branch(repo, copy_branch, force=True)
553
548
  typer.echo(f" Pushed: {copy_branch} (force)", err=True)
554
549
 
555
- if opts.no_pr or not _prompt(f"Create PR for {dest.name}?", opts.no_prompt):
550
+ if opts.no_pr or not prompt_utils.prompt_confirm(f"Create PR for {dest.name}?", opts.no_prompt):
556
551
  return
557
552
 
558
- sync_log = log_path.read_text() if log_path.exists() else ""
553
+ sync_log = read_log()
559
554
  pr_body = config.pr_defaults.format_body(
560
555
  src_repo_url=src_repo_url,
561
556
  src_sha=sha,
@@ -563,15 +558,25 @@ def _commit_and_pr(
563
558
  dest_name=dest.name,
564
559
  )
565
560
 
561
+ if verify_result.failures:
562
+ pr_body = _append_verify_warnings(pr_body, verify_result.failures)
563
+
566
564
  title = opts.pr_title.format(name=config.name, dest_name=dest.name)
567
565
  pr_url = git_ops.create_or_update_pr(
568
566
  dest_root,
569
567
  copy_branch,
570
568
  title,
571
569
  pr_body,
572
- opts.pr_labels.split(",") if opts.pr_labels else None,
573
- opts.pr_reviewers.split(",") if opts.pr_reviewers else None,
574
- opts.pr_assignees.split(",") if opts.pr_assignees else None,
570
+ opts.labels,
571
+ opts.reviewers,
572
+ opts.assignees,
575
573
  )
576
574
  if pr_url:
577
575
  typer.echo(f" Created PR: {pr_url}", err=True)
576
+
577
+
578
+ def _append_verify_warnings(body: str, failures: list[StepFailure]) -> str:
579
+ body += "\n\n---\n## Verification Warnings\n"
580
+ for f in failures:
581
+ body += f"\n- `{f.step}` failed (exit code {f.returncode}, strategy: {f.on_fail})"
582
+ return body