Skip to content

Commit 265a811

Browse files
committed
Add icon ID creation
1 parent cdecc45 commit 265a811

File tree

3 files changed

+94
-74
lines changed

3 files changed

+94
-74
lines changed

src/launchpad/artifact_processor.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
"""Artifact processing logic extracted from LaunchpadService."""
2-
31
from __future__ import annotations
42

53
import contextlib
@@ -42,6 +40,7 @@
4240
from launchpad.size.models.apple import AppleAppInfo
4341
from launchpad.size.models.common import BaseAppInfo
4442
from launchpad.tracing import request_context
43+
from launchpad.utils.file_utils import IdPrefix, id_from_bytes
4544
from launchpad.utils.logging import get_logger
4645
from launchpad.utils.objectstore.service import (
4746
Client as ObjectstoreClient,
@@ -55,8 +54,6 @@
5554

5655

5756
class ArtifactProcessor:
58-
"""Handles the processing of artifacts including download, analysis, and upload."""
59-
6057
def __init__(
6158
self,
6259
sentry_client: SentryClient,
@@ -94,7 +91,7 @@ def process_message(
9491
if artifact_processor is None:
9592
sentry_client = SentryClient(base_url=service_config.sentry_base_url)
9693
objectstore_client = ObjectstoreClientBuilder(
97-
usecase="app-icons", options={"base_url": "http://localhost:8888/"}
94+
usecase="preprod", options={"base_url": "http://localhost:8888/"}
9895
).for_project(organization_id, project_id)
9996
artifact_processor = ArtifactProcessor(sentry_client, statsd, objectstore_client)
10097

@@ -269,8 +266,11 @@ def _process_app_icon(
269266
logger.info(f"No app icon found for {artifact_id} (project: {project_id}, org: {organization_id})")
270267
return None
271268

272-
app_icon_id = self._objectstore_client.put(app_icon, compression="none")
273-
return app_icon_id
269+
image_id = id_from_bytes(app_icon, IdPrefix.ICON)
270+
icon_key = f"{organization_id}/{project_id}/{image_id}"
271+
logger.info(f"Uploading app icon to object store: {icon_key}")
272+
self._objectstore_client.put(app_icon, compression="none", id=icon_key)
273+
return image_id
274274

275275
def _do_distribution(
276276
self,

src/launchpad/utils/file_utils.py

Lines changed: 26 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,78 @@
1-
"""File utilities for app size analyzer."""
2-
31
import hashlib
42
import shutil
53
import tempfile
64

5+
from enum import Enum
6+
from io import BytesIO
77
from pathlib import Path
8+
from typing import IO
89

910
from .logging import get_logger
1011

1112
logger = get_logger(__name__)
1213

14+
_HASH_CHUNK_SIZE = 8192
1315

14-
def calculate_file_hash(file_path: Path, algorithm: str = "md5") -> str:
15-
"""Calculate hash of a file.
16-
17-
Args:
18-
file_path: Path to the file
19-
algorithm: Hash algorithm to use ("md5", "sha1", "sha256")
2016

21-
Returns:
22-
Hexadecimal hash string
17+
class IdPrefix(Enum):
18+
ICON = "icn"
19+
SNAPSHOT = "snap"
2320

24-
Raises:
25-
ValueError: If algorithm is not supported
26-
FileNotFoundError: If file doesn't exist
27-
"""
28-
if not file_path.exists():
29-
raise FileNotFoundError(f"File not found: {file_path}")
3021

22+
def _calculate_hash(data: IO[bytes], algorithm: str) -> str:
23+
hasher = None
3124
if algorithm == "md5":
3225
hasher = hashlib.md5()
3326
elif algorithm == "sha1":
3427
hasher = hashlib.sha1()
3528
elif algorithm == "sha256":
3629
hasher = hashlib.sha256()
37-
else:
30+
31+
if hasher is None:
3832
raise ValueError(f"Unsupported hash algorithm: {algorithm}")
3933

34+
for chunk in iter(lambda: data.read(_HASH_CHUNK_SIZE), b""):
35+
hasher.update(chunk)
36+
37+
return hasher.hexdigest()
38+
39+
40+
def id_from_bytes(data: bytes, prefix: IdPrefix) -> str:
41+
return f"{prefix.value}_{_calculate_hash(BytesIO(data), 'sha256')[:12]}"
42+
43+
44+
def calculate_file_hash(file_path: Path, algorithm: str = "md5") -> str:
45+
if not file_path.exists():
46+
raise FileNotFoundError(f"File not found: {file_path}")
47+
4048
try:
4149
with open(file_path, "rb") as f:
42-
# Read file in chunks to handle large files efficiently
43-
for chunk in iter(lambda: f.read(8192), b""):
44-
hasher.update(chunk)
45-
46-
return hasher.hexdigest()
50+
return _calculate_hash(f, algorithm)
4751
except Exception as e:
4852
raise RuntimeError(f"Failed to calculate hash for {file_path}: {e}")
4953

5054

5155
def get_file_size(file_path: Path) -> int:
52-
"""Get file size in bytes.
53-
54-
Args:
55-
file_path: Path to the file
56-
57-
Returns:
58-
File size in bytes
59-
60-
Raises:
61-
FileNotFoundError: If file doesn't exist
62-
"""
6356
if not file_path.exists():
6457
raise FileNotFoundError(f"File not found: {file_path}")
6558

6659
return file_path.stat().st_size
6760

6861

6962
def to_nearest_block_size(file_size: int, block_size: int) -> int:
70-
"""Round file size up to the nearest filesystem block size."""
71-
7263
if file_size == 0:
7364
return 0
7465

7566
return ((file_size - 1) // block_size + 1) * block_size
7667

7768

7869
def create_temp_directory(prefix: str = "app-analyzer-") -> Path:
79-
"""Create a temporary directory.
80-
81-
Args:
82-
prefix: Prefix for the temporary directory name
83-
84-
Returns:
85-
Path to the created temporary directory
86-
"""
8770
temp_dir = Path(tempfile.mkdtemp(prefix=prefix))
8871
logger.debug(f"Created temporary directory: {temp_dir}")
8972
return temp_dir
9073

9174

9275
def cleanup_directory(directory: Path) -> None:
93-
"""Remove a directory and all its contents.
94-
95-
Args:
96-
directory: Directory to remove
97-
"""
9876
if directory.exists() and directory.is_dir():
9977
shutil.rmtree(directory)
10078
logger.debug(f"Cleaned up directory: {directory}")

tests/unit/test_artifact_processor.py

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
"""Tests for ArtifactProcessor including error handling, retry logic, and message processing."""
2-
31
from unittest.mock import Mock, patch
42

53
import pytest
64

7-
from sentry_kafka_schemas.schema_types.preprod_artifact_events_v1 import PreprodArtifactEvents
5+
from sentry_kafka_schemas.schema_types.preprod_artifact_events_v1 import (
6+
PreprodArtifactEvents,
7+
)
88

99
from launchpad.artifact_processor import ArtifactProcessor
1010
from launchpad.constants import (
@@ -16,17 +16,17 @@
1616
)
1717
from launchpad.sentry_client import SentryClient, SentryClientError
1818
from launchpad.service import ServiceConfig
19+
from launchpad.utils.objectstore.service import Client as ObjectstoreClient
1920
from launchpad.utils.statsd import FakeStatsd
2021

2122

2223
class TestArtifactProcessorErrorHandling:
23-
"""Test error handling and retry logic in ArtifactProcessor."""
24-
2524
def setup_method(self):
2625
"""Set up test fixtures."""
2726
mock_sentry_client = Mock(spec=SentryClient)
2827
mock_statsd = Mock()
29-
self.processor = ArtifactProcessor(mock_sentry_client, mock_statsd)
28+
mock_objectstore_client = Mock(spec=ObjectstoreClient)
29+
self.processor = ArtifactProcessor(mock_sentry_client, mock_statsd, mock_objectstore_client)
3030

3131
def test_retry_operation_success_on_first_attempt(self):
3232
"""Test that _retry_operation succeeds on first attempt."""
@@ -215,7 +215,11 @@ class TestArtifactProcessorMessageHandling:
215215
def test_process_message_ios(self, mock_process, mock_sentry_client):
216216
"""Test processing iOS artifact messages."""
217217
fake_statsd = FakeStatsd()
218-
service_config = ServiceConfig(sentry_base_url="http://test.sentry.io", projects_to_skip=[])
218+
service_config = ServiceConfig(
219+
sentry_base_url="http://test.sentry.io",
220+
projects_to_skip=[],
221+
objectstore_base_url="http://test.objectstore.io",
222+
)
219223

220224
# Create a payload for iOS artifact
221225
payload: PreprodArtifactEvents = {
@@ -230,20 +234,33 @@ def test_process_message_ios(self, mock_process, mock_sentry_client):
230234

231235
# Verify process_artifact was called with correct args
232236
mock_process.assert_called_once_with(
233-
"test-org-123", "test-project-ios", "ios-test-123", [PreprodFeature.SIZE_ANALYSIS]
237+
"test-org-123",
238+
"test-project-ios",
239+
"ios-test-123",
240+
[PreprodFeature.SIZE_ANALYSIS],
234241
)
235242

236243
# Verify metrics were recorded
237244
calls = fake_statsd.calls
238-
assert ("increment", {"metric": "artifact.processing.started", "value": 1, "tags": None}) in calls
239-
assert ("increment", {"metric": "artifact.processing.completed", "value": 1, "tags": None}) in calls
245+
assert (
246+
"increment",
247+
{"metric": "artifact.processing.started", "value": 1, "tags": None},
248+
) in calls
249+
assert (
250+
"increment",
251+
{"metric": "artifact.processing.completed", "value": 1, "tags": None},
252+
) in calls
240253

241254
@patch("launchpad.artifact_processor.SentryClient")
242255
@patch.object(ArtifactProcessor, "process_artifact")
243256
def test_process_message_android(self, mock_process, mock_sentry_client):
244257
"""Test processing Android artifact messages."""
245258
fake_statsd = FakeStatsd()
246-
service_config = ServiceConfig(sentry_base_url="http://test.sentry.io", projects_to_skip=[])
259+
service_config = ServiceConfig(
260+
sentry_base_url="http://test.sentry.io",
261+
projects_to_skip=[],
262+
objectstore_base_url="http://test.objectstore.io",
263+
)
247264

248265
# Create a payload for Android artifact
249266
payload: PreprodArtifactEvents = {
@@ -266,15 +283,25 @@ def test_process_message_android(self, mock_process, mock_sentry_client):
266283

267284
# Verify metrics were recorded
268285
calls = fake_statsd.calls
269-
assert ("increment", {"metric": "artifact.processing.started", "value": 1, "tags": None}) in calls
270-
assert ("increment", {"metric": "artifact.processing.completed", "value": 1, "tags": None}) in calls
286+
assert (
287+
"increment",
288+
{"metric": "artifact.processing.started", "value": 1, "tags": None},
289+
) in calls
290+
assert (
291+
"increment",
292+
{"metric": "artifact.processing.completed", "value": 1, "tags": None},
293+
) in calls
271294

272295
@patch("launchpad.artifact_processor.SentryClient")
273296
@patch.object(ArtifactProcessor, "process_artifact")
274297
def test_process_message_error(self, mock_process, mock_sentry_client):
275298
"""Test error handling in message processing."""
276299
fake_statsd = FakeStatsd()
277-
service_config = ServiceConfig(sentry_base_url="http://test.sentry.io", projects_to_skip=[])
300+
service_config = ServiceConfig(
301+
sentry_base_url="http://test.sentry.io",
302+
projects_to_skip=[],
303+
objectstore_base_url="http://test.objectstore.io",
304+
)
278305

279306
# Make process_artifact raise an exception
280307
mock_process.side_effect = RuntimeError("Download failed: HTTP 404")
@@ -292,7 +319,10 @@ def test_process_message_error(self, mock_process, mock_sentry_client):
292319

293320
# Verify process_artifact was called
294321
mock_process.assert_called_once_with(
295-
"test-org", "test-project", "test-123", [PreprodFeature.SIZE_ANALYSIS, PreprodFeature.BUILD_DISTRIBUTION]
322+
"test-org",
323+
"test-project",
324+
"test-123",
325+
[PreprodFeature.SIZE_ANALYSIS, PreprodFeature.BUILD_DISTRIBUTION],
296326
)
297327

298328
# Verify the metrics were called correctly
@@ -308,7 +338,9 @@ def test_process_message_project_skipped(self, mock_process, mock_sentry_client)
308338
"""Test that projects in the skip list are not processed."""
309339
fake_statsd = FakeStatsd()
310340
service_config = ServiceConfig(
311-
sentry_base_url="http://test.sentry.io", projects_to_skip=["skip-project-1", "skip-project-2"]
341+
sentry_base_url="http://test.sentry.io",
342+
projects_to_skip=["skip-project-1", "skip-project-2"],
343+
objectstore_base_url="http://test.objectstore.io",
312344
)
313345

314346
# Create a payload for a project that should be skipped
@@ -334,7 +366,11 @@ def test_process_message_project_skipped(self, mock_process, mock_sentry_client)
334366
def test_process_message_project_not_skipped(self, mock_process, mock_sentry_client):
335367
"""Test that projects not in the skip list are processed normally."""
336368
fake_statsd = FakeStatsd()
337-
service_config = ServiceConfig(sentry_base_url="http://test.sentry.io", projects_to_skip=["other-project"])
369+
service_config = ServiceConfig(
370+
sentry_base_url="http://test.sentry.io",
371+
projects_to_skip=["other-project"],
372+
objectstore_base_url="http://test.objectstore.io",
373+
)
338374

339375
# Create a payload for a project that should NOT be skipped
340376
payload: PreprodArtifactEvents = {
@@ -357,5 +393,11 @@ def test_process_message_project_not_skipped(self, mock_process, mock_sentry_cli
357393

358394
# Verify normal metrics were recorded
359395
calls = fake_statsd.calls
360-
assert ("increment", {"metric": "artifact.processing.started", "value": 1, "tags": None}) in calls
361-
assert ("increment", {"metric": "artifact.processing.completed", "value": 1, "tags": None}) in calls
396+
assert (
397+
"increment",
398+
{"metric": "artifact.processing.started", "value": 1, "tags": None},
399+
) in calls
400+
assert (
401+
"increment",
402+
{"metric": "artifact.processing.completed", "value": 1, "tags": None},
403+
) in calls

0 commit comments

Comments
 (0)