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.
- beyond_local_file/__init__.py +16 -0
- beyond_local_file/cli.py +165 -0
- beyond_local_file/config.py +254 -0
- beyond_local_file/copy_manager.py +287 -0
- beyond_local_file/formatters.py +530 -0
- beyond_local_file/git_manager.py +91 -0
- beyond_local_file/link_strategy_protocol.py +248 -0
- beyond_local_file/model/__init__.py +21 -0
- beyond_local_file/model/config.py +87 -0
- beyond_local_file/model/processing.py +71 -0
- beyond_local_file/model/translator.py +217 -0
- beyond_local_file/options.py +51 -0
- beyond_local_file/project_processor.py +350 -0
- beyond_local_file/symlink_manager.py +248 -0
- beyond_local_file/sync_state.py +168 -0
- beyond_local_file-0.2.2.dist-info/METADATA +297 -0
- beyond_local_file-0.2.2.dist-info/RECORD +20 -0
- beyond_local_file-0.2.2.dist-info/WHEEL +4 -0
- beyond_local_file-0.2.2.dist-info/entry_points.txt +2 -0
- beyond_local_file-0.2.2.dist-info/licenses/LICENSE +21 -0
|
@@ -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__"]
|
beyond_local_file/cli.py
ADDED
|
@@ -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
|
+
)
|