path-sync 0.2.0__py3-none-any.whl → 0.3.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/cmd_validate.py DELETED
@@ -1,51 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from pathlib import Path
5
-
6
- import typer
7
-
8
- from path_sync import git_ops
9
- from path_sync.models import find_repo_root
10
- from path_sync.typer_app import app
11
- from path_sync.validation import parse_skip_sections, validate_no_unauthorized_changes
12
-
13
- logger = logging.getLogger(__name__)
14
-
15
-
16
- @app.command("validate-no-changes")
17
- def validate_no_changes(
18
- branch: str = typer.Option(
19
- "main", "-b", "--branch", help="Default branch to compare against"
20
- ),
21
- skip_sections_opt: str = typer.Option(
22
- "",
23
- "--skip-sections",
24
- help="Comma-separated path:section_id pairs to skip (e.g., 'justfile:coverage,pyproject.toml:default')",
25
- ),
26
- ) -> None:
27
- """Validate no unauthorized changes to synced files."""
28
- repo_root = find_repo_root(Path.cwd())
29
- repo = git_ops.get_repo(repo_root)
30
-
31
- current_branch = repo.active_branch.name
32
- if current_branch.startswith("sync/"):
33
- logger.info(f"On sync branch {current_branch}, validation skipped")
34
- return
35
- if current_branch == branch:
36
- logger.info(f"On default branch {branch}, validation skipped")
37
- return
38
-
39
- skip_sections = (
40
- parse_skip_sections(skip_sections_opt) if skip_sections_opt else None
41
- )
42
- unauthorized = validate_no_unauthorized_changes(repo_root, branch, skip_sections)
43
-
44
- if unauthorized:
45
- files_list = "\n ".join(unauthorized)
46
- logger.error(
47
- f"Unauthorized changes in {len(unauthorized)} files:\n {files_list}"
48
- )
49
- raise typer.Exit(1)
50
-
51
- logger.info("Validation passed: no unauthorized changes")
path_sync/conftest.py DELETED
@@ -1,15 +0,0 @@
1
- import pytest
2
- from git import Repo
3
-
4
-
5
- @pytest.fixture
6
- def tmp_repo(tmp_path):
7
- """Create a temporary git repo."""
8
-
9
- repo_path = tmp_path / "repo"
10
- repo_path.mkdir()
11
- repo = Repo.init(repo_path)
12
- (repo_path / ".gitkeep").write_text("")
13
- repo.index.add([".gitkeep"])
14
- repo.index.commit("Initial commit")
15
- return repo_path
path_sync/file_utils.py DELETED
@@ -1,7 +0,0 @@
1
- from pathlib import Path
2
-
3
-
4
- def ensure_parents_write_text(path: Path | str, text: str) -> None:
5
- path = Path(path)
6
- path.parent.mkdir(parents=True, exist_ok=True)
7
- path.write_text(text)
path_sync/git_ops.py DELETED
@@ -1,188 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- import os
5
- import subprocess
6
- from contextlib import suppress
7
- from pathlib import Path
8
-
9
- from git import GitCommandError, InvalidGitRepositoryError, NoSuchPathError, Repo
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- def _auth_url(url: str) -> str:
15
- """Inject GH_TOKEN into HTTPS URL for authentication."""
16
- token = os.environ.get("GH_TOKEN", "")
17
- if not token:
18
- return url
19
- if url.startswith("https://github.com/"):
20
- return url.replace("https://", f"https://x-access-token:{token}@")
21
- return url
22
-
23
-
24
- def get_repo(path: Path) -> Repo:
25
- try:
26
- return Repo(path)
27
- except InvalidGitRepositoryError as e:
28
- raise ValueError(f"Not a git repository: {path}") from e
29
-
30
-
31
- def is_git_repo(path: Path) -> bool:
32
- with suppress(InvalidGitRepositoryError, NoSuchPathError):
33
- Repo(path)
34
- return True
35
- return False
36
-
37
-
38
- def get_default_branch(repo: Repo) -> str:
39
- with suppress(GitCommandError):
40
- return repo.git.symbolic_ref("refs/remotes/origin/HEAD", short=True).replace(
41
- "origin/", ""
42
- )
43
- return "main"
44
-
45
-
46
- def clone_repo(url: str, dest: Path) -> Repo:
47
- logger.info(f"Cloning {url} to {dest}")
48
- dest.parent.mkdir(parents=True, exist_ok=True)
49
- return Repo.clone_from(_auth_url(url), str(dest))
50
-
51
-
52
- def checkout_branch(repo: Repo, branch: str) -> None:
53
- current = repo.active_branch.name
54
- if current == branch:
55
- return
56
- logger.info(f"Checking out {branch}")
57
- try:
58
- repo.git.checkout(branch)
59
- except GitCommandError:
60
- repo.git.checkout("-b", branch)
61
-
62
-
63
- def prepare_copy_branch(
64
- repo: Repo, default_branch: str, copy_branch: str, from_default: bool = False
65
- ) -> None:
66
- """Prepare copy_branch for syncing.
67
-
68
- Args:
69
- from_default: If True, fetch origin and reset to origin/default_branch
70
- before creating copy_branch. Use in CI for clean state.
71
- If False, just switch to or create copy_branch from current HEAD.
72
- """
73
- if from_default:
74
- logger.info("Fetching origin")
75
- repo.git.fetch("origin")
76
-
77
- logger.info(f"Checking out {default_branch}")
78
- repo.git.checkout(default_branch)
79
- repo.git.reset("--hard", f"origin/{default_branch}")
80
-
81
- with suppress(GitCommandError):
82
- repo.git.branch("-D", copy_branch)
83
- logger.info(f"Deleted existing local branch: {copy_branch}")
84
-
85
- logger.info(f"Creating fresh branch: {copy_branch}")
86
- repo.git.checkout("-b", copy_branch)
87
- else:
88
- checkout_branch(repo, copy_branch)
89
-
90
-
91
- def get_current_sha(repo: Repo) -> str:
92
- return repo.head.commit.hexsha
93
-
94
-
95
- def get_remote_url(repo: Repo, remote_name: str = "origin") -> str:
96
- try:
97
- return repo.remote(remote_name).url
98
- except ValueError:
99
- return ""
100
-
101
-
102
- def has_changes(repo: Repo) -> bool:
103
- return repo.is_dirty() or len(repo.untracked_files) > 0
104
-
105
-
106
- def commit_changes(repo: Repo, message: str) -> None:
107
- repo.git.add("-A")
108
- if repo.is_dirty():
109
- _ensure_git_user(repo)
110
- repo.git.commit("-m", message)
111
- logger.info(f"Committed: {message}")
112
-
113
-
114
- def _ensure_git_user(repo: Repo) -> None:
115
- """Configure git user if not already set."""
116
- try:
117
- repo.config_reader().get_value("user", "name")
118
- except Exception:
119
- repo.config_writer().set_value("user", "name", "path-sync[bot]").release()
120
- repo.config_writer().set_value(
121
- "user", "email", "path-sync[bot]@users.noreply.github.com"
122
- ).release()
123
-
124
-
125
- def push_branch(repo: Repo, branch: str, force: bool = True) -> None:
126
- logger.info(f"Pushing {branch}" + (" (force)" if force else ""))
127
- args = ["--force", "-u", "origin", branch] if force else ["-u", "origin", branch]
128
- repo.git.push(*args)
129
-
130
-
131
- def update_pr_body(repo_path: Path, branch: str, body: str) -> bool:
132
- cmd = ["gh", "pr", "edit", branch, "--body", body]
133
- result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
134
- if result.returncode != 0:
135
- logger.warning(f"Failed to update PR body: {result.stderr}")
136
- return False
137
- logger.info("Updated PR body")
138
- return True
139
-
140
-
141
- def create_or_update_pr(
142
- repo_path: Path,
143
- branch: str,
144
- title: str,
145
- body: str = "",
146
- labels: list[str] | None = None,
147
- reviewers: list[str] | None = None,
148
- assignees: list[str] | None = None,
149
- ) -> str:
150
- cmd = ["gh", "pr", "create", "--head", branch, "--title", title]
151
- cmd.extend(["--body", body or ""])
152
- if labels:
153
- cmd.extend(["--label", ",".join(labels)])
154
- if reviewers:
155
- cmd.extend(["--reviewer", ",".join(reviewers)])
156
- if assignees:
157
- cmd.extend(["--assignee", ",".join(assignees)])
158
-
159
- result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
160
- if result.returncode != 0:
161
- if "already exists" in result.stderr:
162
- logger.info("PR already exists, updating body")
163
- update_pr_body(repo_path, branch, body)
164
- return ""
165
- raise RuntimeError(f"Failed to create PR: {result.stderr}")
166
- logger.info(f"Created PR: {result.stdout.strip()}")
167
- return result.stdout.strip()
168
-
169
-
170
- def file_has_git_changes(repo: Repo, file_path: Path, base_ref: str = "HEAD") -> bool:
171
- rel_path = str(file_path.relative_to(repo.working_dir))
172
- diff = repo.git.diff("--name-only", base_ref, "--", rel_path)
173
- return bool(diff.strip())
174
-
175
-
176
- def get_changed_files(repo: Repo, base_ref: str = "HEAD") -> list[Path]:
177
- diff = repo.git.diff("--name-only", base_ref)
178
- if not diff.strip():
179
- return []
180
- repo_root = Path(repo.working_dir)
181
- return [repo_root / p for p in diff.strip().split("\n")]
182
-
183
-
184
- def get_file_content_at_ref(repo: Repo, file_path: Path, ref: str) -> str | None:
185
- rel_path = str(file_path.relative_to(repo.working_dir))
186
- with suppress(GitCommandError):
187
- return repo.git.show(f"{ref}:{rel_path}")
188
- return None
path_sync/header.py DELETED
@@ -1,80 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import re
4
- from pathlib import Path
5
-
6
- from path_sync.models import (
7
- DEFAULT_COMMENT_PREFIXES,
8
- DEFAULT_COMMENT_SUFFIXES,
9
- HEADER_TEMPLATE,
10
- HeaderConfig,
11
- )
12
-
13
- COMMENT_PREFIXES = DEFAULT_COMMENT_PREFIXES
14
-
15
- HEADER_PATTERN = re.compile(r"path-sync copy -n (?P<config_name>[\w-]+)")
16
-
17
-
18
- def get_header_line(
19
- extension: str,
20
- config_name: str,
21
- config: HeaderConfig | None = None,
22
- ) -> str:
23
- if config:
24
- prefix = config.comment_prefixes.get(extension, "")
25
- suffix = config.comment_suffixes.get(extension, "")
26
- else:
27
- prefix = DEFAULT_COMMENT_PREFIXES.get(extension, "")
28
- suffix = DEFAULT_COMMENT_SUFFIXES.get(extension, "")
29
-
30
- if not prefix:
31
- raise ValueError(f"No comment prefix found for extension: {extension}")
32
- header_text = HEADER_TEMPLATE.format(config_name=config_name)
33
- return f"{prefix} {header_text}{suffix}"
34
-
35
-
36
- def has_header(content: str) -> bool:
37
- first_line = content.split("\n", 1)[0] if content else ""
38
- return bool(HEADER_PATTERN.search(first_line))
39
-
40
-
41
- def get_config_name(content: str) -> str | None:
42
- first_line = content.split("\n", 1)[0] if content else ""
43
- if match := HEADER_PATTERN.search(first_line):
44
- return match.group("config_name")
45
- return None
46
-
47
-
48
- def add_header(
49
- content: str,
50
- extension: str,
51
- config_name: str,
52
- config: HeaderConfig | None = None,
53
- ) -> str:
54
- header = get_header_line(extension, config_name, config)
55
- return f"{header}\n{content}"
56
-
57
-
58
- def remove_header(content: str) -> str:
59
- if not has_header(content):
60
- return content
61
- lines = content.split("\n", 1)
62
- return lines[1] if len(lines) > 1 else ""
63
-
64
-
65
- def file_get_config_name(path: Path) -> str | None:
66
- """Read first line and extract config name if present."""
67
- if not path.exists() or path.suffix not in DEFAULT_COMMENT_PREFIXES:
68
- return None
69
- try:
70
- with path.open() as f:
71
- first_line = f.readline()
72
- except (UnicodeDecodeError, OSError):
73
- return None
74
- return get_config_name(first_line)
75
-
76
-
77
- def file_has_header(path: Path, config: HeaderConfig | None = None) -> bool:
78
- if config and path.suffix not in config.comment_prefixes:
79
- return False
80
- return file_get_config_name(path) is not None
path_sync/header_test.py DELETED
@@ -1,41 +0,0 @@
1
- from path_sync import header
2
-
3
-
4
- def test_header_generation():
5
- assert header.get_header_line(".py", "my-config") == "# path-sync copy -n my-config"
6
- assert (
7
- header.get_header_line(".go", "my-config") == "// path-sync copy -n my-config"
8
- )
9
- assert (
10
- header.get_header_line(".md", "my-config")
11
- == "<!-- path-sync copy -n my-config -->"
12
- )
13
-
14
-
15
- def test_has_header_matches_any_config_name():
16
- assert header.has_header("# path-sync copy -n my-config\ncode")
17
- assert header.has_header("# path-sync copy -n other_name\ncode")
18
- assert not header.has_header("# DO NOT EDIT: path-sync destination file\ncode")
19
- assert not header.has_header("print('hello')")
20
-
21
-
22
- def test_add_remove_header():
23
- content = "print('hello')"
24
- with_header = header.add_header(content, ".py", "test-config")
25
- assert header.has_header(with_header)
26
- without = header.remove_header(with_header)
27
- assert without == content
28
-
29
-
30
- def test_file_has_header(tmp_path):
31
- py_file = tmp_path / "test.py"
32
- py_file.write_text(header.add_header("content", ".py", "my-config"))
33
- assert header.file_has_header(py_file)
34
-
35
- no_header = tmp_path / "plain.py"
36
- no_header.write_text("content")
37
- assert not header.file_has_header(no_header)
38
-
39
- unsupported = tmp_path / "data.whl"
40
- unsupported.write_bytes(b"\x00\x01\x02\x03")
41
- assert not header.file_has_header(unsupported)
path_sync/models.py DELETED
@@ -1,142 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import glob as glob_mod
4
- from pathlib import Path
5
- from typing import ClassVar
6
-
7
- from pydantic import BaseModel, Field
8
-
9
- LOG_FORMAT = "%(asctime)s %(levelname)s %(message)s"
10
-
11
-
12
- class PathMapping(BaseModel):
13
- src_path: str
14
- dest_path: str = ""
15
-
16
- def resolved_dest_path(self) -> str:
17
- return self.dest_path or self.src_path
18
-
19
- def expand_dest_paths(self, repo_root: Path) -> list[Path]:
20
- dest_path = self.resolved_dest_path()
21
- pattern = repo_root / dest_path
22
-
23
- if "*" in dest_path:
24
- return [Path(p) for p in glob_mod.glob(str(pattern), recursive=True)]
25
- if pattern.is_dir():
26
- return [p for p in pattern.rglob("*") if p.is_file()]
27
- if pattern.exists():
28
- return [pattern]
29
- return []
30
-
31
-
32
- HEADER_TEMPLATE = "path-sync copy -n {config_name}"
33
- DEFAULT_COMMENT_PREFIXES: dict[str, str] = {
34
- ".py": "#",
35
- ".sh": "#",
36
- ".yaml": "#",
37
- ".yml": "#",
38
- ".go": "//",
39
- ".js": "//",
40
- ".ts": "//",
41
- ".md": "<!--",
42
- ".mdc": "<!--",
43
- ".html": "<!--",
44
- }
45
- DEFAULT_COMMENT_SUFFIXES: dict[str, str] = {
46
- ".md": " -->",
47
- ".mdc": " -->",
48
- ".html": " -->",
49
- }
50
-
51
-
52
- class HeaderConfig(BaseModel):
53
- comment_prefixes: dict[str, str] = Field(
54
- default_factory=DEFAULT_COMMENT_PREFIXES.copy
55
- )
56
- comment_suffixes: dict[str, str] = Field(
57
- default_factory=DEFAULT_COMMENT_SUFFIXES.copy
58
- )
59
-
60
-
61
- DEFAULT_BODY_TEMPLATE = """\
62
- Synced from [{src_repo_name}]({src_repo_url}) @ `{src_sha_short}`
63
-
64
- <details>
65
- <summary>Sync Log</summary>
66
-
67
- ```
68
- {sync_log}
69
- ```
70
-
71
- </details>
72
- """
73
-
74
-
75
- class PRDefaults(BaseModel):
76
- title: str = "chore: sync {name} files"
77
- body_template: str = DEFAULT_BODY_TEMPLATE
78
- body_suffix: str = ""
79
- labels: list[str] = Field(default_factory=list)
80
- reviewers: list[str] = Field(default_factory=list)
81
- assignees: list[str] = Field(default_factory=list)
82
-
83
- def format_body(
84
- self,
85
- src_repo_url: str,
86
- src_sha: str,
87
- sync_log: str,
88
- dest_name: str,
89
- ) -> str:
90
- src_repo_name = src_repo_url.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git")
91
- body = self.body_template.format(
92
- src_repo_url=src_repo_url,
93
- src_repo_name=src_repo_name,
94
- src_sha=src_sha,
95
- src_sha_short=src_sha[:8],
96
- sync_log=sync_log,
97
- dest_name=dest_name,
98
- )
99
- if self.body_suffix:
100
- body = f"{body}\n---\n{self.body_suffix}"
101
- return body
102
-
103
-
104
- class Destination(BaseModel):
105
- name: str
106
- repo_url: str = ""
107
- dest_path_relative: str
108
- copy_branch: str = "sync/path-sync"
109
- default_branch: str = "main"
110
- skip_sections: dict[str, list[str]] = Field(default_factory=dict)
111
-
112
-
113
- class SrcConfig(BaseModel):
114
- CONFIG_EXT: ClassVar[str] = ".src.yaml"
115
-
116
- name: str
117
- git_remote: str = "origin"
118
- src_repo_url: str = ""
119
- schedule: str = "0 6 * * *"
120
- header_config: HeaderConfig = Field(default_factory=HeaderConfig)
121
- pr_defaults: PRDefaults = Field(default_factory=PRDefaults)
122
- paths: list[PathMapping] = Field(default_factory=list)
123
- destinations: list[Destination] = Field(default_factory=list)
124
-
125
- def find_destination(self, name: str) -> Destination:
126
- for dest in self.destinations:
127
- if dest.name == name:
128
- return dest
129
- raise ValueError(f"Destination not found: {name}")
130
-
131
-
132
- def resolve_config_path(repo_root: Path, name: str) -> Path:
133
- return repo_root / ".github" / f"{name}{SrcConfig.CONFIG_EXT}"
134
-
135
-
136
- def find_repo_root(start_path: Path) -> Path:
137
- current = start_path.resolve()
138
- while current != current.parent:
139
- if (current / ".git").exists():
140
- return current
141
- current = current.parent
142
- raise ValueError(f"No git repository found from {start_path}")
path_sync/models_test.py DELETED
@@ -1,69 +0,0 @@
1
- from path_sync.models import (
2
- Destination,
3
- PathMapping,
4
- PRDefaults,
5
- SrcConfig,
6
- find_repo_root,
7
- resolve_config_path,
8
- )
9
-
10
-
11
- def test_path_mapping_resolved():
12
- m1 = PathMapping(src_path="src/file.py")
13
- assert m1.resolved_dest_path() == "src/file.py"
14
-
15
- m2 = PathMapping(src_path="src/file.py", dest_path="dest/file.py")
16
- assert m2.resolved_dest_path() == "dest/file.py"
17
-
18
-
19
- def test_resolve_config_path(tmp_path):
20
- src_path = resolve_config_path(tmp_path, "sdlc")
21
- assert src_path == tmp_path / ".github" / "sdlc.src.yaml"
22
-
23
-
24
- def test_find_repo_root(tmp_repo):
25
- subdir = tmp_repo / "a" / "b"
26
- subdir.mkdir(parents=True)
27
- found = find_repo_root(subdir)
28
- assert found == tmp_repo
29
-
30
-
31
- def test_src_config_find_destination():
32
- config = SrcConfig(
33
- name="test",
34
- destinations=[
35
- Destination(name="repo1", dest_path_relative="../repo1"),
36
- Destination(name="repo2", dest_path_relative="../repo2"),
37
- ],
38
- )
39
- dest = config.find_destination("repo1")
40
- assert dest.name == "repo1"
41
-
42
-
43
- def test_destination_skip_sections():
44
- dest = Destination(
45
- name="test",
46
- dest_path_relative="../test",
47
- skip_sections={"justfile": ["pkg-ext"], "pyproject.toml": ["coverage"]},
48
- )
49
- assert dest.skip_sections["justfile"] == ["pkg-ext"]
50
- assert dest.skip_sections.get("unknown", []) == []
51
-
52
-
53
- def test_pr_defaults_format_body():
54
- pr = PRDefaults()
55
- body = pr.format_body(
56
- src_repo_url="https://github.com/user/my-repo",
57
- src_sha="abc12345def67890",
58
- sync_log="INFO Wrote: file.py",
59
- dest_name="dest1",
60
- )
61
- assert "[my-repo](https://github.com/user/my-repo)" in body
62
- assert "`abc12345`" in body
63
- assert "INFO Wrote: file.py" in body
64
-
65
-
66
- def test_pr_defaults_format_body_extracts_repo_name():
67
- pr = PRDefaults(body_template="{src_repo_name}")
68
- assert pr.format_body("https://github.com/u/repo", "sha", "", "") == "repo"
69
- assert pr.format_body("https://github.com/u/repo.git", "sha", "", "") == "repo"