Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
python-version: ['3.9']

steps:
- name: Checkout code
uses: actions/checkout@v4
Expand Down
51 changes: 4 additions & 47 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,53 +19,10 @@ sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg

# IDE
*.pyc
*/*.pyc
.idea
.vscode/
*.swp
*.swo
*~

# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

# Testing
.coverage
htmlcov/
.pytest_cache/
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.tox/

# Documentation
docs/_build/
site/

# Release files
.bumpversion.cfg.bak
*.bak

# Claude
.DS_Store
.claude

# Test data
tests/test_data
tests/test_data
165 changes: 143 additions & 22 deletions labellerr/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
import time
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from functools import wraps
from multiprocessing import cpu_count
from typing import List, Optional, Union

import requests
from requests.adapters import HTTPAdapter
Expand All @@ -20,6 +23,65 @@
create_dataset_parameters = {}


@dataclass
class KeyFrame:
"""
Represents a key frame with validation.
"""
frame_number: int
is_manual: bool = True
method: str = "manual"
source: str = "manual"

def __post_init__(self):
if not isinstance(self.frame_number, int):
raise ValueError("frame_number must be an integer")
if self.frame_number < 0:
raise ValueError("frame_number must be non-negative")
if not isinstance(self.is_manual, bool):
raise ValueError("is_manual must be a boolean")
if not isinstance(self.method, str):
raise ValueError("method must be a string")
if not isinstance(self.source, str):
raise ValueError("source must be a string")


def validate_params(**validations):
"""
Decorator to validate method parameters based on type specifications.

Usage:
@validate_params(project_id=str, file_id=str, keyFrames=list)
def some_method(self, project_id, file_id, keyFrames):
...
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Get function signature to map args to parameter names
import inspect
sig = inspect.signature(func)
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()

# Validate each parameter
for param_name, expected_type in validations.items():
if param_name in bound.arguments:
value = bound.arguments[param_name]
if not isinstance(value, expected_type):
from .exceptions import LabellerrError
type_name = (
" or ".join(t.__name__ for t in expected_type)
if isinstance(expected_type, tuple)
else expected_type.__name__
)
raise LabellerrError(f"{param_name} must be a {type_name}")

return func(*args, **kwargs)
return wrapper
return decorator


class LabellerrClient:
"""
A client for interacting with the Labellerr API.
Expand Down Expand Up @@ -127,7 +189,7 @@ def _build_headers(self, client_id=None, extra_headers=None):
api_secret=self.api_secret,
source="sdk",
client_id=client_id,
extra_headers=extra_headers,
extra_headers=extra_headers
)

def _handle_response(self, response, request_id=None, success_codes=None):
Expand Down Expand Up @@ -275,13 +337,12 @@ def __process_batch(self, client_id, files_list, connection_id=None):

return response

def upload_files(self, client_id, files_list):
@validate_params(client_id=str, files_list=(str, list))
def upload_files(self, client_id: str, files_list: Union[str, List[str]]):
"""
Uploads files to the API.

:param client_id: The ID of the client.
:param dataset_id: The ID of the dataset.
:param data_type: The type of data.
:param files_list: The list of files to upload or a comma-separated string of file paths.
:return: The response from the API.
:raises LabellerrError: If the upload fails.
Expand All @@ -290,6 +351,7 @@ def upload_files(self, client_id, files_list):
# Convert string input to list if necessary
if isinstance(files_list, str):
files_list = files_list.split(",")
files_list = files_list.split(",")
elif not isinstance(files_list, list):
raise LabellerrError(
"files_list must be either a list or a comma-separated string"
Expand Down Expand Up @@ -426,24 +488,18 @@ def create_dataset(
logging.error(f"Failed to create dataset: {e}")
raise

def get_all_dataset(self, client_id, datatype, project_id, scope):
@validate_params(client_id=str, datatype=str, project_id=str, scope=str)
def get_all_dataset(self, client_id: str, datatype: str, project_id: str, scope: str):
"""
Retrieves a dataset by its ID.

:param client_id: The ID of the client.
:param datatype: The type of data for the dataset.
:param project_id: The ID of the project.
:param scope: The scope of the dataset.
:return: The dataset as JSON.
"""
# validate parameters
if not isinstance(client_id, str):
raise LabellerrError("client_id must be a string")
if not isinstance(datatype, str):
raise LabellerrError("datatype must be a string")
if not isinstance(project_id, str):
raise LabellerrError("project_id must be a string")
if not isinstance(scope, str):
raise LabellerrError("scope must be a string")
# scope value should on in the list SCOPE_LIST
# scope value should be in the list SCOPE_LIST
if scope not in constants.SCOPE_LIST:
raise LabellerrError(
f"scope must be one of {', '.join(constants.SCOPE_LIST)}"
Expand Down Expand Up @@ -622,9 +678,7 @@ def _upload_preannotation_sync(
"annotation_format": annotation_format,
"annotation_file": annotation_file,
}
client_utils.validate_required_params(
required_params, list(required_params.keys())
)
client_utils.validate_required_params(required_params, list(required_params.keys()))
client_utils.validate_annotation_format(annotation_format, annotation_file)

url = f"{self.base_url}/actions/upload_answers?project_id={project_id}&answer_format={annotation_format}&client_id={client_id}"
Expand Down Expand Up @@ -924,7 +978,9 @@ def create_local_export(self, project_id, client_id, export_config):
logging.error(f"Failed to create local export: {str(e)}")
raise LabellerrError(f"Failed to create local export: {str(e)}")

def fetch_download_url(self, project_id, uuid, export_id, client_id):
def fetch_download_url(
self, project_id, uuid, export_id, client_id
):
try:
headers = self._build_headers(
client_id=client_id, extra_headers={"Content-Type": "application/json"}
Expand Down Expand Up @@ -954,13 +1010,16 @@ def fetch_download_url(self, project_id, uuid, export_id, client_id):
logging.error(f"Unexpected error in download_function: {str(e)}")
raise LabellerrError(f"Unexpected error in download_function: {str(e)}")

def check_export_status(self, project_id, report_ids, client_id):
@validate_params(project_id=str, report_ids=list, client_id=str)
def check_export_status(
self, project_id: str, report_ids: List[str], client_id: str
):
request_uuid = client_utils.generate_request_id()
try:
if not project_id:
raise LabellerrError("project_id cannot be null")
if not report_ids or not isinstance(report_ids, list):
raise LabellerrError("report_ids must be a non-empty list")
if not report_ids:
raise LabellerrError("report_ids cannot be empty")

# Construct URL
url = f"{constants.BASE_URL}/exports/status?project_id={project_id}&uuid={request_uuid}&client_id={client_id}"
Expand Down Expand Up @@ -1335,3 +1394,65 @@ def create_batches():
raise e
except Exception as e:
raise LabellerrError(f"Failed to upload files: {str(e)}")


@validate_params(client_id=str, project_id=str, file_id=str, key_frames=list)
def link_key_frame(self, client_id: str, project_id: str, file_id: str, key_frames: List[KeyFrame]):
"""
Links key frames to a file in a project.

:param client_id: The ID of the client
:param project_id: The ID of the project
:param file_id: The ID of the file
:param key_frames: List of KeyFrame objects to link
:return: Response from the API
"""
try:
unique_id = str(uuid.uuid4())
url = f"{self.base_url}/actions/add_update_keyframes?client_id={client_id}&uuid={unique_id}"
headers = self._build_headers(
client_id=client_id,
extra_headers={"content-type": "application/json"}
)

body = {
"project_id": project_id,
"file_id": file_id,
"keyframes": [
kf.__dict__ if isinstance(kf, KeyFrame) else kf
for kf in key_frames
]
}

response = self._make_request("POST", url, headers=headers, json=body)
return self._handle_response(response, unique_id)

except LabellerrError as e:
raise e
except Exception as e:
raise LabellerrError(f"Failed to link key frames: {str(e)}")

@validate_params(client_id=str, project_id=str)
def delete_key_frames(self, client_id: str, project_id: str):
"""
Deletes key frames from a project.

:param client_id: The ID of the client
:param project_id: The ID of the project
:return: Response from the API
"""
try:
unique_id = str(uuid.uuid4())
url = f"{self.base_url}/actions/delete_keyframes?project_id={project_id}&uuid={unique_id}&client_id={client_id}"
headers = self._build_headers(
client_id=client_id,
extra_headers={"content-type": "application/json"}
)

response = self._make_request("POST", url, headers=headers)
return self._handle_response(response, unique_id)

except LabellerrError as e:
raise e
except Exception as e:
raise LabellerrError(f"Failed to delete key frames: {str(e)}")
Loading
Loading