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.
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ import shutil
4
5
  import subprocess
5
6
  from dataclasses import dataclass, field
6
7
  from enum import StrEnum
@@ -9,22 +10,20 @@ from pathlib import Path
9
10
  import typer
10
11
  from git import Repo
11
12
 
12
- from path_sync._internal import git_ops
13
- from path_sync._internal.models import Destination, find_repo_root
13
+ from path_sync._internal import cmd_options, git_ops, prompt_utils, verify
14
+ from path_sync._internal.log_capture import capture_log
15
+ from path_sync._internal.models import Destination, OnFailStrategy, find_repo_root
14
16
  from path_sync._internal.models_dep import (
15
17
  DepConfig,
16
- OnFailStrategy,
17
18
  UpdateEntry,
18
- VerifyConfig,
19
19
  resolve_dep_config_path,
20
20
  )
21
21
  from path_sync._internal.typer_app import app
22
+ from path_sync._internal.verify import StepFailure, VerifyStatus
22
23
  from path_sync._internal.yaml_utils import load_yaml_model
23
24
 
24
25
  logger = logging.getLogger(__name__)
25
26
 
26
- MAX_STDERR_LINES = 20
27
-
28
27
 
29
28
  class Status(StrEnum):
30
29
  PASSED = "passed"
@@ -33,18 +32,9 @@ class Status(StrEnum):
33
32
  NO_CHANGES = "no_changes"
34
33
  FAILED = "failed"
35
34
 
36
-
37
- @dataclass
38
- class StepFailure:
39
- step: str
40
- returncode: int
41
- stderr: str
42
- on_fail: OnFailStrategy
43
-
44
35
  @classmethod
45
- def from_error(cls, step: str, e: subprocess.CalledProcessError, on_fail: OnFailStrategy) -> StepFailure:
46
- stderr = _truncate_stderr(e.stderr or "", MAX_STDERR_LINES)
47
- return cls(step=step, returncode=e.returncode, stderr=stderr, on_fail=on_fail)
36
+ def from_verify_status(cls, vs: VerifyStatus) -> Status:
37
+ return cls(vs.value)
48
38
 
49
39
 
50
40
  @dataclass
@@ -53,24 +43,27 @@ class RepoResult:
53
43
  repo_path: Path
54
44
  status: Status
55
45
  failures: list[StepFailure] = field(default_factory=list)
46
+ log_content: str = ""
56
47
 
57
48
 
58
- def _truncate_stderr(text: str, max_lines: int) -> str:
59
- lines = text.strip().splitlines()
60
- if len(lines) <= max_lines:
61
- return text.strip()
62
- skipped = len(lines) - max_lines
63
- return f"... ({skipped} lines skipped)\n" + "\n".join(lines[-max_lines:])
49
+ @dataclass
50
+ class DepUpdateOptions:
51
+ dry_run: bool = False
52
+ skip_verify: bool = False
53
+ reviewers: list[str] | None = None
54
+ assignees: list[str] | None = None
64
55
 
65
56
 
66
57
  @app.command()
67
58
  def dep_update(
68
59
  name: str = typer.Option(..., "-n", "--name", help="Config name"),
69
60
  dest_filter: str = typer.Option("", "-d", "--dest", help="Filter destinations (comma-separated)"),
70
- work_dir: str = typer.Option("", "--work-dir", help="Directory for cloning repos"),
61
+ work_dir: str = typer.Option("", "--work-dir", help="Clone repos here (overrides dest_path_relative)"),
71
62
  dry_run: bool = typer.Option(False, "--dry-run", help="Preview without creating PRs"),
72
63
  skip_verify: bool = typer.Option(False, "--skip-verify", help="Skip verification steps"),
73
64
  src_root_opt: str = typer.Option("", "--src-root", help="Source repo root"),
65
+ pr_reviewers: str = cmd_options.pr_reviewers_option(),
66
+ pr_assignees: str = cmd_options.pr_assignees_option(),
74
67
  ) -> None:
75
68
  """Run dependency updates across repositories."""
76
69
  src_root = Path(src_root_opt) if src_root_opt else find_repo_root(Path.cwd())
@@ -86,8 +79,15 @@ def dep_update(
86
79
  filter_names = [n.strip() for n in dest_filter.split(",")]
87
80
  destinations = [d for d in destinations if d.name in filter_names]
88
81
 
89
- results = _update_and_validate(config, destinations, src_root, work_dir, skip_verify)
90
- _create_prs(config, results, dry_run)
82
+ opts = DepUpdateOptions(
83
+ dry_run=dry_run,
84
+ skip_verify=skip_verify,
85
+ reviewers=cmd_options.split_csv(pr_reviewers) or config.pr.reviewers,
86
+ assignees=cmd_options.split_csv(pr_assignees) or config.pr.assignees,
87
+ )
88
+
89
+ results = _update_and_validate(config, destinations, src_root, work_dir, opts)
90
+ _create_prs(config, results, opts)
91
91
 
92
92
  if any(r.status == Status.SKIPPED for r in results):
93
93
  raise typer.Exit(1)
@@ -98,12 +98,12 @@ def _update_and_validate(
98
98
  destinations: list[Destination],
99
99
  src_root: Path,
100
100
  work_dir: str,
101
- skip_verify: bool,
101
+ opts: DepUpdateOptions,
102
102
  ) -> list[RepoResult]:
103
103
  results: list[RepoResult] = []
104
104
 
105
105
  for dest in destinations:
106
- result = _process_single_repo(config, dest, src_root, work_dir, skip_verify)
106
+ result = _process_single_repo(config, dest, src_root, work_dir, opts)
107
107
 
108
108
  if result.status == Status.FAILED:
109
109
  logger.error(f"{dest.name}: Verification failed, stopping")
@@ -120,131 +120,116 @@ def _process_single_repo(
120
120
  dest: Destination,
121
121
  src_root: Path,
122
122
  work_dir: str,
123
- skip_verify: bool,
123
+ opts: DepUpdateOptions,
124
+ ) -> RepoResult:
125
+ with capture_log(dest.name) as read_log:
126
+ result = _process_single_repo_inner(config, dest, src_root, work_dir, opts)
127
+ result.log_content = read_log()
128
+ return result
129
+
130
+
131
+ def _process_single_repo_inner(
132
+ config: DepConfig,
133
+ dest: Destination,
134
+ src_root: Path,
135
+ work_dir: str,
136
+ opts: DepUpdateOptions,
124
137
  ) -> RepoResult:
125
138
  logger.info(f"Processing {dest.name}...")
126
139
  repo_path = _resolve_repo_path(dest, src_root, work_dir)
127
- repo = _ensure_repo(dest, repo_path)
140
+ repo = _ensure_repo(dest, repo_path, dest.default_branch)
128
141
  git_ops.prepare_copy_branch(repo, dest.default_branch, config.pr.branch, from_default=True)
129
142
 
130
143
  if failure := _run_updates(config.updates, repo_path):
131
144
  logger.warning(f"{dest.name}: Update failed with exit code {failure.returncode}")
132
- return RepoResult(dest, repo_path, Status.SKIPPED, failures=[failure])
145
+ return RepoResult(dest=dest, repo_path=repo_path, status=Status.SKIPPED, failures=[failure])
133
146
 
134
147
  if not git_ops.has_changes(repo):
135
148
  logger.info(f"{dest.name}: No changes, skipping")
136
- return RepoResult(dest, repo_path, Status.NO_CHANGES)
149
+ return RepoResult(dest=dest, repo_path=repo_path, status=Status.NO_CHANGES)
137
150
 
138
151
  git_ops.commit_changes(repo, config.pr.title)
139
152
 
140
- if skip_verify:
141
- return RepoResult(dest, repo_path, Status.PASSED)
153
+ if opts.skip_verify:
154
+ return RepoResult(dest=dest, repo_path=repo_path, status=Status.PASSED)
142
155
 
143
156
  return _verify_repo(repo, repo_path, config.verify, dest)
144
157
 
145
158
 
146
159
  def _run_updates(updates: list[UpdateEntry], repo_path: Path) -> StepFailure | None:
147
- """Returns StepFailure on failure, None on success."""
148
160
  try:
149
161
  for update in updates:
150
- _run_command(update.command, repo_path / update.workdir)
162
+ verify.run_command(update.command, repo_path / update.workdir)
151
163
  return None
152
164
  except subprocess.CalledProcessError as e:
153
- return StepFailure.from_error(e.cmd, e, OnFailStrategy.SKIP)
165
+ return StepFailure(step=e.cmd, returncode=e.returncode, on_fail=OnFailStrategy.SKIP)
154
166
 
155
167
 
156
- def _verify_repo(repo: Repo, repo_path: Path, verify: VerifyConfig, dest: Destination) -> RepoResult:
157
- status, failures = _run_verify_steps(repo, repo_path, verify)
158
- return RepoResult(dest, repo_path, status, failures)
168
+ def _verify_repo(repo: Repo, repo_path: Path, fallback_verify: verify.VerifyConfig, dest: Destination) -> RepoResult:
169
+ effective_verify = dest.resolve_verify(fallback_verify)
170
+ result = verify.run_verify_steps(repo, repo_path, effective_verify)
171
+ status = Status.from_verify_status(result.status)
172
+ return RepoResult(dest=dest, repo_path=repo_path, status=status, failures=result.failures)
159
173
 
160
174
 
161
- def _create_prs(config: DepConfig, results: list[RepoResult], dry_run: bool) -> None:
175
+ def _create_prs(config: DepConfig, results: list[RepoResult], opts: DepUpdateOptions) -> None:
162
176
  for result in results:
163
177
  if result.status == Status.SKIPPED:
164
178
  continue
165
179
 
166
- if dry_run:
180
+ if opts.dry_run:
167
181
  logger.info(f"[DRY RUN] Would create PR for {result.dest.name}")
168
182
  continue
169
183
 
170
184
  repo = git_ops.get_repo(result.repo_path)
171
185
  git_ops.push_branch(repo, config.pr.branch, force=True)
172
186
 
173
- body = _build_pr_body(result.failures)
187
+ body = _build_pr_body(result.log_content, result.failures)
174
188
  git_ops.create_or_update_pr(
175
189
  result.repo_path,
176
190
  config.pr.branch,
177
191
  config.pr.title,
178
192
  body,
179
193
  config.pr.labels or None,
194
+ reviewers=opts.reviewers,
195
+ assignees=opts.assignees,
180
196
  auto_merge=config.pr.auto_merge,
181
197
  )
182
198
  logger.info(f"{result.dest.name}: PR created/updated")
183
199
 
184
200
 
185
201
  def _resolve_repo_path(dest: Destination, src_root: Path, work_dir: str) -> Path:
202
+ if work_dir:
203
+ return Path(work_dir) / dest.name
186
204
  if dest.dest_path_relative:
187
205
  return (src_root / dest.dest_path_relative).resolve()
188
- if not work_dir:
189
- raise typer.BadParameter(f"No dest_path_relative for {dest.name}, --work-dir required")
190
- return Path(work_dir) / dest.name
191
-
192
-
193
- def _ensure_repo(dest: Destination, repo_path: Path):
194
- if repo_path.exists() and git_ops.is_git_repo(repo_path):
195
- return git_ops.get_repo(repo_path)
206
+ raise typer.BadParameter(f"No dest_path_relative for {dest.name}, --work-dir required")
207
+
208
+
209
+ def _ensure_repo(dest: Destination, repo_path: Path, default_branch: str) -> Repo:
210
+ if repo_path.exists():
211
+ if git_ops.is_git_repo(repo_path):
212
+ repo = git_ops.get_repo(repo_path)
213
+ git_ops.fetch_and_reset_to_default(repo, default_branch)
214
+ return repo
215
+ logger.warning(f"Invalid git repo at {repo_path}")
216
+ if not prompt_utils.prompt_confirm(f"Remove {repo_path} and re-clone?"):
217
+ raise typer.Abort()
218
+ shutil.rmtree(repo_path)
196
219
  if not dest.repo_url:
197
220
  raise ValueError(f"Dest {dest.name} not found at {repo_path} and no repo_url configured")
198
221
  return git_ops.clone_repo(dest.repo_url, repo_path)
199
222
 
200
223
 
201
- def _run_command(cmd: str, cwd: Path) -> None:
202
- logger.info(f"Running: {cmd}")
203
- result = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True)
204
- if result.returncode != 0:
205
- stderr = result.stderr.strip()
206
- logger.error(f"Command failed '{cmd}' in {cwd}: {stderr}")
207
- raise subprocess.CalledProcessError(result.returncode, cmd, output=result.stdout, stderr=stderr)
208
-
209
-
210
- def _run_verify_steps(
211
- repo: Repo,
212
- repo_path: Path,
213
- verify: VerifyConfig,
214
- ) -> tuple[Status, list[StepFailure]]:
215
- failures: list[StepFailure] = []
216
-
217
- for step in verify.steps:
218
- on_fail = step.on_fail or verify.on_fail
219
-
220
- try:
221
- _run_command(step.run, repo_path)
222
- except subprocess.CalledProcessError as e:
223
- failure = StepFailure.from_error(step.run, e, on_fail)
224
- match on_fail:
225
- case OnFailStrategy.FAIL:
226
- return (Status.FAILED, [failure])
227
- case OnFailStrategy.SKIP:
228
- return (Status.SKIPPED, [failure])
229
- case OnFailStrategy.WARN:
230
- failures.append(failure)
231
- continue
232
-
233
- if step.commit:
234
- git_ops.stage_and_commit(repo, step.commit.add_paths, step.commit.message)
224
+ def _build_pr_body(log_content: str, failures: list[StepFailure]) -> str:
225
+ body = "Automated dependency update."
235
226
 
236
- return (Status.WARN if failures else Status.PASSED, failures)
227
+ if log_content.strip():
228
+ body += "\n\n## Command Output\n\n```\n" + log_content.strip() + "\n```"
237
229
 
230
+ if failures:
231
+ body += "\n\n---\n## Verification Issues\n"
232
+ for f in failures:
233
+ body += f"\n- `{f.step}` failed (exit code {f.returncode}, strategy: {f.on_fail})"
238
234
 
239
- def _build_pr_body(failures: list[StepFailure]) -> str:
240
- body = "Automated dependency update."
241
- if not failures:
242
- return body
243
-
244
- body += "\n\n---\n## Verification Issues\n"
245
- for f in failures:
246
- body += f"\n### `{f.step}` (strategy: {f.on_fail})\n"
247
- body += f"**Exit code:** {f.returncode}\n"
248
- if f.stderr:
249
- body += f"\n```\n{f.stderr}\n```\n"
250
235
  return body
@@ -0,0 +1,22 @@
1
+ """Shared CLI options for commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+
8
+ def pr_reviewers_option() -> str:
9
+ return typer.Option("", "--pr-reviewers", help="Comma-separated PR reviewers")
10
+
11
+
12
+ def pr_assignees_option() -> str:
13
+ return typer.Option("", "--pr-assignees", help="Comma-separated PR assignees")
14
+
15
+
16
+ def pr_labels_option() -> str:
17
+ return typer.Option("", "--pr-labels", help="Comma-separated PR labels")
18
+
19
+
20
+ def split_csv(value: str) -> list[str] | None:
21
+ """Split comma-separated string, returns None if empty."""
22
+ return [v.strip() for v in value.split(",")] if value else None
@@ -41,6 +41,15 @@ def get_default_branch(repo: Repo) -> str:
41
41
  return "main"
42
42
 
43
43
 
44
+ def fetch_and_reset_to_default(repo: Repo, default_branch: str) -> None:
45
+ """Fetch latest from remote and reset to default branch."""
46
+ logger.info(f"Fetching origin and resetting to {default_branch}")
47
+ repo.remotes.origin.fetch()
48
+ if repo.head.is_detached or repo.active_branch.name != default_branch:
49
+ repo.git.checkout(default_branch)
50
+ repo.git.reset("--hard", f"origin/{default_branch}")
51
+
52
+
44
53
  def clone_repo(url: str, dest: Path) -> Repo:
45
54
  logger.info(f"Cloning {url} to {dest}")
46
55
  dest.parent.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import tempfile
5
+ from collections.abc import Callable, Generator
6
+ from contextlib import contextmanager
7
+ from pathlib import Path
8
+
9
+ LOG_FORMAT = "%(message)s"
10
+
11
+
12
+ @contextmanager
13
+ def capture_log(name: str) -> Generator[Callable[[], str]]:
14
+ """Capture path_sync logger output to a temp file.
15
+
16
+ Args:
17
+ name: Used for temp file naming (e.g., repo name for debugging).
18
+
19
+ Yields:
20
+ Callable that flushes the handler and returns log content.
21
+ """
22
+ with tempfile.TemporaryDirectory(prefix="path-sync-") as tmpdir:
23
+ log_path = Path(tmpdir) / f"{name}.log"
24
+ file_handler = logging.FileHandler(log_path, mode="w")
25
+ file_handler.setLevel(logging.INFO)
26
+ file_handler.setFormatter(logging.Formatter(LOG_FORMAT))
27
+ root_logger = logging.getLogger("path_sync")
28
+ root_logger.addHandler(file_handler)
29
+ try:
30
+
31
+ def read_log() -> str:
32
+ file_handler.flush()
33
+ return log_path.read_text() if log_path.exists() else ""
34
+
35
+ yield read_log
36
+ finally:
37
+ file_handler.close()
38
+ root_logger.removeHandler(file_handler)
@@ -35,12 +35,38 @@ class SyncMode(StrEnum):
35
35
  SCAFFOLD = "scaffold"
36
36
 
37
37
 
38
+ class OnFailStrategy(StrEnum):
39
+ SKIP = "skip"
40
+ FAIL = "fail"
41
+ WARN = "warn"
42
+
43
+
44
+ class CommitConfig(BaseModel):
45
+ message: str
46
+ add_paths: list[str] = Field(default_factory=lambda: ["."])
47
+
48
+
49
+ class VerifyStep(BaseModel):
50
+ run: str
51
+ commit: CommitConfig | None = None
52
+ on_fail: OnFailStrategy | None = None
53
+
54
+
55
+ class VerifyConfig(BaseModel):
56
+ on_fail: OnFailStrategy = OnFailStrategy.WARN
57
+ steps: list[VerifyStep] = Field(default_factory=list)
58
+
59
+
38
60
  class PathMapping(BaseModel):
39
61
  src_path: str
40
62
  dest_path: str = ""
41
63
  sync_mode: SyncMode = SyncMode.SYNC
42
64
  exclude_dirs: set[str] = Field(default_factory=_default_exclude_dirs)
43
65
  exclude_file_patterns: set[str] = Field(default_factory=set)
66
+ wrap: bool | None = None
67
+
68
+ def should_wrap(self, config_default: bool) -> bool:
69
+ return self.wrap if self.wrap is not None else config_default
44
70
 
45
71
  def resolved_dest_path(self) -> str:
46
72
  return self.dest_path or self.src_path
@@ -85,14 +111,19 @@ Synced from [{src_repo_name}]({src_repo_url}) @ `{src_sha_short}`
85
111
  """
86
112
 
87
113
 
88
- class PRDefaults(BaseModel):
89
- title: str = "chore: sync {name} files"
90
- body_template: str = DEFAULT_BODY_TEMPLATE
91
- body_suffix: str = ""
114
+ class PRFieldsBase(BaseModel):
115
+ """Common PR fields shared by copy and dep-update commands."""
116
+
92
117
  labels: list[str] = Field(default_factory=list)
93
118
  reviewers: list[str] = Field(default_factory=list)
94
119
  assignees: list[str] = Field(default_factory=list)
95
120
 
121
+
122
+ class PRDefaults(PRFieldsBase):
123
+ title: str = "chore: sync {name} files"
124
+ body_template: str = DEFAULT_BODY_TEMPLATE
125
+ body_suffix: str = ""
126
+
96
127
  def format_body(
97
128
  self,
98
129
  src_repo_url: str,
@@ -122,6 +153,7 @@ class Destination(BaseModel):
122
153
  default_branch: str = "main"
123
154
  skip_sections: dict[str, list[str]] = Field(default_factory=dict)
124
155
  skip_file_patterns: set[str] = Field(default_factory=set)
156
+ verify: VerifyConfig | None = None
125
157
 
126
158
  def resolved_copy_branch(self, config_name: str) -> str:
127
159
  return self.copy_branch or f"sync/{config_name}"
@@ -129,6 +161,9 @@ class Destination(BaseModel):
129
161
  def is_skipped(self, dest_key: str) -> bool:
130
162
  return any(fnmatch.fnmatch(dest_key, pat) for pat in self.skip_file_patterns)
131
163
 
164
+ def resolve_verify(self, fallback: VerifyConfig | None) -> VerifyConfig:
165
+ return self.verify if self.verify is not None else (fallback or VerifyConfig())
166
+
132
167
 
133
168
  class SrcConfig(BaseModel):
134
169
  CONFIG_EXT: ClassVar[str] = ".src.yaml"
@@ -141,6 +176,8 @@ class SrcConfig(BaseModel):
141
176
  pr_defaults: PRDefaults = Field(default_factory=PRDefaults)
142
177
  paths: list[PathMapping] = Field(default_factory=list)
143
178
  destinations: list[Destination] = Field(default_factory=list)
179
+ verify: VerifyConfig | None = None
180
+ wrap_synced_files: bool = False
144
181
 
145
182
  def find_destination(self, name: str) -> Destination:
146
183
  for dest in self.destinations:
@@ -1,46 +1,28 @@
1
1
  from __future__ import annotations
2
2
 
3
- from enum import StrEnum
4
3
  from pathlib import Path
5
4
  from typing import ClassVar
6
5
 
7
6
  from pydantic import BaseModel, Field
8
7
 
9
- from path_sync._internal.models import Destination, SrcConfig, resolve_config_path
8
+ from path_sync._internal.models import (
9
+ Destination,
10
+ PRFieldsBase,
11
+ SrcConfig,
12
+ VerifyConfig,
13
+ resolve_config_path,
14
+ )
10
15
  from path_sync._internal.yaml_utils import load_yaml_model
11
16
 
12
17
 
13
- class OnFailStrategy(StrEnum):
14
- SKIP = "skip"
15
- FAIL = "fail"
16
- WARN = "warn"
17
-
18
-
19
- class CommitConfig(BaseModel):
20
- message: str
21
- add_paths: list[str] = Field(default_factory=lambda: ["."])
22
-
23
-
24
- class VerifyStep(BaseModel):
25
- run: str
26
- commit: CommitConfig | None = None
27
- on_fail: OnFailStrategy | None = None
28
-
29
-
30
- class VerifyConfig(BaseModel):
31
- on_fail: OnFailStrategy = OnFailStrategy.SKIP
32
- steps: list[VerifyStep] = Field(default_factory=list)
33
-
34
-
35
18
  class UpdateEntry(BaseModel):
36
19
  workdir: str = "."
37
20
  command: str
38
21
 
39
22
 
40
- class PRConfig(BaseModel):
23
+ class PRConfig(PRFieldsBase):
41
24
  branch: str
42
25
  title: str
43
- labels: list[str] = Field(default_factory=list)
44
26
  auto_merge: bool = False
45
27
 
46
28
 
@@ -0,0 +1,25 @@
1
+ """Shared prompt utilities for interactive CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def prompt_confirm(message: str, no_prompt: bool = False) -> bool:
9
+ """Prompt user for confirmation.
10
+
11
+ Args:
12
+ message: The prompt message to display
13
+ no_prompt: If True, auto-confirms without prompting
14
+
15
+ Returns:
16
+ True if confirmed, False otherwise.
17
+ Auto-confirms in non-interactive mode (CI) or when no_prompt=True.
18
+ """
19
+ if no_prompt or not sys.stdin.isatty():
20
+ return True
21
+ try:
22
+ response = input(f"{message} [y/n]: ").strip().lower()
23
+ return response == "y"
24
+ except (EOFError, KeyboardInterrupt):
25
+ return False
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import subprocess
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+
9
+ from git import Repo
10
+
11
+ from path_sync._internal import git_ops
12
+ from path_sync._internal.models import OnFailStrategy, VerifyConfig
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class VerifyStatus(StrEnum):
18
+ PASSED = "passed"
19
+ SKIPPED = "skipped"
20
+ WARN = "warn"
21
+ FAILED = "failed"
22
+
23
+
24
+ @dataclass
25
+ class StepFailure:
26
+ step: str
27
+ returncode: int
28
+ on_fail: OnFailStrategy
29
+
30
+
31
+ @dataclass
32
+ class VerifyResult:
33
+ status: VerifyStatus = VerifyStatus.PASSED
34
+ failures: list[StepFailure] = field(default_factory=list)
35
+
36
+
37
+ def run_command(cmd: str, cwd: Path, dry_run: bool = False) -> None:
38
+ if dry_run:
39
+ logger.info(f"[DRY RUN] Would run: {cmd} from {cwd}")
40
+ return
41
+ logger.info(f"Running: {cmd}")
42
+ result = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True)
43
+ prefix = cmd.split()[0]
44
+ for line in result.stdout.strip().splitlines():
45
+ logger.info(f"[{prefix}] {line}")
46
+ for line in result.stderr.strip().splitlines():
47
+ if result.returncode != 0:
48
+ logger.error(f"[{prefix}] {line}")
49
+ else:
50
+ logger.info(f"[{prefix}] {line}")
51
+ if result.returncode != 0:
52
+ raise subprocess.CalledProcessError(result.returncode, cmd, output=result.stdout, stderr=result.stderr)
53
+
54
+
55
+ def run_verify_steps(
56
+ repo: Repo, repo_path: Path, verify: VerifyConfig, dry_run: bool = False, skip_commit: bool = False
57
+ ) -> VerifyResult:
58
+ if not verify.steps:
59
+ return VerifyResult()
60
+
61
+ failures: list[StepFailure] = []
62
+
63
+ for step in verify.steps:
64
+ on_fail = step.on_fail or verify.on_fail
65
+
66
+ try:
67
+ run_command(step.run, repo_path, dry_run=dry_run)
68
+ except subprocess.CalledProcessError as e:
69
+ failure = StepFailure(step=step.run, returncode=e.returncode, on_fail=on_fail)
70
+ match on_fail:
71
+ case OnFailStrategy.FAIL:
72
+ return VerifyResult(status=VerifyStatus.FAILED, failures=[failure])
73
+ case OnFailStrategy.SKIP:
74
+ return VerifyResult(status=VerifyStatus.SKIPPED, failures=[failure])
75
+ case OnFailStrategy.WARN:
76
+ failures.append(failure)
77
+ continue
78
+
79
+ if step.commit and not dry_run and not skip_commit:
80
+ git_ops.stage_and_commit(repo, step.commit.add_paths, step.commit.message)
81
+
82
+ status = VerifyStatus.WARN if failures else VerifyStatus.PASSED
83
+ return VerifyResult(status=status, failures=failures)
84
+
85
+
86
+ def log_verify_summary(name: str, result: VerifyResult) -> None:
87
+ match result.status:
88
+ case VerifyStatus.PASSED:
89
+ logger.info(f"Verification passed for {name}")
90
+ case VerifyStatus.WARN:
91
+ logger.warning(f"Verification completed with warnings for {name}")
92
+ for f in result.failures:
93
+ logger.warning(f" {f.step} failed (exit {f.returncode})")
94
+ case VerifyStatus.SKIPPED:
95
+ logger.warning(f"Verification skipped for {name}")
96
+ case VerifyStatus.FAILED:
97
+ logger.error(f"Verification failed for {name}")
path_sync/copy.py CHANGED
@@ -1,4 +1,6 @@
1
1
  # Generated by pkg-ext
2
2
  from path_sync._internal.cmd_copy import CopyOptions as _CopyOptions
3
+ from path_sync._internal.cmd_copy import copy as _copy
3
4
 
4
5
  CopyOptions = _CopyOptions
6
+ copy = _copy