Skip to content

Commit 47a4a6a

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 f12c060 commit 47a4a6a

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
@@ -497,9 +497,9 @@ def _get_artifact_type(artifact: Artifact) -> ArtifactType:
497497
apple_app_info=apple_app_info,
498498
android_app_info=android_app_info,
499499
dequeued_at=dequeued_at,
500-
sentry_cli_version=tooling_versions["sentry_cli_version"],
501-
fastlane_version=tooling_versions["fastlane_version"],
502-
gradle_plugin_version=tooling_versions["gradle_plugin_version"],
500+
cli_version=app_info.cli_version,
501+
fastlane_version=app_info.fastlane_version,
502+
gradle_plugin_version=app_info.gradle_plugin_version,
503503
)
504504

505505
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
@@ -27,6 +27,7 @@
2727
from launchpad.size.utils.android_bundle_size import calculate_apk_download_size, calculate_apk_install_size
2828
from launchpad.utils.file_utils import calculate_file_hash
2929
from launchpad.utils.logging import get_logger
30+
from launchpad.utils.metadata_extractor import extract_metadata_from_zip
3031

3132
logger = get_logger(__name__)
3233

@@ -50,12 +51,18 @@ def preprocess(self, artifact: AndroidArtifact) -> AndroidAppInfo:
5051
manifest_dict = artifact.get_manifest().model_dump()
5152
has_proguard_mapping = artifact.get_dex_mapping() is not None
5253

54+
# Extract tooling metadata from .sentry-cli-metadata.txt if present
55+
metadata = extract_metadata_from_zip(artifact.path)
56+
5357
self.app_info = AndroidAppInfo(
5458
name=manifest_dict["application"]["label"] or "Unknown",
5559
version=manifest_dict["version_name"] or "Unknown",
5660
build=manifest_dict["version_code"] or "Unknown",
5761
app_id=manifest_dict["package_name"],
5862
has_proguard_mapping=has_proguard_mapping,
63+
cli_version=metadata.cli_version,
64+
fastlane_version=metadata.fastlane_version,
65+
gradle_plugin_version=metadata.gradle_plugin_version,
5966
)
6067

6168
return self.app_info

src/launchpad/size/analyzers/apple.py

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

4849
from ..models.apple import (
4950
AppleAnalysisResults,
@@ -333,6 +334,9 @@ def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo:
333334
binaries = xcarchive.get_all_binary_paths()
334335
missing_dsym_binaries = [b.name for b in binaries if b.dsym_path is None]
335336

337+
# Extract tooling metadata from .sentry-cli-metadata.txt if present
338+
metadata = extract_metadata_from_zip(xcarchive.path)
339+
336340
return AppleAppInfo(
337341
name=app_name,
338342
app_id=plist.get("CFBundleIdentifier", "unknown.bundle.id"),
@@ -354,6 +358,9 @@ def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo:
354358
primary_icon_name=primary_icon_name,
355359
alternate_icon_names=alternate_icon_names,
356360
missing_dsym_binaries=missing_dsym_binaries,
361+
cli_version=metadata.cli_version,
362+
fastlane_version=metadata.fastlane_version,
363+
gradle_plugin_version=metadata.gradle_plugin_version,
357364
)
358365

359366
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
@@ -28,6 +28,9 @@ class BaseAppInfo(BaseModel):
2828
version: str = Field(..., description="App version")
2929
build: str = Field(..., description="Build number")
3030
app_id: str = Field(..., description="App ID (bundle id on iOS, package name on Android)")
31+
cli_version: str | None = Field(None, description="sentry-cli version extracted from .sentry-cli-metadata.txt")
32+
fastlane_version: str | None = Field(None, description="Fastlane plugin version extracted from .sentry-cli-metadata.txt")
33+
gradle_plugin_version: str | None = Field(None, description="Gradle plugin version extracted from .sentry-cli-metadata.txt")
3134

3235

3336
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)