git-ssh-sync 0.1.0__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.
git_ssh_sync/config.py ADDED
@@ -0,0 +1,212 @@
1
+ """Configuration file management for git-ssh-sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
12
+
13
+
14
+ class ConfigError(Exception):
15
+ """Base class for configuration errors."""
16
+
17
+
18
+ class ProjectAlreadyExistsError(ConfigError):
19
+ """Raised when a project already exists and overwrite was not requested."""
20
+
21
+
22
+ class ProjectNotFoundError(ConfigError):
23
+ """Raised when a project is not registered."""
24
+
25
+
26
+ def _expand_path(value: str) -> str:
27
+ return str(Path(value).expanduser())
28
+
29
+
30
+ class LocalConfig(BaseModel):
31
+ model_config = ConfigDict(extra="forbid")
32
+
33
+ repo_path: str
34
+
35
+ _expand_repo_path = field_validator("repo_path")(_expand_path)
36
+
37
+
38
+ class DevConfig(BaseModel):
39
+ model_config = ConfigDict(extra="forbid")
40
+
41
+ host: str = Field(min_length=1)
42
+ user: str = Field(min_length=1)
43
+ work_path: str = Field(min_length=1)
44
+ cache_path: str = Field(min_length=1)
45
+
46
+ _expand_work_path = field_validator("work_path")(_expand_path)
47
+ _expand_cache_path = field_validator("cache_path")(_expand_path)
48
+
49
+
50
+ class OptionsConfig(BaseModel):
51
+ model_config = ConfigDict(extra="forbid")
52
+
53
+ sync_tags: bool = True
54
+ lfs: bool = False
55
+ submodules: bool = False
56
+ ff_only: bool = True
57
+
58
+
59
+ class ProjectConfig(BaseModel):
60
+ model_config = ConfigDict(extra="forbid")
61
+
62
+ origin: str = Field(min_length=1)
63
+ local: LocalConfig
64
+ dev: DevConfig
65
+ options: OptionsConfig = Field(default_factory=OptionsConfig)
66
+
67
+
68
+ class AppConfig(BaseModel):
69
+ model_config = ConfigDict(extra="forbid")
70
+
71
+ version: int = 1
72
+ projects: dict[str, ProjectConfig] = Field(default_factory=dict)
73
+
74
+
75
+ def default_config_path() -> Path:
76
+ """Return the default config path for the current platform."""
77
+ if sys.platform == "win32":
78
+ base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
79
+ else:
80
+ base = Path.home() / ".config"
81
+ return base / "git-ssh-sync" / "config.yaml"
82
+
83
+
84
+ def format_validation_error(error: ValidationError) -> str:
85
+ """Format validation errors with project and field context."""
86
+ messages: list[str] = []
87
+ for item in error.errors():
88
+ loc = item.get("loc", ())
89
+ field = ".".join(str(part) for part in loc)
90
+ if len(loc) >= 3 and loc[0] == "projects":
91
+ project = loc[1]
92
+ project_field = ".".join(str(part) for part in loc[2:])
93
+ messages.append(
94
+ f"project '{project}' field '{project_field}': {item['msg']}"
95
+ )
96
+ else:
97
+ messages.append(f"field '{field}': {item['msg']}")
98
+ return "Invalid configuration: " + "; ".join(messages)
99
+
100
+
101
+ def load_config(path: Path | None = None) -> AppConfig:
102
+ """Load config.yaml, returning an empty config when it does not exist."""
103
+ config_path = path or default_config_path()
104
+ if not config_path.exists():
105
+ return AppConfig()
106
+
107
+ data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
108
+ try:
109
+ return AppConfig.model_validate(data)
110
+ except ValidationError as error:
111
+ raise ConfigError(format_validation_error(error)) from error
112
+
113
+
114
+ def save_config(config: AppConfig, path: Path | None = None) -> None:
115
+ """Save config.yaml."""
116
+ config_path = path or default_config_path()
117
+ config_path.parent.mkdir(parents=True, exist_ok=True)
118
+ data = config.model_dump(mode="json")
119
+ config_path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
120
+
121
+
122
+ def get_project(config: AppConfig, project: str) -> ProjectConfig:
123
+ """Return a project config or raise a descriptive error."""
124
+ try:
125
+ return config.projects[project]
126
+ except KeyError as error:
127
+ raise ProjectNotFoundError(f"Project '{project}' is not configured.") from error
128
+
129
+
130
+ def build_project_config(
131
+ project: str,
132
+ *,
133
+ origin: str | None,
134
+ dev_host: str | None,
135
+ dev_user: str | None,
136
+ dev_work_path: str | None,
137
+ local_repo_path: str | None = None,
138
+ dev_cache_path: str | None = None,
139
+ options: OptionsConfig | None = None,
140
+ ) -> ProjectConfig:
141
+ """Build and validate a project config, applying init defaults."""
142
+ raw: dict[str, Any] = {
143
+ "origin": origin,
144
+ "local": {
145
+ "repo_path": local_repo_path or f"~/.git-ssh-sync/repos/{project}",
146
+ },
147
+ "dev": {
148
+ "host": dev_host,
149
+ "user": dev_user,
150
+ "work_path": dev_work_path,
151
+ "cache_path": dev_cache_path
152
+ or f"/home/{dev_user}/.git-ssh-sync/cache/{project}.git",
153
+ },
154
+ "options": (options or OptionsConfig()).model_dump(mode="json"),
155
+ }
156
+ try:
157
+ return ProjectConfig.model_validate(raw)
158
+ except ValidationError as error:
159
+ raise ConfigError(
160
+ format_validation_error_for_project(project, error)
161
+ ) from error
162
+
163
+
164
+ def format_validation_error_for_project(project: str, error: ValidationError) -> str:
165
+ """Format project creation validation errors."""
166
+ messages = []
167
+ for item in error.errors():
168
+ field = ".".join(str(part) for part in item["loc"])
169
+ messages.append(f"project '{project}' field '{field}': {item['msg']}")
170
+ return "Invalid configuration: " + "; ".join(messages)
171
+
172
+
173
+ def register_project(
174
+ config: AppConfig,
175
+ project: str,
176
+ project_config: ProjectConfig,
177
+ *,
178
+ force: bool = False,
179
+ ) -> AppConfig:
180
+ """Register or update a project in an app config."""
181
+ if project in config.projects and not force:
182
+ raise ProjectAlreadyExistsError(
183
+ f"Project '{project}' already exists. Use --force to overwrite it."
184
+ )
185
+
186
+ projects = dict(config.projects)
187
+ projects[project] = project_config
188
+ return config.model_copy(update={"projects": projects})
189
+
190
+
191
+ def init_project(
192
+ project: str,
193
+ *,
194
+ origin: str | None,
195
+ dev_host: str | None,
196
+ dev_user: str | None,
197
+ dev_work_path: str | None,
198
+ force: bool = False,
199
+ config_path: Path | None = None,
200
+ ) -> ProjectConfig:
201
+ """Create or update a project in config.yaml."""
202
+ config = load_config(config_path)
203
+ project_config = build_project_config(
204
+ project,
205
+ origin=origin,
206
+ dev_host=dev_host,
207
+ dev_user=dev_user,
208
+ dev_work_path=dev_work_path,
209
+ )
210
+ updated = register_project(config, project, project_config, force=force)
211
+ save_config(updated, config_path)
212
+ return project_config
@@ -0,0 +1,5 @@
1
+ """Console output helpers."""
2
+
3
+ from rich.console import Console
4
+
5
+ console = Console()