From cfd40def52bd5624acad38db3c4c6432a9e51e0d Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 4 Aug 2025 10:51:55 +0400 Subject: [PATCH 1/2] Initial commite for interface change --- docs/source/api_reference/api_client.rst | 1 + docs/source/api_reference/api_managers.rst | 246 +++++++++++ docs/source/api_reference/index.rst | 1 + docs/source/userguide/index.rst | 2 + docs/source/userguide/managers.rst | 414 ++++++++++++++++++ .../userguide/migration_to_managers.rst | 378 ++++++++++++++++ docs/source/userguide/quickstart.rst | 33 ++ .../work_management/test_groups.py | 228 ++++++++++ 8 files changed, 1303 insertions(+) create mode 100644 docs/source/api_reference/api_managers.rst create mode 100644 docs/source/userguide/managers.rst create mode 100644 docs/source/userguide/migration_to_managers.rst create mode 100644 tests/integration/work_management/test_groups.py diff --git a/docs/source/api_reference/api_client.rst b/docs/source/api_reference/api_client.rst index 4e2db478c..c2a6220fd 100644 --- a/docs/source/api_reference/api_client.rst +++ b/docs/source/api_reference/api_client.rst @@ -8,6 +8,7 @@ Contents .. toctree:: :maxdepth: 8 + api_managers api_project api_folder api_item diff --git a/docs/source/api_reference/api_managers.rst b/docs/source/api_reference/api_managers.rst new file mode 100644 index 000000000..f7bb8943c --- /dev/null +++ b/docs/source/api_reference/api_managers.rst @@ -0,0 +1,246 @@ +======== +Managers +======== + +The SAClient provides manager interfaces for organized access to different resource types. +Managers group related functionality together and provide a cleaner, more intuitive API structure. + +Overview +======== + +The manager pattern organizes SAClient functionality into domain-specific managers: + +* **Projects Manager** - Handle project operations (create, search, clone, etc.) +* **Folders Manager** - Handle folder operations (create, list, delete, etc.) +* **Items Manager** - Handle item operations (list, attach, delete, etc.) +* **Annotations Manager** - Handle annotation operations (upload, get, delete, etc.) +* **Users Manager** - Handle user operations (list, get metadata, manage permissions, etc.) + +Usage +===== + +You can access managers through the SAClient instance: + +.. code-block:: python + + from superannotate import SAClient + + client = SAClient() + + # Using managers + project = client.projects.create("My Project", "Description", "Vector") + items = client.items.list("My Project") + users = client.users.list(project="My Project") + + # Traditional methods still work for backward compatibility + project = client.create_project("My Project", "Description", "Vector") + items = client.list_items("My Project") + users = client.list_users(project="My Project") + +Benefits +======== + +* **Better Organization** - Related methods are grouped together +* **Cleaner Interface** - Easier to discover and use related functionality +* **Backward Compatibility** - Existing code continues to work unchanged +* **Extensibility** - Easy to add new methods to specific domains + +Projects Manager +================ + +.. autoclass:: superannotate.lib.app.interface.sdk_interface.ProjectsManager + :members: + :undoc-members: + :show-inheritance: + +**Available Methods:** + +* ``create(project_name, project_description, project_type, ...)`` - Create a new project +* ``list(name, return_metadata, include_complete_item_count, status)`` - List/search for projects +* ``clone(project_name, from_project, ...)`` - Clone an existing project +* ``delete(project)`` - Delete a project +* ``rename(project, new_name)`` - Rename a project + +Folders Manager +=============== + +.. autoclass:: superannotate.lib.app.interface.sdk_interface.FoldersManager + :members: + :undoc-members: + :show-inheritance: + +**Available Methods:** + +* ``create(project, folder_name)`` - Create a new folder in a project + +Items Manager +============= + +.. autoclass:: superannotate.lib.app.interface.sdk_interface.ItemsManager + :members: + :undoc-members: + :show-inheritance: + +**Available Methods:** + +* ``list(project, folder, include, **filters)`` - List items with filtering +* ``attach(project, attachments, annotation_status)`` - Attach items to a project + +Annotations Manager +=================== + +.. autoclass:: superannotate.lib.app.interface.sdk_interface.AnnotationsManager + :members: + :undoc-members: + :show-inheritance: + +**Available Methods:** + +* ``upload(project, annotations, keep_status, data_spec)`` - Upload annotations +* ``get(project, items, data_spec)`` - Get annotations for items + +Users Manager +============= + +.. autoclass:: superannotate.lib.app.interface.sdk_interface.UsersManager + :members: + :undoc-members: + :show-inheritance: + +**Available Methods:** + +* ``list(project, include, **filters)`` - List users with filtering +* ``get_metadata(pk, include)`` - Get user metadata +* ``invite_to_team(emails, admin)`` - Invite contributors to team +* ``add_to_project(project, emails, role)`` - Add contributors to project +* ``search_team_contributors(email, first_name, last_name, return_metadata)`` - Search team contributors + +Migration Guide +=============== + +Existing code using direct SAClient methods will continue to work without changes. +However, you can gradually migrate to the manager interface for better organization: + +**Before (still works):** + +.. code-block:: python + + client = SAClient() + + # Direct methods + project = client.create_project("My Project", "Description", "Vector") + items = client.list_items("My Project") + annotations = client.get_annotations("My Project", ["item1.jpg"]) + users = client.list_users(project="My Project") + +**After (recommended):** + +.. code-block:: python + + client = SAClient() + + # Using managers + project = client.projects.create("My Project", "Description", "Vector") + items = client.items.list("My Project") + annotations = client.annotations.get("My Project", ["item1.jpg"]) + users = client.users.list(project="My Project") + +Examples +======== + +Creating and Managing Projects +------------------------------ + +.. code-block:: python + + from superannotate import SAClient + + client = SAClient() + + # Create a new project + project = client.projects.create( + project_name="Medical Imaging", + project_description="Medical image annotation project", + project_type="Vector" + ) + + # List projects + projects = client.projects.list( + name="Medical", + return_metadata=True, + status="InProgress" + ) + + # Clone an existing project + cloned_project = client.projects.clone( + project_name="Medical Imaging Copy", + from_project="Medical Imaging", + copy_annotation_classes=True, + copy_settings=True + ) + +Working with Items +------------------ + +.. code-block:: python + + # List items with filtering + items = client.items.list( + project="Medical Imaging", + folder="scans", + include=["custom_metadata"], + annotation_status="InProgress" + ) + + # Attach new items + uploaded, failed, duplicated = client.items.attach( + project="Medical Imaging", + attachments=[ + {"name": "scan1.jpg", "url": "https://example.com/scan1.jpg"}, + {"name": "scan2.jpg", "url": "https://example.com/scan2.jpg"} + ] + ) + +Managing Annotations +-------------------- + +.. code-block:: python + + # Upload annotations + result = client.annotations.upload( + project="Medical Imaging/scans", + annotations=[ + { + "metadata": {"name": "scan1.jpg"}, + "instances": [ + {"type": "bbox", "className": "tumor", "points": {...}} + ] + } + ], + data_spec="multimodal" + ) + + # Get annotations + annotations = client.annotations.get( + project="Medical Imaging/scans", + items=["scan1.jpg", "scan2.jpg"], + data_spec="multimodal" + ) + +User Management +--------------- + +.. code-block:: python + + # List project users with custom fields + users = client.users.list( + project="Medical Imaging", + include=["custom_fields"], + role="Annotator" + ) + + # Get specific user metadata + user = client.users.get_metadata( + "annotator@example.com", + include=["custom_fields"] + ) diff --git a/docs/source/api_reference/index.rst b/docs/source/api_reference/index.rst index 7ce246c14..6dfb40362 100644 --- a/docs/source/api_reference/index.rst +++ b/docs/source/api_reference/index.rst @@ -10,5 +10,6 @@ Contents :maxdepth: 2 api_client + api_managers api_metadata helpers diff --git a/docs/source/userguide/index.rst b/docs/source/userguide/index.rst index 018fbcb23..534576a4d 100644 --- a/docs/source/userguide/index.rst +++ b/docs/source/userguide/index.rst @@ -12,5 +12,7 @@ Contents :maxdepth: 1 quickstart + managers + migration_to_managers setup_project utilities diff --git a/docs/source/userguide/managers.rst b/docs/source/userguide/managers.rst new file mode 100644 index 000000000..fd5493300 --- /dev/null +++ b/docs/source/userguide/managers.rst @@ -0,0 +1,414 @@ +================ +Using Managers +================ + +The SuperAnnotate Python SDK provides a manager-based interface that organizes functionality into logical groups. This guide explains how to use managers effectively. + +What are Managers? +================== + +Managers are specialized classes that group related functionality together. Instead of having all methods directly on the SAClient, managers organize methods by domain: + +* **Projects Manager** (``client.projects``) - Project operations +* **Folders Manager** (``client.folders``) - Folder operations +* **Items Manager** (``client.items``) - Item operations +* **Annotations Manager** (``client.annotations``) - Annotation operations +* **Users Manager** (``client.users``) - User operations + +Why Use Managers? +================= + +**Better Organization** + Related methods are grouped together, making the API easier to navigate and understand. + +**Cleaner Interface** + Instead of searching through dozens of methods on SAClient, you can focus on the specific domain you're working with. + +**Backward Compatibility** + All existing SAClient methods continue to work exactly as before. + +**Discoverability** + IDE autocompletion works better when methods are organized by domain. + +Getting Started +=============== + +Basic Usage +----------- + +.. code-block:: python + + from superannotate import SAClient + + client = SAClient() + + # Access managers through the client + projects_manager = client.projects + items_manager = client.items + users_manager = client.users + +Manager vs Direct Methods +------------------------- + +You can use either approach - they're functionally equivalent: + +.. code-block:: python + + # Using managers (recommended) + project = client.projects.create("My Project", "Description", "Vector") + items = client.items.list("My Project") + + # Using direct methods (still supported) + project = client.create_project("My Project", "Description", "Vector") + items = client.list_items("My Project") + +Projects Manager +================ + +The Projects Manager handles all project-related operations. + +Creating Projects +----------------- + +.. code-block:: python + + # Create a basic project + project = client.projects.create( + project_name="Medical Imaging", + project_description="Medical image annotation project", + project_type="Vector" + ) + + # Create a project with settings and classes + project = client.projects.create( + project_name="Advanced Project", + project_description="Project with custom settings", + project_type="Pixel", + settings=[ + {"attribute": "image_quality", "value": "original"} + ], + classes=[ + {"name": "tumor", "color": "#FF0000", "type": "bbox"} + ] + ) + +Listing Projects +---------------- + +.. code-block:: python + + # List by name + projects = client.projects.list(name="Medical") + + # Get project metadata + projects = client.projects.list( + name="Medical", + return_metadata=True, + status="InProgress" + ) + + # Get all projects + all_projects = client.projects.list() + +Cloning Projects +---------------- + +.. code-block:: python + + # Clone a project with all settings + cloned_project = client.projects.clone( + project_name="Medical Imaging Copy", + from_project="Medical Imaging", + copy_annotation_classes=True, + copy_settings=True, + copy_contributors=True + ) + +Managing Projects +----------------- + +.. code-block:: python + + # Delete a project + client.projects.delete("Old Project") + + # Rename a project + client.projects.rename("Old Name", "New Name") + +Items Manager +============= + +The Items Manager handles item-related operations like listing, attaching, and managing items. + +Listing Items +------------- + +.. code-block:: python + + # List all items in a project + items = client.items.list("My Project") + + # List items in a specific folder + items = client.items.list("My Project", folder="subfolder") + + # List items with filtering + items = client.items.list( + project="My Project", + annotation_status="InProgress", + name__contains="scan" + ) + + # Include custom metadata + items = client.items.list( + project="My Project", + include=["custom_metadata"] + ) + +Attaching Items +--------------- + +.. code-block:: python + + # Attach items from URLs + uploaded, failed, duplicated = client.items.attach( + project="My Project", + attachments=[ + {"name": "image1.jpg", "url": "https://example.com/image1.jpg"}, + {"name": "image2.jpg", "url": "https://example.com/image2.jpg"} + ] + ) + + # Attach items from CSV file + uploaded, failed, duplicated = client.items.attach( + project="My Project", + attachments="path/to/attachments.csv" + ) + +Annotations Manager +=================== + +The Annotations Manager handles uploading, downloading, and managing annotations. + +Uploading Annotations +--------------------- + +.. code-block:: python + + # Upload annotations + result = client.annotations.upload( + project="My Project/folder", + annotations=[ + { + "metadata": {"name": "image1.jpg"}, + "instances": [ + { + "type": "bbox", + "className": "person", + "points": {"x1": 100, "y1": 100, "x2": 200, "y2": 200} + } + ] + } + ] + ) + + # Upload for multimodal projects + result = client.annotations.upload( + project="My Multimodal Project", + annotations=annotations_list, + data_spec="multimodal" + ) + +Getting Annotations +------------------- + +.. code-block:: python + + # Get annotations for specific items + annotations = client.annotations.get( + project="My Project", + items=["image1.jpg", "image2.jpg"] + ) + + # Get annotations for multimodal projects + annotations = client.annotations.get( + project="My Multimodal Project", + items=["item1", "item2"], + data_spec="multimodal" + ) + +Users Manager +============= + +The Users Manager handles user-related operations like listing users and managing permissions. + +Listing Users +------------- + +.. code-block:: python + + # List all team users + users = client.users.list() + + # List project users + users = client.users.list(project="My Project") + + # List users with custom fields + users = client.users.list( + include=["custom_fields"], + role="Annotator" + ) + + # Filter users + users = client.users.list( + email__contains="@company.com", + state="Confirmed" + ) + +Getting User Metadata +--------------------- + +.. code-block:: python + + # Get user by email + user = client.users.get_metadata("user@example.com") + + # Get user with custom fields + user = client.users.get_metadata( + "user@example.com", + include=["custom_fields"] + ) + +Managing Contributors +--------------------- + +.. code-block:: python + + # Invite users to team + invited, skipped = client.users.invite_to_team( + emails=["user1@example.com", "user2@example.com"], + admin=False + ) + + # Add contributors to project + added, skipped = client.users.add_to_project( + project="My Project", + emails=["annotator@example.com"], + role="Annotator" + ) + + # Search team contributors + contributors = client.users.search_team_contributors( + email="@company.com", + return_metadata=True + ) + +Folders Manager +=============== + +The Folders Manager handles folder operations within projects. + +Creating Folders +---------------- + +.. code-block:: python + + # Create a folder in a project + folder = client.folders.create("My Project", "new_folder") + +Best Practices +============== + +Consistent Usage +---------------- + +Choose one approach and stick with it throughout your codebase: + +.. code-block:: python + + # Good: Consistent manager usage + client.projects.create(...) + client.items.list(...) + client.annotations.upload(...) + + # Good: Consistent direct method usage + client.create_project(...) + client.list_items(...) + client.upload_annotations(...) + + # Avoid: Mixing approaches unnecessarily + client.projects.create(...) + client.list_items(...) # inconsistent + +Error Handling +-------------- + +Error handling works the same way with managers: + +.. code-block:: python + + try: + project = client.projects.create("My Project", "Description", "Vector") + items = client.items.list("My Project") + except Exception as e: + print(f"Error: {e}") + +IDE Support +----------- + +Managers provide better IDE autocompletion and documentation: + +.. code-block:: python + + # Type 'client.projects.' and your IDE will show: + # - create() + # - search() + # - clone() + + # Type 'client.items.' and your IDE will show: + # - list() + # - attach() + +Migration Guide +=============== + +If you have existing code using direct SAClient methods, you don't need to change anything. However, if you want to migrate to managers: + +**Step 1: Identify the domain** + +.. code-block:: python + + # Project operations → client.projects + client.create_project(...) → client.projects.create(...) + client.search_projects(...) → client.projects.search(...) + + # Item operations → client.items + client.list_items(...) → client.items.list(...) + client.attach_items(...) → client.items.attach(...) + + # User operations → client.users + client.list_users(...) → client.users.list(...) + client.get_user_metadata(...) → client.users.get_metadata(...) + +**Step 2: Update method calls** + +The parameters remain exactly the same, just the calling pattern changes: + +.. code-block:: python + + # Before + items = client.list_items( + project="My Project", + folder="subfolder", + annotation_status="InProgress" + ) + + # After + items = client.items.list( + project="My Project", + folder="subfolder", + annotation_status="InProgress" + ) + +**Step 3: Test thoroughly** + +Since the underlying implementation is the same, behavior should be identical, but always test your changes. diff --git a/docs/source/userguide/migration_to_managers.rst b/docs/source/userguide/migration_to_managers.rst new file mode 100644 index 000000000..4c0ecde0d --- /dev/null +++ b/docs/source/userguide/migration_to_managers.rst @@ -0,0 +1,378 @@ +======================= +Migration to Managers +======================= + +This guide helps you migrate from direct SAClient methods to the new manager-based interface. + +Why Migrate? +============ + +While direct methods continue to work, the manager interface provides: + +* **Better Organization** - Related methods grouped together +* **Improved Discoverability** - Easier to find relevant functionality +* **Cleaner Code** - More intuitive API structure +* **Better IDE Support** - Enhanced autocompletion and documentation + +Migration is Optional +===================== + +**Important**: You don't need to migrate existing code. All direct methods continue to work exactly as before. This guide is for those who want to adopt the new manager interface. + +Quick Reference +=============== + +Here's a quick mapping of common methods: + +Projects +-------- + +.. code-block:: python + + # Before + client.create_project(name, desc, type) + client.search_projects(name="test") + client.clone_project(new_name, from_project) + client.delete_project(project) + client.rename_project(project, new_name) + + # After + client.projects.create(name, desc, type) + client.projects.list(name="test") + client.projects.clone(new_name, from_project) + client.projects.delete(project) + client.projects.rename(project, new_name) + +Items +----- + +.. code-block:: python + + # Before + client.list_items(project, folder="test") + client.attach_items(project, attachments) + client.delete_items(project, items) + + # After + client.items.list(project, folder="test") + client.items.attach(project, attachments) + client.delete_items(project, items) # No manager equivalent yet + +Annotations +----------- + +.. code-block:: python + + # Before + client.upload_annotations(project, annotations) + client.get_annotations(project, items) + client.download_annotations(project, folder) + + # After + client.annotations.upload(project, annotations) + client.annotations.get(project, items) + client.download_annotations(project, folder) # No manager equivalent yet + +Users +----- + +.. code-block:: python + + # Before + client.list_users(project="test") + client.get_user_metadata(email) + client.invite_contributors_to_team(emails) + client.add_contributors_to_project(project, emails, role) + client.search_team_contributors(email="test") + + # After + client.users.list(project="test") + client.users.get_metadata(email) + client.users.invite_to_team(emails) + client.users.add_to_project(project, emails, role) + client.users.search_team_contributors(email="test") + +Folders +------- + +.. code-block:: python + + # Before + client.create_folder(project, folder_name) + client.search_folders(project, name="test") + + # After + client.folders.create(project, folder_name) + client.search_folders(project, name="test") # No manager equivalent yet + +Step-by-Step Migration +====================== + +Step 1: Identify Manager Categories +----------------------------------- + +Group your existing code by functionality: + +.. code-block:: python + + # Project operations + project = client.create_project("Test", "Description", "Vector") + projects = client.search_projects(name="Test") + + # Item operations + items = client.list_items("Test Project") + client.attach_items("Test Project", attachments) + + # User operations + users = client.list_users(project="Test Project") + user = client.get_user_metadata("user@example.com") + +Step 2: Replace Method Calls +---------------------------- + +Update the method calls to use managers: + +.. code-block:: python + + # Project operations → client.projects + project = client.projects.create("Test", "Description", "Vector") + projects = client.projects.list(name="Test") + + # Item operations → client.items + items = client.items.list("Test Project") + client.items.attach("Test Project", attachments) + + # User operations → client.users + users = client.users.list(project="Test Project") + user = client.users.get_metadata("user@example.com") + +Step 3: Test Thoroughly +----------------------- + +Since the underlying implementation is identical, behavior should be the same, but always test: + +.. code-block:: python + + # Test that results are identical + old_result = client.list_items("Test Project") + new_result = client.items.list("Test Project") + assert old_result == new_result + +Common Migration Patterns +========================= + +Pattern 1: Project Workflow +--------------------------- + +.. code-block:: python + + # Before + def setup_project(client, name, description): + project = client.create_project(name, description, "Vector") + client.create_folder(name, "training") + client.create_folder(name, "validation") + return project + + # After + def setup_project(client, name, description): + project = client.projects.create(name, description, "Vector") + client.folders.create(name, "training") + client.folders.create(name, "validation") + return project + +Pattern 2: Data Processing Pipeline +----------------------------------- + +.. code-block:: python + + # Before + def process_annotations(client, project_name): + items = client.list_items(project_name, annotation_status="Completed") + annotations = client.get_annotations(project_name, [item["name"] for item in items]) + return annotations + + # After + def process_annotations(client, project_name): + items = client.items.list(project_name, annotation_status="Completed") + annotations = client.annotations.get(project_name, [item["name"] for item in items]) + return annotations + +Pattern 3: User Management +-------------------------- + +.. code-block:: python + + # Before + def get_project_contributors(client, project_name): + users = client.list_users(project=project_name) + return [client.get_user_metadata(user["email"]) for user in users] + + # After + def get_project_contributors(client, project_name): + users = client.users.list(project=project_name) + return [client.users.get_metadata(user["email"]) for user in users] + +Gradual Migration Strategy +========================== + +You don't need to migrate everything at once. Consider this approach: + +Phase 1: New Code Only +---------------------- + +Use managers for all new code while leaving existing code unchanged: + +.. code-block:: python + + # Existing code - leave as is + def legacy_function(client): + return client.create_project("Old Project", "Description", "Vector") + + # New code - use managers + def new_function(client): + return client.projects.create("New Project", "Description", "Vector") + +Phase 2: Module by Module +------------------------- + +Migrate one module or file at a time: + +.. code-block:: python + + # project_utils.py - migrated + def create_training_project(client, name): + project = client.projects.create(name, "Training project", "Vector") + client.folders.create(name, "images") + return project + + # annotation_utils.py - not yet migrated + def upload_training_data(client, project, annotations): + return client.upload_annotations(project, annotations) + +Phase 3: Complete Migration +--------------------------- + +Eventually migrate all code for consistency: + +.. code-block:: python + + # All code now uses managers consistently + def create_and_populate_project(client, name, attachments, annotations): + project = client.projects.create(name, "Auto-generated", "Vector") + client.items.attach(name, attachments) + client.annotations.upload(name, annotations) + return project + +Best Practices for Migration +============================ + +1. **Test Equivalence** + + .. code-block:: python + + # Verify identical behavior + old_result = client.list_items("Test") + new_result = client.items.list("Test") + assert old_result == new_result + +2. **Update Documentation** + + .. code-block:: python + + def process_project(client, project_name): + """Process project using manager interface. + + Args: + client: SAClient instance + project_name: Name of the project + + Returns: + List of processed items + """ + return client.items.list(project_name, annotation_status="Completed") + +3. **Use Consistent Style** + + .. code-block:: python + + # Good: Consistent manager usage + project = client.projects.create(...) + items = client.items.list(...) + users = client.users.list(...) + + # Avoid: Mixing styles in same function + project = client.projects.create(...) + items = client.list_items(...) # inconsistent + +4. **Handle Errors Consistently** + + .. code-block:: python + + try: + project = client.projects.create("Test", "Description", "Vector") + items = client.items.list("Test") + except Exception as e: + logger.error(f"Failed to setup project: {e}") + +Troubleshooting +=============== + +Method Not Available in Manager +------------------------------- + +Some methods may not yet have manager equivalents. Continue using direct methods: + +.. code-block:: python + + # Use manager when available + project = client.projects.create("Test", "Description", "Vector") + + # Use direct method when manager equivalent doesn't exist + client.set_project_status("Test", "InProgress") + +Import Errors +------------- + +Make sure you're importing from the correct location: + +.. code-block:: python + + # Correct + from superannotate import SAClient + client = SAClient() + client.projects.create(...) + + # Incorrect - managers are not separate imports + # from superannotate import ProjectsManager # This won't work + +Performance Considerations +========================== + +Managers have identical performance to direct methods since they use the same underlying implementation: + +.. code-block:: python + + import time + + # Both approaches have identical performance + start = time.time() + result1 = client.list_items("Test Project") + time1 = time.time() - start + + start = time.time() + result2 = client.items.list("Test Project") + time2 = time.time() - start + + # time1 ≈ time2 + +Conclusion +========== + +Migration to managers is optional but recommended for new projects. The manager interface provides better organization and discoverability while maintaining full backward compatibility with existing code. + +Key takeaways: + +* **No breaking changes** - existing code continues to work +* **Gradual migration** - migrate at your own pace +* **Identical functionality** - same features, better organization +* **Better developer experience** - improved IDE support and code organization diff --git a/docs/source/userguide/quickstart.rst b/docs/source/userguide/quickstart.rst index 144557f56..4d848d356 100644 --- a/docs/source/userguide/quickstart.rst +++ b/docs/source/userguide/quickstart.rst @@ -91,6 +91,35 @@ Custom config.ini example: ---------- +Using Managers (Recommended) +============================= + +The SDK provides manager interfaces that organize functionality into logical groups: + +.. code-block:: python + + from superannotate import SAClient + + sa = SAClient() + + # Using managers for better organization + project = sa.projects.create("Example Project 1", "test", "Vector") + items = sa.items.list("Example Project 1") + users = sa.users.list(project="Example Project 1") + +Available managers: + +* ``sa.projects`` - Project operations (create, list, clone, delete, rename) +* ``sa.folders`` - Folder operations (create, list, delete) +* ``sa.items`` - Item operations (list, attach, delete) +* ``sa.annotations`` - Annotation operations (upload, get, delete) +* ``sa.users`` - User operations (list, get metadata, invite, add to projects) + +For detailed information, see the :doc:`managers` guide. + +---------- + + Creating a project ================== @@ -101,6 +130,10 @@ To create a new "Vector" project with name "Example Project 1" and description project = "Example Project 1" + # Using managers (recommended) + sa.projects.create(project, "test", "Vector") + + # Or using direct methods (still supported) sa.create_project(project, "test", "Vector") ---------- diff --git a/tests/integration/work_management/test_groups.py b/tests/integration/work_management/test_groups.py new file mode 100644 index 000000000..a8e719589 --- /dev/null +++ b/tests/integration/work_management/test_groups.py @@ -0,0 +1,228 @@ +from superannotate import SAClient +from tests.integration.base import BaseTestCase + +sa = SAClient() + + +class TestGroups(BaseTestCase): + PROJECT_NAME = "TestGroups" + PROJECT_TYPE = "Vector" + GROUP_NAME = "TestGroup" + + def setUp(self): + super().setUp() + # Get available team users for testing + team_users = sa.list_users() + assert len(team_users) > 0 + + # Find contributors for testing + self.contributors = [ + u for u in team_users + if u["role"] == "Contributor" and u["state"] == "Confirmed" + ][:2] # Take first 2 contributors + + assert len(self.contributors) >= 1, "Need at least 1 contributor for testing" + + self.contributor_emails = [u["email"] for u in self.contributors] + + def test_create_group_with_contributors(self): + group = sa.groups.create(self.GROUP_NAME, self.contributor_emails) + + assert group.name == self.GROUP_NAME + assert len(group.contributors) == len(self.contributor_emails) + assert all(email in group.contributors for email in self.contributor_emails) + assert group.id is not None + assert group.created_at is not None + + def test_create_group_without_contributors(self): + group = sa.groups.create(f"{self.GROUP_NAME}_empty") + + assert group.name == f"{self.GROUP_NAME}_empty" + assert len(group.contributors) == 0 + assert group.id is not None + + def test_create_duplicate_group_name_raises_exception(self): + sa.groups.create(f"{self.GROUP_NAME}_duplicate") + + with self.assertRaises(Exception): + sa.groups.create(f"{self.GROUP_NAME}_duplicate") + + def test_list_groups(self): + # Create a test group + created_group = sa.groups.create(f"{self.GROUP_NAME}_list", self.contributor_emails[:1]) + + groups = sa.groups.list() + + assert len(groups) > 0 + group_names = [g.name for g in groups] + assert f"{self.GROUP_NAME}_list" in group_names + + # Find our created group + our_group = next(g for g in groups if g.name == f"{self.GROUP_NAME}_list") + assert our_group.id == created_group.id + assert len(our_group.contributors) == 1 + + def test_add_contributor_to_group(self): + if len(self.contributors) < 2: + self.skipTest("Need at least 2 contributors for this test") + + group = sa.groups.create(f"{self.GROUP_NAME}_add", [self.contributor_emails[0]]) + initial_count = len(group.contributors) + + group.add_contributor([self.contributor_emails[1]]) + + assert len(group.contributors) == initial_count + 1 + assert self.contributor_emails[1] in group.contributors + + def test_remove_contributor_from_group(self): + group = sa.groups.create(f"{self.GROUP_NAME}_remove", self.contributor_emails) + initial_count = len(group.contributors) + + group.remove_contributors([self.contributor_emails[0]]) + + assert len(group.contributors) == initial_count - 1 + assert self.contributor_emails[0] not in group.contributors + + def test_remove_contributor_from_group_and_team(self): + if len(self.contributors) < 1: + self.skipTest("Need at least 1 contributor for this test") + + group = sa.groups.create(f"{self.GROUP_NAME}_remove_team", [self.contributor_emails[0]]) + + # This should remove from group and team + group.remove_contributors([self.contributor_emails[0]], remove_from_team=True) + + assert self.contributor_emails[0] not in group.contributors + + def test_rename_group(self): + group = sa.groups.create(f"{self.GROUP_NAME}_rename") + new_name = f"{self.GROUP_NAME}_renamed" + + group.rename(new_name) + + assert group.name == new_name + + def test_rename_group_to_existing_name_raises_exception(self): + sa.groups.create(f"{self.GROUP_NAME}_existing") + group = sa.groups.create(f"{self.GROUP_NAME}_to_rename") + + with self.assertRaises(Exception): + group.rename(f"{self.GROUP_NAME}_existing") + + def test_share_project_with_all_group_members(self): + group = sa.groups.create(f"{self.GROUP_NAME}_share", self.contributor_emails[:1]) + + # Share project with all group members (default behavior) + group.share(project=self.PROJECT_NAME, role="Annotator") + + # Verify the user has access to the project + project_users = sa.list_users(project=self.PROJECT_NAME) + project_emails = [u["email"] for u in project_users] + assert self.contributor_emails[0] in project_emails + + def test_share_project_with_specific_members(self): + if len(self.contributors) < 2: + self.skipTest("Need at least 2 contributors for this test") + + group = sa.groups.create(f"{self.GROUP_NAME}_share_specific", self.contributor_emails) + + # Share with only one specific member + group.share( + project=self.PROJECT_NAME, + role="QA", + contributors=[self.contributor_emails[0]] + ) + + project_users = sa.list_users(project=self.PROJECT_NAME) + project_emails = [u["email"] for u in project_users] + assert self.contributor_emails[0] in project_emails + + def test_share_project_with_folder_scope(self): + # Create a folder first + folder_name = "test_folder" + sa.create_folder(self.PROJECT_NAME, folder_name) + + group = sa.groups.create(f"{self.GROUP_NAME}_folder_share", self.contributor_emails[:1]) + + group.share( + project=self.PROJECT_NAME, + role="Annotator", + scope=[folder_name] + ) + + # Verify access was granted + project_users = sa.list_users(project=self.PROJECT_NAME) + project_emails = [u["email"] for u in project_users] + assert self.contributor_emails[0] in project_emails + + def test_share_invalid_project_raises_exception(self): + group = sa.groups.create(f"{self.GROUP_NAME}_invalid_project") + + with self.assertRaises(Exception): + group.share(project="NonExistentProject", role="Annotator") + + def test_share_invalid_role_raises_exception(self): + group = sa.groups.create(f"{self.GROUP_NAME}_invalid_role") + + with self.assertRaises(Exception): + group.share(project=self.PROJECT_NAME, role="InvalidRole") + + def test_group_to_dict(self): + group = sa.groups.create(f"{self.GROUP_NAME}_dict", self.contributor_emails[:1]) + + group_dict = group.to_dict() + + assert isinstance(group_dict, dict) + assert group_dict["id"] == group.id + assert group_dict["name"] == group.name + assert group_dict["contributors"] == group.contributors + assert group_dict["created_at"] == group.created_at + assert group_dict["projects"] == group.projects + + def test_delete_group(self): + group_name = f"{self.GROUP_NAME}_delete" + sa.groups.create(group_name) + + sa.groups.delete(group_name) + + # Verify group is deleted + groups = sa.groups.list() + group_names = [g.name for g in groups] + assert group_name not in group_names + + def test_delete_group_and_remove_members_from_team(self): + group_name = f"{self.GROUP_NAME}_delete_team" + sa.groups.create(group_name, self.contributor_emails[:1]) + + sa.groups.delete(group_name, remove_from_team=True) + + # Verify group is deleted + groups = sa.groups.list() + group_names = [g.name for g in groups] + assert group_name not in group_names + + def test_delete_nonexistent_group_raises_exception(self): + with self.assertRaises(Exception): + sa.groups.delete("NonExistentGroup") + + def test_add_nonexistent_contributor_raises_exception(self): + group = sa.groups.create(f"{self.GROUP_NAME}_invalid_contributor") + + with self.assertRaises(Exception): + group.add_contributor(["nonexistent@example.com"]) + + def test_remove_nonexistent_contributor_raises_exception(self): + group = sa.groups.create(f"{self.GROUP_NAME}_remove_invalid") + + with self.assertRaises(Exception): + group.remove_contributors(["nonexistent@example.com"]) + + def test_share_with_nonexistent_folder_raises_exception(self): + group = sa.groups.create(f"{self.GROUP_NAME}_invalid_folder") + + with self.assertRaises(Exception): + group.share( + project=self.PROJECT_NAME, + role="Annotator", + scope=["nonexistent_folder"] + ) \ No newline at end of file From fe9e47a8e479f301fabdec658275f39f68f7515a Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 4 Aug 2025 10:53:01 +0400 Subject: [PATCH 2/2] Initial commite for interface change --- README.rst | 70 +- .../lib/app/interface/sdk_interface.py | 1238 ++++++++++++++--- 2 files changed, 1112 insertions(+), 196 deletions(-) diff --git a/README.rst b/README.rst index e9bdad21a..ab693ef69 100644 --- a/README.rst +++ b/README.rst @@ -49,20 +49,21 @@ config.ini example Using superannotate ------------------- +The SDK provides both direct methods and organized manager interfaces: + .. code-block:: python from superannotate import SAClient - - sa_client =SAClient() - + sa_client = SAClient() project = 'Dogs' - sa_client.create_project( - project_name=project, - project_description='Test project generated via SDK', - project_type='Vector' - ) + # Using managers (recommended for better organization) + sa_client.projects.create( + project_name=project, + project_description='Test project generated via SDK', + project_type='Vector' + ) sa_client.create_annotation_class( project=project, @@ -71,29 +72,36 @@ Using superannotate class_type='tag' ) - sa_client.attach_items( - project=project, - attachments=[ - { - 'url': 'https://drive.google.com/uc?export=download&id=1ipOrZNSTlPUkI_hnrW9aUD5yULqqq5Vl', - 'name': 'dog.jpeg' - } - ] - ) - - sa_client.upload_annotations( - project=project, - annotations=[ - { - 'metadata': {'name': 'dog.jpeg'}, - 'instances': [ - {'type': 'tag', 'className': 'dog'} - ] - } - ] - ) - - sa_client.get_annotations(project=project, items=['dog.jpeg']) + sa_client.items.attach( + project=project, + attachments=[ + { + 'url': 'https://drive.google.com/uc?export=download&id=1ipOrZNSTlPUkI_hnrW9aUD5yULqqq5Vl', + 'name': 'dog.jpeg' + } + ] + ) + + sa_client.annotations.upload( + project=project, + annotations=[ + { + 'metadata': {'name': 'dog.jpeg'}, + 'instances': [ + {'type': 'tag', 'className': 'dog'} + ] + } + ] + ) + + sa_client.annotations.get(project=project, items=['dog.jpeg']) + + # Direct methods also work for backward compatibility + # sa_client.create_project(...) + # sa_client.attach_items(...) + # sa_client.upload_annotations(...) + +Available managers: ``projects``, ``folders``, ``items``, ``annotations``, ``users`` Installation ------------ diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 9bc8a103d..5a94e700a 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -281,6 +281,1039 @@ def set_component_value(self, component_id: str, value: Any): return self +class ProjectsManager: + """Manager class for project operations.""" + + def __init__(self, client): + self.client = client + self.controller = client.controller + + def create( + self, + project_name: NotEmptyStr, + project_description: NotEmptyStr, + project_type: PROJECT_TYPE, + settings: List[Setting] = None, + classes: List[AnnotationClassEntity] = None, + workflows: Any = None, + instructions_link: str = None, + workflow: str = None, + ): + """Create a new project in the team. + + :param project_name: the new project's name + :type project_name: str + + :param project_description: the new project's description + :type project_description: str + + :param project_type: the new project type, Vector, Pixel, Video, Document, Tiled, PointCloud, Multimodal. + :type project_type: str + + :param settings: list of settings objects + :type settings: list of dicts + + :param classes: list of class objects + :type classes: list of dicts + + :param workflows: Deprecated + :type workflows: list of dicts + + :param workflow: the name of the workflow already created within the team, which must match exactly. + If None, the default "System workflow" workflow will be set. + :type workflow: str + + :param instructions_link: str of instructions URL + :type instructions_link: str + + :return: dict object metadata the new project + :rtype: dict + """ + return self.projects.create( + project_name=project_name, + project_description=project_description, + project_type=project_type, + settings=settings, + classes=classes, + workflows=workflows, + instructions_link=instructions_link, + workflow=workflow, + ) + + def list( + self, + name: Optional[NotEmptyStr] = None, + return_metadata: bool = False, + include_complete_item_count: bool = False, + status: Optional[Union[PROJECT_STATUS, List[PROJECT_STATUS]]] = None, + ): + """ + Project name based case-insensitive search for projects. + If **name** is None, all the projects will be returned. + + :param name: search string + :type name: str + + :param return_metadata: return metadata of projects instead of names + :type return_metadata: bool + + :param include_complete_item_count: return projects that have completed items and include + the number of completed items in response. + :type include_complete_item_count: bool + + :param status: search projects via project status + :type status: str + + :return: project names or metadatas + :rtype: list of strs or dicts + """ + statuses = [] + if status: + if isinstance(status, (list, tuple, set)): + statuses = list(status) + else: + statuses = [status] + + condition = Condition.get_empty_condition() + if name: + condition &= Condition("name", name, EQ) + if include_complete_item_count: + condition &= Condition( + "completeImagesCount", include_complete_item_count, EQ + ) + for _status in statuses: + condition &= Condition("status", constants.ProjectStatus(_status).value, EQ) + + response = self.controller.projects.list(condition) + if response.errors: + raise AppException(response.errors) + if return_metadata: + return [ + ProjectSerializer(project).serialize( + exclude={ + "settings", + "workflows", + "contributors", + "classes", + "item_count", + } + ) + for project in response.data + ] + return [project.name for project in response.data] + + def clone( + self, + project_name: Union[NotEmptyStr, dict], + from_project: Union[NotEmptyStr, dict], + project_description: Optional[NotEmptyStr] = None, + copy_annotation_classes: Optional[bool] = True, + copy_settings: Optional[bool] = True, + copy_workflow: Optional[bool] = False, + copy_contributors: Optional[bool] = False, + ): + """Create a new project in the team using annotation classes and settings from from_project. + + :param project_name: new project's name + :type project_name: str + + :param from_project: the name of the project being used for duplication + :type from_project: str + + :param project_description: the new project's description. If None, from_project's + description will be used + :type project_description: str + + :param copy_annotation_classes: enables copying annotation classes + :type copy_annotation_classes: bool + + :param copy_settings: enables copying project settings + :type copy_settings: bool + + :param copy_workflow: Deprecated + :type copy_workflow: bool + + :param copy_contributors: enables copying project contributors + :type copy_contributors: bool + + :return: dict object metadata of the new project + :rtype: dict + """ + return self.projects.clone( + project_name=project_name, + from_project=from_project, + project_description=project_description, + copy_annotation_classes=copy_annotation_classes, + copy_settings=copy_settings, + copy_workflow=copy_workflow, + copy_contributors=copy_contributors, + ) + + def delete(self, project: Union[NotEmptyStr, dict]): + """Deletes the project + + :param project: project name + :type project: str + """ + name = project + if isinstance(project, dict): + name = project["name"] + project = self.controller.get_project(name) + response = self.controller.projects.delete(project) + if response.errors: + raise AppException(response.errors) + logger.info(f"Project {name} deleted.") + + def rename(self, project: Union[NotEmptyStr, dict], new_name: NotEmptyStr): + """Rename project + + :param project: project name or project metadata + :type project: str or dict + + :param new_name: project's new name + :type new_name: str + """ + if isinstance(project, dict): + project = project["name"] + project = self.controller.get_project(project) + project.name = new_name + response = self.controller.projects.update(project) + if response.errors: + raise AppException(response.errors) + logger.info(f"Project renamed to {new_name}.") + + +class FoldersManager: + """Manager class for folder operations.""" + + def __init__(self, client): + self.client = client + self.controller = client.controller + + def create(self, project: NotEmptyStr, folder_name: NotEmptyStr): + """ + Create a new folder in the project. + + :param project: project name + :type project: str + + :param folder_name: the new folder's name + :type folder_name: str + + :return: dict object metadata the new folder + :rtype: dict + """ + + project = self.controller.get_project(project) + folder = entities.FolderEntity(name=folder_name) + res = self.controller.folders.create(project, folder) + if res.data: + folder = res.data + logger.info(f"Folder {folder.name} created in project {project.name}") + return FolderSerializer(folder).serialize( + exclude={"completedCount", "is_root"} + ) + if res.errors: + raise AppException(res.errors) + + +class ItemsManager: + """Manager class for item operations.""" + + def __init__(self, client): + self.client = client + self.controller = client.controller + + def list( + self, + project: Union[NotEmptyStr, int], + folder: Optional[Union[NotEmptyStr, int]] = None, + *, + include: List[Literal["custom_metadata", "categories"]] = None, + **filters, + ): + """ + Search items by filtering criteria. + + :param project: The project name, project ID, or folder path (e.g., "project1") to search within. + This can refer to the root of the project or a specific subfolder. + :type project: Union[NotEmptyStr, int] + + :param folder: The folder name or ID to search within. If None, the search will be done in the root folder of + the project. If both "project" and "folder" specify folders, the "project" + value will take priority. + :type folder: Union[NotEmptyStr, int], optional + + :param include: Specifies additional fields to include in the response. + + Possible values are + + - "custom_metadata": Includes custom metadata attached to the item. + - "categories": Includes categories attached to the item. + :type include: list of str, optional + + :param filters: Specifies filtering criteria (e.g., name, ID, annotation status), + with all conditions combined using logical AND. Only items matching all criteria are returned. + If no operation is specified, an exact match is applied. + + + Supported operations: + - __ne: Value is not equal. + - __in: Value is in the list. + - __notin: Value is not in the list. + - __contains: Value has the substring. + - __starts: Value starts with the prefix. + - __ends: Value ends with the suffix. + + Filter params:: + + - id: int + - id__in: list[int] + - name: str + - name__in: list[str] + - name__contains: str + - name__starts: str + - name__ends: str + - annotation_status: str + - annotation_status__in: list[str] + - annotation_status__ne: list[str] + - approval_status: Literal["Approved", "Disapproved", None] + - assignments__user_id: str + - assignments__user_id__ne: str + - assignments__user_id__in: list[str] + - assignments__user_role: str + - assignments__user_id__ne: str + - assignments__user_role__in: list[str] + - assignments__user_role__notin: list[str] + :type filters: ItemFilters + + :return: A list of items that match the filtering criteria. + :rtype: list of dicts + + Request Example: + :: + + client.items.list( + project="Medical Annotations", + folder="folder1", + include=["custom_metadata"], + annotation_status="InProgress", + name_contains="scan" + ) + + Response Example: + :: + + [ + { + "name": "scan_123.jpeg", + "path": "Medical Annotations/folder1", + "url": "https://sa-public-files.s3.../scan_123.jpeg", + "annotation_status": "InProgress", + "createdAt": "2022-02-10T14:32:21.000Z", + "updatedAt": "2022-02-15T20:46:44.000Z", + "custom_metadata": { + "study_date": "2021-12-31", + "patient_id": "62078f8a756ddb2ca9fc9660", + "medical_specialist": "robertboxer@ms.com" + } + } + ] + + Request Example with include categories: + :: + + client.items.list( + project="My Multimodal", + folder="folder1", + include=["categories"] + ) + + Response Example: + :: + + [ + { + "id": 48909383, + "name": "scan_123.jpeg", + "path": "Medical Annotations/folder1", + "url": "https://sa-public-files.s3.../scan_123.jpeg", + "annotation_status": "InProgress", + "createdAt": "2022-02-10T14:32:21.000Z", + "updatedAt": "2022-02-15T20:46:44.000Z", + "entropy_value": None, + "assignments": [], + "categories": [ + { + "createdAt": "2025-01-29T13:51:39.000Z", + "updatedAt": "2025-01-29T13:51:39.000Z", + "id": 328577, + "name": "my_category", + }, + ], + } + ] + + Additional Filter Examples: + :: + + client.items.list( + project="Medical Annotations", + folder="folder2", + annotation_status="Completed", + name__in=["1.jpg", "2.jpg", "3.jpg"] + ) + + # Filter items assigned to a specific QA + client.items.list( + project="Medical Annotations", + assignee__user_id="qa@example.com" + ) + """ + project = ( + self.controller.get_project_by_id(project).data + if isinstance(project, int) + else self.controller.get_project(project) + ) + if ( + include + and "categories" in include + and project.type != ProjectType.MULTIMODAL.value + ): + raise AppException( + "The 'categories' option in the 'include' field is only supported for Multimodal projects." + ) + if folder is None: + folder = self.controller.get_folder(project, "root") + else: + if isinstance(folder, int): + folder = self.controller.get_folder_by_id( + project_id=project.id, folder_id=folder + ).data + else: + folder = self.controller.get_folder(project, folder) + _include = {"assignments"} + if include: + _include.update(set(include)) + include = list(_include) + include_custom_metadata = "custom_metadata" in include + if include_custom_metadata: + include.remove("custom_metadata") + res = self.controller.items.list_items( + project, folder, include=include, **filters + ) + if include_custom_metadata: + item_custom_fields = self.controller.custom_fields.list_fields( + project=project, item_ids=[i.id for i in res] + ) + for i in res: + i.custom_metadata = item_custom_fields[i.id] + exclude = {"meta", "annotator_email", "qa_email"} + if not include_custom_metadata: + exclude.add("custom_metadata") + return BaseSerializer.serialize_iterable(res, exclude=exclude, by_alias=False) + + def attach( + self, + project: Union[NotEmptyStr, dict], + attachments: Union[NotEmptyStr, Path, conlist(Attachment, min_items=1)], + annotation_status: str = None, + ): + """Attach items to a project. + + :param project: project name or project metadata + :type project: str or dict + + :param attachments: list of attachments or path to CSV file + :type attachments: list of dicts or str or Path + + :param annotation_status: annotation status to set for attached items + :type annotation_status: str + + :return: list of attached items + :rtype: list of dicts + """ + if annotation_status is not None: + warnings.warn( + DeprecationWarning( + "The 'annotation_status' parameter is deprecated. " + "Please use the 'set_annotation_statuses' function instead." + ) + ) + project_name, folder_name = extract_project_folder(project) + try: + attachments = parse_obj_as(List[AttachmentEntity], attachments) + unique_attachments = set(attachments) + duplicate_attachments = [ + item + for item, count in collections.Counter(attachments).items() + if count > 1 + ] + except ValidationError: + ( + unique_attachments, + duplicate_attachments, + ) = get_name_url_duplicated_from_csv(attachments) + if duplicate_attachments: + logger.info("Dropping duplicates.") + unique_attachments = parse_obj_as(List[AttachmentEntity], unique_attachments) + uploaded, fails, duplicated = [], [], [] + _unique_attachments = [] + if any(i.integration for i in unique_attachments): + integtation_item_map = { + i.name: i + for i in self.controller.integrations.list().data + if i.type == IntegrationTypeEnum.CUSTOM + } + invalid_integrations = set() + for attachment in unique_attachments: + if attachment.integration: + if attachment.integration in integtation_item_map: + attachment.integration_id = integtation_item_map[ + attachment.integration + ].id + else: + invalid_integrations.add(attachment.integration) + continue + _unique_attachments.append(attachment) + if invalid_integrations: + logger.error( + f"The ['{','.join(invalid_integrations)}'] integrations specified for the items doesn't exist in the " + "list of integrations on the platform. Any associated items will be skipped." + ) + else: + _unique_attachments = unique_attachments + + if _unique_attachments: + logger.info( + f"Attaching {len(_unique_attachments)} file(s) to project {project}." + ) + project, folder = self.controller.get_project_folder( + (project_name, folder_name) + ) + response = self.controller.items.attach( + project=project, + folder=folder, + attachments=_unique_attachments, + annotation_status=annotation_status, + ) + if response.errors: + raise AppException(response.errors) + uploaded, duplicated = response.data + fails = [ + attachment.name + for attachment in _unique_attachments + if attachment.name not in uploaded and attachment.name not in duplicated + ] + return uploaded, fails, duplicated + + +class AnnotationsManager: + """Manager class for annotation operations.""" + + def __init__(self, client): + self.client = client + self.controller = client.controller + + def upload( + self, + project: NotEmptyStr, + annotations: List[dict], + keep_status: bool = None, + *, + data_spec: Literal["default", "multimodal"] = "default", + ): + """Uploads a list of annotation dictionaries to the specified SuperAnnotate project or folder. + + :param project: The project name or folder path where annotations will be uploaded + (e.g., "project1/folder1"). + :type project: str + + :param annotations: A list of annotation dictionaries formatted according to the SuperAnnotate standards. + :type annotations: list of dict + + :param keep_status: If False, the annotation status will be automatically updated to "InProgress." + If True, the current status will remain unchanged. + :type keep_status: bool, optional + + :param data_spec: Specifies the format for processing and transforming annotations before upload. + + Options are: + - default: Retains the annotations in their original format. + - multimodal: Converts annotations for multimodal projects, optimizing for + compact and modality-specific data representation. + :type data_spec: str, optional + + :return: A dictionary containing the results of the upload, categorized into successfully uploaded, + failed, and skipped annotations. + :rtype: dict + + Response Example:: + + { + "succeeded": [], + "failed": [], + "skipped": [] + } + + Example Usage with JSONL Upload for Multimodal Projects:: + + import json + from pathlib import Path + from superannotate import SAClient + + annotations_path = Path("annotations.jsonl") + annotations = [] + + # Reading the JSONL file and converting it into a list of dictionaries + with annotations_path.open("r", encoding="utf-8") as f: + for line in f: + annotations.append(json.loads(line)) + + # Initialize the SuperAnnotate client + sa = SAClient() + + # Call the upload_annotations function + response = sa.annotations.upload( + project="project1/folder1", + annotations=annotations, + keep_status=True, + data_spec='multimodal' + ) + """ + if keep_status is not None: + warnings.warn( + DeprecationWarning( + "The 'keep_status' parameter is deprecated. " + "Please use the 'set_annotation_statuses' function instead." + ) + ) + project, folder = self.controller.get_project_folder_by_path(project) + response = self.controller.annotations.upload_multiple( + project=project, + folder=folder, + annotations=annotations, + keep_status=keep_status, + user=self.controller.current_user, + output_format=data_spec, + ) + if response.errors: + raise AppException(response.errors) + return response.data + + def get( + self, + project: Union[NotEmptyStr, int], + items: Optional[Union[List[NotEmptyStr], List[int]]] = None, + *, + data_spec: Literal["default", "multimodal"] = "default", + ): + """Returns annotations for the given project and items. + + :param project: The project name or ID. + :type project: Union[str, int] + + :param items: A list of item names or IDs to retrieve annotations for. + If None, annotations for all items in the project will be returned. + :type items: Optional[Union[List[str], List[int]]] + + :param data_spec: Specifies the format for processing and transforming annotations before upload. + + Options are: + - default: Retains the annotations in their original format. + - multimodal: Converts annotations for multimodal projects, optimizing for + compact and multimodal-specific data representation. + + :type data_spec: str, optional + + Example Usage of Multimodal Projects:: + + from superannotate import SAClient + + + sa = SAClient() + + # Call the get_annotations function + response = sa.annotations.get( + project="project1/folder1", + items=["item_1", "item_2"], + data_spec='multimodal' + ) + + :return: A list of annotation dictionaries for the specified items. + :rtype: list of dicts + """ + project, folder = self.controller.get_project_folder_by_path(project) + response = self.controller.annotations.get_multiple( + project=project, + folder=folder, + items=items, + output_format=data_spec, + ) + if response.errors: + raise AppException(response.errors) + return response.data + + +class UsersManager: + """Manager class for user operations.""" + + def __init__(self, client): + self.client = client + self.controller = client.controller + + def list( + self, + *, + project: Union[int, str] = None, + include: List[Literal["custom_fields", "categories"]] = None, + **filters, + ): + """ + Search users, including their scores, by filtering criteria. + + :param project: Project name or ID, if provided, results will be for project-level, + otherwise results will be for team level. + :type project: str or int + + :param include: Specifies additional fields to be included in the response. + + Possible values are + + - "custom_fields": Includes custom fields and scores assigned to each user. + - "categories": Includes a list of categories assigned to each project contributor. + Note: 'project' parameter must be specified when including 'categories'. + :type include: list of str, optional + + :param filters: Specifies filtering criteria, with all conditions combined using logical AND. + + - Only users matching all filter conditions are returned. + + - If no filter operation is provided, an exact match is applied + + Supported operations: + + - __in: Value is in the provided list. + - __notin: Value is not in the provided list. + - __ne: Value is not equal to the given value. + - __contains: Value contains the specified substring. + - __starts: Value starts with the given prefix. + - __ends: Value ends with the given suffix. + - __gt: Value is greater than the given number. + - __gte: Value is greater than or equal to the given number. + - __lt: Value is less than the given number. + - __lte: Value is less than or equal to the given number. + + Filter params:: + + - id: int + - id__in: list[int] + - email: str + - email__in: list[str] + - email__contains: str + - email__starts: str + - email__ends: str + + Following params if project is not selected:: + + - state: Literal["Confirmed", "Pending"] + - state__in: List[Literal["Confirmed", "Pending"]] + - role: Literal["admin", "contributor"] + - role__in: List[Literal["admin", "contributor"]] + + Scores and Custom Field Filtering: + + - Scores and other custom fields must be prefixed with `custom_field__` . + - Example: custom_field__Due_date__gte="1738281600" (filtering users whose Due_date is after the given Unix timestamp). + + - **Text** custom field only works with the following filter params: __in, __notin, __contains + - **Numeric** custom field only works with the following filter params: __in, __notin, __ne, __gt, __gte, __lt, __lte + - **Single-select** custom field only works with the following filter params: __in, __notin, __contains + - **Multi-select** custom field only works with the following filter params: __in, __notin + - **Date picker** custom field only works with the following filter params: __gt, __gte, __lt, __lte + + **If custom field has a space, please use the following format to filter them**: + :: + + user_filters = {"custom_field__accuracy score 30D__lt": 90} + client.users.list(include=["custom_fields"], **user_filters) + + + :type filters: UserFilters, optional + + :return: A list of team/project users metadata that matches the filtering criteria + :rtype: list of dicts + + Request Example: + :: + + client.users.list( + email__contains="@superannotate.com", + include=["custom_fields"], + state__in=["Confirmed"] + custom_fields__Tag__in=["Tag1", "Tag3"] + ) + + Response Example: + :: + + [ + { + "createdAt": "2023-02-02T14:25:42.000Z", + "updatedAt": "2025-01-23T16:39:03.000Z", + "custom_fields": { + "Ann Quality threshold": 80, + "Tag": ["Tag1", "Tag2", "Tag3"], + }, + "email": "example@superannotate.com", + "id": 30328, + "role": "TeamOwner", + "state": "Confirmed", + "team_id": 44311, + } + ] + + Request Example: + :: + + # Project level scores + + scores = client.users.list( + include=["custom_fields"], + project="my_multimodal", + email__contains="@superannotate.com", + custom_field__speed__gte=90, + custom_field__weight__lte=1, + ) + + Response Example: + :: + + # Project level scores + + [ + { + "createdAt": "2025-03-07T13:19:59.000Z", + "updatedAt": "2025-03-07T13:19:59.000Z", + "custom_fields": {"speed": 92, "weight": 0.8}, + "email": "example@superannotate.com", + "id": 715121, + "role": "Annotator", + "state": "Confirmed", + "team_id": 1234, + } + ] + + Request Example: + :: + + # Team level scores + + user_filters = { + "custom_field__accuracy score 30D__lt": 95, + "custom_field__speed score 7D__lt": 15 + } + + scores = client.users.list( + include=["custom_fields"], + email__contains="@superannotate.com", + role="Contributor", + **user_filters + ) + + Response Example: + :: + + # Team level scores + + [ + { + "createdAt": "2025-03-07T13:19:59.000Z", + "updatedAt": "2025-03-07T13:19:59.000Z", + "custom_fields": { + "Test custom field": 80, + "Tag custom fields": ["Tag1", "Tag2"], + "accuracy score 30D": 95, + "accuracy score 14D": 47, + "accuracy score 7D": 24, + "speed score 30D": 33, + "speed score 14D": 22, + "speed score 7D": 11, + }, + "email": "example@superannotate.com", + "id": 715121, + "role": "Contributor", + "state": "Confirmed", + "team_id": 1234, + } + ] + + """ + if project is not None: + if isinstance(project, int): + project = self.controller.get_project_by_id(project).data + else: + project = self.controller.get_project(project) + response = BaseSerializer.serialize_iterable( + self.controller.work_management.list_users( + project=project, include=include, **filters + ) + ) + if project: + for user in response: + user["role"] = self.controller.service_provider.get_role_name( + project, user["role"] + ) + return response + + def get_metadata( + self, pk: Union[int, str], include: List[Literal["custom_fields"]] = None + ): + """ + Returns user metadata including optionally, custom fields + + :param pk: The email address or ID of the team user. + :type pk: str or int + + :param include: Specifies additional fields to include in the response. + + Possible values are + + - "custom_fields": If provided, the response will include custom fields associated with the team user. + + :type include: list of str, optional + + :return: metadata of team user. + :rtype: dict + + Request Example: + :: + + client.users.get_metadata( + "example@email.com", + include=["custom_fields"] + ) + + Response Example: + :: + + { + "createdAt": "2023-11-27T07:10:24.000Z", + "updatedAt": "2025-02-03T13:35:09.000Z", + "custom_fields": { + "ann_quality_threshold": 80, + "tag": ["Tag1", "Tag2", "Tag3"], + "due_date": 1738671238.7, + }, + "email": "example@email.com", + "id": 124341, + "role": "Contributor", + "state": "Confirmed", + "team_id": 23245, + } + """ + user = self.controller.work_management.get_user_metadata(pk=pk, include=include) + return BaseSerializer(user).serialize(by_alias=False) + + def invite_to_team( + self, emails: conlist(EmailStr, min_items=1), admin: bool = False + ) -> Tuple[List[str], List[str]]: + """Invites contributors to the team. + + :param emails: list of contributor emails + :type emails: list + + :param admin: enables admin privileges for the contributor + :type admin: bool + + :return: lists of invited, skipped contributors of the team + :rtype: tuple (2 members) of lists of strs + """ + response = self.controller.invite_contributors_to_team( + emails=emails, set_admin=admin + ) + if response.errors: + raise AppException(response.errors) + return response.data + + def add_to_project( + self, + project: NotEmptyStr, + emails: conlist(EmailStr, min_items=1), + role: str, + ) -> Tuple[List[str], List[str]]: + """Add contributors to project. + + :param project: project name + :type project: str + + :param emails: users email + :type emails: list + + :param role: user role to apply, one of ProjectAdmin , Annotator , QA + :type role: str + + :return: lists of added, skipped contributors of the project + :rtype: tuple (2 members) of lists of strs + """ + project = self.controller.projects.get_by_name(project).data + contributors = [ + entities.ContributorEntity( + user_id=email, + user_role=self.controller.service_provider.get_role_id(project, role), + ) + for email in emails + ] + response = self.controller.projects.add_contributors( + self.controller.team, project, contributors + ) + if response.errors: + raise AppException(response.errors) + return response.data + + def search_team_contributors( + self, + email: EmailStr = None, + first_name: NotEmptyStr = None, + last_name: NotEmptyStr = None, + return_metadata: bool = True, + ): + """Search for team contributors. + + :param email: contributor email + :type email: str + + :param first_name: contributor first name + :type first_name: str + + :param last_name: contributor last name + :type last_name: str + + :param return_metadata: return metadata of contributors instead of names + :type return_metadata: bool + + :return: contributor names or metadatas + :rtype: list of strs or dicts + """ + condition = Condition.get_empty_condition() + if email: + condition &= Condition("email", email, EQ) + if first_name: + condition &= Condition("first_name", first_name, EQ) + if last_name: + condition &= Condition("last_name", last_name, EQ) + + response = self.controller.service_provider.search_team_contributors(condition) + if response.errors: + raise AppException(response.errors) + if return_metadata: + return [ + ContributorSerializer(contributor).serialize() + for contributor in response.data + ] + return [contributor.email for contributor in response.data] + + class SAClient(BaseInterfaceFacade, metaclass=TrackableMeta): """Create SAClient instance to authorize SDK in a team scope. In case of no argument has been provided, SA_TOKEN environmental variable @@ -301,6 +1334,13 @@ def __init__( ): super().__init__(token, config_path) + # Initialize managers + self.projects = ProjectsManager(self) + self.folders = FoldersManager(self) + self.items = ItemsManager(self) + self.annotations = AnnotationsManager(self) + self.users = UsersManager(self) + def get_project_by_id(self, project_id: int): """Returns the project metadata @@ -425,8 +1465,7 @@ def get_user_metadata( "team_id": 23245, } """ - user = self.controller.work_management.get_user_metadata(pk=pk, include=include) - return BaseSerializer(user).serialize(by_alias=False) + return self.users.get_metadata(pk=pk, include=include) def set_user_custom_field( self, pk: Union[int, str], custom_field_name: str, value: Any @@ -657,22 +1696,7 @@ def list_users( ] """ - if project is not None: - if isinstance(project, int): - project = self.controller.get_project_by_id(project).data - else: - project = self.controller.get_project(project) - response = BaseSerializer.serialize_iterable( - self.controller.work_management.list_users( - project=project, include=include, **filters - ) - ) - if project: - for user in response: - user["role"] = self.controller.service_provider.get_role_name( - project, user["role"] - ) - return response + return self.users.list(project=project, include=include, **filters) def pause_user_activity( self, pk: Union[int, str], projects: Union[List[int], List[str], Literal["*"]] @@ -1050,14 +2074,12 @@ def search_team_contributors( :return: metadata of found users :rtype: list of dicts """ - - contributors = self.controller.search_team_contributors( - email=email, first_name=first_name, last_name=last_name - ).data - - if not return_metadata: - return [contributor["email"] for contributor in contributors] - return contributors + return self.users.search_team_contributors( + email=email, + first_name=first_name, + last_name=last_name, + return_metadata=return_metadata, + ) def search_projects( self, @@ -1086,40 +2108,12 @@ def search_projects( :return: project names or metadatas :rtype: list of strs or dicts """ - statuses = [] - if status: - if isinstance(status, (list, tuple, set)): - statuses = list(status) - else: - statuses = [status] - - condition = Condition.get_empty_condition() - if name: - condition &= Condition("name", name, EQ) - if include_complete_item_count: - condition &= Condition( - "completeImagesCount", include_complete_item_count, EQ - ) - for _status in statuses: - condition &= Condition("status", constants.ProjectStatus(_status).value, EQ) - - response = self.controller.projects.list(condition) - if response.errors: - raise AppException(response.errors) - if return_metadata: - return [ - ProjectSerializer(project).serialize( - exclude={ - "settings", - "workflows", - "contributors", - "classes", - "item_count", - } - ) - for project in response.data - ] - return [project.name for project in response.data] + return self.projects.list( + name=name, + return_metadata=return_metadata, + include_complete_item_count=include_complete_item_count, + status=status, + ) def create_project( self, @@ -1472,18 +2466,7 @@ def create_folder(self, project: NotEmptyStr, folder_name: NotEmptyStr): :return: dict object metadata the new folder :rtype: dict """ - - project = self.controller.get_project(project) - folder = entities.FolderEntity(name=folder_name) - res = self.controller.folders.create(project, folder) - if res.data: - folder = res.data - logger.info(f"Folder {folder.name} created in project {project.name}") - return FolderSerializer(folder).serialize( - exclude={"completedCount", "is_root"} - ) - if res.errors: - raise AppException(res.errors) + return self.folders.create(project=project, folder_name=folder_name) def delete_project(self, project: Union[NotEmptyStr, dict]): """Deletes the project @@ -1491,10 +2474,7 @@ def delete_project(self, project: Union[NotEmptyStr, dict]): :param project: project name :type project: str """ - name = project - if isinstance(project, dict): - name = project["name"] - self.controller.projects.delete(name=name) + return self.projects.delete(project) def rename_project(self, project: NotEmptyStr, new_name: NotEmptyStr): """Renames the project @@ -1505,16 +2485,7 @@ def rename_project(self, project: NotEmptyStr, new_name: NotEmptyStr): :param new_name: project's new name :type new_name: str """ - old_name = project - project = self.controller.get_project(old_name) # noqa - project.name = new_name - response = self.controller.projects.update(project) - if response.errors: - raise AppException(response.errors) - logger.info( - "Successfully renamed project %s to %s.", old_name, response.data.name - ) - return ProjectSerializer(response.data).serialize() + return self.projects.rename(project, new_name) def get_folder_metadata(self, project: NotEmptyStr, folder_name: NotEmptyStr): """Returns folder metadata @@ -3448,22 +4419,7 @@ def add_contributors_to_project( :return: lists of added, skipped contributors of the project :rtype: tuple (2 members) of lists of strs """ - project = self.controller.projects.get_by_name(project).data - contributors = [ - entities.ContributorEntity( - user_id=email, - user_role=self.controller.service_provider.get_role_id(project, role), - ) - for email in emails - ] - response = self.controller.projects.add_contributors( - team=self.controller.get_team().data, - project=project, - contributors=contributors, - ) - if response.errors: - raise AppException(response.errors) - return response.data + return self.users.add_to_project(project=project, emails=emails, role=role) def invite_contributors_to_team( self, emails: conlist(EmailStr, min_items=1), admin: bool = False @@ -3479,12 +4435,7 @@ def invite_contributors_to_team( :return: lists of invited, skipped contributors of the team :rtype: tuple (2 members) of lists of strs """ - response = self.controller.invite_contributors_to_team( - emails=emails, set_admin=admin - ) - if response.errors: - raise AppException(response.errors) - return response.data + return self.users.invite_to_team(emails=emails, admin=admin) def get_annotations( self, @@ -3527,22 +4478,11 @@ def get_annotations( :return: list of annotations :rtype: list of dict """ - if isinstance(project, str): - project, folder = self.controller.get_project_folder_by_path(project) - else: - project = self.controller.get_project_by_id(project_id=project).data - folder = self.controller.get_folder_by_id( - project_id=project.id, folder_id=project.folder_id - ).data - response = self.controller.annotations.list( - project, - folder, - items, - transform_version="llmJsonV2" if data_spec == "multimodal" else None, + return self.annotations.get( + project=project, + items=items, + data_spec=data_spec, ) - if response.errors: - raise AppException(response.errors) - return response.data def get_annotations_per_frame( self, project: NotEmptyStr, video: NotEmptyStr, fps: int = 1 @@ -4072,48 +5012,12 @@ def list_items( assignee__user_id="qa@example.com" ) """ - project = ( - self.controller.get_project_by_id(project).data - if isinstance(project, int) - else self.controller.get_project(project) - ) - if ( - include - and "categories" in include - and project.type != ProjectType.MULTIMODAL.value - ): - raise AppException( - "The 'categories' option in the 'include' field is only supported for Multimodal projects." - ) - if folder is None: - folder = self.controller.get_folder(project, "root") - else: - if isinstance(folder, int): - folder = self.controller.get_folder_by_id( - project_id=project.id, folder_id=folder - ).data - else: - folder = self.controller.get_folder(project, folder) - _include = {"assignments"} - if include: - _include.update(set(include)) - include = list(_include) - include_custom_metadata = "custom_metadata" in include - if include_custom_metadata: - include.remove("custom_metadata") - res = self.controller.items.list_items( - project, folder, include=include, **filters + return self.items.list( + project=project, + folder=folder, + include=include, + **filters ) - if include_custom_metadata: - item_custom_fields = self.controller.custom_fields.list_fields( - project=project, item_ids=[i.id for i in res] - ) - for i in res: - i.custom_metadata = item_custom_fields[i.id] - exclude = {"meta", "annotator_email", "qa_email"} - if not include_custom_metadata: - exclude.add("custom_metadata") - return BaseSerializer.serialize_iterable(res, exclude=exclude, by_alias=False) def list_projects( self, @@ -4359,7 +5263,11 @@ def attach_items( for attachment in _unique_attachments if attachment.name not in uploaded and attachment.name not in duplicated ] - return uploaded, fails, duplicated + return self.items.attach( + project=project, + attachments=attachments, + annotation_status=annotation_status, + ) def generate_items( self,