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 +5 -3
- path_sync/_internal/cmd_copy.py +94 -89
- path_sync/_internal/cmd_dep_update.py +82 -97
- path_sync/_internal/cmd_options.py +22 -0
- path_sync/_internal/git_ops.py +9 -0
- path_sync/_internal/log_capture.py +38 -0
- path_sync/_internal/models.py +41 -4
- path_sync/_internal/models_dep.py +8 -26
- path_sync/_internal/prompt_utils.py +25 -0
- path_sync/_internal/verify.py +97 -0
- path_sync/copy.py +2 -0
- path_sync/dep_update.py +7 -7
- path_sync/sections.py +19 -1
- path_sync/validate_no_changes.py +4 -0
- {path_sync-0.4.1.dist-info → path_sync-0.6.0.dist-info}/METADATA +172 -4
- path_sync-0.6.0.dist-info/RECORD +29 -0
- path_sync-0.4.1.dist-info/RECORD +0 -24
- {path_sync-0.4.1.dist-info → path_sync-0.6.0.dist-info}/WHEEL +0 -0
- {path_sync-0.4.1.dist-info → path_sync-0.6.0.dist-info}/entry_points.txt +0 -0
- {path_sync-0.4.1.dist-info → path_sync-0.6.0.dist-info}/licenses/LICENSE +0 -0
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.
|
|
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
|
]
|
path_sync/_internal/cmd_copy.py
CHANGED
|
@@ -2,8 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import glob
|
|
4
4
|
import logging
|
|
5
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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 =
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
209
|
-
changes = _sync_destination(config, dest, src_root, current_sha, src_repo_url, opts,
|
|
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
|
-
|
|
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
|
|
239
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
527
|
+
read_log: Callable[[], str],
|
|
528
|
+
verify_result: VerifyResult,
|
|
535
529
|
) -> None:
|
|
536
|
-
if opts.
|
|
537
|
-
logger.info("
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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.
|
|
573
|
-
opts.
|
|
574
|
-
opts.
|
|
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
|