Skip to content

Commit ac1492d

Browse files
author
Brandon Kaplan
authored
Prod-1820 forma add sync library function to fetch cluster spec of latest submission in project (#110)
1 parent b756ff9 commit ac1492d

File tree

5 files changed

+244
-3
lines changed

5 files changed

+244
-3
lines changed

sync/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""Library for leveraging the power of Sync"""
22

3-
__version__ = "1.4.1"
3+
__version__ = "1.5.0"
44

55
TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"

sync/api/projects.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Project functions
22
"""
3+
34
import io
45
import json
56
import logging
@@ -416,6 +417,19 @@ def get_project_submission(project_id: str, submission_id: str) -> Response[dict
416417
return Response(result=response["result"])
417418

418419

420+
def get_submissions(project_id: str) -> Response[dict]:
421+
"""Get submissions for a project id
422+
423+
:param project_id: project ID
424+
:type project_id: str
425+
:return: List of Submission Configuration objects
426+
:rtype: dict
427+
"""
428+
recent_submissions = get_default_client().get_project_submissions(project_id)
429+
if recent_submissions.get("result") and len(recent_submissions["result"]) > 0:
430+
return Response(result=recent_submissions["result"])
431+
432+
419433
def get_latest_project_config_recommendation(
420434
project_id: str,
421435
) -> Response[Union[AWSProjectConfiguration, AzureProjectConfiguration]]:
@@ -427,7 +441,7 @@ def get_latest_project_config_recommendation(
427441
:rtype: AWSProjectConfiguration or AzureProjectConfiguration
428442
"""
429443
latest_recommendation = get_default_client().get_latest_project_recommendation(project_id)
430-
if latest_recommendation.get("result"):
444+
if latest_recommendation.get("result") and len(latest_recommendation["result"]) > 0:
431445
return Response(
432446
result=latest_recommendation["result"][0]["recommendation"]["configuration"]
433447
)
@@ -436,7 +450,7 @@ def get_latest_project_config_recommendation(
436450
def get_cluster_definition_and_recommendation(
437451
project_id: str, cluster_spec_str: str
438452
) -> Response[dict]:
439-
"""Print Current Cluster Definition and Project Configuration Recommendatio.
453+
"""Print Current Cluster Definition and Project Configuration Recommendation.
440454
Throws error if no cluster recommendation found for project
441455
442456
:param project_id: project ID

sync/cli/projects.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
delete_project,
88
get_project,
99
get_projects,
10+
get_submissions,
1011
reset_project,
1112
update_project,
1213
)
@@ -165,3 +166,47 @@ def delete(project: dict):
165166
click.echo(response.result)
166167
else:
167168
click.echo(str(response.error), err=True)
169+
170+
171+
@projects.command
172+
@click.argument("project", callback=validate_project)
173+
@click.option(
174+
"--success-only",
175+
is_flag=True,
176+
default=False,
177+
help="Only show the most recent successful submission",
178+
)
179+
def get_latest_submission_config(project: dict, success_only: bool = False):
180+
"""
181+
Get the latest submission configuration for a project.
182+
"""
183+
184+
try:
185+
submissions = get_submissions(project["id"]).result
186+
except AttributeError as e:
187+
click.echo(f"Failed to retrieve submissions. {e}", err=True)
188+
return
189+
190+
if not submissions:
191+
click.echo("No submissions found.", err=True)
192+
return
193+
194+
if success_only:
195+
latest_successful_submission = next(
196+
(submission for submission in submissions if submission["state"] == "SUCCESS"), None
197+
)
198+
199+
if not latest_successful_submission:
200+
click.echo("No successful submissions found.", err=True)
201+
return
202+
203+
submission_to_show = latest_successful_submission
204+
else:
205+
submission_to_show = submissions[0]
206+
click.echo(
207+
json.dumps(
208+
submission_to_show.get("configuration", {}),
209+
indent=2,
210+
cls=DateTimeEncoderNaiveUTCDropMicroseconds,
211+
)
212+
)

sync/projects.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from uuid import UUID
2+
3+
from sync.api.projects import get_project_by_app_id, get_submissions
4+
5+
6+
class SubmissionRetrievalError(Exception):
7+
"""Custom exception for errors retrieving submissions."""
8+
9+
10+
class NoSubmissionsFoundError(SubmissionRetrievalError):
11+
"""No submissions were found."""
12+
13+
14+
class NoSuccessfulSubmissionsFoundError(SubmissionRetrievalError):
15+
"""No successful submissions were found."""
16+
17+
18+
class InvalidUUIDError(ValueError):
19+
"""Raised when the provided UUID is invalid."""
20+
21+
22+
class ProjectResolutionError(Exception):
23+
"""Raised when there is an error resolving the project ID."""
24+
25+
26+
def validate_uuid(value: str) -> str:
27+
try:
28+
UUID(value)
29+
return value
30+
except ValueError:
31+
raise InvalidUUIDError(f"Invalid UUID: {value}")
32+
33+
34+
def resolve_project_id(value: str) -> str:
35+
try:
36+
return validate_uuid(value)
37+
except InvalidUUIDError:
38+
try:
39+
project_response = get_project_by_app_id(value)
40+
if project_response.error:
41+
raise ProjectResolutionError(
42+
f"Error resolving project ID: {project_response.error}"
43+
)
44+
return project_response.result.get("id")
45+
except Exception as e:
46+
raise ProjectResolutionError(
47+
f"An error occurred while resolving the project ID: {str(e)}"
48+
)
49+
50+
51+
def get_latest_submission_config(project_id: str, success_only: bool = False) -> dict:
52+
"""
53+
Get the latest submission configuration for a project.
54+
55+
:param project_id: Sync project ID
56+
:type project_id: str
57+
:param success_only: Only show the most recent successful submission, if omitted shows the most
58+
recent submission regardless of state
59+
:type success_only: bool, optional
60+
"""
61+
62+
try:
63+
submissions = get_submissions(project_id).result
64+
except Exception as e:
65+
raise SubmissionRetrievalError(
66+
f"Failed to retrieve submissions for project '{project_id}'. {e}"
67+
) from e
68+
69+
if not submissions:
70+
raise NoSubmissionsFoundError(f"No submissions found for project '{project_id}'.")
71+
72+
if success_only:
73+
latest_successful_submission = next(
74+
(submission for submission in submissions if submission["state"] == "SUCCESS"), None
75+
)
76+
77+
if not latest_successful_submission:
78+
raise NoSuccessfulSubmissionsFoundError(
79+
f"No successful submissions found for project '{project_id}'."
80+
)
81+
82+
submission_to_show = latest_successful_submission
83+
else:
84+
submission_to_show = submissions[0]
85+
config = submission_to_show.get("configuration", {})
86+
return config

tests/test_projects.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from unittest.mock import patch
2+
3+
import pytest
4+
from pytest import raises
5+
6+
from sync.projects import NoSuccessfulSubmissionsFoundError, get_latest_submission_config
7+
8+
mock_base_configuration = {
9+
"node_type_id": "Standard_DS3_v2",
10+
"driver_node_type_id": "Standard_DS3_v2",
11+
"custom_tags": {
12+
"Vendor": "Databricks",
13+
"Creator": "",
14+
"ClusterName": "job-951738965563208-run-13053153698407-Job_cluster",
15+
"ClusterId": "0315-161419-efa397m4",
16+
"JobId": "951738965563208",
17+
},
18+
"num_workers": 2,
19+
"spark_conf": {"spark.databricks.isv.product": "sync-gradient"},
20+
"spark_version": "13.3.x-scala2.12",
21+
"runtime_engine": "STANDARD",
22+
"azure_attributes": {
23+
"availability": "ON_DEMAND_AZURE",
24+
"first_on_demand": 1,
25+
"spot_bid_max_price": -1.0,
26+
},
27+
}
28+
mock_success_configuration = {"state": "SUCCESS", **mock_base_configuration}
29+
mock_failed_configuration = {"state": "FAILED", **mock_base_configuration}
30+
31+
mock_base_submission = {
32+
"created_at": "2024-03-15T16:27:12Z",
33+
"updated_at": "2024-03-15T16:27:21Z",
34+
"id": "b7ee7431-3188-44ac-95fc-6f2068de39b6",
35+
"project_id": "ff10d866-ebf5-46ff-84dc-b5430a5eea45",
36+
}
37+
mock_success_submission = {
38+
**mock_base_submission,
39+
"state": "SUCCESS",
40+
"configuration": mock_success_configuration,
41+
}
42+
mock_failed_submission = {
43+
**mock_base_submission,
44+
"state": "FAILED",
45+
"configuration": mock_failed_configuration,
46+
}
47+
48+
49+
@pytest.fixture
50+
def mock_mixed_submissions():
51+
yield {"result": [mock_failed_submission, mock_success_submission]}
52+
53+
54+
@pytest.fixture
55+
def mock_no_success_submissions():
56+
yield {"result": [mock_failed_submission]}
57+
58+
59+
@pytest.fixture
60+
def mock_get_submissions_empty():
61+
"""Mocks get_submissions function to return an empty list."""
62+
yield {"result": []}
63+
64+
65+
@patch("sync.clients.sync.SyncClient._send")
66+
def test_get_latest_submission_config_success(mock_send, mock_mixed_submissions):
67+
"""Test get_latest_submission_config returns the latest successful submission configuration."""
68+
mock_send.return_value = mock_mixed_submissions
69+
with patch("sync.api.projects.get_submissions", lambda x: mock_mixed_submissions):
70+
project_id = "ff10d866-ebf5-46ff-84dc-b5430a5eea45"
71+
config = get_latest_submission_config(project_id, success_only=True)
72+
assert "node_type_id" in config
73+
assert "Vendor" in config.get("custom_tags", "")
74+
assert config["state"] == "SUCCESS"
75+
76+
77+
@patch("sync.clients.sync.SyncClient._send")
78+
def test_get_latest_submission_config_failure_ok(mock_send, mock_mixed_submissions):
79+
mock_send.return_value = mock_mixed_submissions
80+
with patch("sync.api.projects.get_submissions", lambda x: mock_mixed_submissions):
81+
project_id = "ff10d866-ebf5-46ff-84dc-b5430a5eea45"
82+
config = get_latest_submission_config(project_id, success_only=False)
83+
assert "node_type_id" in config
84+
assert "Vendor" in config.get("custom_tags", "")
85+
assert config["state"] == "FAILED"
86+
87+
88+
@patch("sync.clients.sync.SyncClient._send")
89+
def test_get_latest_submission_raises_no_successful_submissions_found_error(
90+
mock_send, mock_no_success_submissions
91+
):
92+
mock_send.return_value = mock_no_success_submissions
93+
with patch("sync.api.projects.get_submissions", lambda x: mock_no_success_submissions):
94+
project_id = "ff10d866-ebf5-46ff-84dc-b5430a5eea45"
95+
with raises(NoSuccessfulSubmissionsFoundError):
96+
get_latest_submission_config(project_id, success_only=True)

0 commit comments

Comments
 (0)