path-sync 0.3.1__tar.gz → 0.3.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: path-sync
3
- Version: 0.3.1
3
+ Version: 0.3.2
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.100.0
16
+ Requires-Dist: zero-3rdparty>=0.101.1
17
17
  Description-Content-Type: text/markdown
18
18
 
19
19
  # path-sync
@@ -3,7 +3,7 @@
3
3
  from path_sync import copy
4
4
  from path_sync import config
5
5
 
6
- VERSION = "0.3.1"
6
+ VERSION = "0.3.2"
7
7
  __all__ = [
8
8
  "copy",
9
9
  "config",
@@ -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(..., "-n", "--name", help="Config name"),
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
- src_root = find_repo_root(Path.cwd())
156
- config_path = resolve_config_path(src_root, name)
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
- src_content = header.remove_header(src.read_text())
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
@@ -9,6 +9,24 @@ from pydantic import BaseModel, Field
9
9
 
10
10
  LOG_FORMAT = "%(asctime)s %(levelname)s %(message)s"
11
11
 
12
+ DEFAULT_EXCLUDE_DIRS: frozenset[str] = frozenset(
13
+ {
14
+ "__pycache__",
15
+ ".git",
16
+ ".venv",
17
+ "venv",
18
+ "node_modules",
19
+ ".mypy_cache",
20
+ ".pytest_cache",
21
+ ".ruff_cache",
22
+ ".tox",
23
+ }
24
+ )
25
+
26
+
27
+ def _default_exclude_dirs() -> set[str]:
28
+ return set(DEFAULT_EXCLUDE_DIRS)
29
+
12
30
 
13
31
  class SyncMode(StrEnum):
14
32
  SYNC = "sync"
@@ -20,10 +38,14 @@ class PathMapping(BaseModel):
20
38
  src_path: str
21
39
  dest_path: str = ""
22
40
  sync_mode: SyncMode = SyncMode.SYNC
41
+ exclude_dirs: set[str] = Field(default_factory=_default_exclude_dirs)
23
42
 
24
43
  def resolved_dest_path(self) -> str:
25
44
  return self.dest_path or self.src_path
26
45
 
46
+ def is_excluded(self, path: Path) -> bool:
47
+ return bool(self.exclude_dirs & set(path.parts))
48
+
27
49
  def expand_dest_paths(self, repo_root: Path) -> list[Path]:
28
50
  dest_path = self.resolved_dest_path()
29
51
  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.1"
6
+ version = "0.3.2"
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.100.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