|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +import subprocess |
3 | 4 | from enum import Enum |
| 5 | +from pathlib import Path |
4 | 6 |
|
| 7 | +import yaml |
5 | 8 | from pydantic import BaseModel, HttpUrl, field_validator |
6 | 9 |
|
7 | 10 |
|
@@ -29,10 +32,158 @@ class LibraryType(str, Enum): |
29 | 32 | SHARED = "shared" |
30 | 33 |
|
31 | 34 |
|
| 35 | +def check_git_tag_exists(repo_url: str, tag: str) -> bool: |
| 36 | + """Check if a git tag exists in the remote repository""" |
| 37 | + try: |
| 38 | + # Check if tag exists remotely without cloning |
| 39 | + result = subprocess.run( |
| 40 | + ["git", "ls-remote", "--tags", repo_url, f"refs/tags/{tag}"], |
| 41 | + capture_output=True, |
| 42 | + text=True, |
| 43 | + timeout=30, |
| 44 | + ) |
| 45 | + return result.returncode == 0 and result.stdout.strip() != "" |
| 46 | + except (subprocess.TimeoutExpired, subprocess.SubprocessError): |
| 47 | + return False |
| 48 | + |
| 49 | + |
| 50 | +def check_existing_library_config( |
| 51 | + repo_url: str, library_id: str, infra_repo_path: Path | None = None |
| 52 | +) -> dict | None: |
| 53 | + """ |
| 54 | + Check if a library already exists in libraries.yaml and return its configuration. |
| 55 | + Returns None if not found, or a dict with the library configuration if found. |
| 56 | + """ |
| 57 | + if not infra_repo_path: |
| 58 | + return None |
| 59 | + |
| 60 | + libraries_yaml_path = infra_repo_path / "bin" / "yaml" / "libraries.yaml" |
| 61 | + |
| 62 | + if not libraries_yaml_path.exists(): |
| 63 | + return None |
| 64 | + |
| 65 | + try: |
| 66 | + with open(libraries_yaml_path, encoding="utf-8") as f: |
| 67 | + libraries_data = yaml.safe_load(f) |
| 68 | + |
| 69 | + if not isinstance(libraries_data, dict): |
| 70 | + return None |
| 71 | + |
| 72 | + # The structure is: libraries -> language -> library_name |
| 73 | + libraries_section = libraries_data.get("libraries", {}) |
| 74 | + if not libraries_section: |
| 75 | + return None |
| 76 | + |
| 77 | + # Check in different language sections (c, cpp, rust, etc.) |
| 78 | + for lang_libs in libraries_section.values(): |
| 79 | + if not isinstance(lang_libs, dict): |
| 80 | + continue |
| 81 | + |
| 82 | + # Check if library_id exists in this language section |
| 83 | + if library_id in lang_libs: |
| 84 | + return lang_libs[library_id] |
| 85 | + |
| 86 | + # Also check by GitHub URL/repo since library_id might be different |
| 87 | + for lib_config in lang_libs.values(): |
| 88 | + if isinstance(lib_config, dict): |
| 89 | + lib_url = lib_config.get("url") |
| 90 | + lib_repo = lib_config.get("repo") |
| 91 | + |
| 92 | + if lib_url == repo_url: |
| 93 | + return lib_config |
| 94 | + |
| 95 | + # Also check repo field which might be in format "owner/repo" |
| 96 | + if lib_repo and repo_url.endswith(f"/{lib_repo}"): |
| 97 | + return lib_config |
| 98 | + |
| 99 | + return None |
| 100 | + |
| 101 | + except (yaml.YAMLError, FileNotFoundError, PermissionError): |
| 102 | + return None |
| 103 | + |
| 104 | + |
| 105 | +def check_existing_library_config_remote(repo_url: str, library_id: str) -> dict | None: |
| 106 | + """ |
| 107 | + Check if a library already exists by temporarily cloning the infra repository. |
| 108 | + This is used during interactive detection when we don't have the repo yet. |
| 109 | + """ |
| 110 | + import tempfile |
| 111 | + |
| 112 | + from .subprocess_utils import run_command |
| 113 | + |
| 114 | + try: |
| 115 | + with tempfile.TemporaryDirectory() as tmpdir: |
| 116 | + infra_path = Path(tmpdir) / "infra" |
| 117 | + |
| 118 | + # Clone the infra repository |
| 119 | + result = run_command( |
| 120 | + [ |
| 121 | + "git", |
| 122 | + "clone", |
| 123 | + "--depth", |
| 124 | + "1", |
| 125 | + "https://github.com/compiler-explorer/infra", |
| 126 | + str(infra_path), |
| 127 | + ], |
| 128 | + clean_env=False, |
| 129 | + ) |
| 130 | + |
| 131 | + if result.returncode != 0: |
| 132 | + return None |
| 133 | + |
| 134 | + return check_existing_library_config(repo_url, library_id, infra_path) |
| 135 | + |
| 136 | + except Exception: |
| 137 | + return None |
| 138 | + |
| 139 | + |
| 140 | +def determine_version_format(repo_url: str, version: str) -> tuple[str, str | None]: |
| 141 | + """ |
| 142 | + Determine the actual version format by checking git tags. |
| 143 | + Returns (normalized_version, target_prefix) |
| 144 | + """ |
| 145 | + if not repo_url: |
| 146 | + # No repo URL, can't check tags - just normalize by removing 'v' prefix |
| 147 | + if version.startswith("v"): |
| 148 | + return version[1:], "v" |
| 149 | + return version, None |
| 150 | + |
| 151 | + # Check if version starts with 'v' |
| 152 | + if version.startswith("v"): |
| 153 | + # User entered v1.2.3 - check if both v1.2.3 and 1.2.3 exist |
| 154 | + version_with_v = version |
| 155 | + version_without_v = version[1:] |
| 156 | + |
| 157 | + if check_git_tag_exists(repo_url, version_with_v): |
| 158 | + # v1.2.3 exists - use it and set target_prefix |
| 159 | + return version_without_v, "v" |
| 160 | + elif check_git_tag_exists(repo_url, version_without_v): |
| 161 | + # Only 1.2.3 exists - user made a mistake, use 1.2.3 |
| 162 | + return version_without_v, None |
| 163 | + else: |
| 164 | + # Neither exists - assume user intended v1.2.3 format |
| 165 | + return version_without_v, "v" |
| 166 | + else: |
| 167 | + # User entered 1.2.3 - check if both 1.2.3 and v1.2.3 exist |
| 168 | + version_without_v = version |
| 169 | + version_with_v = f"v{version}" |
| 170 | + |
| 171 | + if check_git_tag_exists(repo_url, version_without_v): |
| 172 | + # 1.2.3 exists - use it without prefix |
| 173 | + return version_without_v, None |
| 174 | + elif check_git_tag_exists(repo_url, version_with_v): |
| 175 | + # Only v1.2.3 exists - should use target_prefix |
| 176 | + return version_without_v, "v" |
| 177 | + else: |
| 178 | + # Neither exists - assume user intended 1.2.3 format |
| 179 | + return version_without_v, None |
| 180 | + |
| 181 | + |
32 | 182 | class LibraryConfig(BaseModel): |
33 | 183 | language: Language |
34 | 184 | github_url: HttpUrl | None = None |
35 | 185 | version: str | list[str] # Support single version or list of versions |
| 186 | + target_prefix: str | None = None # Prefix for version tags (e.g., 'v') |
36 | 187 | is_header_only: bool | None = None |
37 | 188 | build_tool: BuildTool | None = None |
38 | 189 | link_type: LinkType | None = None |
@@ -64,6 +215,35 @@ def validate_version(cls, v): |
64 | 215 | else: |
65 | 216 | raise ValueError("Version must be a string or list of strings") |
66 | 217 |
|
| 218 | + def normalize_versions_with_git_lookup(self): |
| 219 | + """ |
| 220 | + Normalize versions by checking git tags and set target_prefix if needed. |
| 221 | + This should be called after the model is fully populated with github_url. |
| 222 | + """ |
| 223 | + if not self.github_url: |
| 224 | + return |
| 225 | + |
| 226 | + versions = self.get_versions() |
| 227 | + normalized_versions = [] |
| 228 | + target_prefix = None |
| 229 | + |
| 230 | + for version in versions: |
| 231 | + normalized_version, prefix = determine_version_format(str(self.github_url), version) |
| 232 | + normalized_versions.append(normalized_version) |
| 233 | + |
| 234 | + # Set target_prefix if any version needs it |
| 235 | + if prefix: |
| 236 | + target_prefix = prefix |
| 237 | + |
| 238 | + # Update the model |
| 239 | + if len(normalized_versions) == 1: |
| 240 | + self.version = normalized_versions[0] |
| 241 | + else: |
| 242 | + self.version = normalized_versions |
| 243 | + |
| 244 | + if target_prefix: |
| 245 | + self.target_prefix = target_prefix |
| 246 | + |
67 | 247 | def get_versions(self) -> list[str]: |
68 | 248 | """Get list of versions, handling both single and multiple version cases""" |
69 | 249 | if isinstance(self.version, str): |
|
0 commit comments