path-sync 0.3.1__tar.gz → 0.3.3__tar.gz
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-0.3.1 → path_sync-0.3.3}/PKG-INFO +2 -2
- {path_sync-0.3.1 → path_sync-0.3.3}/path_sync/__init__.py +1 -1
- {path_sync-0.3.1 → path_sync-0.3.3}/path_sync/_internal/cmd_boot.py +6 -1
- {path_sync-0.3.1 → path_sync-0.3.3}/path_sync/_internal/cmd_copy.py +49 -6
- {path_sync-0.3.1 → path_sync-0.3.3}/path_sync/_internal/cmd_validate.py +6 -1
- {path_sync-0.3.1 → path_sync-0.3.3}/path_sync/_internal/models.py +26 -0
- {path_sync-0.3.1 → path_sync-0.3.3}/path_sync/sections.py +3 -3
- {path_sync-0.3.1 → path_sync-0.3.3}/pyproject.toml +2 -2
- {path_sync-0.3.1 → path_sync-0.3.3}/.gitignore +0 -0
- {path_sync-0.3.1 → path_sync-0.3.3}/LICENSE +0 -0
- {path_sync-0.3.1 → path_sync-0.3.3}/README.md +0 -0
- {path_sync-0.3.1 → path_sync-0.3.3}/path_sync/__main__.py +0 -0
- {path_sync-0.3.1 → path_sync-0.3.3}/path_sync/_internal/__init__.py +0 -0
- {path_sync-0.3.1 → path_sync-0.3.3}/path_sync/_internal/file_utils.py +0 -0
- {path_sync-0.3.1 → path_sync-0.3.3}/path_sync/_internal/git_ops.py +0 -0
- {path_sync-0.3.1 → path_sync-0.3.3}/path_sync/_internal/header.py +0 -0
- {path_sync-0.3.1 → path_sync-0.3.3}/path_sync/_internal/typer_app.py +0 -0
- {path_sync-0.3.1 → path_sync-0.3.3}/path_sync/_internal/validation.py +0 -0
- {path_sync-0.3.1 → path_sync-0.3.3}/path_sync/_internal/yaml_utils.py +0 -0
- {path_sync-0.3.1 → path_sync-0.3.3}/path_sync/config.py +0 -0
- {path_sync-0.3.1 → path_sync-0.3.3}/path_sync/copy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: path-sync
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
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,7 +13,7 @@ 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.101.1
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
|
|
19
19
|
# path-sync
|
|
@@ -28,9 +28,14 @@ def boot(
|
|
|
28
28
|
sync_paths: Annotated[list[str], typer.Option("-p", "--path", help="Paths to sync (glob patterns)")] = [],
|
|
29
29
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without writing"),
|
|
30
30
|
regen: bool = typer.Option(False, "--regen", help="Regenerate config"),
|
|
31
|
+
src_root_opt: str = typer.Option(
|
|
32
|
+
"",
|
|
33
|
+
"--src-root",
|
|
34
|
+
help="Source repo root (default: find git root from cwd)",
|
|
35
|
+
),
|
|
31
36
|
) -> None:
|
|
32
37
|
"""Initialize or update SRC repo config."""
|
|
33
|
-
repo_root = find_repo_root(Path.cwd())
|
|
38
|
+
repo_root = Path(src_root_opt) if src_root_opt else find_repo_root(Path.cwd())
|
|
34
39
|
config_path = resolve_config_path(repo_root, name)
|
|
35
40
|
|
|
36
41
|
if config_path.exists() and not regen:
|
|
@@ -86,7 +86,18 @@ class CopyOptions:
|
|
|
86
86
|
|
|
87
87
|
@app.command()
|
|
88
88
|
def copy(
|
|
89
|
-
name: str = typer.Option(
|
|
89
|
+
name: str = typer.Option("", "-n", "--name", help="Config name (used with src-root to find config)"),
|
|
90
|
+
config_path_opt: str = typer.Option(
|
|
91
|
+
"",
|
|
92
|
+
"-c",
|
|
93
|
+
"--config-path",
|
|
94
|
+
help="Full path to config file (alternative to --name)",
|
|
95
|
+
),
|
|
96
|
+
src_root_opt: str = typer.Option(
|
|
97
|
+
"",
|
|
98
|
+
"--src-root",
|
|
99
|
+
help="Source repo root (default: find git root from cwd)",
|
|
100
|
+
),
|
|
90
101
|
dest_filter: str = typer.Option("", "-d", "--dest", help="Filter destinations (comma-separated)"),
|
|
91
102
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without writing"),
|
|
92
103
|
force_overwrite: bool = typer.Option(
|
|
@@ -152,8 +163,15 @@ def copy(
|
|
|
152
163
|
),
|
|
153
164
|
) -> None:
|
|
154
165
|
"""Copy files from SRC to DEST repositories."""
|
|
155
|
-
|
|
156
|
-
|
|
166
|
+
if name and config_path_opt:
|
|
167
|
+
logger.error("Cannot use both --name and --config-path")
|
|
168
|
+
raise typer.Exit(EXIT_ERROR if detailed_exit_code else 1)
|
|
169
|
+
if not name and not config_path_opt:
|
|
170
|
+
logger.error("Either --name or --config-path is required")
|
|
171
|
+
raise typer.Exit(EXIT_ERROR if detailed_exit_code else 1)
|
|
172
|
+
|
|
173
|
+
src_root = Path(src_root_opt) if src_root_opt else find_repo_root(Path.cwd())
|
|
174
|
+
config_path = Path(config_path_opt) if config_path_opt else resolve_config_path(src_root, name)
|
|
157
175
|
|
|
158
176
|
if not config_path.exists():
|
|
159
177
|
logger.error(f"Config not found: {config_path}")
|
|
@@ -316,7 +334,7 @@ def _sync_path(
|
|
|
316
334
|
logger.warning(f"Glob matched no files: {mapping.src_path}")
|
|
317
335
|
for src_file in matches:
|
|
318
336
|
src_path = Path(src_file)
|
|
319
|
-
if src_path.is_file():
|
|
337
|
+
if src_path.is_file() and not mapping.is_excluded(src_path):
|
|
320
338
|
rel = src_path.relative_to(src_root / glob_prefix)
|
|
321
339
|
dest_path = dest_root / dest_base / rel
|
|
322
340
|
dest_key = str(Path(dest_base) / rel)
|
|
@@ -334,7 +352,7 @@ def _sync_path(
|
|
|
334
352
|
elif src_pattern.is_dir():
|
|
335
353
|
dest_base = mapping.resolved_dest_path()
|
|
336
354
|
for src_file in src_pattern.rglob("*"):
|
|
337
|
-
if src_file.is_file():
|
|
355
|
+
if src_file.is_file() and not mapping.is_excluded(src_file):
|
|
338
356
|
rel = src_file.relative_to(src_pattern)
|
|
339
357
|
dest_path = dest_root / dest_base / rel
|
|
340
358
|
dest_key = str(Path(dest_base) / rel)
|
|
@@ -379,7 +397,10 @@ def _copy_file(
|
|
|
379
397
|
dry_run: bool,
|
|
380
398
|
force_overwrite: bool = False,
|
|
381
399
|
) -> int:
|
|
382
|
-
|
|
400
|
+
try:
|
|
401
|
+
src_content = header.remove_header(src.read_text())
|
|
402
|
+
except UnicodeDecodeError:
|
|
403
|
+
return _copy_binary_file(src, dest_path, sync_mode, dry_run)
|
|
383
404
|
|
|
384
405
|
match sync_mode:
|
|
385
406
|
case SyncMode.SCAFFOLD:
|
|
@@ -391,6 +412,28 @@ def _copy_file(
|
|
|
391
412
|
return _handle_sync(src_content, dest_path, skip_list, config_name, dry_run, force_overwrite)
|
|
392
413
|
|
|
393
414
|
|
|
415
|
+
def _copy_binary_file(src: Path, dest_path: Path, sync_mode: SyncMode, dry_run: bool) -> int:
|
|
416
|
+
src_bytes = src.read_bytes()
|
|
417
|
+
match sync_mode:
|
|
418
|
+
case SyncMode.SCAFFOLD:
|
|
419
|
+
if dest_path.exists():
|
|
420
|
+
return 0
|
|
421
|
+
case SyncMode.REPLACE | SyncMode.SYNC:
|
|
422
|
+
if dest_path.exists() and dest_path.read_bytes() == src_bytes:
|
|
423
|
+
return 0
|
|
424
|
+
return _write_binary_file(dest_path, src_bytes, dry_run)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _write_binary_file(dest_path: Path, content: bytes, dry_run: bool) -> int:
|
|
428
|
+
if dry_run:
|
|
429
|
+
logger.info(f"[DRY RUN] Would write binary: {dest_path}")
|
|
430
|
+
return 1
|
|
431
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
432
|
+
dest_path.write_bytes(content)
|
|
433
|
+
logger.info(f"Wrote binary: {dest_path}")
|
|
434
|
+
return 1
|
|
435
|
+
|
|
436
|
+
|
|
394
437
|
def _handle_scaffold(content: str, dest_path: Path, dry_run: bool) -> int:
|
|
395
438
|
if dest_path.exists():
|
|
396
439
|
return 0
|
|
@@ -21,9 +21,14 @@ def validate_no_changes(
|
|
|
21
21
|
"--skip-sections",
|
|
22
22
|
help="Comma-separated path:section_id pairs to skip (e.g., 'justfile:coverage,pyproject.toml:default')",
|
|
23
23
|
),
|
|
24
|
+
src_root_opt: str = typer.Option(
|
|
25
|
+
"",
|
|
26
|
+
"--src-root",
|
|
27
|
+
help="Source repo root (default: find git root from cwd)",
|
|
28
|
+
),
|
|
24
29
|
) -> None:
|
|
25
30
|
"""Validate no unauthorized changes to synced files."""
|
|
26
|
-
repo_root = find_repo_root(Path.cwd())
|
|
31
|
+
repo_root = Path(src_root_opt) if src_root_opt else find_repo_root(Path.cwd())
|
|
27
32
|
repo = git_ops.get_repo(repo_root)
|
|
28
33
|
|
|
29
34
|
current_branch = repo.active_branch.name
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import fnmatch
|
|
3
4
|
import glob as glob_mod
|
|
4
5
|
from enum import StrEnum
|
|
5
6
|
from pathlib import Path
|
|
@@ -9,6 +10,24 @@ from pydantic import BaseModel, Field
|
|
|
9
10
|
|
|
10
11
|
LOG_FORMAT = "%(asctime)s %(levelname)s %(message)s"
|
|
11
12
|
|
|
13
|
+
DEFAULT_EXCLUDE_DIRS: frozenset[str] = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"__pycache__",
|
|
16
|
+
".git",
|
|
17
|
+
".venv",
|
|
18
|
+
"venv",
|
|
19
|
+
"node_modules",
|
|
20
|
+
".mypy_cache",
|
|
21
|
+
".pytest_cache",
|
|
22
|
+
".ruff_cache",
|
|
23
|
+
".tox",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _default_exclude_dirs() -> set[str]:
|
|
29
|
+
return set(DEFAULT_EXCLUDE_DIRS)
|
|
30
|
+
|
|
12
31
|
|
|
13
32
|
class SyncMode(StrEnum):
|
|
14
33
|
SYNC = "sync"
|
|
@@ -20,10 +39,17 @@ class PathMapping(BaseModel):
|
|
|
20
39
|
src_path: str
|
|
21
40
|
dest_path: str = ""
|
|
22
41
|
sync_mode: SyncMode = SyncMode.SYNC
|
|
42
|
+
exclude_dirs: set[str] = Field(default_factory=_default_exclude_dirs)
|
|
43
|
+
exclude_file_patterns: set[str] = Field(default_factory=set)
|
|
23
44
|
|
|
24
45
|
def resolved_dest_path(self) -> str:
|
|
25
46
|
return self.dest_path or self.src_path
|
|
26
47
|
|
|
48
|
+
def is_excluded(self, path: Path) -> bool:
|
|
49
|
+
if self.exclude_dirs & set(path.parts):
|
|
50
|
+
return True
|
|
51
|
+
return any(fnmatch.fnmatch(path.name, pat) for pat in self.exclude_file_patterns)
|
|
52
|
+
|
|
27
53
|
def expand_dest_paths(self, repo_root: Path) -> list[Path]:
|
|
28
54
|
dest_path = self.resolved_dest_path()
|
|
29
55
|
pattern = repo_root / dest_path
|
|
@@ -43,7 +43,7 @@ def has_sections(content: str, path: Path) -> bool:
|
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
def parse_sections(content: str, path: Path) -> list[Section]:
|
|
46
|
-
return _parse_sections(content, TOOL_NAME, get_comment_config(path))
|
|
46
|
+
return _parse_sections(content, TOOL_NAME, get_comment_config(path), str(path))
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
def wrap_in_default_section(content: str, path: Path) -> str:
|
|
@@ -51,7 +51,7 @@ def wrap_in_default_section(content: str, path: Path) -> str:
|
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def extract_sections(content: str, path: Path) -> dict[str, str]:
|
|
54
|
-
return _extract_sections(content, TOOL_NAME, get_comment_config(path))
|
|
54
|
+
return _extract_sections(content, TOOL_NAME, get_comment_config(path), str(path))
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
def replace_sections(
|
|
@@ -69,4 +69,4 @@ def compare_sections(
|
|
|
69
69
|
path: Path,
|
|
70
70
|
skip: set[str] | None = None,
|
|
71
71
|
) -> list[str]:
|
|
72
|
-
return _compare_sections(baseline_content, current_content, TOOL_NAME, get_comment_config(path), skip)
|
|
72
|
+
return _compare_sections(baseline_content, current_content, TOOL_NAME, get_comment_config(path), skip, str(path))
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# === OK_EDIT: path-sync header ===
|
|
4
4
|
[project]
|
|
5
5
|
name = "path-sync"
|
|
6
|
-
version = "0.3.
|
|
6
|
+
version = "0.3.3"
|
|
7
7
|
description = "Sync files from a source repo to multiple destination repos"
|
|
8
8
|
requires-python = ">=3.13"
|
|
9
9
|
license = "MIT"
|
|
@@ -29,7 +29,7 @@ dependencies = [
|
|
|
29
29
|
"pyyaml>=6.0",
|
|
30
30
|
"typer>=0.16.0",
|
|
31
31
|
"gitpython>=3.1.0",
|
|
32
|
-
"zero-3rdparty>=0.
|
|
32
|
+
"zero-3rdparty>=0.101.1",
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
[project.scripts]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|