Skip to content

Conversation

@BryanFauble
Copy link
Member

Problem:

  • The synapseclient library requires users to instantiate entity classes and call methods on those instances for common operations like storing and deleting, which creates unnecessary friction.
  • Following the factory-style pattern established with the get/get_async implementation, additional methods need to be exposed as standalone functions for a more streamlined developer experience.
  • This PR addresses a subset of the methods identified in the Jira ticket: store and delete.

Solution:

  • Implemented new factory functions following the pattern established by get/get_async:
    • delete / delete_async: Unified interface for deleting any Synapse entity type (File, Folder, Project, Table, Dataset, Team, etc.) with support for:
      • Deletion by entity object or string Synapse ID (e.g., "syn123456" or "syn123456.4")
      • Version-specific deletion with clear precedence rules (explicit version parameter > entity's version_number attribute > ID string version)
      • Safety validation requiring version_only=True when deleting specific versions
      • Appropriate warnings for entity types that don't support version-specific deletion
    • store / store_async: Unified interface for storing any Synapse entity type with type-specific option classes:
      • StoreFileOptions: Controls for synapse_store, content_type, merge_existing_annotations, associate_activity_to_new_version
      • StoreContainerOptions: Controls for failure_strategy (LOG_EXCEPTION vs RAISE_EXCEPTION)
      • StoreTableOptions: Controls for dry_run and job_timeout
      • StoreJSONSchemaOptions: Required options for JSONSchema entities including schema_body, version, dry_run
      • StoreGridOptions: Controls for attach_to_previous_session and timeout
  • Updated synapseclient/operations/__init__.py to expose all new functions and option classes at the package level.

Testing:

  • Added comprehensive integration test suites covering both synchronous and asynchronous variants:
    • 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/Folder
    • test_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

@BryanFauble BryanFauble requested a review from a team as a code owner January 2, 2026 22:23
@BryanFauble BryanFauble requested review from a team and Copilot and removed request for a team January 2, 2026 22:29
Copy link
Contributor

Copilot AI left a 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_async functions supporting 15+ entity types with 5 specialized option classes (StoreFileOptions, StoreContainerOptions, StoreTableOptions, StoreJSONSchemaOptions, StoreGridOptions)
  • New delete/delete_async functions 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
Copy link

Copilot AI Jan 2, 2026

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.

Suggested change
# Grid deletion is fire-and-forget, no need to verify
# For Grid entities, this test only checks that delete() can be called without raising.

Copilot uses AI. Check for mistakes.
Comment on lines +467 to +468
f"Deleting a specific version requires version_only=True. "
f"Use delete('{entity}', version_only=True) to delete version {final_version}."
Copy link

Copilot AI Jan 2, 2026

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.

Suggested change
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}."

Copilot uses AI. Check for mistakes.
Comment on lines +488 to +493
# 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
Copy link

Copilot AI Jan 2, 2026

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.

Suggested change
# 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

Copilot uses AI. Check for mistakes.
Comment on lines +515 to +516
# Set the entity's version_number to the final version so delete_async uses it
entity.version_number = final_version_for_entity
Copy link

Copilot AI Jan 2, 2026

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.

Suggested change
# 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

Copilot uses AI. Check for mistakes.
Comment on lines +145 to +147
if container_options and container_options.failure_strategy:
if container_options.failure_strategy == "RAISE_EXCEPTION":
failure_strategy = FailureStrategy.RAISE_EXCEPTION
Copy link

Copilot AI Jan 2, 2026

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.

Suggested change
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'."
)

Copilot uses AI. Check for mistakes.
Comment on lines +436 to +468
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
)
Copy link

Copilot AI Jan 2, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +781 to +787
if isinstance(entity, (File, RecordSet)):
return await _handle_store_file_entity(
entity=entity,
parent=parent,
file_options=file_options,
synapse_client=synapse_client,
)
Copy link

Copilot AI Jan 2, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +117 to +129
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)
Copy link

Copilot AI Jan 2, 2026

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +510 to +516
# 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", ""
)
Copy link

Copilot AI Jan 2, 2026

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.

Copilot uses AI. Check for mistakes.
from synapseclient.core.utils import is_synapse_id_str

if TYPE_CHECKING:
from synapseclient import Synapse
Copy link

Copilot AI Jan 2, 2026

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.

Suggested change
from synapseclient import Synapse

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants