Skip to content

Commit 8602527

Browse files
runningcodeclaude
andcommitted
refactor: Move metadata extraction to analyzers (EME-606)
Moves tooling metadata extraction from artifact_processor into the platform-specific analyzers where app_info is created. This better encapsulates the metadata extraction logic and makes it more consistent with other app_info field population. Also renames sentry_cli_version to cli_version for consistency with the metadata file format. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent db5046d commit 8602527

File tree

7 files changed

+307
-6
lines changed

7 files changed

+307
-6
lines changed

src/launchpad/api/update_api_models.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ class UpdateData(BaseModel):
6363
apple_app_info: Optional[AppleAppInfo] = None
6464
android_app_info: Optional[AndroidAppInfo] = None
6565
dequeued_at: Optional[datetime] = Field(None, description="Timestamp when message was dequeued from Kafka")
66-
sentry_cli_version: Optional[str] = Field(None, description="Version of sentry-cli used to upload the artifact")
67-
fastlane_version: Optional[str] = Field(None, description="Version of fastlane plugin used")
68-
gradle_plugin_version: Optional[str] = Field(None, description="Version of gradle plugin used")
66+
cli_version: Optional[str] = Field(None, description="sentry-cli version extracted from .sentry-cli-metadata.txt")
67+
fastlane_version: Optional[str] = Field(None, description="Fastlane plugin version extracted from .sentry-cli-metadata.txt")
68+
gradle_plugin_version: Optional[str] = Field(None, description="Gradle plugin version extracted from .sentry-cli-metadata.txt")
6969

7070
@field_serializer("dequeued_at")
7171
def serialize_datetime(self, dt: datetime | None) -> str | None:

src/launchpad/artifact_processor.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -526,9 +526,9 @@ def _get_artifact_type(artifact: Artifact) -> ArtifactType:
526526
apple_app_info=apple_app_info,
527527
android_app_info=android_app_info,
528528
dequeued_at=dequeued_at,
529-
sentry_cli_version=tooling_versions["sentry_cli_version"],
530-
fastlane_version=tooling_versions["fastlane_version"],
531-
gradle_plugin_version=tooling_versions["gradle_plugin_version"],
529+
cli_version=app_info.cli_version,
530+
fastlane_version=app_info.fastlane_version,
531+
gradle_plugin_version=app_info.gradle_plugin_version,
532532
)
533533

534534
return update_data.model_dump(exclude_none=True)

src/launchpad/size/analyzers/android.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from launchpad.size.utils.android_bundle_size import calculate_apk_download_size, calculate_apk_install_size
3333
from launchpad.utils.file_utils import calculate_file_hash
3434
from launchpad.utils.logging import get_logger
35+
from launchpad.utils.metadata_extractor import extract_metadata_from_zip
3536

3637
logger = get_logger(__name__)
3738

@@ -55,12 +56,18 @@ def preprocess(self, artifact: AndroidArtifact) -> AndroidAppInfo:
5556
manifest_dict = artifact.get_manifest().model_dump()
5657
has_proguard_mapping = artifact.get_dex_mapping() is not None
5758

59+
# Extract tooling metadata from .sentry-cli-metadata.txt if present
60+
metadata = extract_metadata_from_zip(artifact.path)
61+
5862
self.app_info = AndroidAppInfo(
5963
name=manifest_dict["application"]["label"] or "Unknown",
6064
version=manifest_dict["version_name"] or "Unknown",
6165
build=manifest_dict["version_code"] or "Unknown",
6266
app_id=manifest_dict["package_name"],
6367
has_proguard_mapping=has_proguard_mapping,
68+
cli_version=metadata.cli_version,
69+
fastlane_version=metadata.fastlane_version,
70+
gradle_plugin_version=metadata.gradle_plugin_version,
6471
)
6572

6673
return self.app_info

src/launchpad/size/analyzers/apple.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from launchpad.utils.apple.code_signature_validator import CodeSignatureValidator
4444
from launchpad.utils.file_utils import get_file_size, to_nearest_block_size
4545
from launchpad.utils.logging import get_logger
46+
from launchpad.utils.metadata_extractor import extract_metadata_from_zip
4647

4748
from ..models.apple import (
4849
AppleAnalysisResults,
@@ -308,6 +309,9 @@ def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo:
308309
binaries = xcarchive.get_all_binary_paths()
309310
missing_dsym_binaries = [b.name for b in binaries if b.dsym_path is None]
310311

312+
# Extract tooling metadata from .sentry-cli-metadata.txt if present
313+
metadata = extract_metadata_from_zip(xcarchive.path)
314+
311315
return AppleAppInfo(
312316
name=app_name,
313317
app_id=plist.get("CFBundleIdentifier", "unknown.bundle.id"),
@@ -328,6 +332,9 @@ def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo:
328332
primary_icon_name=primary_icon_name,
329333
alternate_icon_names=alternate_icon_names,
330334
missing_dsym_binaries=missing_dsym_binaries,
335+
cli_version=metadata.cli_version,
336+
fastlane_version=metadata.fastlane_version,
337+
gradle_plugin_version=metadata.gradle_plugin_version,
331338
)
332339

333340
def _get_profile_type(self, profile_data: dict[str, Any]) -> Tuple[str, str]:

src/launchpad/size/models/common.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ class BaseAppInfo(BaseModel):
2222
version: str = Field(..., description="App version")
2323
build: str = Field(..., description="Build number")
2424
app_id: str = Field(..., description="App ID (bundle id on iOS, package name on Android)")
25+
cli_version: str | None = Field(None, description="sentry-cli version extracted from .sentry-cli-metadata.txt")
26+
fastlane_version: str | None = Field(None, description="Fastlane plugin version extracted from .sentry-cli-metadata.txt")
27+
gradle_plugin_version: str | None = Field(None, description="Gradle plugin version extracted from .sentry-cli-metadata.txt")
2528

2629

2730
class FileAnalysis(BaseModel):
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Utility for extracting metadata from .sentry-cli-metadata.txt files in artifacts."""
2+
3+
import zipfile
4+
5+
from pathlib import Path
6+
from typing import Dict, Optional
7+
8+
from launchpad.utils.logging import get_logger
9+
10+
logger = get_logger(__name__)
11+
12+
METADATA_FILENAME = ".sentry-cli-metadata.txt"
13+
14+
15+
class ToolingMetadata:
16+
"""Container for tooling version metadata extracted from artifacts."""
17+
18+
def __init__(
19+
self,
20+
cli_version: Optional[str] = None,
21+
fastlane_version: Optional[str] = None,
22+
gradle_plugin_version: Optional[str] = None,
23+
):
24+
self.cli_version = cli_version
25+
self.fastlane_version = fastlane_version
26+
self.gradle_plugin_version = gradle_plugin_version
27+
28+
def __repr__(self) -> str:
29+
return f"ToolingMetadata(cli_version={self.cli_version}, fastlane_version={self.fastlane_version}, gradle_plugin_version={self.gradle_plugin_version})"
30+
31+
32+
def extract_metadata_from_zip(zip_path: Path) -> ToolingMetadata:
33+
"""Extract tooling metadata from a .sentry-cli-metadata.txt file inside a zip.
34+
35+
Args:
36+
zip_path: Path to the zip file to search
37+
38+
Returns:
39+
ToolingMetadata object with extracted version information
40+
"""
41+
try:
42+
with zipfile.ZipFile(zip_path, "r") as zf:
43+
# Look for .sentry-cli-metadata.txt anywhere in the zip
44+
metadata_files = [name for name in zf.namelist() if name.endswith(METADATA_FILENAME)]
45+
46+
if not metadata_files:
47+
logger.debug(f"No {METADATA_FILENAME} found in {zip_path}")
48+
return ToolingMetadata()
49+
50+
# Use the first metadata file found
51+
metadata_file = metadata_files[0]
52+
logger.debug(f"Found metadata file: {metadata_file}")
53+
54+
with zf.open(metadata_file) as f:
55+
content = f.read().decode("utf-8")
56+
return _parse_metadata_content(content)
57+
58+
except Exception as e:
59+
logger.warning(f"Failed to extract metadata from {zip_path}: {e}")
60+
return ToolingMetadata()
61+
62+
63+
def _parse_metadata_content(content: str) -> ToolingMetadata:
64+
"""Parse the content of .sentry-cli-metadata.txt file.
65+
66+
Expected format:
67+
sentry-cli-version: 2.58.2
68+
fastlane-plugin: 1.2.3
69+
gradle-plugin: 4.12.0
70+
71+
Args:
72+
content: The text content of the metadata file
73+
74+
Returns:
75+
ToolingMetadata object with parsed version information
76+
"""
77+
metadata: Dict[str, str] = {}
78+
79+
for line in content.strip().split("\n"):
80+
line = line.strip()
81+
if not line or ":" not in line:
82+
continue
83+
84+
key, value = line.split(":", 1)
85+
key = key.strip()
86+
value = value.strip()
87+
88+
metadata[key] = value
89+
90+
return ToolingMetadata(
91+
cli_version=metadata.get("sentry-cli-version"),
92+
fastlane_version=metadata.get("fastlane-plugin"),
93+
gradle_plugin_version=metadata.get("gradle-plugin"),
94+
)
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""Tests for metadata extraction from .sentry-cli-metadata.txt files."""
2+
3+
import tempfile
4+
import zipfile
5+
6+
from pathlib import Path
7+
8+
from launchpad.utils.metadata_extractor import (
9+
ToolingMetadata,
10+
_parse_metadata_content,
11+
extract_metadata_from_zip,
12+
)
13+
14+
15+
class TestParseMetadataContent:
16+
"""Tests for parsing .sentry-cli-metadata.txt content."""
17+
18+
def test_parse_all_fields(self):
19+
content = """sentry-cli-version: 2.58.2
20+
fastlane-plugin: 1.2.3
21+
gradle-plugin: 4.12.0"""
22+
metadata = _parse_metadata_content(content)
23+
assert metadata.cli_version == "2.58.2"
24+
assert metadata.fastlane_version == "1.2.3"
25+
assert metadata.gradle_plugin_version == "4.12.0"
26+
27+
def test_parse_partial_fields(self):
28+
content = """sentry-cli-version: 2.58.2
29+
fastlane-plugin: 1.2.3"""
30+
metadata = _parse_metadata_content(content)
31+
assert metadata.cli_version == "2.58.2"
32+
assert metadata.fastlane_version == "1.2.3"
33+
assert metadata.gradle_plugin_version is None
34+
35+
def test_parse_only_cli_version(self):
36+
content = "sentry-cli-version: 2.58.2"
37+
metadata = _parse_metadata_content(content)
38+
assert metadata.cli_version == "2.58.2"
39+
assert metadata.fastlane_version is None
40+
assert metadata.gradle_plugin_version is None
41+
42+
def test_parse_empty_content(self):
43+
content = ""
44+
metadata = _parse_metadata_content(content)
45+
assert metadata.cli_version is None
46+
assert metadata.fastlane_version is None
47+
assert metadata.gradle_plugin_version is None
48+
49+
def test_parse_with_extra_whitespace(self):
50+
content = """ sentry-cli-version: 2.58.2
51+
fastlane-plugin: 1.2.3
52+
gradle-plugin: 4.12.0 """
53+
metadata = _parse_metadata_content(content)
54+
assert metadata.cli_version == "2.58.2"
55+
assert metadata.fastlane_version == "1.2.3"
56+
assert metadata.gradle_plugin_version == "4.12.0"
57+
58+
def test_parse_with_extra_lines(self):
59+
content = """
60+
sentry-cli-version: 2.58.2
61+
62+
fastlane-plugin: 1.2.3
63+
64+
gradle-plugin: 4.12.0
65+
"""
66+
metadata = _parse_metadata_content(content)
67+
assert metadata.cli_version == "2.58.2"
68+
assert metadata.fastlane_version == "1.2.3"
69+
assert metadata.gradle_plugin_version == "4.12.0"
70+
71+
def test_parse_with_unknown_fields(self):
72+
content = """sentry-cli-version: 2.58.2
73+
unknown-field: some-value
74+
fastlane-plugin: 1.2.3"""
75+
metadata = _parse_metadata_content(content)
76+
assert metadata.cli_version == "2.58.2"
77+
assert metadata.fastlane_version == "1.2.3"
78+
assert metadata.gradle_plugin_version is None
79+
80+
81+
class TestExtractMetadataFromZip:
82+
"""Tests for extracting metadata from zip files."""
83+
84+
def test_extract_from_zip_root(self):
85+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tf:
86+
try:
87+
with zipfile.ZipFile(tf.name, "w") as zf:
88+
zf.writestr(
89+
".sentry-cli-metadata.txt",
90+
"sentry-cli-version: 2.58.2\nfastlane-plugin: 1.2.3\ngradle-plugin: 4.12.0",
91+
)
92+
zf.writestr("some-file.txt", "content")
93+
94+
metadata = extract_metadata_from_zip(Path(tf.name))
95+
assert metadata.cli_version == "2.58.2"
96+
assert metadata.fastlane_version == "1.2.3"
97+
assert metadata.gradle_plugin_version == "4.12.0"
98+
finally:
99+
Path(tf.name).unlink()
100+
101+
def test_extract_from_nested_path(self):
102+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tf:
103+
try:
104+
with zipfile.ZipFile(tf.name, "w") as zf:
105+
zf.writestr(
106+
"some/nested/path/.sentry-cli-metadata.txt",
107+
"sentry-cli-version: 3.0.0",
108+
)
109+
zf.writestr("other-file.txt", "content")
110+
111+
metadata = extract_metadata_from_zip(Path(tf.name))
112+
assert metadata.cli_version == "3.0.0"
113+
assert metadata.fastlane_version is None
114+
assert metadata.gradle_plugin_version is None
115+
finally:
116+
Path(tf.name).unlink()
117+
118+
def test_extract_when_missing(self):
119+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tf:
120+
try:
121+
with zipfile.ZipFile(tf.name, "w") as zf:
122+
zf.writestr("some-file.txt", "content")
123+
zf.writestr("other-file.txt", "content")
124+
125+
metadata = extract_metadata_from_zip(Path(tf.name))
126+
assert metadata.cli_version is None
127+
assert metadata.fastlane_version is None
128+
assert metadata.gradle_plugin_version is None
129+
finally:
130+
Path(tf.name).unlink()
131+
132+
def test_extract_multiple_metadata_files(self):
133+
# Should use the first one found
134+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tf:
135+
try:
136+
with zipfile.ZipFile(tf.name, "w") as zf:
137+
zf.writestr(
138+
"first/.sentry-cli-metadata.txt",
139+
"sentry-cli-version: 1.0.0",
140+
)
141+
zf.writestr(
142+
"second/.sentry-cli-metadata.txt",
143+
"sentry-cli-version: 2.0.0",
144+
)
145+
146+
metadata = extract_metadata_from_zip(Path(tf.name))
147+
# Should get one of them (order not guaranteed in zip namelist)
148+
assert metadata.cli_version in ["1.0.0", "2.0.0"]
149+
finally:
150+
Path(tf.name).unlink()
151+
152+
def test_extract_from_invalid_zip(self):
153+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tf:
154+
try:
155+
tf.write(b"not a valid zip file")
156+
tf.flush()
157+
158+
metadata = extract_metadata_from_zip(Path(tf.name))
159+
# Should return empty metadata on error
160+
assert metadata.cli_version is None
161+
assert metadata.fastlane_version is None
162+
assert metadata.gradle_plugin_version is None
163+
finally:
164+
Path(tf.name).unlink()
165+
166+
167+
class TestToolingMetadata:
168+
"""Tests for ToolingMetadata container class."""
169+
170+
def test_create_with_all_fields(self):
171+
metadata = ToolingMetadata(
172+
cli_version="2.58.2",
173+
fastlane_version="1.2.3",
174+
gradle_plugin_version="4.12.0",
175+
)
176+
assert metadata.cli_version == "2.58.2"
177+
assert metadata.fastlane_version == "1.2.3"
178+
assert metadata.gradle_plugin_version == "4.12.0"
179+
180+
def test_create_with_defaults(self):
181+
metadata = ToolingMetadata()
182+
assert metadata.cli_version is None
183+
assert metadata.fastlane_version is None
184+
assert metadata.gradle_plugin_version is None
185+
186+
def test_repr(self):
187+
metadata = ToolingMetadata(cli_version="2.58.2")
188+
repr_str = repr(metadata)
189+
assert "ToolingMetadata" in repr_str
190+
assert "cli_version=2.58.2" in repr_str

0 commit comments

Comments
 (0)