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 CHANGED
@@ -3,7 +3,7 @@
3
3
  from path_sync import copy
4
4
  from path_sync import config
5
5
 
6
- VERSION = "0.3.3"
6
+ VERSION = "0.3.5"
7
7
  __all__ = [
8
8
  "copy",
9
9
  "config",
@@ -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
- @dataclass
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.no_checkout:
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 _sync_path(
315
+ def _iter_sync_files(
316
316
  mapping: PathMapping,
317
317
  src_root: Path,
318
318
  dest_root: Path,
319
- dest: Destination,
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
- changes += _copy_file(
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
- changes += _copy_file(
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
- dest_path = dest_root / dest_base
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
- src_pattern,
364
+ src_path,
375
365
  dest_path,
376
366
  dest,
377
- dest_base,
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
 
@@ -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
- changed_ids = sections.compare_sections(baseline_content, current_content, path, file_skip)
59
- unauthorized.extend(f"{rel_path}:{sid}" for sid in changed_ids)
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
- compare_sections as _compare_sections,
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
- "compare_sections",
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(dest_content, src_sections, TOOL_NAME, get_comment_config(path), skip_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 compare_sections(
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
- ) -> list[str]:
72
- return _compare_sections(baseline_content, current_content, TOOL_NAME, get_comment_config(path), skip, str(path))
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
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.101.1
16
+ Requires-Dist: zero-3rdparty>=0.102.0
17
17
  Description-Content-Type: text/markdown
18
18
 
19
19
  # path-sync
20
20
 
21
+ [![PyPI](https://img.shields.io/pypi/v/path-sync)](https://pypi.org/project/path-sync/)
22
+ [![GitHub](https://img.shields.io/github/license/EspenAlbert/path-sync)](https://github.com/EspenAlbert/path-sync)
23
+ [![codecov](https://codecov.io/gh/EspenAlbert/path-sync/graph/badge.svg)](https://codecov.io/gh/EspenAlbert/path-sync)
24
+ [![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](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 (`src_path` required, `dest_path` optional) |
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=2ju41FHOI1W8Ewd9lcAf-v3Mp7vRXFeD72hSCyPlOoI,153
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=lUFGO3gqpGr7msztDudUrejmfghrfOPWiOMKBP6YotE,1927
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=-iqEoAGTA2oBeR8HXbJfzJ0hmnmtq27LBCeSgVbOo9k,18635
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=okHJkM2NsJ6Xe2HjJw6XqBTnIzCTQGniJRou6wra56g,4459
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=qhEha-pJiM5zkZlr4sj2I4ZqvqcWMEfL4IZu_LGatLI,2226
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.3.dist-info/METADATA,sha256=E0c9qnwBL5yTRW7XXcH6yvFaHjQiM7ieV_xpEwXiBtI,8231
18
- path_sync-0.3.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
19
- path_sync-0.3.3.dist-info/entry_points.txt,sha256=jTsL0c-9gP-4_Jt3EPgihtpLcwQR0AFAf1AUpD50AlI,54
20
- path_sync-0.3.3.dist-info/licenses/LICENSE,sha256=OphKV48tcMv6ep-7j-8T6nycykPT0g8ZlMJ9zbGvdPs,1066
21
- path_sync-0.3.3.dist-info/RECORD,,
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,,
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Your Name
3
+ Copyright (c) 2026 Espen Albert
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal