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 CHANGED
@@ -1 +1,10 @@
1
- """Path sync package for syncing files across repositories."""
1
+ # Generated by pkg-ext
2
+ # flake8: noqa
3
+ from path_sync import copy
4
+ from path_sync import config
5
+
6
+ VERSION = "0.3.0"
7
+ __all__ = [
8
+ "copy",
9
+ "config",
10
+ ]
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
@@ -0,0 +1,4 @@
1
+ # Generated by pkg-ext
2
+ from path_sync._internal.cmd_copy import CopyOptions as _CopyOptions
3
+
4
+ CopyOptions = _CopyOptions
path_sync/sections.py CHANGED
@@ -1,115 +1,72 @@
1
1
  from __future__ import annotations
2
2
 
3
- import re
4
- from dataclasses import dataclass
3
+ from pathlib import Path
5
4
 
6
- SECTION_START_PATTERN = re.compile(
7
- r"^#\s*===\s*DO_NOT_EDIT:\s*path-sync\s+(?P<id>\w+)\s*===$", re.MULTILINE
5
+ from zero_3rdparty.sections import (
6
+ Section,
7
+ get_comment_config,
8
8
  )
9
- SECTION_END_PATTERN = re.compile(r"^#\s*===\s*OK_EDIT\s*===$", re.MULTILINE)
10
-
11
-
12
- @dataclass
13
- class Section:
14
- id: str
15
- content: str
16
- start_line: int
17
- end_line: int
18
-
19
-
20
- def has_sections(content: str) -> bool:
21
- return bool(SECTION_START_PATTERN.search(content))
22
-
23
-
24
- def parse_sections(content: str) -> list[Section]:
25
- lines = content.split("\n")
26
- sections: list[Section] = []
27
- current_id: str | None = None
28
- current_start: int = -1
29
- content_lines: list[str] = []
30
-
31
- for i, line in enumerate(lines):
32
- if start_match := SECTION_START_PATTERN.match(line):
33
- if current_id is not None:
34
- raise ValueError(
35
- f"Nested section at line {i}: found '{start_match.group('id')}' inside '{current_id}'"
36
- )
37
- current_id = start_match.group("id")
38
- current_start = i
39
- content_lines = []
40
- elif SECTION_END_PATTERN.match(line):
41
- if current_id is None:
42
- continue # standalone OK_EDIT marks editable region, not an error
43
- sections.append(
44
- Section(
45
- id=current_id,
46
- content="\n".join(content_lines),
47
- start_line=current_start,
48
- end_line=i,
49
- )
50
- )
51
- current_id = None
52
- current_start = -1
53
- content_lines = []
54
- elif current_id is not None:
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
- skip = set(skip_sections or [])
79
- dest_parsed = parse_sections(dest_content)
80
- dest_ids = {s.id for s in dest_parsed}
81
- dest_sections = {s.id: s.content for s in dest_parsed}
82
- lines = dest_content.split("\n")
83
- result: list[str] = []
84
-
85
- current_section_id: str | None = None
86
- for line in lines:
87
- if start_match := SECTION_START_PATTERN.match(line):
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.2.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 before sync |
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 --local` |
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/path-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/path-sync` by default)
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}")