-
Notifications
You must be signed in to change notification settings - Fork 73
[SYNPY-1671] Adding store & delete factory method #1300
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR introduces factory-style methods store, store_async, delete, and delete_async to simplify common operations on Synapse entities. The implementation follows the pattern established by the existing get/get_async methods and provides a unified interface for storing and deleting various entity types with type-specific configuration options.
Key Changes:
- New
store/store_asyncfunctions supporting 15+ entity types with 5 specialized option classes (StoreFileOptions, StoreContainerOptions, StoreTableOptions, StoreJSONSchemaOptions, StoreGridOptions) - New
delete/delete_asyncfunctions with version-specific deletion support and clear precedence rules for version parameters - Comprehensive integration test suites covering both synchronous and asynchronous variants
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| synapseclient/operations/store_operations.py | Implements store/store_async factory methods with entity-specific handlers and option classes |
| synapseclient/operations/delete_operations.py | Implements delete/delete_async factory methods with version handling and validation |
| synapseclient/operations/init.py | Exports new functions and option classes at package level |
| tests/integration/synapseclient/operations/synchronous/test_factory_operations_store.py | Integration tests for synchronous store operations covering all supported entity types |
| tests/integration/synapseclient/operations/synchronous/test_delete_operations.py | Integration tests for synchronous delete operations including version-specific deletion |
| tests/integration/synapseclient/operations/async/test_factory_operations_store_async.py | Async variants of store operation integration tests |
| tests/integration/synapseclient/operations/async/test_delete_operations_async.py | Async variants of delete operation integration tests |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| # WHEN I delete the grid using delete | ||
| delete(stored_grid, synapse_client=self.syn) | ||
| # Grid deletion is fire-and-forget, no need to verify |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test description says "Grid deletion is fire-and-forget, no need to verify" but this is misleading - the delete operation is still being called and could potentially fail silently. Either verify the deletion succeeded or document why verification is not necessary for Grid entities specifically.
| # Grid deletion is fire-and-forget, no need to verify | |
| # For Grid entities, this test only checks that delete() can be called without raising. |
| f"Deleting a specific version requires version_only=True. " | ||
| f"Use delete('{entity}', version_only=True) to delete version {final_version}." |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error message includes a hardcoded suggestion with 'delete()' but does not specify the async variant. When this error is raised from delete_async(), the suggestion should say delete_async() instead. Consider making the function name dynamic or providing context-appropriate suggestions.
| f"Deleting a specific version requires version_only=True. " | |
| f"Use delete('{entity}', version_only=True) to delete version {final_version}." | |
| "Deleting a specific version requires version_only=True. " | |
| f"Pass version_only=True when calling this function to delete version {final_version}." |
| # Emit warning only when there's an actual version conflict (both are set and different) | ||
| if ( | ||
| version_only | ||
| and version is not None | ||
| and entity_version is not None | ||
| and version != entity_version |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The type hint for version parameter allows Union[int, str], but the comparison at line 493 version != entity_version doesn't account for type coercion (e.g., version=2 as string "2" vs entity_version=2 as int). This could cause false positive warnings. Consider normalizing both to the same type before comparison, or document that version should always be an int.
| # Emit warning only when there's an actual version conflict (both are set and different) | |
| if ( | |
| version_only | |
| and version is not None | |
| and entity_version is not None | |
| and version != entity_version | |
| # Normalize versions for comparison to avoid false conflicts between str/int | |
| normalized_version = ( | |
| int(version) if isinstance(version, str) and version.isdigit() else version | |
| ) | |
| normalized_entity_version = ( | |
| int(entity_version) | |
| if isinstance(entity_version, str) and entity_version.isdigit() | |
| else entity_version | |
| ) | |
| # Emit warning only when there's an actual version conflict (both are set and different) | |
| if ( | |
| version_only | |
| and normalized_version is not None | |
| and normalized_entity_version is not None | |
| and normalized_version != normalized_entity_version |
| # Set the entity's version_number to the final version so delete_async uses it | ||
| entity.version_number = final_version_for_entity |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The version parameter type is Union[int, str] but this is assigned directly to entity.version_number at line 516 without type conversion. If final_version_for_entity is a string, this could cause issues if version_number expects an int. Consider converting to int or clarifying the expected type.
| # Set the entity's version_number to the final version so delete_async uses it | |
| entity.version_number = final_version_for_entity | |
| # Normalize final_version_for_entity to an int before assigning | |
| if isinstance(final_version_for_entity, str): | |
| try: | |
| final_version_for_entity_int = int(final_version_for_entity) | |
| except ValueError as exc: | |
| raise ValueError( | |
| f"Invalid version value '{final_version_for_entity}'; an integer is required." | |
| ) from exc | |
| else: | |
| final_version_for_entity_int = final_version_for_entity | |
| # Set the entity's version_number to the final version so delete_async uses it | |
| entity.version_number = final_version_for_entity_int |
| if container_options and container_options.failure_strategy: | ||
| if container_options.failure_strategy == "RAISE_EXCEPTION": | ||
| failure_strategy = FailureStrategy.RAISE_EXCEPTION |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The failure_strategy validation only checks for "RAISE_EXCEPTION" but silently defaults to LOG_EXCEPTION for any other value including typos. Consider validating that the value is one of the expected values ("LOG_EXCEPTION" or "RAISE_EXCEPTION") and raising an error for invalid values.
| if container_options and container_options.failure_strategy: | |
| if container_options.failure_strategy == "RAISE_EXCEPTION": | |
| failure_strategy = FailureStrategy.RAISE_EXCEPTION | |
| if container_options and container_options.failure_strategy is not None: | |
| strategy_map = { | |
| "LOG_EXCEPTION": FailureStrategy.LOG_EXCEPTION, | |
| "RAISE_EXCEPTION": FailureStrategy.RAISE_EXCEPTION, | |
| } | |
| try: | |
| failure_strategy = strategy_map[container_options.failure_strategy] | |
| except KeyError: | |
| raise ValueError( | |
| f"Invalid failure_strategy '{container_options.failure_strategy}'. " | |
| "Valid values are 'LOG_EXCEPTION' or 'RAISE_EXCEPTION'." | |
| ) |
| async def test_store_async_dataset_collection_basic( | ||
| self, project_model: Project | ||
| ) -> None: | ||
| """Test storing a DatasetCollection entity.""" | ||
| # GIVEN a new dataset collection | ||
| dataset_collection = DatasetCollection( | ||
| name=f"test_dataset_collection_{str(uuid.uuid4())[:8]}", | ||
| description="Test dataset collection for store factory operations", | ||
| parent_id=project_model.id, | ||
| include_default_columns=False, | ||
| ) | ||
|
|
||
| # WHEN I store the dataset collection using store_async | ||
| stored_collection = await store_async( | ||
| dataset_collection, synapse_client=self.syn | ||
| ) | ||
| self.schedule_for_cleanup(stored_collection.id) | ||
|
|
||
| # THEN the dataset collection is created with all fields | ||
| assert stored_collection.id is not None | ||
| assert stored_collection.name == dataset_collection.name | ||
| assert stored_collection.description == dataset_collection.description | ||
| assert stored_collection.parent_id == project_model.id | ||
| assert stored_collection.etag is not None | ||
|
|
||
| # WHEN I delete the dataset collection using delete_async | ||
| await delete_async(stored_collection, synapse_client=self.syn) | ||
|
|
||
| # THEN the dataset collection should no longer be retrievable | ||
| with pytest.raises(Exception): | ||
| await DatasetCollection(id=stored_collection.id).get_async( | ||
| synapse_client=self.syn | ||
| ) |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test creates but never cleans up the DatasetCollection on line 462. The DatasetCollection is stored and then deleted on line 462, but if the deletion fails, cleanup won't be scheduled. Schedule cleanup immediately after storing (line 452) to ensure proper cleanup even if subsequent operations fail.
| if isinstance(entity, (File, RecordSet)): | ||
| return await _handle_store_file_entity( | ||
| entity=entity, | ||
| parent=parent, | ||
| file_options=file_options, | ||
| synapse_client=synapse_client, | ||
| ) |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The handling of RecordSet entities treats them as File entities (line 781), but RecordSet is conceptually different and may have different requirements. Verify that all file_options parameters are appropriate for RecordSet entities.
| if file_options: | ||
| if file_options.synapse_store is not None: | ||
| entity.synapse_store = file_options.synapse_store | ||
| if file_options.content_type is not None: | ||
| entity.content_type = file_options.content_type | ||
| if file_options.merge_existing_annotations is not None: | ||
| entity.merge_existing_annotations = file_options.merge_existing_annotations | ||
| if file_options.associate_activity_to_new_version is not None: | ||
| entity.associate_activity_to_new_version = ( | ||
| file_options.associate_activity_to_new_version | ||
| ) | ||
|
|
||
| return await entity.store_async(parent=parent, synapse_client=synapse_client) |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function modifies the entity's attributes (lines 119-127) in place before calling store_async. If store_async fails, the entity object will be left in a modified state. Consider whether this side effect is acceptable or if options should be passed through to store_async instead.
| if file_options: | |
| if file_options.synapse_store is not None: | |
| entity.synapse_store = file_options.synapse_store | |
| if file_options.content_type is not None: | |
| entity.content_type = file_options.content_type | |
| if file_options.merge_existing_annotations is not None: | |
| entity.merge_existing_annotations = file_options.merge_existing_annotations | |
| if file_options.associate_activity_to_new_version is not None: | |
| entity.associate_activity_to_new_version = ( | |
| file_options.associate_activity_to_new_version | |
| ) | |
| return await entity.store_async(parent=parent, synapse_client=synapse_client) | |
| original_values = {} | |
| if file_options: | |
| if file_options.synapse_store is not None: | |
| original_values["synapse_store"] = entity.synapse_store | |
| entity.synapse_store = file_options.synapse_store | |
| if file_options.content_type is not None: | |
| original_values["content_type"] = entity.content_type | |
| entity.content_type = file_options.content_type | |
| if file_options.merge_existing_annotations is not None: | |
| original_values["merge_existing_annotations"] = ( | |
| entity.merge_existing_annotations | |
| ) | |
| entity.merge_existing_annotations = file_options.merge_existing_annotations | |
| if file_options.associate_activity_to_new_version is not None: | |
| original_values["associate_activity_to_new_version"] = ( | |
| entity.associate_activity_to_new_version | |
| ) | |
| entity.associate_activity_to_new_version = ( | |
| file_options.associate_activity_to_new_version | |
| ) | |
| try: | |
| return await entity.store_async(parent=parent, synapse_client=synapse_client) | |
| except Exception: | |
| # Restore original attribute values if store_async fails | |
| for attr_name, original_value in original_values.items(): | |
| setattr(entity, attr_name, original_value) | |
| raise |
| # scope_ids may have numeric strings without 'syn' prefix | ||
| assert len(stored_view.scope_ids) == 1 | ||
| # Check if it matches with or without the 'syn' prefix | ||
| scope_id = stored_view.scope_ids[0] | ||
| assert scope_id == project_model.id or scope_id == project_model.id.replace( | ||
| "syn", "" | ||
| ) |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Test assertions at lines 514-516 check for exact ID match or ID without 'syn' prefix. However, the comment "scope_ids may have numeric strings without 'syn' prefix" suggests API inconsistency. This fragile assertion could fail if API behavior changes. Consider using a more robust assertion that normalizes IDs before comparison.
| from synapseclient.core.utils import is_synapse_id_str | ||
|
|
||
| if TYPE_CHECKING: | ||
| from synapseclient import Synapse |
Copilot
AI
Jan 2, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Import of 'Synapse' is not used.
| from synapseclient import Synapse |
Problem:
synapseclientlibrary requires users to instantiate entity classes and call methods on those instances for common operations like storing and deleting, which creates unnecessary friction.get/get_asyncimplementation, additional methods need to be exposed as standalone functions for a more streamlined developer experience.storeanddelete.Solution:
get/get_async:delete/delete_async: Unified interface for deleting any Synapse entity type (File, Folder, Project, Table, Dataset, Team, etc.) with support for:"syn123456"or"syn123456.4")versionparameter > entity'sversion_numberattribute > ID string version)version_only=Truewhen deleting specific versionsstore/store_async: Unified interface for storing any Synapse entity type with type-specific option classes:StoreFileOptions: Controls forsynapse_store,content_type,merge_existing_annotations,associate_activity_to_new_versionStoreContainerOptions: Controls forfailure_strategy(LOG_EXCEPTION vs RAISE_EXCEPTION)StoreTableOptions: Controls fordry_runandjob_timeoutStoreJSONSchemaOptions: Required options for JSONSchema entities includingschema_body,version,dry_runStoreGridOptions: Controls forattach_to_previous_sessionandtimeoutsynapseclient/operations/__init__.pyto expose all new functions and option classes at the package level.Testing:
test_delete_operations_async.py/test_delete_operations.py: Tests for file deletion by ID string and object, version-specific deletion with various precedence scenarios, error handling for invalid IDs and missing version numbers, warning logging for unsupported version deletion on entity types like Project/Foldertest_factory_operations_store_async.py/test_factory_operations_store.py: Tests for storing all supported entity types (Project, Folder, File, Table, Dataset, EntityView, Team, Evaluation, CurationTask, JSONSchema, Grid, etc.), option class functionality, update workflows, and error handling for unsupported types