Skip to content

Commit

Permalink
Handle rate limiting
Browse files Browse the repository at this point in the history
  • Loading branch information
jahwag authored Jul 19, 2024
1 parent 6c2beb5 commit 139c577
Show file tree
Hide file tree
Showing 15 changed files with 453 additions and 41 deletions.
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
![License](https://img.shields.io/badge/License-MIT-blue.svg)
[![PyPI version](https://badge.fury.io/py/claudesync.svg)](https://badge.fury.io/py/claudesync)

ClaudeSync is a powerful tool designed to seamlessly synchronize your local files with [Claude.ai](https://www.anthropic.com/claude) projects.
ClaudeSync is a powerful tool designed to seamlessly synchronize your local files with [Claude.ai](https://www.anthropic.com/claude) projects.

## Overview and Scope

Expand Down Expand Up @@ -43,7 +43,7 @@ ClaudeSync bridges the gap between your local development environment and Claude

2. **Login to Claude.ai:**
```bash
claudesync login claude.ai
claudesync api login claude.ai
```

3. **Select an organization:**
Expand All @@ -65,6 +65,11 @@ ClaudeSync bridges the gap between your local development environment and Claude

## Advanced Usage

### API Management
- Login to Claude.ai: `claudesync api login claude.ai`
- Logout: `claudesync api logout`
- Set upload delay: `claudesync api ratelimit --delay <seconds>`

### Organization Management
- List organizations: `claudesync organization ls`
- Select active organization: `claudesync organization select`
Expand Down Expand Up @@ -106,4 +111,5 @@ ClaudeSync is licensed under the MIT License. See the [LICENSE](LICENSE) file fo

---

Made with ❤️ by the ClaudeSync team
Made with ❤️ by the ClaudeSync team
```
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "claudesync"
version = "0.2.9"
version = "0.3.0"
authors = [
{name = "Jahziah Wagner", email = "[email protected]"},
]
Expand Down
23 changes: 21 additions & 2 deletions src/claudesync/cli/auth.py → src/claudesync/cli/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
from ..utils import handle_errors


@click.command()
@click.group()
def api():
"""Manage api."""
pass


@api.command()
@click.argument("provider", required=False)
@click.pass_obj
@handle_errors
Expand All @@ -25,10 +31,23 @@ def login(config, provider):
click.echo("Logged in successfully.")


@click.command()
@api.command()
@click.pass_obj
def logout(config):
"""Log out from the current AI provider."""
for key in ["session_key", "active_provider", "active_organization_id"]:
config.set(key, None)
click.echo("Logged out successfully.")


@click.command()
@click.option("--delay", type=float, required=True, help="Upload delay in seconds")
@click.pass_obj
@handle_errors
def ratelimit(config, delay):
"""Set the delay between file uploads during sync."""
if delay < 0:
click.echo("Error: Upload delay must be a non-negative number.")
return
config.set("upload_delay", delay)
click.echo(f"Upload delay set to {delay} seconds.")
6 changes: 2 additions & 4 deletions src/claudesync/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
import click_completion
import click_completion.core

# Import commands from other CLI files
from .auth import login, logout
from .api import api
from .organization import organization
from .project import project
from .sync import ls, sync, schedule
Expand Down Expand Up @@ -48,8 +47,7 @@ def status(config):
click.echo(f"{key.replace('_', ' ').capitalize()}: {value or 'Not set'}")


cli.add_command(login)
cli.add_command(logout)
cli.add_command(api)
cli.add_command(organization)
cli.add_command(project)
cli.add_command(ls)
Expand Down
4 changes: 4 additions & 0 deletions src/claudesync/cli/sync.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import click
import sys
import os
import time
import shutil
from crontab import CronTab
from claudesync.utils import calculate_checksum, get_local_files
Expand Down Expand Up @@ -37,6 +38,7 @@ def sync(config):
active_organization_id = config.get("active_organization_id")
active_project_id = config.get("active_project_id")
local_path = config.get("local_path")
upload_delay = config.get("upload_delay", 0.5)

if not local_path:
click.echo(
Expand Down Expand Up @@ -72,6 +74,7 @@ def sync(config):
provider.upload_file(
active_organization_id, active_project_id, local_file, content
)
time.sleep(upload_delay) # Add delay after upload
else:
click.echo(f"Uploading new file {local_file} to remote...")
with open(
Expand All @@ -81,6 +84,7 @@ def sync(config):
provider.upload_file(
active_organization_id, active_project_id, local_file, content
)
time.sleep(upload_delay) # Add delay after upload

click.echo("Sync completed successfully.")

Expand Down
6 changes: 4 additions & 2 deletions src/claudesync/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ def __init__(self):
def _load_config(self):
if not self.config_file.exists():
self.config_dir.mkdir(parents=True, exist_ok=True)
return {"log_level": "INFO"} # Default log level
return {"log_level": "INFO", "upload_delay": 0.5} # Default values
with open(self.config_file, "r") as f:
config = json.load(f)
if "log_level" not in config:
config["log_level"] = "INFO" # Set default if not present
config["log_level"] = "INFO"
if "upload_delay" not in config:
config["upload_delay"] = 0.5
return config

def _save_config(self):
Expand Down
167 changes: 167 additions & 0 deletions tests/cli/test_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import unittest
from unittest.mock import patch, MagicMock
from claudesync.providers.claude_ai import ClaudeAIProvider
from claudesync.exceptions import ProviderError


class TestClaudeAIProvider(unittest.TestCase):
def setUp(self):
self.provider = ClaudeAIProvider("test_session_key")

@patch("claudesync.providers.claude_ai.requests.request")
def test_get_organizations(self, mock_request):
mock_response = MagicMock()
mock_response.json.return_value = {
"account": {
"memberships": [
{"organization": {"uuid": "org1", "name": "Organization 1"}},
{"organization": {"uuid": "org2", "name": "Organization 2"}},
]
}
}
mock_request.return_value = mock_response

organizations = self.provider.get_organizations()

self.assertEqual(len(organizations), 2)
self.assertEqual(organizations[0]["id"], "org1")
self.assertEqual(organizations[0]["name"], "Organization 1")
self.assertEqual(organizations[1]["id"], "org2")
self.assertEqual(organizations[1]["name"], "Organization 2")

@patch("claudesync.providers.claude_ai.requests.request")
def test_get_projects(self, mock_request):
mock_response = MagicMock()
mock_response.json.return_value = [
{"uuid": "proj1", "name": "Project 1", "archived_at": None},
{"uuid": "proj2", "name": "Project 2", "archived_at": "2023-01-01"},
]
mock_request.return_value = mock_response

projects = self.provider.get_projects("org1", include_archived=True)

self.assertEqual(len(projects), 2)
self.assertEqual(projects[0]["id"], "proj1")
self.assertEqual(projects[0]["name"], "Project 1")
self.assertIsNone(projects[0]["archived_at"])
self.assertEqual(projects[1]["id"], "proj2")
self.assertEqual(projects[1]["name"], "Project 2")
self.assertEqual(projects[1]["archived_at"], "2023-01-01")

@patch("claudesync.providers.claude_ai.requests.request")
def test_list_files(self, mock_request):
mock_response = MagicMock()
mock_response.json.return_value = [
{
"uuid": "file1",
"file_name": "file1.txt",
"content": "content1",
"created_at": "2023-01-01",
},
{
"uuid": "file2",
"file_name": "file2.py",
"content": "content2",
"created_at": "2023-01-02",
},
]
mock_request.return_value = mock_response

files = self.provider.list_files("org1", "proj1")

self.assertEqual(len(files), 2)
self.assertEqual(files[0]["uuid"], "file1")
self.assertEqual(files[0]["file_name"], "file1.txt")
self.assertEqual(files[0]["content"], "content1")
self.assertEqual(files[0]["created_at"], "2023-01-01")
self.assertEqual(files[1]["uuid"], "file2")
self.assertEqual(files[1]["file_name"], "file2.py")
self.assertEqual(files[1]["content"], "content2")
self.assertEqual(files[1]["created_at"], "2023-01-02")

@patch("claudesync.providers.claude_ai.requests.request")
def test_upload_file(self, mock_request):
mock_response = MagicMock()
mock_response.json.return_value = {
"uuid": "new_file",
"file_name": "new_file.txt",
}
mock_request.return_value = mock_response

result = self.provider.upload_file(
"org1", "proj1", "new_file.txt", "file content"
)

self.assertEqual(result["uuid"], "new_file")
self.assertEqual(result["file_name"], "new_file.txt")
mock_request.assert_called_once_with(
"POST",
"https://claude.ai/api/organizations/org1/projects/proj1/docs",
headers=unittest.mock.ANY,
cookies={"sessionKey": "test_session_key"},
json={"file_name": "new_file.txt", "content": "file content"},
)

@patch("claudesync.providers.claude_ai.requests.request")
def test_delete_file(self, mock_request):
mock_response = MagicMock()
mock_response.json.return_value = None
mock_request.return_value = mock_response

self.provider.delete_file("org1", "proj1", "file1")

mock_request.assert_called_once_with(
"DELETE",
"https://claude.ai/api/organizations/org1/projects/proj1/docs/file1",
headers=unittest.mock.ANY,
cookies={"sessionKey": "test_session_key"},
)

@patch("claudesync.providers.claude_ai.requests.request")
def test_create_project(self, mock_request):
mock_response = MagicMock()
mock_response.json.return_value = {"uuid": "new_proj", "name": "New Project"}
mock_request.return_value = mock_response

result = self.provider.create_project(
"org1", "New Project", "Project Description"
)

self.assertEqual(result["uuid"], "new_proj")
self.assertEqual(result["name"], "New Project")
mock_request.assert_called_once_with(
"POST",
"https://claude.ai/api/organizations/org1/projects",
headers=unittest.mock.ANY,
cookies={"sessionKey": "test_session_key"},
json={
"name": "New Project",
"description": "Project Description",
"is_private": True,
},
)

@patch("claudesync.providers.claude_ai.requests.request")
def test_archive_project(self, mock_request):
mock_response = MagicMock()
mock_response.json.return_value = {
"uuid": "proj1",
"name": "Project 1",
"is_archived": True,
}
mock_request.return_value = mock_response

result = self.provider.archive_project("org1", "proj1")

self.assertTrue(result["is_archived"])
mock_request.assert_called_once_with(
"PUT",
"https://claude.ai/api/organizations/org1/projects/proj1",
headers=unittest.mock.ANY,
cookies={"sessionKey": "test_session_key"},
json={"is_archived": True},
)


if __name__ == "__main__":
unittest.main()
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loading

0 comments on commit 139c577

Please sign in to comment.