Skip to content

Commit d9f3786

Browse files
authored
[SYNPY-1513] Validate input submission ID in getSubmission(...) (#1135)
1 parent 56ebcd0 commit d9f3786

File tree

4 files changed

+254
-8
lines changed

4 files changed

+254
-8
lines changed

synapseclient/client.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
is_integer,
115115
is_json,
116116
require_param,
117+
validate_submission_id,
117118
)
118119
from synapseclient.core.version_check import version_check
119120

@@ -4807,12 +4808,15 @@ def _POST_paginated(self, uri: str, body, **kwargs):
48074808
if next_page_token is None:
48084809
break
48094810

4810-
def getSubmission(self, id, **kwargs):
4811+
def getSubmission(
4812+
self, id: typing.Union[str, int, collections.abc.Mapping], **kwargs
4813+
) -> Submission:
48114814
"""
4812-
Gets a [synapseclient.evaluation.Submission][] object by its id.
4815+
Gets a [synapseclient.evaluation.Submission][] object based on a given ID
4816+
or previous [synapseclient.evaluation.Submission][] object.
48134817
48144818
Arguments:
4815-
id: The id of the submission to retrieve
4819+
id: The ID of the submission to retrieve or a [synapseclient.evaluation.Submission][] object
48164820
48174821
Returns:
48184822
A [synapseclient.evaluation.Submission][] object
@@ -4823,7 +4827,7 @@ def getSubmission(self, id, **kwargs):
48234827
on the *downloadFile*, *downloadLocation*, and *ifcollision* parameters
48244828
"""
48254829

4826-
submission_id = id_of(id)
4830+
submission_id = validate_submission_id(id)
48274831
uri = Submission.getURI(submission_id)
48284832
submission = Submission(**self.restGET(uri))
48294833

@@ -4852,18 +4856,20 @@ def getSubmission(self, id, **kwargs):
48524856

48534857
return submission
48544858

4855-
def getSubmissionStatus(self, submission):
4859+
def getSubmissionStatus(
4860+
self, submission: typing.Union[str, int, collections.abc.Mapping]
4861+
) -> SubmissionStatus:
48564862
"""
4857-
Downloads the status of a Submission.
4863+
Downloads the status of a Submission given its ID or previous [synapseclient.evaluation.Submission][] object.
48584864
48594865
Arguments:
4860-
submission: The submission to lookup
4866+
submission: The submission to lookup (ID or [synapseclient.evaluation.Submission][] object)
48614867
48624868
Returns:
48634869
A [synapseclient.evaluation.SubmissionStatus][] object
48644870
"""
48654871

4866-
submission_id = id_of(submission)
4872+
submission_id = validate_submission_id(submission)
48674873
uri = SubmissionStatus.getURI(submission_id)
48684874
val = self.restGET(uri)
48694875
return SubmissionStatus(**val)

synapseclient/core/utils.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import hashlib
1212
import importlib
1313
import inspect
14+
import logging
1415
import numbers
1516
import os
1617
import platform
@@ -30,6 +31,8 @@
3031
import requests
3132
from opentelemetry import trace
3233

34+
from synapseclient.core.logging_setup import DEFAULT_LOGGER_NAME
35+
3336
if TYPE_CHECKING:
3437
from synapseclient.models import File, Folder, Project
3538

@@ -47,6 +50,10 @@
4750

4851
SLASH_PREFIX_REGEX = re.compile(r"\/[A-Za-z]:")
4952

53+
# Set up logging
54+
LOGGER_NAME = DEFAULT_LOGGER_NAME
55+
LOGGER = logging.getLogger(LOGGER_NAME)
56+
5057

5158
def md5_for_file(
5259
filename: str, block_size: int = 2 * MB, callback: typing.Callable = None
@@ -242,6 +249,43 @@ def id_of(obj: typing.Union[str, collections.abc.Mapping, numbers.Number]) -> st
242249
raise ValueError("Invalid parameters: couldn't find id of " + str(obj))
243250

244251

252+
def validate_submission_id(
253+
submission_id: typing.Union[str, int, collections.abc.Mapping]
254+
) -> str:
255+
"""
256+
Ensures that a given submission ID is either an integer or a string that
257+
can be converted to an integer. Version notation is not supported for submission
258+
IDs, therefore decimals are not allowed.
259+
260+
Arguments:
261+
submission_id: The submission ID to validate
262+
263+
Returns:
264+
The submission ID as a string
265+
266+
"""
267+
if isinstance(submission_id, int):
268+
return str(submission_id)
269+
elif isinstance(submission_id, str) and submission_id.isdigit():
270+
return submission_id
271+
elif isinstance(submission_id, collections.abc.Mapping):
272+
syn_id = _get_from_members_items_or_properties(submission_id, "id")
273+
if syn_id is not None:
274+
return validate_submission_id(syn_id)
275+
else:
276+
try:
277+
int_submission_id = int(float(submission_id))
278+
except ValueError:
279+
raise ValueError(
280+
f"Submission ID '{submission_id}' is not a valid submission ID. Please use digits only."
281+
)
282+
LOGGER.warning(
283+
f"Submission ID '{submission_id}' contains decimals which are not supported. "
284+
f"Submission ID will be converted to '{int_submission_id}'."
285+
)
286+
return str(int_submission_id)
287+
288+
245289
def concrete_type_of(obj: collections.abc.Mapping):
246290
"""
247291
Return the concrete type of an object representing a Synapse entity.

tests/unit/synapseclient/core/unit_test_utils.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# unit tests for utils.py
22

33
import base64
4+
import logging
45
import os
56
import re
67
import tempfile
@@ -100,6 +101,49 @@ def __init__(self, id_attr_name: str, id: str) -> None:
100101
assert utils.id_of(foo) == "123"
101102

102103

104+
@pytest.mark.parametrize(
105+
"input_value, expected_output, expected_warning",
106+
[
107+
# Test 1: Valid inputs
108+
("123", "123", None),
109+
(123, "123", None),
110+
({"id": "222"}, "222", None),
111+
# Test 2: Invalid inputs that should be corrected
112+
(
113+
"123.0",
114+
"123",
115+
"Submission ID '123.0' contains decimals which are not supported",
116+
),
117+
(
118+
123.0,
119+
"123",
120+
"Submission ID '123.0' contains decimals which are not supported",
121+
),
122+
(
123+
{"id": "999.222"},
124+
"999",
125+
"Submission ID '999.222' contains decimals which are not supported",
126+
),
127+
],
128+
)
129+
def test_validate_submission_id(input_value, expected_output, expected_warning, caplog):
130+
with caplog.at_level(logging.WARNING):
131+
assert utils.validate_submission_id(input_value) == expected_output
132+
if expected_warning:
133+
assert expected_warning in caplog.text
134+
else:
135+
assert not caplog.text
136+
137+
138+
def test_validate_submission_id_letters_input() -> None:
139+
letters_input = "syn123"
140+
expected_error = f"Submission ID '{letters_input}' is not a valid submission ID. Please use digits only."
141+
with pytest.raises(ValueError) as err:
142+
utils.validate_submission_id(letters_input)
143+
144+
assert str(err.value) == expected_error
145+
146+
103147
# TODO: Add a test for is_synapse_id_str(...)
104148
# https://sagebionetworks.jira.com/browse/SYNPY-1425
105149

tests/unit/synapseclient/unit_test_client.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import logging
88
import os
99
import tempfile
10+
import typing
1011
import urllib.request as urllib_request
1112
import uuid
1213
from pathlib import Path
@@ -59,6 +60,7 @@
5960
)
6061
from synapseclient.core.models.dict_object import DictObject
6162
from synapseclient.core.upload import upload_functions
63+
from synapseclient.evaluation import Submission, SubmissionStatus
6264

6365
GET_FILE_HANDLE_FOR_DOWNLOAD = (
6466
"synapseclient.core.download.download_functions.get_file_handle_for_download_async"
@@ -2995,6 +2997,156 @@ def test_get_submission_with_annotations(syn: Synapse) -> None:
29952997
assert evaluation_id == response["evaluationId"]
29962998

29972999

3000+
def run_get_submission_test(
3001+
syn: Synapse,
3002+
submission_id: typing.Union[str, int],
3003+
expected_id: str,
3004+
should_warn: bool = False,
3005+
caplog=None,
3006+
) -> None:
3007+
"""
3008+
Common code for test_get_submission_valid_id and test_get_submission_invalid_id.
3009+
Generates a dummy submission dictionary for regression testing, mocks the API calls,
3010+
and validates the expected output for getSubmission. For invalid submission IDs, this
3011+
will check that a warning was logged for the user before transforming their input.
3012+
3013+
Arguments:
3014+
syn: Synapse object
3015+
submission_id: Submission ID to test
3016+
expected_id: Submission ID that should be returned
3017+
should_warn: Whether or not a warning should be logged
3018+
caplog: pytest caplog fixture
3019+
3020+
Returns:
3021+
None
3022+
3023+
"""
3024+
evaluation_id = (98765,)
3025+
submission = {
3026+
"evaluationId": evaluation_id,
3027+
"entityId": submission_id,
3028+
"versionNumber": 1,
3029+
"entityBundleJSON": json.dumps({}),
3030+
}
3031+
3032+
with patch.object(syn, "restGET") as restGET, patch.object(
3033+
syn, "_getWithEntityBundle"
3034+
) as get_entity:
3035+
restGET.return_value = submission
3036+
3037+
if should_warn:
3038+
with caplog.at_level(logging.WARNING):
3039+
syn.getSubmission(submission_id)
3040+
assert f"contains decimals which are not supported" in caplog.text
3041+
else:
3042+
syn.getSubmission(submission_id)
3043+
3044+
restGET.assert_called_once_with(f"/evaluation/submission/{expected_id}")
3045+
get_entity.assert_called_once_with(
3046+
entityBundle={},
3047+
entity=submission_id,
3048+
submission=str(expected_id),
3049+
)
3050+
3051+
3052+
@pytest.mark.parametrize(
3053+
"submission_id, expected_id",
3054+
[("123", "123"), (123, "123"), ({"id": 123}, "123"), ({"id": "123"}, "123")],
3055+
)
3056+
def test_get_submission_valid_id(syn: Synapse, submission_id, expected_id) -> None:
3057+
"""Test getSubmission with valid submission ID"""
3058+
run_get_submission_test(syn, submission_id, expected_id)
3059+
3060+
3061+
@pytest.mark.parametrize(
3062+
"submission_id, expected_id",
3063+
[
3064+
("123.0", "123"),
3065+
(123.0, "123"),
3066+
({"id": 123.0}, "123"),
3067+
({"id": "123.0"}, "123"),
3068+
],
3069+
)
3070+
def test_get_submission_invalid_id(
3071+
syn: Synapse, submission_id, expected_id, caplog
3072+
) -> None:
3073+
"""Test getSubmission with invalid submission ID"""
3074+
run_get_submission_test(
3075+
syn, submission_id, expected_id, should_warn=True, caplog=caplog
3076+
)
3077+
3078+
3079+
def test_get_submission_and_submission_status_interchangeability(
3080+
syn: Synapse, caplog
3081+
) -> None:
3082+
"""Test interchangeability of getSubmission and getSubmissionStatus."""
3083+
3084+
# Establish some dummy variables to work with
3085+
evaluation_id = 98765
3086+
submission_id = 9745366.0
3087+
expected_submission_id = "9745366"
3088+
3089+
# Establish an expected return for `getSubmissionStatus`
3090+
submission_status_return = {
3091+
"id": expected_submission_id,
3092+
"etag": "000",
3093+
"status": "RECEIVED",
3094+
}
3095+
3096+
# Establish an expected return for `getSubmission`
3097+
submission_return = {
3098+
"id": expected_submission_id,
3099+
"evaluationId": evaluation_id,
3100+
"entityId": expected_submission_id,
3101+
"versionNumber": 1,
3102+
"entityBundleJSON": json.dumps({}),
3103+
}
3104+
3105+
# Let's mock all the API calls made within these two methods
3106+
with patch.object(syn, "restGET") as restGET, patch.object(
3107+
Submission, "getURI"
3108+
) as get_submission_uri, patch.object(
3109+
SubmissionStatus, "getURI"
3110+
) as get_status_uri, patch.object(
3111+
syn, "_getWithEntityBundle"
3112+
):
3113+
get_submission_uri.return_value = (
3114+
f"/evaluation/submission/{expected_submission_id}"
3115+
)
3116+
get_status_uri.return_value = (
3117+
f"/evaluation/submission/{expected_submission_id}/status"
3118+
)
3119+
3120+
# Establish a return for all the calls to restGET we will be making in this test
3121+
restGET.side_effect = [
3122+
# Step 1 call to `getSubmission`
3123+
submission_return,
3124+
# Step 2 call to `getSubmissionStatus`
3125+
submission_status_return,
3126+
]
3127+
3128+
# Step 1: Call `getSubmission` with float ID
3129+
restGET.return_value = submission_return
3130+
submission_result = syn.getSubmission(submission_id)
3131+
3132+
# Step 2: Call `getSubmissionStatus` with the `Submission` object from above
3133+
restGET.reset_mock()
3134+
restGET.return_value = submission_status_return
3135+
submission_status_result = syn.getSubmissionStatus(submission_result)
3136+
3137+
# Validate that getSubmission and getSubmissionStatus are called with correct URIs
3138+
# in `getURI` calls
3139+
get_submission_uri.assert_called_once_with(expected_submission_id)
3140+
get_status_uri.assert_called_once_with(expected_submission_id)
3141+
3142+
# Validate final output is as expected
3143+
assert (
3144+
submission_result["id"]
3145+
== submission_status_result["id"]
3146+
== expected_submission_id
3147+
)
3148+
3149+
29983150
class TestTableSnapshot:
29993151
def test__create_table_snapshot(self, syn: Synapse) -> None:
30003152
"""Testing creating table snapshots"""

0 commit comments

Comments
 (0)