beyond-local-file 0.2.2__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.
@@ -0,0 +1,16 @@
1
+ """Beyond Local File - A CLI tool for managing symlinks across projects.
2
+
3
+ This package provides a command-line interface for synchronizing files and
4
+ directories from managed projects to target locations using symlinks, with
5
+ automatic git exclude management.
6
+ """
7
+
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ try:
11
+ __version__ = version("beyond-local-file")
12
+ except PackageNotFoundError:
13
+ # Package is not installed, fallback for development
14
+ __version__ = "0.0.0.dev0"
15
+
16
+ __all__ = ["__version__"]
@@ -0,0 +1,165 @@
1
+ """CLI tool for managing links between project directories and target locations.
2
+
3
+ This tool provides commands to synchronize symlinks (and physical file copies)
4
+ and check their status, with automatic Git exclude file management.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from . import __version__
12
+ from .copy_manager import CopyConflictAction
13
+ from .options import OutputFormat
14
+ from .project_processor import (
15
+ CheckOperation,
16
+ ProjectProcessor,
17
+ SyncOperation,
18
+ load_config_projects,
19
+ )
20
+ from .symlink_manager import Action
21
+
22
+
23
+ def ask_user_for_action(target_path: str, expected_source: str | None = None) -> Action:
24
+ """Prompt user for action when a path already exists.
25
+
26
+ Args:
27
+ target_path: The path that already exists.
28
+ expected_source: The expected source path that the symlink should point to.
29
+
30
+ Returns:
31
+ The user's chosen action: SKIP, OVERWRITE, or ABORT.
32
+ """
33
+ options = ", ".join(f"{a.value}-{a.name.lower()}" for a in Action)
34
+ choices = [str(a.value) for a in Action]
35
+ default = str(Action.SKIP.value)
36
+
37
+ click.echo(f"\nThe path of {target_path} already exists.")
38
+
39
+ # Show what it should be
40
+ if expected_source:
41
+ click.echo("\nShould be:")
42
+ click.echo(f" (a link to) {expected_source}")
43
+
44
+ # Show what the current path is
45
+ target = Path(target_path)
46
+ if target.is_symlink():
47
+ current_target = target.readlink()
48
+ click.echo("\nBut was:")
49
+ click.echo(f" (a link to) {current_target}")
50
+ elif target.is_dir():
51
+ click.echo("\nBut was:")
52
+ click.echo(" (a directory)")
53
+ elif target.is_file():
54
+ click.echo("\nBut was:")
55
+ click.echo(" (a regular file)")
56
+
57
+ choice = click.prompt(
58
+ f"\nWhat do you want to do? ({options})",
59
+ type=click.Choice(choices),
60
+ show_choices=True,
61
+ default=default,
62
+ )
63
+ return Action(int(choice))
64
+
65
+
66
+ def ask_user_for_conflict(managed_file: Path, target_file: Path) -> str:
67
+ """Prompt user to resolve a bidirectional copy conflict.
68
+
69
+ Args:
70
+ managed_file: Path to the managed (source) file.
71
+ target_file: Path to the target (copied) file.
72
+
73
+ Returns:
74
+ One of CopyConflictAction values: managed, target, or skip.
75
+ """
76
+ click.echo("\nConflict detected: both managed and target files have changed")
77
+ click.echo(f" managed: {managed_file}")
78
+ click.echo(f" target: {target_file}")
79
+
80
+ choice = click.prompt(
81
+ "\nChoose resolution: [m]anaged / [t]arget / [s]kip",
82
+ type=click.Choice(["m", "t", "s"]),
83
+ show_choices=False,
84
+ default="s",
85
+ )
86
+ resolution_map = {
87
+ "m": CopyConflictAction.MANAGED,
88
+ "t": CopyConflictAction.TARGET,
89
+ "s": CopyConflictAction.SKIP,
90
+ }
91
+ return resolution_map[choice]
92
+
93
+
94
+ @click.group()
95
+ @click.version_option(version=__version__, prog_name="beyond-local-file")
96
+ @click.option(
97
+ "-c",
98
+ "--config",
99
+ default="config.yml",
100
+ help="Path to config file",
101
+ )
102
+ @click.pass_context
103
+ def cli(ctx, config):
104
+ """Manage links between project directories and target locations."""
105
+ ctx.ensure_object(dict)
106
+ ctx.obj["config"] = config
107
+
108
+
109
+ @cli.group()
110
+ def link():
111
+ """Link management commands (symlinks and file copies)."""
112
+ pass
113
+
114
+
115
+ @link.command()
116
+ @click.argument("project_name", required=False)
117
+ @click.pass_context
118
+ def sync(ctx, project_name):
119
+ """Synchronize links from project directory to target locations.
120
+
121
+ Creates symlinks (or physical copies for items marked with copy: true)
122
+ for all items in the project directory to each target location specified
123
+ in the config.
124
+ """
125
+ config = ctx.obj["config"]
126
+ result = load_config_projects(config, project_name)
127
+ if result is None:
128
+ return
129
+
130
+ config_projects, config_dir = result
131
+ operation = SyncOperation(config_dir, ask_user_for_action, ask_user_for_conflict)
132
+ ProjectProcessor.process_all_units(config_projects, operation)
133
+
134
+
135
+ @link.command()
136
+ @click.argument("project_name", required=False)
137
+ @click.option("--extra-exclude", is_flag=True, help="Show extra entries in git exclude file")
138
+ @click.option(
139
+ "--format",
140
+ "output_format",
141
+ type=click.Choice([f.value for f in OutputFormat]),
142
+ default=OutputFormat.TABLE.value,
143
+ show_default=True,
144
+ help="Output format: table (compact) or verbose (detailed per-project).",
145
+ )
146
+ @click.pass_context
147
+ def check(ctx, project_name, extra_exclude, output_format):
148
+ """Check link status and Git exclude configuration.
149
+
150
+ Displays the status of symlinks, file copies, and Git exclude entries
151
+ for each project and target location.
152
+ """
153
+ config = ctx.obj["config"]
154
+ result = load_config_projects(config, project_name)
155
+ if result is None:
156
+ return
157
+
158
+ config_projects, config_dir = result
159
+ operation = CheckOperation(config_dir, extra_exclude, OutputFormat(output_format))
160
+ ProjectProcessor.process_all_units(config_projects, operation)
161
+ operation.render()
162
+
163
+
164
+ if __name__ == "__main__":
165
+ cli()
@@ -0,0 +1,254 @@
1
+ """Configuration management for the link CLI tool."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+ from .model.config import ConfigProject, Mapping
9
+
10
+
11
+ class Config:
12
+ """Manages configuration loaded from a YAML file.
13
+
14
+ The configuration file maps project names to their target paths.
15
+ Each project can have one or more mappings, where each mapping can be:
16
+
17
+ - **Simple string mapping**: Syncs everything from project to target::
18
+
19
+ project-a: /path/to/target
20
+
21
+ - **Dict mapping**: Supports selective subpath sync and copy strategy::
22
+
23
+ project-b:
24
+ target: /path/to/target
25
+ subpath:
26
+ - .kiro/hooks
27
+ - path: .qoder/rules.md
28
+ copy: true
29
+
30
+ Mappings can be combined in a list for multiple targets::
31
+
32
+ project-c:
33
+ - /path/to/target1
34
+ - target: /path/to/target2
35
+ subpath:
36
+ - .kiro/hooks
37
+
38
+ Attributes:
39
+ config_path: Path to the configuration file.
40
+ """
41
+
42
+ def __init__(self, config_path: Path):
43
+ """Initialize the Config with a config file path.
44
+
45
+ Args:
46
+ config_path: Path to the YAML configuration file.
47
+ """
48
+ self.config_path = Path(config_path).resolve()
49
+ self._data: dict[str, Any] | None = None
50
+
51
+ def load(self) -> dict[str, Any]:
52
+ """Load the configuration from the YAML file.
53
+
54
+ Returns:
55
+ The loaded configuration dictionary.
56
+
57
+ Raises:
58
+ FileNotFoundError: If the config file does not exist.
59
+ """
60
+ if not self.config_path.exists():
61
+ raise FileNotFoundError(f"Config file not found: {self.config_path}")
62
+
63
+ with open(self.config_path) as f:
64
+ self._data = yaml.safe_load(f)
65
+
66
+ return self._data
67
+
68
+ def get_config_projects(self, project_name: str | None = None) -> dict[str, ConfigProject]:
69
+ """Get project configurations using new model structure.
70
+
71
+ Parses YAML according to the formal grammar, preserving the mapping structure.
72
+ Each project has a list of mappings, where each mapping defines targets and
73
+ optional sync rules (subpaths, copy strategy).
74
+
75
+ Grammar structure:
76
+ - project-name: string-mapping | dict-mapping | list-of-mappings
77
+ - string-mapping: target-path (string)
78
+ - dict-mapping: {target: target-path(s), subpath?: [...], ...}
79
+ - list-of-mappings: [string-mapping | dict-mapping, ...]
80
+
81
+ Args:
82
+ project_name: Optional project name to filter. If provided, only
83
+ returns configuration for that project.
84
+
85
+ Returns:
86
+ Dictionary mapping project names to ConfigProject objects.
87
+ Each ConfigProject contains the project path and list of mappings.
88
+
89
+ Raises:
90
+ ValueError: If the specified project_name is not in the config.
91
+ """
92
+ if self._data is None:
93
+ self.load()
94
+
95
+ if project_name:
96
+ if project_name not in self._data:
97
+ raise ValueError(f"Project '{project_name}' not found in config")
98
+ config_project = self._build_config_project(project_name, self._data[project_name])
99
+ return {project_name: config_project}
100
+
101
+ result = {}
102
+ for name, value in self._data.items():
103
+ result[name] = self._build_config_project(name, value)
104
+ return result
105
+
106
+ def _resolve_project_path(self, project_name: str) -> Path:
107
+ """Resolve project path relative to config file directory.
108
+
109
+ Args:
110
+ project_name: The project name/path from the config file.
111
+
112
+ Returns:
113
+ Resolved absolute Path to the project directory.
114
+ """
115
+ project_path = Path(project_name)
116
+ if project_path.is_absolute():
117
+ return project_path.resolve()
118
+ # Resolve relative to config file's directory
119
+ config_dir = self.config_path.parent
120
+ return (config_dir / project_path).resolve()
121
+
122
+ def _normalize_targets(self, targets: str | list[str]) -> list[Path]:
123
+ """Normalize target paths to resolved Path objects.
124
+
125
+ Args:
126
+ targets: A single target path string or list of target paths.
127
+
128
+ Returns:
129
+ List of resolved Path objects.
130
+ """
131
+ if isinstance(targets, str):
132
+ targets = [targets]
133
+ return [Path(t).resolve() for t in targets]
134
+
135
+ def _parse_subpaths(self, raw: str | list | None) -> tuple[list[str] | None, set[str] | None]:
136
+ """Parse subpath entries, extracting copy flags.
137
+
138
+ Each entry can be a plain string or a dict with ``path`` and
139
+ optional ``copy: true``.
140
+
141
+ Args:
142
+ raw: Raw subpath value from YAML — string, list, or None.
143
+
144
+ Returns:
145
+ Tuple of (subpath list, set of paths marked for copy).
146
+ Either element may be None when there are no entries.
147
+ """
148
+ if raw is None:
149
+ return None, None
150
+
151
+ if isinstance(raw, str):
152
+ return [raw], None
153
+
154
+ subpaths: list[str] = []
155
+ copy_paths: set[str] = set()
156
+
157
+ for entry in raw:
158
+ if isinstance(entry, str):
159
+ subpaths.append(entry)
160
+ elif isinstance(entry, dict) and "path" in entry:
161
+ path = entry["path"]
162
+ subpaths.append(path)
163
+ if entry.get("copy", False):
164
+ copy_paths.add(path)
165
+
166
+ return subpaths or None, copy_paths or None
167
+
168
+ def _build_config_project(self, name: str, value: str | list | dict) -> ConfigProject:
169
+ """Build a ConfigProject from raw YAML value according to grammar.
170
+
171
+ Grammar productions:
172
+ - string-mapping: target-path (string)
173
+ - dict-mapping: {target: target-path(s), subpath?: [...], ...}
174
+ - list-of-mappings: [string-mapping | dict-mapping, ...]
175
+
176
+ Args:
177
+ name: The project name.
178
+ value: The raw YAML value (string, list, or dict).
179
+
180
+ Returns:
181
+ ConfigProject with list of mappings preserving the YAML structure.
182
+ """
183
+ project_path = self._resolve_project_path(name)
184
+
185
+ # Single string mapping: project-name: /target
186
+ if isinstance(value, str):
187
+ mapping = self._parse_string_mapping(value)
188
+ return ConfigProject(
189
+ managed_project_name=name,
190
+ managed_project_path=project_path,
191
+ mappings=[mapping],
192
+ )
193
+
194
+ # Single dict mapping: project-name: {target: /target, subpath: [...]}
195
+ if isinstance(value, dict) and "target" in value:
196
+ mapping = self._parse_dict_mapping(value)
197
+ return ConfigProject(
198
+ managed_project_name=name,
199
+ managed_project_path=project_path,
200
+ mappings=[mapping],
201
+ )
202
+
203
+ # List of mappings: project-name: [mapping1, mapping2, ...]
204
+ if isinstance(value, list):
205
+ mappings = []
206
+ for item in value:
207
+ if isinstance(item, str):
208
+ mappings.append(self._parse_string_mapping(item))
209
+ elif isinstance(item, dict) and "target" in item:
210
+ mappings.append(self._parse_dict_mapping(item))
211
+ return ConfigProject(
212
+ managed_project_name=name,
213
+ managed_project_path=project_path,
214
+ mappings=mappings,
215
+ )
216
+
217
+ # Fallback: treat as string mapping
218
+ mapping = self._parse_string_mapping(str(value))
219
+ return ConfigProject(
220
+ managed_project_name=name,
221
+ managed_project_path=project_path,
222
+ mappings=[mapping],
223
+ )
224
+
225
+ def _parse_string_mapping(self, target: str) -> Mapping:
226
+ """Parse a string mapping: /target or [/target1, /target2].
227
+
228
+ Args:
229
+ target: Target path string.
230
+
231
+ Returns:
232
+ Mapping with single target, no subpaths.
233
+ """
234
+ targets = self._normalize_targets(target)
235
+ return Mapping(targets=targets, subpaths=None, copy_paths=None)
236
+
237
+ def _parse_dict_mapping(self, mapping_dict: dict) -> Mapping:
238
+ """Parse a dict mapping with target and optional subpath/copy.
239
+
240
+ Args:
241
+ mapping_dict: Dictionary with 'target' key and optional 'subpath'.
242
+
243
+ Returns:
244
+ Mapping with targets, subpaths, and copy_paths.
245
+ """
246
+ targets = self._normalize_targets(mapping_dict["target"])
247
+ raw_subpaths = mapping_dict.get("subpath")
248
+ subpaths, copy_paths = self._parse_subpaths(raw_subpaths)
249
+
250
+ return Mapping(
251
+ targets=targets,
252
+ subpaths=subpaths,
253
+ copy_paths=copy_paths,
254
+ )