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/__init__.py +10 -1
- path_sync/__main__.py +3 -3
- path_sync/config.py +14 -0
- path_sync/copy.py +4 -0
- path_sync/sections.py +61 -104
- {path_sync-0.2.0.dist-info → path_sync-0.3.0.dist-info}/METADATA +11 -5
- path_sync-0.3.0.dist-info/RECORD +10 -0
- path_sync-0.3.0.dist-info/licenses/LICENSE +21 -0
- path_sync/cmd_boot.py +0 -89
- path_sync/cmd_copy.py +0 -522
- path_sync/cmd_copy_test.py +0 -159
- path_sync/cmd_validate.py +0 -51
- path_sync/conftest.py +0 -15
- path_sync/file_utils.py +0 -7
- path_sync/git_ops.py +0 -188
- path_sync/header.py +0 -80
- path_sync/header_test.py +0 -41
- path_sync/models.py +0 -142
- path_sync/models_test.py +0 -69
- path_sync/sections_test.py +0 -128
- path_sync/typer_app.py +0 -8
- path_sync/validation.py +0 -84
- path_sync/validation_test.py +0 -114
- path_sync/yaml_utils.py +0 -19
- path_sync-0.2.0.dist-info/RECORD +0 -23
- {path_sync-0.2.0.dist-info → path_sync-0.3.0.dist-info}/WHEEL +0 -0
- {path_sync-0.2.0.dist-info → path_sync-0.3.0.dist-info}/entry_points.txt +0 -0
path_sync/__init__.py
CHANGED
path_sync/__main__.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
|
-
from path_sync import cmd_boot, cmd_copy, cmd_validate # noqa: F401
|
|
4
|
-
from path_sync.models import LOG_FORMAT
|
|
5
|
-
from path_sync.typer_app import app
|
|
3
|
+
from path_sync._internal import cmd_boot, cmd_copy, cmd_validate # noqa: F401
|
|
4
|
+
from path_sync._internal.models import LOG_FORMAT
|
|
5
|
+
from path_sync._internal.typer_app import app
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def main() -> None:
|
path_sync/config.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Generated by pkg-ext
|
|
2
|
+
from path_sync._internal.models import Destination as _Destination
|
|
3
|
+
from path_sync._internal.models import HeaderConfig as _HeaderConfig
|
|
4
|
+
from path_sync._internal.models import PathMapping as _PathMapping
|
|
5
|
+
from path_sync._internal.models import PRDefaults as _PRDefaults
|
|
6
|
+
from path_sync._internal.models import SrcConfig as _SrcConfig
|
|
7
|
+
from path_sync._internal.models import SyncMode as _SyncMode
|
|
8
|
+
|
|
9
|
+
Destination = _Destination
|
|
10
|
+
HeaderConfig = _HeaderConfig
|
|
11
|
+
PRDefaults = _PRDefaults
|
|
12
|
+
PathMapping = _PathMapping
|
|
13
|
+
SrcConfig = _SrcConfig
|
|
14
|
+
SyncMode = _SyncMode
|
path_sync/copy.py
ADDED
path_sync/sections.py
CHANGED
|
@@ -1,115 +1,72 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
from zero_3rdparty.sections import (
|
|
6
|
+
Section,
|
|
7
|
+
get_comment_config,
|
|
8
8
|
)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
content_lines.append(line)
|
|
56
|
-
|
|
57
|
-
if current_id is not None:
|
|
58
|
-
raise ValueError(
|
|
59
|
-
f"Unclosed section '{current_id}' starting at line {current_start}"
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
return sections
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def wrap_in_default_section(content: str) -> str:
|
|
66
|
-
return f"# === DO_NOT_EDIT: path-sync default ===\n{content}\n# === OK_EDIT ==="
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def extract_sections(content: str) -> dict[str, str]:
|
|
70
|
-
return {s.id: s.content for s in parse_sections(content)}
|
|
9
|
+
from zero_3rdparty.sections import (
|
|
10
|
+
compare_sections as _compare_sections,
|
|
11
|
+
)
|
|
12
|
+
from zero_3rdparty.sections import (
|
|
13
|
+
extract_sections as _extract_sections,
|
|
14
|
+
)
|
|
15
|
+
from zero_3rdparty.sections import (
|
|
16
|
+
has_sections as _has_sections,
|
|
17
|
+
)
|
|
18
|
+
from zero_3rdparty.sections import (
|
|
19
|
+
parse_sections as _parse_sections,
|
|
20
|
+
)
|
|
21
|
+
from zero_3rdparty.sections import (
|
|
22
|
+
replace_sections as _replace_sections,
|
|
23
|
+
)
|
|
24
|
+
from zero_3rdparty.sections import (
|
|
25
|
+
wrap_in_default_section as _wrap_in_default_section,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Section",
|
|
30
|
+
"compare_sections",
|
|
31
|
+
"extract_sections",
|
|
32
|
+
"has_sections",
|
|
33
|
+
"parse_sections",
|
|
34
|
+
"replace_sections",
|
|
35
|
+
"wrap_in_default_section",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
TOOL_NAME = "path-sync"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def has_sections(content: str, path: Path) -> bool:
|
|
42
|
+
return _has_sections(content, TOOL_NAME, get_comment_config(path))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_sections(content: str, path: Path) -> list[Section]:
|
|
46
|
+
return _parse_sections(content, TOOL_NAME, get_comment_config(path))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def wrap_in_default_section(content: str, path: Path) -> str:
|
|
50
|
+
return _wrap_in_default_section(content, TOOL_NAME, get_comment_config(path))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def extract_sections(content: str, path: Path) -> dict[str, str]:
|
|
54
|
+
return _extract_sections(content, TOOL_NAME, get_comment_config(path))
|
|
71
55
|
|
|
72
56
|
|
|
73
57
|
def replace_sections(
|
|
74
58
|
dest_content: str,
|
|
75
59
|
src_sections: dict[str, str],
|
|
60
|
+
path: Path,
|
|
76
61
|
skip_sections: list[str] | None = None,
|
|
77
62
|
) -> str:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
current_section_id = start_match.group("id")
|
|
89
|
-
result.append(line)
|
|
90
|
-
elif SECTION_END_PATTERN.match(line):
|
|
91
|
-
if current_section_id:
|
|
92
|
-
should_replace = (
|
|
93
|
-
current_section_id in src_sections
|
|
94
|
-
and current_section_id not in skip
|
|
95
|
-
)
|
|
96
|
-
content = (
|
|
97
|
-
src_sections[current_section_id]
|
|
98
|
-
if should_replace
|
|
99
|
-
else dest_sections.get(current_section_id, "")
|
|
100
|
-
)
|
|
101
|
-
if content:
|
|
102
|
-
result.append(content)
|
|
103
|
-
result.append(line)
|
|
104
|
-
current_section_id = None
|
|
105
|
-
elif current_section_id is None:
|
|
106
|
-
result.append(line)
|
|
107
|
-
|
|
108
|
-
# Append new sections from source not in dest
|
|
109
|
-
for sid in src_sections:
|
|
110
|
-
if sid not in dest_ids and sid not in skip:
|
|
111
|
-
result.append(f"# === DO_NOT_EDIT: path-sync {sid} ===")
|
|
112
|
-
result.append(src_sections[sid])
|
|
113
|
-
result.append("# === OK_EDIT ===")
|
|
114
|
-
|
|
115
|
-
return "\n".join(result)
|
|
63
|
+
return _replace_sections(dest_content, src_sections, TOOL_NAME, get_comment_config(path), skip_sections)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def compare_sections(
|
|
67
|
+
baseline_content: str,
|
|
68
|
+
current_content: str,
|
|
69
|
+
path: Path,
|
|
70
|
+
skip: set[str] | None = None,
|
|
71
|
+
) -> list[str]:
|
|
72
|
+
return _compare_sections(baseline_content, current_content, TOOL_NAME, get_comment_config(path), skip)
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: path-sync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Sync files from a source repo to multiple destination repos
|
|
4
5
|
Author-email: EspenAlbert <espen.albert1@gmail.com>
|
|
5
6
|
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: cli,repository,sync,template
|
|
6
9
|
Classifier: Development Status :: 4 - Beta
|
|
7
10
|
Classifier: Programming Language :: Python :: 3.13
|
|
8
11
|
Requires-Python: >=3.13
|
|
@@ -10,6 +13,7 @@ Requires-Dist: gitpython>=3.1.0
|
|
|
10
13
|
Requires-Dist: pydantic>=2.0
|
|
11
14
|
Requires-Dist: pyyaml>=6.0
|
|
12
15
|
Requires-Dist: typer>=0.16.0
|
|
16
|
+
Requires-Dist: zero-3rdparty>=0.100.0
|
|
13
17
|
Description-Content-Type: text/markdown
|
|
14
18
|
|
|
15
19
|
# path-sync
|
|
@@ -69,7 +73,7 @@ By default, prompts before each git operation. See [Usage Scenarios](#usage-scen
|
|
|
69
73
|
| `--dry-run` | Preview without writing (requires existing repos) |
|
|
70
74
|
| `-y, --no-prompt` | Skip confirmations (for CI) |
|
|
71
75
|
| `--local` | No git ops after sync (no commit/push/PR) |
|
|
72
|
-
| `--no-checkout` | Skip branch switching
|
|
76
|
+
| `--no-checkout` | Skip branch switching (assumes already on correct branch) |
|
|
73
77
|
| `--checkout-from-default` | Reset to origin/default before sync |
|
|
74
78
|
| `--no-pr` | Push but skip PR creation |
|
|
75
79
|
| `--force-overwrite` | Overwrite files even if header removed (opted out) |
|
|
@@ -98,10 +102,12 @@ Options:
|
|
|
98
102
|
| CI fresh sync | `copy -n cfg --checkout-from-default -y` |
|
|
99
103
|
| Local preview | `copy -n cfg --dry-run` |
|
|
100
104
|
| Local test files | `copy -n cfg --local` |
|
|
101
|
-
| Already on branch | `copy -n cfg --no-checkout
|
|
105
|
+
| Already on branch | `copy -n cfg --no-checkout` |
|
|
102
106
|
| Push, manual PR | `copy -n cfg --no-pr -y` |
|
|
103
107
|
| Force opted-out | `copy -n cfg --force-overwrite` |
|
|
104
108
|
|
|
109
|
+
**Interactive prompt behavior**: Declining the checkout prompt syncs files but skips commit/push/PR (same as `--local`). Use `--no-checkout` when you're already on the correct branch and want to proceed with git operations.
|
|
110
|
+
|
|
105
111
|
## Section Markers
|
|
106
112
|
|
|
107
113
|
For partial file syncing (e.g., `justfile`, `pyproject.toml`), wrap sections with markers:
|
|
@@ -143,7 +149,7 @@ destinations:
|
|
|
143
149
|
- name: dest1
|
|
144
150
|
repo_url: https://github.com/user/dest1
|
|
145
151
|
dest_path_relative: ../dest1
|
|
146
|
-
copy_branch: sync/
|
|
152
|
+
# copy_branch: sync/cursor # defaults to sync/{config_name}
|
|
147
153
|
default_branch: main
|
|
148
154
|
skip_sections:
|
|
149
155
|
justfile: [coverage]
|
|
@@ -228,7 +234,7 @@ jobs:
|
|
|
228
234
|
```
|
|
229
235
|
|
|
230
236
|
**Validation skips automatically when:**
|
|
231
|
-
- On a `sync/*` branch (path-sync uses `sync/
|
|
237
|
+
- On a `sync/*` branch (path-sync uses `sync/{config_name}` by default)
|
|
232
238
|
- On the default branch (comparing against itself)
|
|
233
239
|
|
|
234
240
|
The workflow triggers exclude these branches too, reducing unnecessary CI runs.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
path_sync/__init__.py,sha256=GxwE45z99FV9EG57V7d0ld557zw2SzYk_83nEbBGtRk,153
|
|
2
|
+
path_sync/__main__.py,sha256=HDj3qgijDcK8k976qsAvKMvUq9fZEJTK-dZldUc5-no,326
|
|
3
|
+
path_sync/config.py,sha256=XYuEK_bjSpAk_nZN0oxpEA-S3t9F6Qn0yznYLoQHIw8,568
|
|
4
|
+
path_sync/copy.py,sha256=BpflW4086XJFSHHK4taYPgXtF07xHB8qrgm8TYqdM4E,120
|
|
5
|
+
path_sync/sections.py,sha256=dMEux8wT6nu9fcPgqgoWCc7QFNOxGVx5RVwcHhEK_lw,1894
|
|
6
|
+
path_sync-0.3.0.dist-info/METADATA,sha256=8oFXF5R7ULoTo2CFdDRcsXjpRW6G0yZ-aGFjDHh1iJk,8231
|
|
7
|
+
path_sync-0.3.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
8
|
+
path_sync-0.3.0.dist-info/entry_points.txt,sha256=jTsL0c-9gP-4_Jt3EPgihtpLcwQR0AFAf1AUpD50AlI,54
|
|
9
|
+
path_sync-0.3.0.dist-info/licenses/LICENSE,sha256=OphKV48tcMv6ep-7j-8T6nycykPT0g8ZlMJ9zbGvdPs,1066
|
|
10
|
+
path_sync-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
path_sync/cmd_boot.py
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import Annotated
|
|
6
|
-
|
|
7
|
-
import typer
|
|
8
|
-
|
|
9
|
-
from path_sync import git_ops
|
|
10
|
-
from path_sync.file_utils import ensure_parents_write_text
|
|
11
|
-
from path_sync.models import (
|
|
12
|
-
Destination,
|
|
13
|
-
PathMapping,
|
|
14
|
-
SrcConfig,
|
|
15
|
-
find_repo_root,
|
|
16
|
-
resolve_config_path,
|
|
17
|
-
)
|
|
18
|
-
from path_sync.typer_app import app
|
|
19
|
-
from path_sync.yaml_utils import dump_yaml_model, load_yaml_model
|
|
20
|
-
|
|
21
|
-
logger = logging.getLogger(__name__)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@app.command()
|
|
25
|
-
def boot(
|
|
26
|
-
name: str = typer.Option(..., "-n", "--name", help="Config name"),
|
|
27
|
-
dest_paths: Annotated[
|
|
28
|
-
list[str], typer.Option("-d", "--dest", help="Destination relative paths")
|
|
29
|
-
] = [],
|
|
30
|
-
sync_paths: Annotated[
|
|
31
|
-
list[str], typer.Option("-p", "--path", help="Paths to sync (glob patterns)")
|
|
32
|
-
] = [],
|
|
33
|
-
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without writing"),
|
|
34
|
-
regen: bool = typer.Option(False, "--regen", help="Regenerate config"),
|
|
35
|
-
) -> None:
|
|
36
|
-
"""Initialize or update SRC repo config."""
|
|
37
|
-
repo_root = find_repo_root(Path.cwd())
|
|
38
|
-
config_path = resolve_config_path(repo_root, name)
|
|
39
|
-
|
|
40
|
-
if config_path.exists() and not regen:
|
|
41
|
-
load_yaml_model(config_path, SrcConfig)
|
|
42
|
-
logger.info(f"Using existing config: {config_path}")
|
|
43
|
-
else:
|
|
44
|
-
src_repo = git_ops.get_repo(repo_root)
|
|
45
|
-
src_repo_url = git_ops.get_remote_url(src_repo, "origin")
|
|
46
|
-
|
|
47
|
-
destinations = _build_destinations(repo_root, dest_paths)
|
|
48
|
-
path_mappings = [PathMapping(src_path=p) for p in sync_paths]
|
|
49
|
-
|
|
50
|
-
config = SrcConfig(
|
|
51
|
-
name=name,
|
|
52
|
-
src_repo_url=src_repo_url,
|
|
53
|
-
paths=path_mappings,
|
|
54
|
-
destinations=destinations,
|
|
55
|
-
)
|
|
56
|
-
config_content = dump_yaml_model(config)
|
|
57
|
-
_write_file(config_path, config_content, dry_run, "config")
|
|
58
|
-
|
|
59
|
-
if dry_run:
|
|
60
|
-
logger.info("Dry run complete - no files written")
|
|
61
|
-
else:
|
|
62
|
-
logger.info("Boot complete")
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def _build_destinations(repo_root: Path, dest_paths: list[str]) -> list[Destination]:
|
|
66
|
-
destinations = []
|
|
67
|
-
for rel_path in dest_paths:
|
|
68
|
-
dest_dir = (repo_root / rel_path).resolve()
|
|
69
|
-
dest_name = dest_dir.name
|
|
70
|
-
|
|
71
|
-
dest = Destination(name=dest_name, dest_path_relative=rel_path)
|
|
72
|
-
|
|
73
|
-
if git_ops.is_git_repo(dest_dir):
|
|
74
|
-
dest_repo = git_ops.get_repo(dest_dir)
|
|
75
|
-
dest.repo_url = git_ops.get_remote_url(dest_repo, "origin")
|
|
76
|
-
dest.default_branch = git_ops.get_default_branch(dest_repo)
|
|
77
|
-
logger.info(f"Found git repo at {dest_dir}: {dest.repo_url}")
|
|
78
|
-
|
|
79
|
-
destinations.append(dest)
|
|
80
|
-
|
|
81
|
-
return destinations
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def _write_file(path: Path, content: str, dry_run: bool, desc: str) -> None:
|
|
85
|
-
if dry_run:
|
|
86
|
-
logger.info(f"[DRY RUN] Would write {desc}: {path}")
|
|
87
|
-
return
|
|
88
|
-
ensure_parents_write_text(path, content)
|
|
89
|
-
logger.info(f"Wrote {desc}: {path}")
|