path-sync 0.3.5__py3-none-any.whl → 0.4.1__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,10 +1,12 @@
1
1
  # Generated by pkg-ext
2
2
  # flake8: noqa
3
- from path_sync import copy
4
3
  from path_sync import config
4
+ from path_sync import copy
5
+ from path_sync import dep_update
5
6
 
6
- VERSION = "0.3.5"
7
+ VERSION = "0.4.1"
7
8
  __all__ = [
8
- "copy",
9
9
  "config",
10
+ "copy",
11
+ "dep_update",
10
12
  ]
path_sync/__main__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
 
3
- from path_sync._internal import cmd_boot, cmd_copy, cmd_validate # noqa: F401
3
+ from path_sync._internal import cmd_boot, cmd_copy, cmd_dep_update, cmd_validate # noqa: F401
4
4
  from path_sync._internal.models import LOG_FORMAT
5
5
  from path_sync._internal.typer_app import app
6
6
 
@@ -0,0 +1,250 @@
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
+ import typer
10
+ from git import Repo
11
+
12
+ from path_sync._internal import git_ops
13
+ from path_sync._internal.models import Destination, find_repo_root
14
+ from path_sync._internal.models_dep import (
15
+ DepConfig,
16
+ OnFailStrategy,
17
+ UpdateEntry,
18
+ VerifyConfig,
19
+ resolve_dep_config_path,
20
+ )
21
+ from path_sync._internal.typer_app import app
22
+ from path_sync._internal.yaml_utils import load_yaml_model
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ MAX_STDERR_LINES = 20
27
+
28
+
29
+ class Status(StrEnum):
30
+ PASSED = "passed"
31
+ SKIPPED = "skipped"
32
+ WARN = "warn"
33
+ NO_CHANGES = "no_changes"
34
+ FAILED = "failed"
35
+
36
+
37
+ @dataclass
38
+ class StepFailure:
39
+ step: str
40
+ returncode: int
41
+ stderr: str
42
+ on_fail: OnFailStrategy
43
+
44
+ @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)
48
+
49
+
50
+ @dataclass
51
+ class RepoResult:
52
+ dest: Destination
53
+ repo_path: Path
54
+ status: Status
55
+ failures: list[StepFailure] = field(default_factory=list)
56
+
57
+
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:])
64
+
65
+
66
+ @app.command()
67
+ def dep_update(
68
+ name: str = typer.Option(..., "-n", "--name", help="Config name"),
69
+ 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"),
71
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview without creating PRs"),
72
+ skip_verify: bool = typer.Option(False, "--skip-verify", help="Skip verification steps"),
73
+ src_root_opt: str = typer.Option("", "--src-root", help="Source repo root"),
74
+ ) -> None:
75
+ """Run dependency updates across repositories."""
76
+ src_root = Path(src_root_opt) if src_root_opt else find_repo_root(Path.cwd())
77
+ config_path = resolve_dep_config_path(src_root, name)
78
+ if not config_path.exists():
79
+ logger.error(f"Config not found: {config_path}")
80
+ raise typer.Exit(1)
81
+
82
+ config = load_yaml_model(config_path, DepConfig)
83
+ destinations = config.load_destinations(src_root)
84
+
85
+ if dest_filter:
86
+ filter_names = [n.strip() for n in dest_filter.split(",")]
87
+ destinations = [d for d in destinations if d.name in filter_names]
88
+
89
+ results = _update_and_validate(config, destinations, src_root, work_dir, skip_verify)
90
+ _create_prs(config, results, dry_run)
91
+
92
+ if any(r.status == Status.SKIPPED for r in results):
93
+ raise typer.Exit(1)
94
+
95
+
96
+ def _update_and_validate(
97
+ config: DepConfig,
98
+ destinations: list[Destination],
99
+ src_root: Path,
100
+ work_dir: str,
101
+ skip_verify: bool,
102
+ ) -> list[RepoResult]:
103
+ results: list[RepoResult] = []
104
+
105
+ for dest in destinations:
106
+ result = _process_single_repo(config, dest, src_root, work_dir, skip_verify)
107
+
108
+ if result.status == Status.FAILED:
109
+ logger.error(f"{dest.name}: Verification failed, stopping")
110
+ raise typer.Exit(1)
111
+
112
+ if result.status != Status.NO_CHANGES:
113
+ results.append(result)
114
+
115
+ return results
116
+
117
+
118
+ def _process_single_repo(
119
+ config: DepConfig,
120
+ dest: Destination,
121
+ src_root: Path,
122
+ work_dir: str,
123
+ skip_verify: bool,
124
+ ) -> RepoResult:
125
+ logger.info(f"Processing {dest.name}...")
126
+ repo_path = _resolve_repo_path(dest, src_root, work_dir)
127
+ repo = _ensure_repo(dest, repo_path)
128
+ git_ops.prepare_copy_branch(repo, dest.default_branch, config.pr.branch, from_default=True)
129
+
130
+ if failure := _run_updates(config.updates, repo_path):
131
+ logger.warning(f"{dest.name}: Update failed with exit code {failure.returncode}")
132
+ return RepoResult(dest, repo_path, Status.SKIPPED, failures=[failure])
133
+
134
+ if not git_ops.has_changes(repo):
135
+ logger.info(f"{dest.name}: No changes, skipping")
136
+ return RepoResult(dest, repo_path, Status.NO_CHANGES)
137
+
138
+ git_ops.commit_changes(repo, config.pr.title)
139
+
140
+ if skip_verify:
141
+ return RepoResult(dest, repo_path, Status.PASSED)
142
+
143
+ return _verify_repo(repo, repo_path, config.verify, dest)
144
+
145
+
146
+ def _run_updates(updates: list[UpdateEntry], repo_path: Path) -> StepFailure | None:
147
+ """Returns StepFailure on failure, None on success."""
148
+ try:
149
+ for update in updates:
150
+ _run_command(update.command, repo_path / update.workdir)
151
+ return None
152
+ except subprocess.CalledProcessError as e:
153
+ return StepFailure.from_error(e.cmd, e, OnFailStrategy.SKIP)
154
+
155
+
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)
159
+
160
+
161
+ def _create_prs(config: DepConfig, results: list[RepoResult], dry_run: bool) -> None:
162
+ for result in results:
163
+ if result.status == Status.SKIPPED:
164
+ continue
165
+
166
+ if dry_run:
167
+ logger.info(f"[DRY RUN] Would create PR for {result.dest.name}")
168
+ continue
169
+
170
+ repo = git_ops.get_repo(result.repo_path)
171
+ git_ops.push_branch(repo, config.pr.branch, force=True)
172
+
173
+ body = _build_pr_body(result.failures)
174
+ git_ops.create_or_update_pr(
175
+ result.repo_path,
176
+ config.pr.branch,
177
+ config.pr.title,
178
+ body,
179
+ config.pr.labels or None,
180
+ auto_merge=config.pr.auto_merge,
181
+ )
182
+ logger.info(f"{result.dest.name}: PR created/updated")
183
+
184
+
185
+ def _resolve_repo_path(dest: Destination, src_root: Path, work_dir: str) -> Path:
186
+ if dest.dest_path_relative:
187
+ 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)
196
+ if not dest.repo_url:
197
+ raise ValueError(f"Dest {dest.name} not found at {repo_path} and no repo_url configured")
198
+ return git_ops.clone_repo(dest.repo_url, repo_path)
199
+
200
+
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)
235
+
236
+ return (Status.WARN if failures else Status.PASSED, failures)
237
+
238
+
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
+ return body
@@ -107,6 +107,22 @@ def commit_changes(repo: Repo, message: str) -> None:
107
107
  logger.info(f"Committed: {message}")
108
108
 
109
109
 
110
+ def stage_and_commit(repo: Repo, add_paths: list[str], message: str) -> bool:
111
+ """Stage specified paths and commit if there are changes. Returns True if a commit was made."""
112
+ include = [p for p in add_paths if not p.startswith("!")]
113
+ exclude = [p[1:] for p in add_paths if p.startswith("!")]
114
+ for path in include:
115
+ repo.git.add(path)
116
+ for path in exclude:
117
+ repo.git.reset("HEAD", "--", path)
118
+ if not repo.is_dirty(index=True):
119
+ return False
120
+ _ensure_git_user(repo)
121
+ repo.git.commit("-m", message)
122
+ logger.info(f"Committed: {message}")
123
+ return True
124
+
125
+
110
126
  def _ensure_git_user(repo: Repo) -> None:
111
127
  """Configure git user if not already set."""
112
128
  try:
@@ -140,6 +156,7 @@ def create_or_update_pr(
140
156
  labels: list[str] | None = None,
141
157
  reviewers: list[str] | None = None,
142
158
  assignees: list[str] | None = None,
159
+ auto_merge: bool = False,
143
160
  ) -> str:
144
161
  cmd = ["gh", "pr", "create", "--head", branch, "--title", title]
145
162
  cmd.extend(["--body", body or ""])
@@ -155,10 +172,24 @@ def create_or_update_pr(
155
172
  if "already exists" in result.stderr:
156
173
  logger.info("PR already exists, updating body")
157
174
  update_pr_body(repo_path, branch, body)
175
+ if auto_merge:
176
+ _enable_auto_merge(repo_path, branch)
158
177
  return ""
159
178
  raise RuntimeError(f"Failed to create PR: {result.stderr}")
160
- logger.info(f"Created PR: {result.stdout.strip()}")
161
- return result.stdout.strip()
179
+ pr_url = result.stdout.strip()
180
+ logger.info(f"Created PR: {pr_url}")
181
+ if auto_merge:
182
+ _enable_auto_merge(repo_path, pr_url)
183
+ return pr_url
184
+
185
+
186
+ def _enable_auto_merge(repo_path: Path, pr_ref: str) -> None:
187
+ cmd = ["gh", "pr", "merge", "--auto", "--squash", pr_ref]
188
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
189
+ if result.returncode != 0:
190
+ logger.warning(f"Auto-merge failed (branch protection may not be configured): {result.stderr}")
191
+ else:
192
+ logger.info(f"Enabled auto-merge for {pr_ref}")
162
193
 
163
194
 
164
195
  def file_has_git_changes(repo: Repo, file_path: Path, base_ref: str = "HEAD") -> bool:
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+ from pathlib import Path
5
+ from typing import ClassVar
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from path_sync._internal.models import Destination, SrcConfig, resolve_config_path
10
+ from path_sync._internal.yaml_utils import load_yaml_model
11
+
12
+
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
+ class UpdateEntry(BaseModel):
36
+ workdir: str = "."
37
+ command: str
38
+
39
+
40
+ class PRConfig(BaseModel):
41
+ branch: str
42
+ title: str
43
+ labels: list[str] = Field(default_factory=list)
44
+ auto_merge: bool = False
45
+
46
+
47
+ class DepConfig(BaseModel):
48
+ CONFIG_EXT: ClassVar[str] = ".dep.yaml"
49
+
50
+ name: str
51
+ from_config: str
52
+ include_destinations: list[str] = Field(default_factory=list)
53
+ exclude_destinations: list[str] = Field(default_factory=list)
54
+ updates: list[UpdateEntry]
55
+ verify: VerifyConfig = Field(default_factory=VerifyConfig)
56
+ pr: PRConfig
57
+
58
+ def load_destinations(self, repo_root: Path) -> list[Destination]:
59
+ src_config_path = resolve_config_path(repo_root, self.from_config)
60
+ src_config = load_yaml_model(src_config_path, SrcConfig)
61
+ destinations = src_config.destinations
62
+ if self.include_destinations:
63
+ destinations = [d for d in destinations if d.name in self.include_destinations]
64
+ if self.exclude_destinations:
65
+ destinations = [d for d in destinations if d.name not in self.exclude_destinations]
66
+ return destinations
67
+
68
+
69
+ def resolve_dep_config_path(repo_root: Path, name: str) -> Path:
70
+ return repo_root / ".github" / f"{name}{DepConfig.CONFIG_EXT}"
@@ -0,0 +1,18 @@
1
+ # Generated by pkg-ext
2
+ from path_sync._internal.cmd_dep_update import dep_update as _dep_update
3
+ from path_sync._internal.models_dep import CommitConfig as _CommitConfig
4
+ from path_sync._internal.models_dep import DepConfig as _DepConfig
5
+ from path_sync._internal.models_dep import OnFailStrategy as _OnFailStrategy
6
+ from path_sync._internal.models_dep import PRConfig as _PRConfig
7
+ from path_sync._internal.models_dep import UpdateEntry as _UpdateEntry
8
+ from path_sync._internal.models_dep import VerifyConfig as _VerifyConfig
9
+ from path_sync._internal.models_dep import VerifyStep as _VerifyStep
10
+
11
+ dep_update = _dep_update
12
+ CommitConfig = _CommitConfig
13
+ DepConfig = _DepConfig
14
+ OnFailStrategy = _OnFailStrategy
15
+ PRConfig = _PRConfig
16
+ UpdateEntry = _UpdateEntry
17
+ VerifyConfig = _VerifyConfig
18
+ VerifyStep = _VerifyStep
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: path-sync
3
- Version: 0.3.5
3
+ Version: 0.4.1
4
4
  Summary: Sync files from a source repo to multiple destination repos
5
5
  Author-email: EspenAlbert <espen.albert1@gmail.com>
6
6
  License-Expression: MIT
@@ -1,21 +1,24 @@
1
- path_sync/__init__.py,sha256=UMITgfcKgKSh0CYFUQ2rtlzNjyI3s329DAQGoxna7HQ,153
2
- path_sync/__main__.py,sha256=HDj3qgijDcK8k976qsAvKMvUq9fZEJTK-dZldUc5-no,326
1
+ path_sync/__init__.py,sha256=OHLo0rlS3tquOhUn2sHNgEfAYvtsJaQmT1MYmM94Nuk,204
2
+ path_sync/__main__.py,sha256=zAjlCOVF1duQi_RSVWbncfmnjoqaik9YPl2jf5mfcR0,342
3
3
  path_sync/config.py,sha256=XYuEK_bjSpAk_nZN0oxpEA-S3t9F6Qn0yznYLoQHIw8,568
4
4
  path_sync/copy.py,sha256=BpflW4086XJFSHHK4taYPgXtF07xHB8qrgm8TYqdM4E,120
5
+ path_sync/dep_update.py,sha256=UjjoUIIaZyGwTYmDo3OWQDdpY1zZ4dbdXGwy4xLDuRc,804
5
6
  path_sync/sections.py,sha256=dB0RGUhRWcZj9c1364UULEEnYHMf1LkWUBZ8EayIyKM,2122
6
7
  path_sync/_internal/__init__.py,sha256=iPkMhrpiyXBijo2Hp-y_2zEYxAXnHLnStKM0X0HHd4U,56
7
8
  path_sync/_internal/cmd_boot.py,sha256=cFomUyPOhNX9fi5_BYL2Sm7O1oq7vntD8b7clQ7mL1E,3104
8
9
  path_sync/_internal/cmd_copy.py,sha256=0iuhq3vlL1bah2EJEvkHZlXqdsOwdkKktt8V7lVjKIY,18241
10
+ path_sync/_internal/cmd_dep_update.py,sha256=7mKs-ZdPoAfl3L2Q57345lPtOFtua5taF8dYAe0U1d8,8315
9
11
  path_sync/_internal/cmd_validate.py,sha256=e6m-JZlXAGr0ZRqfLhhrlmjs4w79p2WWnZo4I05PGpo,1798
10
12
  path_sync/_internal/file_utils.py,sha256=5C33qzKFQdwChi5YwUWBujj126t0P6dbGSU_5hWExpE,194
11
- path_sync/_internal/git_ops.py,sha256=rpG_r7VNH1KlBgqM9mz7xop0mpdy76Vs3rzCoxE1dIQ,5895
13
+ path_sync/_internal/git_ops.py,sha256=OJ82-TUUqlFGSCcaeya9J-x8_10aG5iu55SZew_dzbk,7085
12
14
  path_sync/_internal/header.py,sha256=evgY2q_gfDdEytEt_jyJ7M_KdGzCpfdKBUnoh3v-0Go,2593
13
15
  path_sync/_internal/models.py,sha256=IA6lb_BFXntcZnn9bWJZYenlnDvk9ddNBA67l5qFrmA,4577
16
+ path_sync/_internal/models_dep.py,sha256=KKgcOEUgWPa_speyp_XgwBQDp8L1hS24WN1C4SMMG-w,2014
14
17
  path_sync/_internal/typer_app.py,sha256=lEGMRXql3Se3VbmwAohvpUaL2cbY-RwhPUq8kL7bPbc,177
15
18
  path_sync/_internal/validation.py,sha256=23kwtmsiHiYlKbVU8mtwr8J0MqSlnvbuRRR5NQAsJ08,2446
16
19
  path_sync/_internal/yaml_utils.py,sha256=yj6Bl54EltjLEcVKaiA5Ahb9byT6OUMh0xIEzTsrvnQ,498
17
- path_sync-0.3.5.dist-info/METADATA,sha256=3xIOv0AH7FTcmMY76wkjT8z9nN-Nlis1PIC4aJWTLZ8,10430
18
- path_sync-0.3.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
19
- path_sync-0.3.5.dist-info/entry_points.txt,sha256=jTsL0c-9gP-4_Jt3EPgihtpLcwQR0AFAf1AUpD50AlI,54
20
- path_sync-0.3.5.dist-info/licenses/LICENSE,sha256=MnHjsc6ccjI5Iiw2R3jLEAApIcrEpLdIcZxkilhSPxc,1069
21
- path_sync-0.3.5.dist-info/RECORD,,
20
+ path_sync-0.4.1.dist-info/METADATA,sha256=1nIEtOCDLLRoti_7s-OK0x-rHPWtNZqTJd350laIHTw,10430
21
+ path_sync-0.4.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
22
+ path_sync-0.4.1.dist-info/entry_points.txt,sha256=jTsL0c-9gP-4_Jt3EPgihtpLcwQR0AFAf1AUpD50AlI,54
23
+ path_sync-0.4.1.dist-info/licenses/LICENSE,sha256=MnHjsc6ccjI5Iiw2R3jLEAApIcrEpLdIcZxkilhSPxc,1069
24
+ path_sync-0.4.1.dist-info/RECORD,,