path-sync 0.2.1__py3-none-any.whl → 0.3.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.
@@ -3,7 +3,8 @@ from __future__ import annotations
3
3
  import logging
4
4
  from pathlib import Path
5
5
 
6
- from path_sync import git_ops, header, sections
6
+ from path_sync import sections
7
+ from path_sync._internal import git_ops, header
7
8
 
8
9
  logger = logging.getLogger(__name__)
9
10
 
@@ -22,25 +23,6 @@ def parse_skip_sections(value: str) -> dict[str, set[str]]:
22
23
  return result
23
24
 
24
25
 
25
- def compare_sections(
26
- baseline_content: str,
27
- current_content: str,
28
- skip: set[str],
29
- ) -> list[str]:
30
- """Return section IDs with unauthorized changes (modified or removed)."""
31
- baseline_secs = sections.extract_sections(baseline_content)
32
- current_secs = sections.extract_sections(current_content)
33
-
34
- changed: list[str] = []
35
- for sec_id, baseline_text in baseline_secs.items():
36
- if sec_id in skip:
37
- continue
38
- current_text = current_secs.get(sec_id, "")
39
- if baseline_text != current_text:
40
- changed.append(sec_id)
41
- return changed
42
-
43
-
44
26
  def validate_no_unauthorized_changes(
45
27
  repo_root: Path,
46
28
  default_branch: str = "main",
@@ -68,12 +50,12 @@ def validate_no_unauthorized_changes(
68
50
  if baseline_content is None:
69
51
  continue
70
52
 
71
- baseline_has_sections = sections.has_sections(baseline_content)
72
- current_has_sections = sections.has_sections(current_content)
53
+ baseline_has_sections = sections.has_sections(baseline_content, path)
54
+ current_has_sections = sections.has_sections(current_content, path)
73
55
 
74
56
  if baseline_has_sections:
75
57
  file_skip = skip.get(rel_path, set())
76
- changed_ids = compare_sections(baseline_content, current_content, file_skip)
58
+ changed_ids = sections.compare_sections(baseline_content, current_content, path, file_skip)
77
59
  unauthorized.extend(f"{rel_path}:{sid}" for sid in changed_ids)
78
60
  elif current_has_sections:
79
61
  unauthorized.append(rel_path)
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.1
3
+ Version: 0.3.1
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,21 @@
1
+ path_sync/__init__.py,sha256=S6tn88icmaqW7h_DeyDXb_Koaq9rIeUUuIyQVf31VF4,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/_internal/__init__.py,sha256=iPkMhrpiyXBijo2Hp-y_2zEYxAXnHLnStKM0X0HHd4U,56
7
+ path_sync/_internal/cmd_boot.py,sha256=44IUnemVzYO_qcZsHnCG1oeQHoGIavLy-9BNKIV8Tlo,2918
8
+ path_sync/_internal/cmd_copy.py,sha256=Cl81112qBKRWU-99a8aaVHjl3RD4GCBYlHRNj39wems,16896
9
+ path_sync/_internal/cmd_validate.py,sha256=L84W5iCUxCbM04muiOvDOTdvN3HA0Ubxy8Fk_XMQf1E,1612
10
+ path_sync/_internal/file_utils.py,sha256=5C33qzKFQdwChi5YwUWBujj126t0P6dbGSU_5hWExpE,194
11
+ path_sync/_internal/git_ops.py,sha256=rpG_r7VNH1KlBgqM9mz7xop0mpdy76Vs3rzCoxE1dIQ,5895
12
+ path_sync/_internal/header.py,sha256=evgY2q_gfDdEytEt_jyJ7M_KdGzCpfdKBUnoh3v-0Go,2593
13
+ path_sync/_internal/models.py,sha256=D4qGXh4NnT01xf2odeLYULtgr245TkdjaqW26BPnxP4,3767
14
+ path_sync/_internal/typer_app.py,sha256=lEGMRXql3Se3VbmwAohvpUaL2cbY-RwhPUq8kL7bPbc,177
15
+ path_sync/_internal/validation.py,sha256=qhEha-pJiM5zkZlr4sj2I4ZqvqcWMEfL4IZu_LGatLI,2226
16
+ path_sync/_internal/yaml_utils.py,sha256=yj6Bl54EltjLEcVKaiA5Ahb9byT6OUMh0xIEzTsrvnQ,498
17
+ path_sync-0.3.1.dist-info/METADATA,sha256=dkhwUJMoG5BDWpZkZpY1rBm1qm4hBzo_rQwkqS1um_8,8231
18
+ path_sync-0.3.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
19
+ path_sync-0.3.1.dist-info/entry_points.txt,sha256=jTsL0c-9gP-4_Jt3EPgihtpLcwQR0AFAf1AUpD50AlI,54
20
+ path_sync-0.3.1.dist-info/licenses/LICENSE,sha256=OphKV48tcMv6ep-7j-8T6nycykPT0g8ZlMJ9zbGvdPs,1066
21
+ path_sync-0.3.1.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.
@@ -1,180 +0,0 @@
1
- import pytest
2
-
3
- from path_sync.cmd_copy import (
4
- CopyOptions,
5
- _cleanup_orphans,
6
- _ensure_dest_repo,
7
- _sync_path,
8
- )
9
- from path_sync.header import add_header, has_header
10
- from path_sync.models import Destination, PathMapping
11
-
12
- CONFIG_NAME = "test-config"
13
-
14
-
15
- def _make_dest(**kwargs) -> Destination:
16
- defaults = {"name": "test", "dest_path_relative": "."}
17
- return Destination(**(defaults | kwargs)) # pyright: ignore[reportArgumentType]
18
-
19
-
20
- def test_sync_single_file(tmp_path):
21
- src_root = tmp_path / "src"
22
- dest_root = tmp_path / "dest"
23
- src_root.mkdir()
24
- dest_root.mkdir()
25
-
26
- (src_root / "file.py").write_text("content")
27
-
28
- mapping = PathMapping(src_path="file.py", dest_path="out.py")
29
- changes, synced = _sync_path(
30
- mapping, src_root, dest_root, _make_dest(), CONFIG_NAME, False, False
31
- )
32
-
33
- assert changes == 1
34
- assert dest_root / "out.py" in synced
35
- result = (dest_root / "out.py").read_text()
36
- assert has_header(result)
37
- assert f"path-sync copy -n {CONFIG_NAME}" in result
38
-
39
-
40
- def test_sync_skips_opted_out_file(tmp_path):
41
- src_root = tmp_path / "src"
42
- dest_root = tmp_path / "dest"
43
- src_root.mkdir()
44
- dest_root.mkdir()
45
-
46
- (src_root / "file.py").write_text("new content")
47
- (dest_root / "file.py").write_text("local content without header")
48
-
49
- mapping = PathMapping(src_path="file.py")
50
- changes, _ = _sync_path(
51
- mapping, src_root, dest_root, _make_dest(), CONFIG_NAME, False, False
52
- )
53
-
54
- assert changes == 0
55
- assert (dest_root / "file.py").read_text() == "local content without header"
56
-
57
-
58
- def test_force_overwrite_adds_header_when_content_matches(tmp_path):
59
- src_root = tmp_path / "src"
60
- dest_root = tmp_path / "dest"
61
- src_root.mkdir()
62
- dest_root.mkdir()
63
-
64
- content = "same content"
65
- (src_root / "file.py").write_text(content)
66
- (dest_root / "file.py").write_text(content) # No header, same content
67
-
68
- mapping = PathMapping(src_path="file.py")
69
- changes, _ = _sync_path(
70
- mapping, src_root, dest_root, _make_dest(), CONFIG_NAME, False, True
71
- )
72
-
73
- assert changes == 1
74
- result = (dest_root / "file.py").read_text()
75
- assert has_header(result)
76
- assert content in result
77
-
78
-
79
- def test_cleanup_orphans(tmp_path):
80
- dest_root = tmp_path / "dest"
81
- dest_root.mkdir()
82
-
83
- # File with matching config header - will be orphaned
84
- orphan = dest_root / "orphan.py"
85
- orphan.write_text(add_header("orphan content", ".py", CONFIG_NAME))
86
-
87
- # File with different config - should not be deleted
88
- other = dest_root / "other.py"
89
- other.write_text(add_header("other content", ".py", "other-config"))
90
-
91
- synced: set = set() # No files synced
92
- deleted = _cleanup_orphans(dest_root, CONFIG_NAME, synced, dry_run=False)
93
-
94
- assert deleted == 1
95
- assert not orphan.exists()
96
- assert other.exists()
97
-
98
-
99
- def test_sync_with_sections_replaces_managed(tmp_path):
100
- src_root = tmp_path / "src"
101
- dest_root = tmp_path / "dest"
102
- src_root.mkdir()
103
- dest_root.mkdir()
104
-
105
- src_content = """\
106
- # === DO_NOT_EDIT: path-sync standard ===
107
- new recipe
108
- # === OK_EDIT ==="""
109
- (src_root / "file.sh").write_text(src_content)
110
-
111
- dest_content = add_header(
112
- """\
113
- # === DO_NOT_EDIT: path-sync standard ===
114
- old recipe
115
- # === OK_EDIT ===
116
- # my custom stuff""",
117
- ".sh",
118
- CONFIG_NAME,
119
- )
120
- (dest_root / "file.sh").write_text(dest_content)
121
-
122
- mapping = PathMapping(src_path="file.sh")
123
- changes, _ = _sync_path(
124
- mapping, src_root, dest_root, _make_dest(), CONFIG_NAME, False, False
125
- )
126
-
127
- assert changes == 1
128
- result = (dest_root / "file.sh").read_text()
129
- assert "new recipe" in result
130
- assert "old recipe" not in result
131
- assert "# my custom stuff" in result
132
-
133
-
134
- def test_sync_with_sections_skip(tmp_path):
135
- src_root = tmp_path / "src"
136
- dest_root = tmp_path / "dest"
137
- src_root.mkdir()
138
- dest_root.mkdir()
139
-
140
- src_content = """\
141
- # === DO_NOT_EDIT: path-sync standard ===
142
- source
143
- # === OK_EDIT ==="""
144
- (src_root / "file.sh").write_text(src_content)
145
-
146
- dest_content = add_header(
147
- """\
148
- # === DO_NOT_EDIT: path-sync standard ===
149
- keep this
150
- # === OK_EDIT ===""",
151
- ".sh",
152
- CONFIG_NAME,
153
- )
154
- (dest_root / "file.sh").write_text(dest_content)
155
-
156
- dest = _make_dest(skip_sections={"file.sh": ["standard"]})
157
- mapping = PathMapping(src_path="file.sh")
158
- changes, _ = _sync_path(
159
- mapping, src_root, dest_root, dest, CONFIG_NAME, False, False
160
- )
161
-
162
- assert changes == 0
163
- assert "keep this" in (dest_root / "file.sh").read_text()
164
-
165
-
166
- def test_ensure_dest_repo_dry_run_errors_if_missing(tmp_path):
167
- dest = _make_dest()
168
- dest_root = tmp_path / "missing_repo"
169
- with pytest.raises(ValueError, match="Destination repo not found"):
170
- _ensure_dest_repo(dest, dest_root, dry_run=True)
171
-
172
-
173
- def test_copy_options_defaults():
174
- opts = CopyOptions()
175
- assert not opts.dry_run
176
- assert not opts.force_overwrite
177
- assert not opts.no_checkout
178
- assert not opts.local
179
- assert not opts.no_prompt
180
- assert not opts.no_pr
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/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)