devsync 0.5.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.
- aiconfigkit/__init__.py +0 -0
- aiconfigkit/__main__.py +6 -0
- aiconfigkit/ai_tools/__init__.py +0 -0
- aiconfigkit/ai_tools/base.py +236 -0
- aiconfigkit/ai_tools/capability_registry.py +262 -0
- aiconfigkit/ai_tools/claude.py +91 -0
- aiconfigkit/ai_tools/claude_desktop.py +97 -0
- aiconfigkit/ai_tools/cline.py +92 -0
- aiconfigkit/ai_tools/copilot.py +92 -0
- aiconfigkit/ai_tools/cursor.py +109 -0
- aiconfigkit/ai_tools/detector.py +169 -0
- aiconfigkit/ai_tools/kiro.py +85 -0
- aiconfigkit/ai_tools/mcp_syncer.py +291 -0
- aiconfigkit/ai_tools/roo.py +110 -0
- aiconfigkit/ai_tools/translator.py +390 -0
- aiconfigkit/ai_tools/winsurf.py +102 -0
- aiconfigkit/cli/__init__.py +0 -0
- aiconfigkit/cli/delete.py +118 -0
- aiconfigkit/cli/download.py +274 -0
- aiconfigkit/cli/install.py +237 -0
- aiconfigkit/cli/install_new.py +937 -0
- aiconfigkit/cli/list.py +275 -0
- aiconfigkit/cli/main.py +454 -0
- aiconfigkit/cli/mcp_configure.py +232 -0
- aiconfigkit/cli/mcp_install.py +166 -0
- aiconfigkit/cli/mcp_sync.py +165 -0
- aiconfigkit/cli/package.py +383 -0
- aiconfigkit/cli/package_create.py +323 -0
- aiconfigkit/cli/package_install.py +472 -0
- aiconfigkit/cli/template.py +19 -0
- aiconfigkit/cli/template_backup.py +261 -0
- aiconfigkit/cli/template_init.py +499 -0
- aiconfigkit/cli/template_install.py +261 -0
- aiconfigkit/cli/template_list.py +172 -0
- aiconfigkit/cli/template_uninstall.py +146 -0
- aiconfigkit/cli/template_update.py +225 -0
- aiconfigkit/cli/template_validate.py +234 -0
- aiconfigkit/cli/tools.py +47 -0
- aiconfigkit/cli/uninstall.py +125 -0
- aiconfigkit/cli/update.py +309 -0
- aiconfigkit/core/__init__.py +0 -0
- aiconfigkit/core/checksum.py +211 -0
- aiconfigkit/core/component_detector.py +905 -0
- aiconfigkit/core/conflict_resolution.py +329 -0
- aiconfigkit/core/git_operations.py +539 -0
- aiconfigkit/core/mcp/__init__.py +1 -0
- aiconfigkit/core/mcp/credentials.py +279 -0
- aiconfigkit/core/mcp/manager.py +308 -0
- aiconfigkit/core/mcp/set_manager.py +1 -0
- aiconfigkit/core/mcp/validator.py +1 -0
- aiconfigkit/core/models.py +1661 -0
- aiconfigkit/core/package_creator.py +743 -0
- aiconfigkit/core/package_manifest.py +248 -0
- aiconfigkit/core/repository.py +298 -0
- aiconfigkit/core/secret_detector.py +438 -0
- aiconfigkit/core/template_manifest.py +283 -0
- aiconfigkit/core/version.py +201 -0
- aiconfigkit/storage/__init__.py +0 -0
- aiconfigkit/storage/library.py +429 -0
- aiconfigkit/storage/mcp_tracker.py +1 -0
- aiconfigkit/storage/package_tracker.py +234 -0
- aiconfigkit/storage/template_library.py +229 -0
- aiconfigkit/storage/template_tracker.py +296 -0
- aiconfigkit/storage/tracker.py +416 -0
- aiconfigkit/tui/__init__.py +5 -0
- aiconfigkit/tui/installer.py +511 -0
- aiconfigkit/utils/__init__.py +0 -0
- aiconfigkit/utils/atomic_write.py +90 -0
- aiconfigkit/utils/backup.py +169 -0
- aiconfigkit/utils/dotenv.py +128 -0
- aiconfigkit/utils/git_helpers.py +187 -0
- aiconfigkit/utils/logging.py +60 -0
- aiconfigkit/utils/namespace.py +134 -0
- aiconfigkit/utils/paths.py +205 -0
- aiconfigkit/utils/project.py +109 -0
- aiconfigkit/utils/streaming.py +216 -0
- aiconfigkit/utils/ui.py +194 -0
- aiconfigkit/utils/validation.py +187 -0
- devsync-0.5.5.dist-info/LICENSE +21 -0
- devsync-0.5.5.dist-info/METADATA +477 -0
- devsync-0.5.5.dist-info/RECORD +84 -0
- devsync-0.5.5.dist-info/WHEEL +5 -0
- devsync-0.5.5.dist-info/entry_points.txt +2 -0
- devsync-0.5.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Template manifest parsing and validation."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from aiconfigkit.core.models import TemplateBundle, TemplateDefinition, TemplateFile, TemplateManifest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TemplateManifestError(Exception):
|
|
12
|
+
"""Raised when template manifest is invalid."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_manifest(manifest_path: Path) -> TemplateManifest:
|
|
18
|
+
"""
|
|
19
|
+
Load and validate template manifest from YAML file.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
manifest_path: Path to templatekit.yaml
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Parsed and validated TemplateManifest
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
TemplateManifestError: If manifest is invalid or missing
|
|
29
|
+
FileNotFoundError: If manifest file doesn't exist
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
>>> from pathlib import Path
|
|
33
|
+
>>> manifest = load_manifest(Path("repo/templatekit.yaml"))
|
|
34
|
+
>>> manifest.name
|
|
35
|
+
'My Templates'
|
|
36
|
+
"""
|
|
37
|
+
if not manifest_path.exists():
|
|
38
|
+
raise FileNotFoundError(f"Manifest not found: {manifest_path}")
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
42
|
+
data = yaml.safe_load(f)
|
|
43
|
+
except yaml.YAMLError as e:
|
|
44
|
+
raise TemplateManifestError(f"Invalid YAML in manifest: {e}") from e
|
|
45
|
+
|
|
46
|
+
if not data:
|
|
47
|
+
raise TemplateManifestError("Manifest file is empty")
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
return parse_manifest(data, manifest_path)
|
|
51
|
+
except (ValueError, KeyError) as e:
|
|
52
|
+
raise TemplateManifestError(f"Invalid manifest structure: {e}") from e
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_manifest(data: dict[str, Any], manifest_path: Path) -> TemplateManifest:
|
|
56
|
+
"""
|
|
57
|
+
Parse manifest data into TemplateManifest object.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
data: Parsed YAML data
|
|
61
|
+
manifest_path: Path to manifest (for validation context)
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
TemplateManifest object
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ValueError: If required fields are missing or invalid
|
|
68
|
+
"""
|
|
69
|
+
# Required fields
|
|
70
|
+
if "name" not in data:
|
|
71
|
+
raise ValueError("Manifest missing required field: name")
|
|
72
|
+
if "description" not in data:
|
|
73
|
+
raise ValueError("Manifest missing required field: description")
|
|
74
|
+
if "version" not in data:
|
|
75
|
+
raise ValueError("Manifest missing required field: version")
|
|
76
|
+
if "templates" not in data:
|
|
77
|
+
raise ValueError("Manifest missing required field: templates")
|
|
78
|
+
|
|
79
|
+
# Parse templates
|
|
80
|
+
templates = []
|
|
81
|
+
for template_data in data["templates"]:
|
|
82
|
+
template = parse_template(template_data, manifest_path)
|
|
83
|
+
templates.append(template)
|
|
84
|
+
|
|
85
|
+
# Parse bundles (optional)
|
|
86
|
+
bundles = []
|
|
87
|
+
if "bundles" in data:
|
|
88
|
+
for bundle_data in data["bundles"]:
|
|
89
|
+
bundle = parse_bundle(bundle_data)
|
|
90
|
+
bundles.append(bundle)
|
|
91
|
+
|
|
92
|
+
# Validate bundle references
|
|
93
|
+
template_names = {t.name for t in templates}
|
|
94
|
+
for bundle in bundles:
|
|
95
|
+
for template_ref in bundle.template_refs:
|
|
96
|
+
if template_ref not in template_names:
|
|
97
|
+
raise ValueError(f"Bundle '{bundle.name}' references non-existent template '{template_ref}'")
|
|
98
|
+
|
|
99
|
+
return TemplateManifest(
|
|
100
|
+
name=data["name"],
|
|
101
|
+
description=data["description"],
|
|
102
|
+
version=data["version"],
|
|
103
|
+
author=data.get("author"),
|
|
104
|
+
templates=templates,
|
|
105
|
+
bundles=bundles,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_template(data: dict[str, Any], manifest_path: Path) -> TemplateDefinition:
|
|
110
|
+
"""
|
|
111
|
+
Parse template definition from manifest data.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
data: Template data from manifest
|
|
115
|
+
manifest_path: Path to manifest (for file validation)
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
TemplateDefinition object
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
ValueError: If template data is invalid
|
|
122
|
+
"""
|
|
123
|
+
if "name" not in data:
|
|
124
|
+
raise ValueError("Template missing required field: name")
|
|
125
|
+
if "description" not in data:
|
|
126
|
+
raise ValueError(f"Template '{data.get('name', 'unknown')}' missing required field: description")
|
|
127
|
+
if "files" not in data or not data["files"]:
|
|
128
|
+
raise ValueError(f"Template '{data['name']}' must have at least one file")
|
|
129
|
+
|
|
130
|
+
# Parse files
|
|
131
|
+
files = []
|
|
132
|
+
for file_data in data["files"]:
|
|
133
|
+
if isinstance(file_data, str):
|
|
134
|
+
# Simple format: just path string
|
|
135
|
+
file_obj = TemplateFile(path=file_data, ide="all")
|
|
136
|
+
elif isinstance(file_data, dict):
|
|
137
|
+
# Detailed format with path and ide
|
|
138
|
+
if "path" not in file_data:
|
|
139
|
+
raise ValueError(f"Template '{data['name']}' file entry missing 'path'")
|
|
140
|
+
file_obj = TemplateFile(path=file_data["path"], ide=file_data.get("ide", "all"))
|
|
141
|
+
else:
|
|
142
|
+
raise ValueError(f"Invalid file entry in template '{data['name']}': {file_data}")
|
|
143
|
+
|
|
144
|
+
# Validate file exists in repository
|
|
145
|
+
repo_path = manifest_path.parent
|
|
146
|
+
file_path = repo_path / file_obj.path
|
|
147
|
+
if not file_path.exists():
|
|
148
|
+
raise ValueError(f"Template '{data['name']}' references non-existent file: {file_obj.path}")
|
|
149
|
+
|
|
150
|
+
files.append(file_obj)
|
|
151
|
+
|
|
152
|
+
return TemplateDefinition(
|
|
153
|
+
name=data["name"],
|
|
154
|
+
description=data["description"],
|
|
155
|
+
files=files,
|
|
156
|
+
tags=data.get("tags", []),
|
|
157
|
+
dependencies=data.get("dependencies", []),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def parse_bundle(data: dict[str, Any]) -> TemplateBundle:
|
|
162
|
+
"""
|
|
163
|
+
Parse bundle definition from manifest data.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
data: Bundle data from manifest
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
TemplateBundle object
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
ValueError: If bundle data is invalid
|
|
173
|
+
"""
|
|
174
|
+
if "name" not in data:
|
|
175
|
+
raise ValueError("Bundle missing required field: name")
|
|
176
|
+
if "description" not in data:
|
|
177
|
+
raise ValueError(f"Bundle '{data.get('name', 'unknown')}' missing required field: description")
|
|
178
|
+
if "templates" not in data or not data["templates"]:
|
|
179
|
+
raise ValueError(f"Bundle '{data['name']}' must reference at least one template")
|
|
180
|
+
|
|
181
|
+
return TemplateBundle(
|
|
182
|
+
name=data["name"],
|
|
183
|
+
description=data["description"],
|
|
184
|
+
template_refs=data["templates"],
|
|
185
|
+
tags=data.get("tags", []),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def validate_manifest_size(manifest_path: Path, template_count: int, soft_limit_templates: int = 100) -> list[str]:
|
|
190
|
+
"""
|
|
191
|
+
Check manifest against soft limits and return warnings.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
manifest_path: Path to manifest
|
|
195
|
+
template_count: Number of templates in manifest
|
|
196
|
+
soft_limit_templates: Soft limit for template count
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
List of warning messages (empty if no warnings)
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
>>> warnings = validate_manifest_size(Path("repo/templatekit.yaml"), 150)
|
|
203
|
+
>>> len(warnings) > 0
|
|
204
|
+
True
|
|
205
|
+
"""
|
|
206
|
+
warnings = []
|
|
207
|
+
|
|
208
|
+
# Check template count
|
|
209
|
+
if template_count > soft_limit_templates:
|
|
210
|
+
warnings.append(
|
|
211
|
+
f"⚠️ Repository contains {template_count} templates "
|
|
212
|
+
f"(soft limit: {soft_limit_templates}). "
|
|
213
|
+
f"Large repositories may take longer to install."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Check repository size (approximate based on manifest directory)
|
|
217
|
+
repo_path = manifest_path.parent
|
|
218
|
+
total_size = 0
|
|
219
|
+
for file_path in repo_path.rglob("*"):
|
|
220
|
+
if file_path.is_file():
|
|
221
|
+
total_size += file_path.stat().st_size
|
|
222
|
+
|
|
223
|
+
size_mb = total_size / (1024 * 1024)
|
|
224
|
+
soft_limit_mb = 50
|
|
225
|
+
|
|
226
|
+
if size_mb > soft_limit_mb:
|
|
227
|
+
warnings.append(
|
|
228
|
+
f"⚠️ Repository size is {size_mb:.1f}MB "
|
|
229
|
+
f"(soft limit: {soft_limit_mb}MB). "
|
|
230
|
+
f"Installation may take longer."
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return warnings
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def validate_dependencies(templates: list[TemplateDefinition]) -> list[str]:
|
|
237
|
+
"""
|
|
238
|
+
Validate template dependencies for circular references.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
templates: List of template definitions
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
List of error messages (empty if valid)
|
|
245
|
+
|
|
246
|
+
Example:
|
|
247
|
+
>>> errors = validate_dependencies(templates)
|
|
248
|
+
>>> len(errors) == 0
|
|
249
|
+
True
|
|
250
|
+
"""
|
|
251
|
+
errors = []
|
|
252
|
+
template_names = {t.name for t in templates}
|
|
253
|
+
|
|
254
|
+
# Build dependency graph
|
|
255
|
+
dependencies = {t.name: set(t.dependencies) for t in templates}
|
|
256
|
+
|
|
257
|
+
# Check for circular dependencies using DFS
|
|
258
|
+
def has_cycle(node: str, visited: set[str], rec_stack: set[str]) -> bool:
|
|
259
|
+
visited.add(node)
|
|
260
|
+
rec_stack.add(node)
|
|
261
|
+
|
|
262
|
+
for neighbor in dependencies.get(node, []):
|
|
263
|
+
if neighbor not in visited:
|
|
264
|
+
if has_cycle(neighbor, visited, rec_stack):
|
|
265
|
+
return True
|
|
266
|
+
elif neighbor in rec_stack:
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
rec_stack.remove(node)
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
visited: set[str] = set()
|
|
273
|
+
for template in templates:
|
|
274
|
+
if template.name not in visited:
|
|
275
|
+
if has_cycle(template.name, visited, set()):
|
|
276
|
+
errors.append(f"Circular dependency detected involving template '{template.name}'")
|
|
277
|
+
|
|
278
|
+
# Check for non-existent dependencies
|
|
279
|
+
for dep in template.dependencies:
|
|
280
|
+
if dep not in template_names:
|
|
281
|
+
errors.append(f"Template '{template.name}' depends on non-existent template '{dep}'")
|
|
282
|
+
|
|
283
|
+
return errors
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Semantic version management for packages."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from packaging.version import InvalidVersion, Version
|
|
8
|
+
from packaging.version import parse as parse_version
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class VersionError(Exception):
|
|
12
|
+
"""Raised when version operations fail."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VersionManager:
|
|
18
|
+
"""
|
|
19
|
+
Manager for semantic versioning operations.
|
|
20
|
+
|
|
21
|
+
Handles parsing, comparison, and validation of package versions
|
|
22
|
+
using semantic versioning (major.minor.patch).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, repository_path: Optional[Path] = None):
|
|
26
|
+
"""
|
|
27
|
+
Initialize version manager.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
repository_path: Path to Git repository for querying versions from tags
|
|
31
|
+
"""
|
|
32
|
+
self.repository_path = repository_path
|
|
33
|
+
|
|
34
|
+
def parse(self, version_string: str) -> Version:
|
|
35
|
+
"""
|
|
36
|
+
Parse version string into Version object.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
version_string: Version string (e.g., "1.0.0", "v2.1.3")
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Version object
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
VersionError: If version string is invalid
|
|
46
|
+
"""
|
|
47
|
+
# Strip leading 'v' if present
|
|
48
|
+
if version_string.startswith("v"):
|
|
49
|
+
version_string = version_string[1:]
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
return parse_version(version_string)
|
|
53
|
+
except InvalidVersion as e:
|
|
54
|
+
raise VersionError(f"Invalid version string '{version_string}': {e}")
|
|
55
|
+
|
|
56
|
+
def compare(self, version1: str, version2: str) -> int:
|
|
57
|
+
"""
|
|
58
|
+
Compare two version strings.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
version1: First version string
|
|
62
|
+
version2: Second version string
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
-1 if version1 < version2
|
|
66
|
+
0 if version1 == version2
|
|
67
|
+
1 if version1 > version2
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
VersionError: If either version string is invalid
|
|
71
|
+
"""
|
|
72
|
+
v1 = self.parse(version1)
|
|
73
|
+
v2 = self.parse(version2)
|
|
74
|
+
|
|
75
|
+
if v1 < v2:
|
|
76
|
+
return -1
|
|
77
|
+
elif v1 > v2:
|
|
78
|
+
return 1
|
|
79
|
+
else:
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
def is_compatible(self, required_version: str, available_version: str) -> bool:
|
|
83
|
+
"""
|
|
84
|
+
Check if available version satisfies required version.
|
|
85
|
+
|
|
86
|
+
Uses semantic versioning compatibility rules:
|
|
87
|
+
- Major version must match exactly (breaking changes)
|
|
88
|
+
- Minor version must be >= required (backwards compatible features)
|
|
89
|
+
- Patch version must be >= required (backwards compatible fixes)
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
required_version: Minimum required version
|
|
93
|
+
available_version: Available version to check
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
True if compatible, False otherwise
|
|
97
|
+
"""
|
|
98
|
+
required = self.parse(required_version)
|
|
99
|
+
available = self.parse(available_version)
|
|
100
|
+
|
|
101
|
+
# Major version must match (breaking changes)
|
|
102
|
+
if required.major != available.major:
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
# Available must be >= required
|
|
106
|
+
return available >= required
|
|
107
|
+
|
|
108
|
+
def get_available_versions(self) -> list[str]:
|
|
109
|
+
"""
|
|
110
|
+
Query available versions from Git tags.
|
|
111
|
+
|
|
112
|
+
Looks for tags matching semantic version format (with or without 'v' prefix).
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
List of version strings sorted in descending order (newest first)
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
VersionError: If repository path not set or Git operation fails
|
|
119
|
+
"""
|
|
120
|
+
if not self.repository_path:
|
|
121
|
+
raise VersionError("Repository path not set")
|
|
122
|
+
|
|
123
|
+
if not self.repository_path.exists():
|
|
124
|
+
raise VersionError(f"Repository path does not exist: {self.repository_path}")
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
# Run git tag command
|
|
128
|
+
result = subprocess.run(
|
|
129
|
+
["git", "tag", "--list"],
|
|
130
|
+
cwd=self.repository_path,
|
|
131
|
+
capture_output=True,
|
|
132
|
+
text=True,
|
|
133
|
+
check=True,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Parse tags into versions
|
|
137
|
+
versions = []
|
|
138
|
+
for tag in result.stdout.strip().split("\n"):
|
|
139
|
+
if not tag:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
# Try to parse as version (strip 'v' prefix if present)
|
|
144
|
+
version = self.parse(tag)
|
|
145
|
+
versions.append(str(version))
|
|
146
|
+
except VersionError:
|
|
147
|
+
# Skip non-version tags
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
# Sort in descending order (newest first)
|
|
151
|
+
versions.sort(key=lambda v: self.parse(v), reverse=True)
|
|
152
|
+
|
|
153
|
+
return versions
|
|
154
|
+
|
|
155
|
+
except subprocess.CalledProcessError as e:
|
|
156
|
+
raise VersionError(f"Failed to query Git tags: {e}")
|
|
157
|
+
|
|
158
|
+
def get_latest_version(self) -> Optional[str]:
|
|
159
|
+
"""
|
|
160
|
+
Get the latest version from Git tags.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Latest version string, or None if no versions found
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
VersionError: If repository path not set or Git operation fails
|
|
167
|
+
"""
|
|
168
|
+
versions = self.get_available_versions()
|
|
169
|
+
return versions[0] if versions else None
|
|
170
|
+
|
|
171
|
+
def validate_version_string(self, version_string: str) -> bool:
|
|
172
|
+
"""
|
|
173
|
+
Validate that version string follows semantic versioning format.
|
|
174
|
+
|
|
175
|
+
Enforces strict semantic versioning: major.minor.patch (three components).
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
version_string: Version string to validate
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
True if valid, False otherwise
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
version = self.parse(version_string)
|
|
185
|
+
# Enforce strict semantic versioning: major.minor.patch
|
|
186
|
+
# Check that the version has exactly 3 numeric components
|
|
187
|
+
version_str = str(version)
|
|
188
|
+
parts = version_str.split(".")
|
|
189
|
+
# Must have at least major.minor.patch
|
|
190
|
+
if len(parts) < 3:
|
|
191
|
+
return False
|
|
192
|
+
# First three parts must be numeric
|
|
193
|
+
try:
|
|
194
|
+
int(parts[0])
|
|
195
|
+
int(parts[1])
|
|
196
|
+
int(parts[2])
|
|
197
|
+
except (ValueError, IndexError):
|
|
198
|
+
return False
|
|
199
|
+
return True
|
|
200
|
+
except VersionError:
|
|
201
|
+
return False
|
|
File without changes
|