Skip to content

Commit ebc759c

Browse files
authored
[SYNPY-1417] Updates for the annotation model (Sage-Bionetworks#1081)
Finishing touches for the annotation model. Defaulting to empty dict.
1 parent 994e5f2 commit ebc759c

File tree

13 files changed

+343
-63
lines changed

13 files changed

+343
-63
lines changed

synapseclient/api/annotations.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,18 @@ def set_annotations(
2323
"""Call to synapse and set the annotations for the given input.
2424
2525
Arguments:
26-
annotations: The annotations to set. This is expected to have the id, etag, and annotations filled in.
27-
synapse_client: If not passed in or None this will use the last client from the `.login()` method.
26+
annotations: The annotations to set. This is expected to have the id, etag,
27+
and annotations filled in.
28+
synapse_client: If not passed in or None this will use the last client from
29+
the `.login()` method.
2830
2931
Returns: The annotations set in Synapse.
3032
"""
3133
annotations_dict = asdict(annotations)
3234

33-
synapse_annotations = _convert_to_annotations_list(annotations_dict["annotations"])
35+
synapse_annotations = _convert_to_annotations_list(
36+
annotations_dict["annotations"] or {}
37+
)
3438
from synapseclient import Synapse
3539

3640
return Synapse.get_client(synapse_client=synapse_client).restPUT(

synapseclient/models/annotations.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import asyncio
2-
from dataclasses import dataclass
2+
from dataclasses import dataclass, field
33
from datetime import date, datetime
44
from typing import Dict, List, Optional, Union
55

@@ -48,7 +48,7 @@ class Annotations(AnnotationsSynchronousProtocol):
4848
],
4949
],
5050
None,
51-
]
51+
] = field(default_factory=dict)
5252
"""Additional metadata associated with the object. The key is the name of your
5353
desired annotations. The value is an object containing a list of string values
5454
(use empty list to represent no values for key) and the value type associated with
@@ -145,4 +145,4 @@ def from_dict(
145145
else:
146146
annotations[key] = dict_to_convert[key]
147147

148-
return annotations if annotations else None
148+
return annotations

synapseclient/models/file.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ class File(FileSynchronousProtocol, AccessControllable):
207207
of your desired annotations. The value is an object containing a list of
208208
values (use empty list to represent no values for key) and the value type
209209
associated with all values in the list. To remove all annotations set this
210-
to an empty dict `{}`.
210+
to an empty dict `{}` or None and store the entity.
211211
212212
Attributes:
213213
content_type: (New Upload Only)
@@ -349,7 +349,7 @@ class File(FileSynchronousProtocol, AccessControllable):
349349
List[datetime],
350350
],
351351
]
352-
] = field(default=None, compare=False)
352+
] = field(default_factory=dict, compare=False)
353353
"""Additional metadata associated with the folder. The key is the name of your
354354
desired annotations. The value is an object containing a list of values
355355
(use empty list to represent no values for key) and the value type associated with
@@ -512,7 +512,7 @@ def _set_last_persistent_instance(self) -> None:
512512
dataclasses.replace(self.activity) if self.activity else None
513513
)
514514
self._last_persistent_instance.annotations = (
515-
deepcopy(self.annotations) if self.annotations else None
515+
deepcopy(self.annotations) if self.annotations else {}
516516
)
517517

518518
def _fill_from_file_handle(self) -> None:
@@ -562,7 +562,7 @@ def fill_from_dict(
562562

563563
if set_annotations:
564564
self.annotations = Annotations.from_dict(
565-
synapse_file.get("annotations", None)
565+
synapse_file.get("annotations", {})
566566
)
567567
return self
568568

synapseclient/models/folder.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ class Folder(FolderSynchronousProtocol, AccessControllable, StorableContainer):
5858
annotations: Additional metadata associated with the folder. The key is the name
5959
of your desired annotations. The value is an object containing a list of
6060
values (use empty list to represent no values for key) and the value type
61-
associated with all values in the list. To remove all annotations set this
62-
to an empty dict `{}`.
61+
associated with all values in the list. To remove all annotations set this
62+
to an empty dict `{}` or None and store the entity.
6363
create_or_update: (Store only) Indicates whether the method should
6464
automatically perform an update if the resource conflicts with an existing
6565
Synapse object. When True this means that any changes to the resource will
@@ -124,11 +124,12 @@ class Folder(FolderSynchronousProtocol, AccessControllable, StorableContainer):
124124
List[datetime],
125125
],
126126
]
127-
] = field(default=None, compare=False)
127+
] = field(default_factory=dict, compare=False)
128128
"""Additional metadata associated with the folder. The key is the name of your
129129
desired annotations. The value is an object containing a list of values
130130
(use empty list to represent no values for key) and the value type associated with
131-
all values in the list. To remove all annotations set this to an empty dict `{}`."""
131+
all values in the list. To remove all annotations set this to an empty dict `{}`
132+
or None and store the entity."""
132133

133134
is_restricted: bool = field(default=False, repr=False)
134135
"""
@@ -173,7 +174,7 @@ def _set_last_persistent_instance(self) -> None:
173174
del self._last_persistent_instance
174175
self._last_persistent_instance = replace(self)
175176
self._last_persistent_instance.annotations = (
176-
deepcopy(self.annotations) if self.annotations else None
177+
deepcopy(self.annotations) if self.annotations else {}
177178
)
178179

179180
def fill_from_dict(

synapseclient/models/project.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ class Project(ProjectSynchronousProtocol, AccessControllable, StorableContainer)
5454
annotations: Additional metadata associated with the folder. The key is the name
5555
of your desired annotations. The value is an object containing a list of
5656
values (use empty list to represent no values for key) and the value type
57-
associated with all values in the list. To remove all annotations set this
58-
to an empty dict `{}`.
57+
associated with all values in the list. To remove all annotations set this
58+
to an empty dict `{}` or None and store the entity.
5959
create_or_update: (Store only) Indicates whether the method should
6060
automatically perform an update if the resource conflicts with an existing
6161
Synapse object. When True this means that any changes to the resource will
@@ -157,11 +157,12 @@ class Project(ProjectSynchronousProtocol, AccessControllable, StorableContainer)
157157
List[datetime],
158158
],
159159
]
160-
] = field(default=None, compare=False)
160+
] = field(default_factory=dict, compare=False)
161161
"""Additional metadata associated with the folder. The key is the name of your
162162
desired annotations. The value is an object containing a list of values
163163
(use empty list to represent no values for key) and the value type associated with
164-
all values in the list. To remove all annotations set this to an empty dict `{}`."""
164+
all values in the list. To remove all annotations set this to an empty dict `{}`
165+
or None and store the entity."""
165166

166167
create_or_update: bool = field(default=True, repr=False)
167168
"""
@@ -200,7 +201,7 @@ def _set_last_persistent_instance(self) -> None:
200201
del self._last_persistent_instance
201202
self._last_persistent_instance = replace(self)
202203
self._last_persistent_instance.annotations = (
203-
deepcopy(self.annotations) if self.annotations else None
204+
deepcopy(self.annotations) if self.annotations else {}
204205
)
205206

206207
def fill_from_dict(
@@ -229,7 +230,7 @@ def fill_from_dict(
229230
self.parent_id = synapse_project.get("parentId", None)
230231
if set_annotations:
231232
self.annotations = Annotations.from_dict(
232-
synapse_project.get("annotations", None)
233+
synapse_project.get("annotations", {})
233234
)
234235
return self
235236

synapseclient/models/services/storable_entity_components.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ async def _store_activity_and_annotations(
186186
)
187187
if (
188188
hasattr(root_resource, "annotations")
189-
and root_resource.annotations is not None
189+
and (root_resource.annotations or last_persistent_instance)
190190
and (
191191
last_persistent_instance is None
192192
or last_persistent_instance.annotations != root_resource.annotations

synapseclient/models/table.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import asyncio
22
import os
3-
from dataclasses import dataclass
3+
from dataclasses import dataclass, field
44
from datetime import date, datetime
55
from enum import Enum
66
from typing import Any, Dict, List, Optional, Union
@@ -376,10 +376,11 @@ class Table(TableSynchronousProtocol, AccessControllable):
376376
is_search_enabled: When creating or updating a table or view specifies if full
377377
text search should be enabled. Note that enabling full text search might
378378
slow down the indexing of the table or view.
379-
annotations: Additional metadata associated with the table. The key is the
380-
name of your desired annotations. The value is an object containing a list
381-
of values (use empty list to represent no values for key) and the value
382-
type associated with all values in the list.
379+
annotations: Additional metadata associated with the table. The key is the name
380+
of your desired annotations. The value is an object containing a list of
381+
values (use empty list to represent no values for key) and the value type
382+
associated with all values in the list. To remove all annotations set this
383+
to an empty dict `{}` or None and store the entity.
383384
384385
"""
385386

@@ -454,11 +455,12 @@ class Table(TableSynchronousProtocol, AccessControllable):
454455
List[datetime],
455456
],
456457
]
457-
] = None
458+
] = field(default_factory=dict)
458459
"""Additional metadata associated with the table. The key is the name of your
459460
desired annotations. The value is an object containing a list of values
460461
(use empty list to represent no values for key) and the value type associated with
461-
all values in the list. To remove all annotations set this to an empty dict `{}`."""
462+
all values in the list. To remove all annotations set this to an empty dict `{}`
463+
or None and store the entity."""
462464

463465
def fill_from_dict(
464466
self, synapse_table: Synapse_Table, set_annotations: bool = True
@@ -488,7 +490,7 @@ def fill_from_dict(
488490
]
489491
if set_annotations:
490492
self.annotations = Annotations.from_dict(
491-
synapse_table.get("annotations", None)
493+
synapse_table.get("annotations", {})
492494
)
493495
return self
494496

tests/integration/synapseclient/models/async/test_file_async.py

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,122 @@ async def test_store_annotations(self, project_model: Project, file: File) -> No
413413
assert file.annotations["my_key_double"] == [1.2, 3.4, 5.6]
414414
assert file.annotations["my_key_long"] == [1, 2, 3]
415415

416+
@pytest.mark.asyncio
417+
async def test_setting_annotations_directly(
418+
self, project_model: Project, file: File
419+
) -> None:
420+
# GIVEN a file with the annotation
421+
file.name = str(uuid.uuid4())
422+
file.annotations["my_single_key_string"] = "a"
423+
file.annotations["my_key_string"] = ["b", "a", "c"]
424+
file.annotations["my_key_bool"] = [False, False, False]
425+
file.annotations["my_key_double"] = [1.2, 3.4, 5.6]
426+
file.annotations["my_key_long"] = [1, 2, 3]
427+
428+
# WHEN I store the file
429+
file = await file.store_async(parent=project_model)
430+
self.schedule_for_cleanup(file.id)
431+
432+
# THEN I expect the file annotations to have been stored
433+
assert len(file.annotations.keys()) == 5
434+
assert file.annotations["my_single_key_string"] == ["a"]
435+
assert file.annotations["my_key_string"] == ["b", "a", "c"]
436+
assert file.annotations["my_key_bool"] == [False, False, False]
437+
assert file.annotations["my_key_double"] == [1.2, 3.4, 5.6]
438+
assert file.annotations["my_key_long"] == [1, 2, 3]
439+
440+
# WHEN I update the annotations and store the file again
441+
file.annotations["my_key_string"] = ["new", "values", "here"]
442+
await file.store_async()
443+
444+
# THEN I expect the file annotations to have been updated
445+
assert len(file.annotations.keys()) == 5
446+
assert file.annotations["my_single_key_string"] == ["a"]
447+
assert file.annotations["my_key_string"] == ["new", "values", "here"]
448+
assert file.annotations["my_key_bool"] == [False, False, False]
449+
assert file.annotations["my_key_double"] == [1.2, 3.4, 5.6]
450+
assert file.annotations["my_key_long"] == [1, 2, 3]
451+
452+
@pytest.mark.asyncio
453+
async def test_removing_annotations_to_none(
454+
self, project_model: Project, file: File
455+
) -> None:
456+
# GIVEN an annotation
457+
annotations_for_my_file = {
458+
"my_single_key_string": "a",
459+
"my_key_string": ["b", "a", "c"],
460+
"my_key_bool": [False, False, False],
461+
"my_key_double": [1.2, 3.4, 5.6],
462+
"my_key_long": [1, 2, 3],
463+
}
464+
465+
# AND a file with the annotation
466+
file.name = str(uuid.uuid4())
467+
file.annotations = annotations_for_my_file
468+
469+
# WHEN I store the file
470+
file = await file.store_async(parent=project_model)
471+
self.schedule_for_cleanup(file.id)
472+
473+
# THEN I expect the file annotations to have been stored
474+
assert file.annotations.keys() == annotations_for_my_file.keys()
475+
assert file.annotations["my_single_key_string"] == ["a"]
476+
assert file.annotations["my_key_string"] == ["b", "a", "c"]
477+
assert file.annotations["my_key_bool"] == [False, False, False]
478+
assert file.annotations["my_key_double"] == [1.2, 3.4, 5.6]
479+
assert file.annotations["my_key_long"] == [1, 2, 3]
480+
481+
# WHEN I update the annotations to None
482+
file.annotations = None
483+
await file.store_async()
484+
485+
# THEN I expect the file annotations to have been removed
486+
assert not file.annotations and isinstance(file.annotations, dict)
487+
488+
# AND retrieving the file gives an empty dict for the annoations
489+
file_copy = await File(id=file.id, download_file=False).get_async()
490+
assert not file_copy.annotations and isinstance(file_copy.annotations, dict)
491+
492+
@pytest.mark.asyncio
493+
async def test_removing_annotations_to_empty_dict(
494+
self, project_model: Project, file: File
495+
) -> None:
496+
# GIVEN an annotation
497+
annotations_for_my_file = {
498+
"my_single_key_string": "a",
499+
"my_key_string": ["b", "a", "c"],
500+
"my_key_bool": [False, False, False],
501+
"my_key_double": [1.2, 3.4, 5.6],
502+
"my_key_long": [1, 2, 3],
503+
}
504+
505+
# AND a file with the annotation
506+
file.name = str(uuid.uuid4())
507+
file.annotations = annotations_for_my_file
508+
509+
# WHEN I store the file
510+
file = await file.store_async(parent=project_model)
511+
self.schedule_for_cleanup(file.id)
512+
513+
# THEN I expect the file annotations to have been stored
514+
assert file.annotations.keys() == annotations_for_my_file.keys()
515+
assert file.annotations["my_single_key_string"] == ["a"]
516+
assert file.annotations["my_key_string"] == ["b", "a", "c"]
517+
assert file.annotations["my_key_bool"] == [False, False, False]
518+
assert file.annotations["my_key_double"] == [1.2, 3.4, 5.6]
519+
assert file.annotations["my_key_long"] == [1, 2, 3]
520+
521+
# WHEN I update the annotations to an empty dict
522+
file.annotations = {}
523+
await file.store_async()
524+
525+
# THEN I expect the file annotations to have been removed
526+
assert not file.annotations and isinstance(file.annotations, dict)
527+
528+
# AND retrieving the file gives an empty dict for the annoations
529+
file_copy = await File(id=file.id, download_file=False).get_async()
530+
assert not file_copy.annotations and isinstance(file_copy.annotations, dict)
531+
416532
@pytest.mark.asyncio
417533
async def test_store_without_upload(
418534
self, project_model: Project, file: File
@@ -1453,7 +1569,7 @@ async def test_copy_activity_only(self, file: File) -> None:
14531569

14541570
# THEN I expect the activities to be the same and annotations on the second to be None
14551571
assert file_1.annotations != file_2.annotations
1456-
assert file_2.annotations is None
1572+
assert not file_2.annotations and isinstance(file_2.annotations, dict)
14571573
assert file_1.activity == file_2.activity
14581574

14591575
@pytest.mark.asyncio
@@ -1505,7 +1621,7 @@ async def test_copy_with_no_activity_or_annotations(self, file: File) -> None:
15051621
# THEN I expect the activities to be the same and annotations on the second to be None
15061622
assert file_1.annotations != file_2.annotations
15071623
assert file_1.activity != file_2.activity
1508-
assert file_2.annotations is None
1624+
assert not file_2.annotations and isinstance(file_2.annotations, dict)
15091625
assert file_2.activity is None
15101626

15111627
@pytest.mark.asyncio

0 commit comments

Comments
 (0)