path-sync 0.3.3__py3-none-any.whl → 0.3.5__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 +1 -1
- path_sync/_internal/cmd_copy.py +32 -44
- path_sync/_internal/models.py +4 -1
- path_sync/_internal/validation.py +5 -2
- path_sync/sections.py +17 -6
- {path_sync-0.3.3.dist-info → path_sync-0.3.5.dist-info}/METADATA +56 -3
- {path_sync-0.3.3.dist-info → path_sync-0.3.5.dist-info}/RECORD +10 -10
- {path_sync-0.3.3.dist-info → path_sync-0.3.5.dist-info}/licenses/LICENSE +1 -1
- {path_sync-0.3.3.dist-info → path_sync-0.3.5.dist-info}/WHEEL +0 -0
- {path_sync-0.3.3.dist-info → path_sync-0.3.5.dist-info}/entry_points.txt +0 -0
path_sync/__init__.py
CHANGED
path_sync/_internal/cmd_copy.py
CHANGED
|
@@ -8,6 +8,7 @@ from dataclasses import dataclass, field
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
import typer
|
|
11
|
+
from pydantic import BaseModel
|
|
11
12
|
|
|
12
13
|
from path_sync import sections
|
|
13
14
|
from path_sync._internal import git_ops, header
|
|
@@ -68,8 +69,7 @@ def capture_sync_log(dest_name: str):
|
|
|
68
69
|
root_logger.removeHandler(file_handler)
|
|
69
70
|
|
|
70
71
|
|
|
71
|
-
|
|
72
|
-
class CopyOptions:
|
|
72
|
+
class CopyOptions(BaseModel):
|
|
73
73
|
dry_run: bool = False
|
|
74
74
|
force_overwrite: bool = False
|
|
75
75
|
no_checkout: bool = False
|
|
@@ -237,10 +237,10 @@ def _sync_destination(
|
|
|
237
237
|
|
|
238
238
|
# --no-checkout means "I'm already on the right branch"
|
|
239
239
|
# Prompt decline means "skip git operations for this run"
|
|
240
|
-
if opts.
|
|
241
|
-
skip_git_ops = False
|
|
242
|
-
elif opts.dry_run:
|
|
240
|
+
if opts.dry_run:
|
|
243
241
|
skip_git_ops = True
|
|
242
|
+
elif opts.no_checkout:
|
|
243
|
+
skip_git_ops = False
|
|
244
244
|
elif _prompt(f"Switch {dest.name} to {copy_branch}?", opts.no_prompt):
|
|
245
245
|
git_ops.prepare_copy_branch(
|
|
246
246
|
repo=dest_repo,
|
|
@@ -312,19 +312,12 @@ def _sync_paths(
|
|
|
312
312
|
return result
|
|
313
313
|
|
|
314
314
|
|
|
315
|
-
def
|
|
315
|
+
def _iter_sync_files(
|
|
316
316
|
mapping: PathMapping,
|
|
317
317
|
src_root: Path,
|
|
318
318
|
dest_root: Path,
|
|
319
|
-
|
|
320
|
-
config_name: str,
|
|
321
|
-
dry_run: bool,
|
|
322
|
-
force_overwrite: bool,
|
|
323
|
-
) -> tuple[int, set[Path]]:
|
|
319
|
+
):
|
|
324
320
|
src_pattern = src_root / mapping.src_path
|
|
325
|
-
changes = 0
|
|
326
|
-
synced: set[Path] = set()
|
|
327
|
-
sync_mode = mapping.sync_mode
|
|
328
321
|
|
|
329
322
|
if "*" in mapping.src_path:
|
|
330
323
|
glob_prefix = mapping.src_path.split("*")[0].rstrip("/")
|
|
@@ -336,53 +329,48 @@ def _sync_path(
|
|
|
336
329
|
src_path = Path(src_file)
|
|
337
330
|
if src_path.is_file() and not mapping.is_excluded(src_path):
|
|
338
331
|
rel = src_path.relative_to(src_root / glob_prefix)
|
|
339
|
-
dest_path = dest_root / dest_base / rel
|
|
340
332
|
dest_key = str(Path(dest_base) / rel)
|
|
341
|
-
|
|
342
|
-
src_path,
|
|
343
|
-
dest_path,
|
|
344
|
-
dest,
|
|
345
|
-
dest_key,
|
|
346
|
-
config_name,
|
|
347
|
-
sync_mode,
|
|
348
|
-
dry_run,
|
|
349
|
-
force_overwrite,
|
|
350
|
-
)
|
|
351
|
-
synced.add(dest_path)
|
|
333
|
+
yield src_path, dest_key, dest_root / dest_base / rel
|
|
352
334
|
elif src_pattern.is_dir():
|
|
353
335
|
dest_base = mapping.resolved_dest_path()
|
|
354
336
|
for src_file in src_pattern.rglob("*"):
|
|
355
337
|
if src_file.is_file() and not mapping.is_excluded(src_file):
|
|
356
338
|
rel = src_file.relative_to(src_pattern)
|
|
357
|
-
dest_path = dest_root / dest_base / rel
|
|
358
339
|
dest_key = str(Path(dest_base) / rel)
|
|
359
|
-
|
|
360
|
-
src_file,
|
|
361
|
-
dest_path,
|
|
362
|
-
dest,
|
|
363
|
-
dest_key,
|
|
364
|
-
config_name,
|
|
365
|
-
sync_mode,
|
|
366
|
-
dry_run,
|
|
367
|
-
force_overwrite,
|
|
368
|
-
)
|
|
369
|
-
synced.add(dest_path)
|
|
340
|
+
yield src_file, dest_key, dest_root / dest_base / rel
|
|
370
341
|
elif src_pattern.is_file():
|
|
371
342
|
dest_base = mapping.resolved_dest_path()
|
|
372
|
-
|
|
343
|
+
yield src_pattern, dest_base, dest_root / dest_base
|
|
344
|
+
else:
|
|
345
|
+
logger.warning(f"Source not found: {mapping.src_path}")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _sync_path(
|
|
349
|
+
mapping: PathMapping,
|
|
350
|
+
src_root: Path,
|
|
351
|
+
dest_root: Path,
|
|
352
|
+
dest: Destination,
|
|
353
|
+
config_name: str,
|
|
354
|
+
dry_run: bool,
|
|
355
|
+
force_overwrite: bool,
|
|
356
|
+
) -> tuple[int, set[Path]]:
|
|
357
|
+
changes = 0
|
|
358
|
+
synced: set[Path] = set()
|
|
359
|
+
|
|
360
|
+
for src_path, dest_key, dest_path in _iter_sync_files(mapping, src_root, dest_root):
|
|
361
|
+
if dest.is_skipped(dest_key):
|
|
362
|
+
continue
|
|
373
363
|
changes += _copy_file(
|
|
374
|
-
|
|
364
|
+
src_path,
|
|
375
365
|
dest_path,
|
|
376
366
|
dest,
|
|
377
|
-
|
|
367
|
+
dest_key,
|
|
378
368
|
config_name,
|
|
379
|
-
sync_mode,
|
|
369
|
+
mapping.sync_mode,
|
|
380
370
|
dry_run,
|
|
381
371
|
force_overwrite,
|
|
382
372
|
)
|
|
383
373
|
synced.add(dest_path)
|
|
384
|
-
else:
|
|
385
|
-
logger.warning(f"Source not found: {mapping.src_path}")
|
|
386
374
|
|
|
387
375
|
return changes, synced
|
|
388
376
|
|
path_sync/_internal/models.py
CHANGED
|
@@ -121,11 +121,14 @@ class Destination(BaseModel):
|
|
|
121
121
|
copy_branch: str = ""
|
|
122
122
|
default_branch: str = "main"
|
|
123
123
|
skip_sections: dict[str, list[str]] = Field(default_factory=dict)
|
|
124
|
+
skip_file_patterns: set[str] = Field(default_factory=set)
|
|
124
125
|
|
|
125
126
|
def resolved_copy_branch(self, config_name: str) -> str:
|
|
126
|
-
"""Returns branch name, defaulting to sync/{config_name} if not set."""
|
|
127
127
|
return self.copy_branch or f"sync/{config_name}"
|
|
128
128
|
|
|
129
|
+
def is_skipped(self, dest_key: str) -> bool:
|
|
130
|
+
return any(fnmatch.fnmatch(dest_key, pat) for pat in self.skip_file_patterns)
|
|
131
|
+
|
|
129
132
|
|
|
130
133
|
class SrcConfig(BaseModel):
|
|
131
134
|
CONFIG_EXT: ClassVar[str] = ".src.yaml"
|
|
@@ -31,6 +31,7 @@ def validate_no_unauthorized_changes(
|
|
|
31
31
|
"""Find files with unauthorized changes in DO_NOT_EDIT sections.
|
|
32
32
|
|
|
33
33
|
Returns 'path:section_id' for section changes or 'path' for full-file.
|
|
34
|
+
Missing sections (user opted out) are warned but not treated as errors.
|
|
34
35
|
"""
|
|
35
36
|
repo = git_ops.get_repo(repo_root)
|
|
36
37
|
base_ref = f"origin/{default_branch}"
|
|
@@ -55,8 +56,10 @@ def validate_no_unauthorized_changes(
|
|
|
55
56
|
|
|
56
57
|
if baseline_has_sections:
|
|
57
58
|
file_skip = skip.get(rel_path, set())
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
changes = sections.changed_sections(baseline_content, current_content, path, file_skip)
|
|
60
|
+
for sid in changes.missing:
|
|
61
|
+
logger.warning(f"Section '{sid}' removed from {rel_path}, consider updating src.yaml")
|
|
62
|
+
unauthorized.extend(f"{rel_path}:{sid}" for sid in changes.modified)
|
|
60
63
|
elif current_has_sections:
|
|
61
64
|
unauthorized.append(rel_path)
|
|
62
65
|
else:
|
path_sync/sections.py
CHANGED
|
@@ -4,10 +4,11 @@ from pathlib import Path
|
|
|
4
4
|
|
|
5
5
|
from zero_3rdparty.sections import (
|
|
6
6
|
Section,
|
|
7
|
+
SectionChanges,
|
|
7
8
|
get_comment_config,
|
|
8
9
|
)
|
|
9
10
|
from zero_3rdparty.sections import (
|
|
10
|
-
|
|
11
|
+
changed_sections as _changed_sections,
|
|
11
12
|
)
|
|
12
13
|
from zero_3rdparty.sections import (
|
|
13
14
|
extract_sections as _extract_sections,
|
|
@@ -27,7 +28,8 @@ from zero_3rdparty.sections import (
|
|
|
27
28
|
|
|
28
29
|
__all__ = [
|
|
29
30
|
"Section",
|
|
30
|
-
"
|
|
31
|
+
"SectionChanges",
|
|
32
|
+
"changed_sections",
|
|
31
33
|
"extract_sections",
|
|
32
34
|
"has_sections",
|
|
33
35
|
"parse_sections",
|
|
@@ -59,14 +61,23 @@ def replace_sections(
|
|
|
59
61
|
src_sections: dict[str, str],
|
|
60
62
|
path: Path,
|
|
61
63
|
skip_sections: list[str] | None = None,
|
|
64
|
+
*,
|
|
65
|
+
keep_deleted_sections: bool = False,
|
|
62
66
|
) -> str:
|
|
63
|
-
return _replace_sections(
|
|
67
|
+
return _replace_sections(
|
|
68
|
+
dest_content,
|
|
69
|
+
src_sections,
|
|
70
|
+
TOOL_NAME,
|
|
71
|
+
get_comment_config(path),
|
|
72
|
+
skip_sections,
|
|
73
|
+
keep_deleted_sections=keep_deleted_sections,
|
|
74
|
+
)
|
|
64
75
|
|
|
65
76
|
|
|
66
|
-
def
|
|
77
|
+
def changed_sections(
|
|
67
78
|
baseline_content: str,
|
|
68
79
|
current_content: str,
|
|
69
80
|
path: Path,
|
|
70
81
|
skip: set[str] | None = None,
|
|
71
|
-
) ->
|
|
72
|
-
return
|
|
82
|
+
) -> SectionChanges:
|
|
83
|
+
return _changed_sections(baseline_content, current_content, TOOL_NAME, get_comment_config(path), skip, str(path))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: path-sync
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
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
|
|
@@ -13,11 +13,16 @@ Requires-Dist: gitpython>=3.1.0
|
|
|
13
13
|
Requires-Dist: pydantic>=2.0
|
|
14
14
|
Requires-Dist: pyyaml>=6.0
|
|
15
15
|
Requires-Dist: typer>=0.16.0
|
|
16
|
-
Requires-Dist: zero-3rdparty>=0.
|
|
16
|
+
Requires-Dist: zero-3rdparty>=0.102.0
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
|
|
19
19
|
# path-sync
|
|
20
20
|
|
|
21
|
+
[](https://pypi.org/project/path-sync/)
|
|
22
|
+
[](https://github.com/EspenAlbert/path-sync)
|
|
23
|
+
[](https://codecov.io/gh/EspenAlbert/path-sync)
|
|
24
|
+
[](https://espenalbert.github.io/path-sync/)
|
|
25
|
+
|
|
21
26
|
Sync files from a source repo to multiple destination repos.
|
|
22
27
|
|
|
23
28
|
## Overview
|
|
@@ -133,6 +138,26 @@ destinations:
|
|
|
133
138
|
skip_sections:
|
|
134
139
|
justfile: [coverage] # keep local coverage recipe
|
|
135
140
|
```
|
|
141
|
+
|
|
142
|
+
## Skipping Files per Destination
|
|
143
|
+
|
|
144
|
+
Use `skip_file_patterns` to exclude files for specific destinations. Patterns match against the **destination path** (after `dest_path` remapping):
|
|
145
|
+
|
|
146
|
+
```yaml
|
|
147
|
+
paths:
|
|
148
|
+
- src_path: scripts/
|
|
149
|
+
dest_path: tools/ # remapped in destination
|
|
150
|
+
destinations:
|
|
151
|
+
- name: dest1
|
|
152
|
+
dest_path_relative: ../dest1
|
|
153
|
+
skip_file_patterns:
|
|
154
|
+
- "tools/internal/*" # matches destination path, not src
|
|
155
|
+
- "*.test.py"
|
|
156
|
+
- "docs/draft.md"
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Patterns use [fnmatch](https://docs.python.org/3/library/fnmatch.html) syntax (`*` matches any characters, `?` matches single character).
|
|
160
|
+
|
|
136
161
|
## Config Reference
|
|
137
162
|
|
|
138
163
|
**Source config** (`.github/{name}.src.yaml`):
|
|
@@ -145,6 +170,10 @@ paths:
|
|
|
145
170
|
- src_path: .cursor/**/*.mdc
|
|
146
171
|
- src_path: templates/justfile
|
|
147
172
|
dest_path: justfile
|
|
173
|
+
- src_path: scripts/
|
|
174
|
+
exclude_file_patterns:
|
|
175
|
+
- "*.pyc"
|
|
176
|
+
- "test_*.py"
|
|
148
177
|
destinations:
|
|
149
178
|
- name: dest1
|
|
150
179
|
repo_url: https://github.com/user/dest1
|
|
@@ -153,6 +182,8 @@ destinations:
|
|
|
153
182
|
default_branch: main
|
|
154
183
|
skip_sections:
|
|
155
184
|
justfile: [coverage]
|
|
185
|
+
skip_file_patterns:
|
|
186
|
+
- "scripts/internal/*"
|
|
156
187
|
```
|
|
157
188
|
|
|
158
189
|
| Field | Description |
|
|
@@ -160,11 +191,33 @@ destinations:
|
|
|
160
191
|
| `name` | Config identifier |
|
|
161
192
|
| `src_repo_url` | Source repo URL (auto-detected from git remote) |
|
|
162
193
|
| `schedule` | Cron for scheduled sync workflow |
|
|
163
|
-
| `paths` | Files/globs to sync (
|
|
194
|
+
| `paths` | Files/globs to sync (see path options below) |
|
|
164
195
|
| `destinations` | Target repos with sync settings |
|
|
165
196
|
| `header_config` | Comment style per extension (has defaults) |
|
|
166
197
|
| `pr_defaults` | PR title, labels, reviewers, assignees |
|
|
167
198
|
|
|
199
|
+
**Path options**:
|
|
200
|
+
|
|
201
|
+
| Field | Description |
|
|
202
|
+
|-------|-------------|
|
|
203
|
+
| `src_path` | Source file, directory, or glob pattern (required) |
|
|
204
|
+
| `dest_path` | Destination path (defaults to `src_path`) |
|
|
205
|
+
| `sync_mode` | `sync` (default), `replace`, or `scaffold` |
|
|
206
|
+
| `exclude_dirs` | Directory names to skip (defaults: `__pycache__`, `.git`, `.venv`, etc.) |
|
|
207
|
+
| `exclude_file_patterns` | Filename patterns to skip, supports globs (`*.pyc`, `test_*.py`) |
|
|
208
|
+
|
|
209
|
+
**Destination options**:
|
|
210
|
+
|
|
211
|
+
| Field | Description |
|
|
212
|
+
|-------|-------------|
|
|
213
|
+
| `name` | Destination identifier (required) |
|
|
214
|
+
| `repo_url` | Repo URL for cloning if not found locally |
|
|
215
|
+
| `dest_path_relative` | Path to destination repo relative to source (required) |
|
|
216
|
+
| `copy_branch` | Branch for sync (defaults to `sync/{config_name}`) |
|
|
217
|
+
| `default_branch` | Default branch to compare against (defaults to `main`) |
|
|
218
|
+
| `skip_sections` | Map of `{dest_path: [section_ids]}` to preserve locally |
|
|
219
|
+
| `skip_file_patterns` | Patterns to skip for this destination (matches dest path, fnmatch syntax) |
|
|
220
|
+
|
|
168
221
|
## Header Format
|
|
169
222
|
|
|
170
223
|
Synced files have a header comment identifying the source config:
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
path_sync/__init__.py,sha256=
|
|
1
|
+
path_sync/__init__.py,sha256=UMITgfcKgKSh0CYFUQ2rtlzNjyI3s329DAQGoxna7HQ,153
|
|
2
2
|
path_sync/__main__.py,sha256=HDj3qgijDcK8k976qsAvKMvUq9fZEJTK-dZldUc5-no,326
|
|
3
3
|
path_sync/config.py,sha256=XYuEK_bjSpAk_nZN0oxpEA-S3t9F6Qn0yznYLoQHIw8,568
|
|
4
4
|
path_sync/copy.py,sha256=BpflW4086XJFSHHK4taYPgXtF07xHB8qrgm8TYqdM4E,120
|
|
5
|
-
path_sync/sections.py,sha256=
|
|
5
|
+
path_sync/sections.py,sha256=dB0RGUhRWcZj9c1364UULEEnYHMf1LkWUBZ8EayIyKM,2122
|
|
6
6
|
path_sync/_internal/__init__.py,sha256=iPkMhrpiyXBijo2Hp-y_2zEYxAXnHLnStKM0X0HHd4U,56
|
|
7
7
|
path_sync/_internal/cmd_boot.py,sha256=cFomUyPOhNX9fi5_BYL2Sm7O1oq7vntD8b7clQ7mL1E,3104
|
|
8
|
-
path_sync/_internal/cmd_copy.py,sha256
|
|
8
|
+
path_sync/_internal/cmd_copy.py,sha256=0iuhq3vlL1bah2EJEvkHZlXqdsOwdkKktt8V7lVjKIY,18241
|
|
9
9
|
path_sync/_internal/cmd_validate.py,sha256=e6m-JZlXAGr0ZRqfLhhrlmjs4w79p2WWnZo4I05PGpo,1798
|
|
10
10
|
path_sync/_internal/file_utils.py,sha256=5C33qzKFQdwChi5YwUWBujj126t0P6dbGSU_5hWExpE,194
|
|
11
11
|
path_sync/_internal/git_ops.py,sha256=rpG_r7VNH1KlBgqM9mz7xop0mpdy76Vs3rzCoxE1dIQ,5895
|
|
12
12
|
path_sync/_internal/header.py,sha256=evgY2q_gfDdEytEt_jyJ7M_KdGzCpfdKBUnoh3v-0Go,2593
|
|
13
|
-
path_sync/_internal/models.py,sha256=
|
|
13
|
+
path_sync/_internal/models.py,sha256=IA6lb_BFXntcZnn9bWJZYenlnDvk9ddNBA67l5qFrmA,4577
|
|
14
14
|
path_sync/_internal/typer_app.py,sha256=lEGMRXql3Se3VbmwAohvpUaL2cbY-RwhPUq8kL7bPbc,177
|
|
15
|
-
path_sync/_internal/validation.py,sha256=
|
|
15
|
+
path_sync/_internal/validation.py,sha256=23kwtmsiHiYlKbVU8mtwr8J0MqSlnvbuRRR5NQAsJ08,2446
|
|
16
16
|
path_sync/_internal/yaml_utils.py,sha256=yj6Bl54EltjLEcVKaiA5Ahb9byT6OUMh0xIEzTsrvnQ,498
|
|
17
|
-
path_sync-0.3.
|
|
18
|
-
path_sync-0.3.
|
|
19
|
-
path_sync-0.3.
|
|
20
|
-
path_sync-0.3.
|
|
21
|
-
path_sync-0.3.
|
|
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,,
|
|
File without changes
|
|
File without changes
|