Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Project improvements #55

Merged
merged 5 commits into from
Aug 25, 2024
Merged
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 pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "claudesync"
version = "0.5.2"
version = "0.5.3"
authors = [
{name = "Jahziah Wagner", email = "[email protected]"},
]
Expand Down
59 changes: 58 additions & 1 deletion src/claudesync/cli/project.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import os

import click
from tqdm import tqdm

from claudesync.exceptions import ProviderError
from .submodule import submodule
from ..syncmanager import SyncManager
from ..syncmanager import SyncManager, retry_on_403
from ..utils import (
handle_errors,
validate_and_get_provider,
Expand Down Expand Up @@ -235,4 +237,59 @@ def sync(config, category):
click.echo("Project sync completed successfully, including available submodules.")


@project.command()
@click.option(
"-a", "--include-archived", is_flag=True, help="Include archived projects"
)
@click.option("-y", "--yes", is_flag=True, help="Skip confirmation prompt")
@click.pass_obj
@handle_errors
def truncate(config, include_archived, yes):
"""Truncate all projects."""
provider = validate_and_get_provider(config)
active_organization_id = config.get("active_organization_id")

projects = provider.get_projects(
active_organization_id, include_archived=include_archived
)

if not projects:
click.echo("No projects found.")
return

if not yes:
click.echo("This will delete ALL files from the following projects:")
for project in projects:
status = " (Archived)" if project.get("archived_at") else ""
click.echo(f" - {project['name']} (ID: {project['id']}){status}")
if not click.confirm(
"Are you sure you want to continue? This may take some time."
):
click.echo("Operation cancelled.")
return

with tqdm(total=len(projects), desc="Deleting files from projects") as pbar:
for project in projects:
delete_files_from_project(
provider, active_organization_id, project["id"], project["name"]
)
pbar.update(1)

click.echo("All files have been deleted from all projects.")


@retry_on_403()
def delete_files_from_project(provider, organization_id, project_id, project_name):
try:
files = provider.list_files(organization_id, project_id)
with tqdm(
total=len(files), desc=f"Deleting files from {project_name}", leave=False
) as file_pbar:
for file in files:
provider.delete_file(organization_id, project_id, file["uuid"])
file_pbar.update(1)
except ProviderError as e:
click.echo(f"Error deleting files from project {project_name}: {str(e)}")


project.add_command(submodule)
48 changes: 32 additions & 16 deletions src/claudesync/cli/submodule.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def ls(config):
@click.pass_obj
@handle_errors
def create(config):
"""Create new projects for each detected submodule."""
"""Create new projects for each detected submodule that doesn't already exist remotely."""
provider = validate_and_get_provider(config, require_project=True)
active_organization_id = config.get("active_organization_id")
active_project_id = config.get("active_project_id")
Expand All @@ -67,25 +67,41 @@ def create(config):
click.echo("No submodules detected in the current project.")
return

click.echo(f"Detected {len(submodules)} submodule(s). Creating projects for each:")
# Fetch all remote projects
all_remote_projects = provider.get_projects(
active_organization_id, include_archived=False
)

click.echo(
f"Detected {len(submodules)} submodule(s). Checking for existing remote projects:"
)

for i, submodule in enumerate(submodules, 1):
submodule_name = os.path.basename(submodule)
new_project_name = f"{active_project_name}-SubModule-{submodule_name}"
description = f"Submodule '{submodule_name}' for project '{active_project_name}' (ID: {active_project_id})"

try:
new_project = provider.create_project(
active_organization_id, new_project_name, description
)
click.echo(
f"{i}. Created project '{new_project_name}' (ID: {new_project['uuid']}) for submodule '{submodule_name}'"
)
except ProviderError as e:
# Check if the submodule project already exists
existing_project = next(
(p for p in all_remote_projects if p["name"] == new_project_name), None
)

if existing_project:
click.echo(
f"Failed to create project for submodule '{submodule_name}': {str(e)}"
f"{i}. Submodule '{submodule_name}' already exists as project "
f"'{new_project_name}' (ID: {existing_project['id']}). Skipping."
)

click.echo(
"\nSubmodule projects created successfully. You can now select and sync these projects individually."
)
else:
description = f"Submodule '{submodule_name}' for project '{active_project_name}' (ID: {active_project_id})"
try:
new_project = provider.create_project(
active_organization_id, new_project_name, description
)
click.echo(
f"{i}. Created project '{new_project_name}' (ID: {new_project['uuid']}) for submodule '{submodule_name}'"
)
except ProviderError as e:
click.echo(
f"Failed to create project for submodule '{submodule_name}': {str(e)}"
)

click.echo("\nSubmodule project creation process completed.")
34 changes: 26 additions & 8 deletions src/claudesync/providers/claude_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,22 @@ def handle_http_error(self, e):
self.logger.debug(f"Request failed: {str(e)}")
self.logger.debug(f"Response status code: {e.code}")
self.logger.debug(f"Response headers: {e.headers}")
content = e.read().decode("utf-8")
self.logger.debug(f"Response content: {content}")

try:
# Check if the content is gzip-encoded
if e.headers.get("Content-Encoding") == "gzip":
content = gzip.decompress(e.read())
else:
content = e.read()

# Try to decode the content as UTF-8
content_str = content.decode("utf-8")
except UnicodeDecodeError:
# If UTF-8 decoding fails, try to decode as ISO-8859-1
content_str = content.decode("iso-8859-1")

self.logger.debug(f"Response content: {content_str}")

if e.code == 403:
error_msg = (
"Received a 403 Forbidden error. Your session key might be invalid. "
Expand All @@ -88,19 +102,23 @@ def handle_http_error(self, e):
)
self.logger.error(error_msg)
raise ProviderError(error_msg)
if e.code == 429:
elif e.code == 429:
try:
error_data = json.loads(content)
error_data = json.loads(content_str)
resets_at_unix = json.loads(error_data["error"]["message"])["resetsAt"]
resets_at_local = datetime.fromtimestamp(
resets_at_unix, tz=timezone.utc
).astimezone()
formatted_time = resets_at_local.strftime("%a %b %d %Y %H:%M:%S %Z%z")
print(f"Message limit exceeded. Try again after {formatted_time}")
error_msg = f"Message limit exceeded. Try again after {formatted_time}"
except (KeyError, json.JSONDecodeError) as parse_error:
print(f"Failed to parse error response: {parse_error}")
raise ProviderError("HTTP 429: Too Many Requests")
raise ProviderError(f"API request failed: {str(e)}")
error_msg = f"HTTP 429: Too Many Requests. Failed to parse error response: {parse_error}"
self.logger.error(error_msg)
raise ProviderError(error_msg)
else:
error_msg = f"API request failed with status code {e.code}: {content_str}"
self.logger.error(error_msg)
raise ProviderError(error_msg)

def _make_request_stream(self, method, endpoint, data=None):
url = f"{self.BASE_URL}{endpoint}"
Expand Down
57 changes: 31 additions & 26 deletions src/claudesync/syncmanager.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import functools
import os
import time
import logging
Expand All @@ -12,6 +13,33 @@
logger = logging.getLogger(__name__)


def retry_on_403(max_retries=3, delay=1):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
self = args[0] if len(args) > 0 else None
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except ProviderError as e:
if "403 Forbidden" in str(e) and attempt < max_retries - 1:
if self and hasattr(self, "logger"):
self.logger.warning(
f"Received 403 error. Retrying in {delay} seconds... (Attempt {attempt + 1}/{max_retries})"
)
else:
print(
f"Received 403 error. Retrying in {delay} seconds... (Attempt {attempt + 1}/{max_retries})"
)
time.sleep(delay)
else:
raise

return wrapper

return decorator


class SyncManager:
"""
Manages the synchronization process between local and remote files.
Expand All @@ -35,29 +63,6 @@ def __init__(self, provider, config):
self.max_retries = 3 # Maximum number of retries for 403 errors
self.retry_delay = 1 # Delay between retries in seconds

def retry_on_403(func):
"""
Decorator to retry a function on 403 Forbidden error.

This decorator will retry the wrapped function up to max_retries times
if a ProviderError with a 403 Forbidden message is encountered.
"""

def wrapper(self, *args, **kwargs):
for attempt in range(self.max_retries):
try:
return func(self, *args, **kwargs)
except ProviderError as e:
if "403 Forbidden" in str(e) and attempt < self.max_retries - 1:
logger.warning(
f"Received 403 error. Retrying in {self.retry_delay} seconds..."
)
time.sleep(self.retry_delay)
else:
raise

return wrapper

def sync(self, local_files, remote_files):
"""
Main synchronization method that orchestrates the sync process.
Expand Down Expand Up @@ -98,7 +103,7 @@ def sync(self, local_files, remote_files):

self.prune_remote_files(remote_files, remote_files_to_delete)

@retry_on_403
@retry_on_403()
def update_existing_file(
self,
local_file,
Expand Down Expand Up @@ -142,7 +147,7 @@ def update_existing_file(
synced_files.add(local_file)
remote_files_to_delete.remove(local_file)

@retry_on_403
@retry_on_403()
def upload_new_file(self, local_file, synced_files):
"""
Upload a new file to the remote project.
Expand Down Expand Up @@ -271,7 +276,7 @@ def prune_remote_files(self, remote_files, remote_files_to_delete):
for file_to_delete in list(remote_files_to_delete):
self.delete_remote_files(file_to_delete, remote_files)

@retry_on_403
@retry_on_403()
def delete_remote_files(self, file_to_delete, remote_files):
"""
Delete a file from the remote project that no longer exists locally.
Expand Down