From fbf9d1b3b86a2b9221c7942ab68870a735708c5b Mon Sep 17 00:00:00 2001 From: jahwag <540380+jahwag@users.noreply.github.com> Date: Wed, 21 Aug 2024 23:51:36 +0200 Subject: [PATCH] Submodules, file subsets and chats&messages (#50) --- pyproject.toml | 15 +- requirements.txt | 11 +- src/claudesync/cli/category.py | 61 ++++ src/claudesync/cli/chat.py | 196 +++++++++++ src/claudesync/cli/config.py | 4 + src/claudesync/cli/main.py | 3 +- src/claudesync/cli/project.py | 116 ++++++- src/claudesync/cli/submodule.py | 85 +++++ src/claudesync/cli/sync.py | 23 -- src/claudesync/config_manager.py | 105 +++++- src/claudesync/providers/base_claude_ai.py | 60 ++++ src/claudesync/providers/base_provider.py | 10 + src/claudesync/providers/claude_ai.py | 42 ++- src/claudesync/providers/claude_ai_curl.py | 26 ++ src/claudesync/utils.py | 53 ++- tests/cli/test_project.py | 363 +++++++++++++++------ tests/cli/test_sync.py | 108 ------ 17 files changed, 994 insertions(+), 287 deletions(-) create mode 100644 src/claudesync/cli/category.py create mode 100644 src/claudesync/cli/submodule.py delete mode 100644 tests/cli/test_sync.py diff --git a/pyproject.toml b/pyproject.toml index 55f1f1e..9eab00c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "claudesync" -version = "0.5.1" +version = "0.5.2" authors = [ {name = "Jahziah Wagner", email = "jahziah.wagner+pypi@gmail.com"}, ] @@ -14,11 +14,14 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "Click>=8.1.7", - "pathspec>=0.12.1", - "crontab>=1.0.1", - "click_completion>=0.5.2", - "tqdm>=4.66.4", + "click==8.1.7", + "click_completion==0.5.2", + "pathspec==0.12.1", + "pytest==8.3.2", + "python_crontab==3.2.0", + "setuptools==73.0.1", + "sseclient_py==1.8.0", + "tqdm==4.66.5", ] keywords = [ "sync", diff --git a/requirements.txt b/requirements.txt index 998062e..5e1df63 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,9 @@ -Click>=8.1.7 -pathspec>=0.12.1 -crontab>=1.0.1 +click>=8.1.7 click_completion>=0.5.2 -tqdm>=4.66.4 +pathspec>=0.12.1 +pytest>=8.3.2 +python_crontab>=3.2.0 +setuptools>=73.0.1 +sseclient_py>=1.8.0 +tqdm>=4.66.5 pytest-cov>=5.0.0 \ No newline at end of file diff --git a/src/claudesync/cli/category.py b/src/claudesync/cli/category.py new file mode 100644 index 0000000..3b242c5 --- /dev/null +++ b/src/claudesync/cli/category.py @@ -0,0 +1,61 @@ +import click +from ..utils import handle_errors + + +@click.group() +def category(): + """Manage file categories.""" + pass + + +@category.command() +@click.argument("name") +@click.option("--description", required=True, help="Description of the category") +@click.option( + "--patterns", required=True, multiple=True, help="File patterns for the category" +) +@click.pass_obj +@handle_errors +def add(config, name, description, patterns): + """Add a new file category.""" + config.add_file_category(name, description, list(patterns)) + click.echo(f"File category '{name}' added successfully.") + + +@category.command() +@click.argument("name") +@click.pass_obj +@handle_errors +def remove(config, name): + """Remove a file category.""" + config.remove_file_category(name) + click.echo(f"File category '{name}' removed successfully.") + + +@category.command() +@click.argument("name") +@click.option("--description", help="New description for the category") +@click.option("--patterns", multiple=True, help="New file patterns for the category") +@click.pass_obj +@handle_errors +def update(config, name, description, patterns): + """Update an existing file category.""" + config.update_file_category(name, description, list(patterns) if patterns else None) + click.echo(f"File category '{name}' updated successfully.") + + +@category.command() +@click.pass_obj +@handle_errors +def ls(config): + """List all file categories.""" + categories = config.get("file_categories", {}) + if not categories: + click.echo("No file categories defined.") + else: + for name, data in categories.items(): + click.echo(f"\nCategory: {name}") + click.echo(f"Description: {data['description']}") + click.echo("Patterns:") + for pattern in data["patterns"]: + click.echo(f" - {pattern}") diff --git a/src/claudesync/cli/chat.py b/src/claudesync/cli/chat.py index 771e8c9..49927b5 100644 --- a/src/claudesync/cli/chat.py +++ b/src/claudesync/cli/chat.py @@ -1,3 +1,5 @@ +import os + import click import logging from ..exceptions import ProviderError @@ -136,3 +138,197 @@ def confirm_and_delete_chat(provider, organization_id, chat): click.echo(f"Successfully deleted chat: {chat.get('name', 'Unnamed')}") else: click.echo(f"Failed to delete chat: {chat.get('name', 'Unnamed')}") + + +@chat.command() +@click.option("--name", default="", help="Name of the chat conversation") +@click.option("--project", help="UUID of the project to associate the chat with") +@click.pass_obj +@handle_errors +def create(config, name, project): + """Create a new chat conversation on the active provider.""" + provider = validate_and_get_provider(config) + organization_id = config.get("active_organization_id") + active_project_id = config.get("active_project_id") + active_project_name = config.get("active_project_name") + local_path = config.get("local_path") + + if not organization_id: + click.echo("No active organization set.") + return + + if not project: + project = select_project( + active_project_id, + active_project_name, + local_path, + organization_id, + provider, + ) + if project is None: + return + + try: + new_chat = provider.create_chat( + organization_id, chat_name=name, project_uuid=project + ) + click.echo(f"Created new chat conversation: {new_chat['uuid']}") + if name: + click.echo(f"Chat name: {name}") + click.echo(f"Associated project: {project}") + except Exception as e: + click.echo(f"Failed to create chat conversation: {str(e)}") + + +@chat.command() +@click.argument("message", nargs=-1, required=True) +@click.option("--chat", help="UUID of the chat to send the message to") +@click.option("--timezone", default="UTC", help="Timezone for the message") +@click.pass_obj +@handle_errors +def send(config, message, chat, timezone): + """Send a message to a specified chat or create a new chat and send the message.""" + provider = validate_and_get_provider(config) + organization_id = config.get("active_organization_id") + active_project_id = config.get("active_project_id") + active_project_name = config.get("active_project_name") + local_path = config.get("local_path") + + if not organization_id: + click.echo("No active organization set.") + return + + message = " ".join(message) # Join all message parts into a single string + + try: + chat = create_chat( + active_project_id, + active_project_name, + chat, + local_path, + organization_id, + provider, + ) + if chat is None: + return + + # Send message and process the streaming response + for event in provider.send_message(organization_id, chat, message, timezone): + if "completion" in event: + click.echo(event["completion"], nl=False) + elif "content" in event: + click.echo(event["content"], nl=False) + elif "error" in event: + click.echo(f"\nError: {event['error']}") + elif "message_limit" in event: + click.echo( + f"\nRemaining messages: {event['message_limit']['remaining']}" + ) + + click.echo() # Print a newline at the end of the response + + except Exception as e: + click.echo(f"Failed to send message: {str(e)}") + + +def create_chat( + active_project_id, active_project_name, chat, local_path, organization_id, provider +): + if not chat: + selected_project = select_project( + active_project_id, + active_project_name, + local_path, + organization_id, + provider, + ) + if selected_project is None: + return + + # Create a new chat with the selected project + new_chat = provider.create_chat(organization_id, project_uuid=selected_project) + chat = new_chat["uuid"] + click.echo(f"New chat created with ID: {chat}") + return chat + + +def select_project( + active_project_id, active_project_name, local_path, organization_id, provider +): + all_projects = provider.get_projects(organization_id) + if not all_projects: + click.echo("No projects found in the active organization.") + return None + + # Filter projects to include only the active project and its submodules + filtered_projects = [ + p + for p in all_projects + if p["id"] == active_project_id + or ( + p["name"].startswith(f"{active_project_name}-SubModule-") + and not p.get("archived_at") + ) + ] + + if not filtered_projects: + click.echo("No active project or related submodules found.") + return None + + # Determine the current working directory + current_dir = os.path.abspath(os.getcwd()) + + default_project = get_default_project( + active_project_id, + active_project_name, + current_dir, + filtered_projects, + local_path, + ) + + click.echo("Available projects:") + for idx, proj in enumerate(filtered_projects, 1): + project_type = ( + "Active Project" if proj["id"] == active_project_id else "Submodule" + ) + default_marker = " (default)" if idx - 1 == default_project else "" + click.echo( + f"{idx}. {proj['name']} (ID: {proj['id']}) - {project_type}{default_marker}" + ) + + while True: + prompt = "Enter the number of the project to associate with the chat" + if default_project is not None: + default_project_name = filtered_projects[default_project]["name"] + prompt += f" (default: {default_project + 1} - {default_project_name})" + selection = click.prompt( + prompt, + type=int, + default=default_project + 1 if default_project is not None else None, + ) + if 1 <= selection <= len(filtered_projects): + project = filtered_projects[selection - 1]["id"] + break + click.echo("Invalid selection. Please try again.") + return project + + +def get_default_project( + active_project_id, active_project_name, current_dir, filtered_projects, local_path +): + # Find the project that matches the current directory + default_project = None + for idx, proj in enumerate(filtered_projects): + if proj["id"] == active_project_id: + project_path = os.path.abspath(local_path) + else: + submodule_name = proj["name"].replace( + f"{active_project_name}-SubModule-", "" + ) + project_path = os.path.abspath( + os.path.join(local_path, "services", submodule_name) + ) + if current_dir.startswith(project_path): + default_project = idx + break + return default_project diff --git a/src/claudesync/cli/config.py b/src/claudesync/cli/config.py index 9be24c3..7e52032 100644 --- a/src/claudesync/cli/config.py +++ b/src/claudesync/cli/config.py @@ -1,5 +1,6 @@ import click +from .category import category from ..exceptions import ConfigurationError from ..utils import handle_errors @@ -60,3 +61,6 @@ def ls(config): """List all configuration values.""" for key, value in config.config.items(): click.echo(f"{key}: {value}") + + +config.add_command(category) diff --git a/src/claudesync/cli/main.py b/src/claudesync/cli/main.py index 769e389..b3df3c1 100644 --- a/src/claudesync/cli/main.py +++ b/src/claudesync/cli/main.py @@ -7,7 +7,7 @@ from .api import api from .organization import organization from .project import project -from .sync import ls, sync, schedule +from .sync import ls, schedule from .config import config import logging @@ -58,7 +58,6 @@ def status(config): cli.add_command(organization) cli.add_command(project) cli.add_command(ls) -cli.add_command(sync) cli.add_command(schedule) cli.add_command(config) cli.add_command(chat) diff --git a/src/claudesync/cli/project.py b/src/claudesync/cli/project.py index 98a771a..aca8864 100644 --- a/src/claudesync/cli/project.py +++ b/src/claudesync/cli/project.py @@ -1,14 +1,15 @@ import os import click - from claudesync.exceptions import ProviderError +from .submodule import submodule from ..syncmanager import SyncManager from ..utils import ( handle_errors, validate_and_get_provider, - validate_and_store_local_path, get_local_files, + detect_submodules, + validate_and_store_local_path, ) @@ -77,23 +78,45 @@ def archive(config): @project.command() +@click.option( + "-a", + "--all", + "show_all", + is_flag=True, + help="Include submodule projects in the selection", +) @click.pass_context @handle_errors -def select(ctx): +def select(ctx, show_all): """Set the active project for syncing.""" config = ctx.obj provider = validate_and_get_provider(config) active_organization_id = config.get("active_organization_id") + active_project_name = config.get("active_project_name") projects = provider.get_projects(active_organization_id, include_archived=False) - if not projects: + + if show_all: + selectable_projects = projects + else: + # Filter out submodule projects + selectable_projects = [p for p in projects if "-SubModule-" not in p["name"]] + + if not selectable_projects: click.echo("No active projects found.") return + click.echo("Available projects:") - for idx, project in enumerate(projects, 1): - click.echo(f" {idx}. {project['name']} (ID: {project['id']})") + for idx, project in enumerate(selectable_projects, 1): + project_type = ( + "Main Project" + if not project["name"].startswith(f"{active_project_name}-SubModule-") + else "Submodule" + ) + click.echo(f" {idx}. {project['name']} (ID: {project['id']}) - {project_type}") + selection = click.prompt("Enter the number of the project to select", type=int) - if 1 <= selection <= len(projects): - selected_project = projects[selection - 1] + if 1 <= selection <= len(selectable_projects): + selected_project = selectable_projects[selection - 1] config.set("active_project_id", selected_project["id"]) config.set("active_project_name", selected_project["name"]) click.echo( @@ -130,17 +153,80 @@ def ls(config, show_all): @project.command() +@click.option("--category", help="Specify the file category to sync") @click.pass_obj @handle_errors -def sync(config): - """Synchronize only the project files.""" +def sync(config, category): + """Synchronize the project files, including submodules if they exist remotely.""" provider = validate_and_get_provider(config, require_project=True) - sync_manager = SyncManager(provider, config) - remote_files = provider.list_files( - sync_manager.active_organization_id, sync_manager.active_project_id + active_organization_id = config.get("active_organization_id") + active_project_id = config.get("active_project_id") + active_project_name = config.get("active_project_name") + local_path = config.get("local_path") + + if not local_path: + click.echo("No local path set. Please select or create a project first.") + return + + # Detect local submodules + submodule_detect_filenames = config.get("submodule_detect_filenames", []) + local_submodules = detect_submodules(local_path, submodule_detect_filenames) + + # Fetch all remote projects + all_remote_projects = provider.get_projects( + active_organization_id, include_archived=False ) - local_files = get_local_files(config.get("local_path")) + + # Find remote submodule projects + remote_submodule_projects = [ + project + for project in all_remote_projects + if project["name"].startswith(f"{active_project_name}-SubModule-") + ] + + # Sync main project + sync_manager = SyncManager(provider, config) + remote_files = provider.list_files(active_organization_id, active_project_id) + local_files = get_local_files(local_path, category) sync_manager.sync(local_files, remote_files) + click.echo(f"Main project '{active_project_name}' synced successfully.") + + # Sync submodules + for local_submodule, detected_file in local_submodules: + submodule_name = os.path.basename(local_submodule) + remote_project = next( + ( + proj + for proj in remote_submodule_projects + if proj["name"].endswith(f"-{submodule_name}") + ), + None, + ) + + if remote_project: + click.echo(f"Syncing submodule '{submodule_name}'...") + submodule_path = os.path.join(local_path, local_submodule) + submodule_files = get_local_files(submodule_path, category) + remote_submodule_files = provider.list_files( + active_organization_id, remote_project["id"] + ) + + # Create a new SyncManager for the submodule + submodule_config = config.config.copy() + submodule_config["active_project_id"] = remote_project["id"] + submodule_config["active_project_name"] = remote_project["name"] + submodule_config["local_path"] = submodule_path + submodule_sync_manager = SyncManager(provider, submodule_config) + + submodule_sync_manager.sync(submodule_files, remote_submodule_files) + click.echo(f"Submodule '{submodule_name}' synced successfully.") + else: + click.echo( + f"No remote project found for submodule '{submodule_name}'. Skipping sync." + ) + + click.echo("Project sync completed successfully, including available submodules.") + - click.echo("Project sync completed successfully.") +project.add_command(submodule) diff --git a/src/claudesync/cli/submodule.py b/src/claudesync/cli/submodule.py new file mode 100644 index 0000000..74dcacf --- /dev/null +++ b/src/claudesync/cli/submodule.py @@ -0,0 +1,85 @@ +import os + +import click +from claudesync.exceptions import ProviderError +from ..utils import ( + handle_errors, + validate_and_get_provider, + detect_submodules, +) + + +@click.group() +def submodule(): + """Manage submodules within the current project.""" + pass + + +@submodule.command() +@click.pass_obj +@handle_errors +def ls(config): + """List all detected submodules in the current project.""" + local_path = config.get("local_path") + if not local_path: + click.echo("No local path set. Please select or create a project first.") + return + + submodule_detect_filenames = config.get("submodule_detect_filenames", []) + submodules = detect_submodules(local_path, submodule_detect_filenames) + + if not submodules: + click.echo("No submodules detected in the current project.") + else: + click.echo("Detected submodules:") + for submodule, detected_file in submodules: + click.echo(f" - {submodule} [{detected_file}]") + + +@submodule.command() +@click.pass_obj +@handle_errors +def create(config): + """Create new projects for each detected submodule.""" + 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") + active_project_name = config.get("active_project_name") + local_path = config.get("local_path") + + if not local_path: + click.echo("No local path set. Please select or create a project first.") + return + + submodule_detect_filenames = config.get("submodule_detect_filenames", []) + submodules_with_files = detect_submodules(local_path, submodule_detect_filenames) + + # Extract only the submodule paths from the list of tuples + submodules = [submodule for submodule, _ in submodules_with_files] + + if not submodules: + click.echo("No submodules detected in the current project.") + return + + click.echo(f"Detected {len(submodules)} submodule(s). Creating projects for each:") + + 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: + click.echo( + f"Failed to create project for submodule '{submodule_name}': {str(e)}" + ) + + click.echo( + "\nSubmodule projects created successfully. You can now select and sync these projects individually." + ) diff --git a/src/claudesync/cli/sync.py b/src/claudesync/cli/sync.py index f6f5c08..7e9721e 100644 --- a/src/claudesync/cli/sync.py +++ b/src/claudesync/cli/sync.py @@ -4,10 +4,7 @@ import click from crontab import CronTab -from claudesync.utils import get_local_files from ..utils import handle_errors, validate_and_get_provider -from ..syncmanager import SyncManager -from ..chat_sync import sync_chats @click.command() @@ -31,26 +28,6 @@ def ls(config): ) -@click.command() -@click.pass_obj -@handle_errors -def sync(config): - """Synchronize both projects and chats.""" - provider = validate_and_get_provider(config, require_project=True) - - # Sync projects - sync_manager = SyncManager(provider, config) - remote_files = provider.list_files( - sync_manager.active_organization_id, sync_manager.active_project_id - ) - local_files = get_local_files(config.get("local_path")) - sync_manager.sync(local_files, remote_files) - - # Sync chats - sync_chats(provider, config) - click.echo("Project and chat sync completed successfully.") - - def validate_local_path(local_path): if not local_path: click.echo( diff --git a/src/claudesync/config_manager.py b/src/claudesync/config_manager.py index 1be2da5..2400811 100644 --- a/src/claudesync/config_manager.py +++ b/src/claudesync/config_manager.py @@ -39,9 +39,67 @@ def _get_default_config(self): return { "log_level": "INFO", "upload_delay": 0.5, - "max_file_size": 32 * 1024, # Default 32 KB - "two_way_sync": False, # Default to False + "max_file_size": 32 * 1024, + "two_way_sync": False, "curl_use_file_input": False, + "submodule_detect_filenames": [ + "pom.xml", + "build.gradle", + "package.json", + "setup.py", + "Cargo.toml", + "go.mod", + ], + "file_categories": { + "all_files": { + "description": "All files not ignored", + "patterns": ["*"], + }, + "all_source_code": { + "description": "All source code files", + "patterns": [ + "*.java", + "*.py", + "*.js", + "*.ts", + "*.c", + "*.cpp", + "*.h", + "*.hpp", + "*.go", + "*.rs", + ], + }, + "production_code": { + "description": "Production source code", + "patterns": [ + "src/**/*.java", + "src/**/*.py", + "src/**/*.js", + "src/**/*.ts", + ], + }, + "test_code": { + "description": "Test source code", + "patterns": [ + "test/**/*.java", + "tests/**/*.py", + "**/test_*.py", + "**/*Test.java", + ], + }, + "build_config": { + "description": "Build configuration files", + "patterns": [ + "pom.xml", + "build.gradle", + "package.json", + "setup.py", + "Cargo.toml", + "go.mod", + ], + }, + }, } def _load_config(self): @@ -65,9 +123,11 @@ def _load_config(self): for key, value in defaults.items(): if key not in config: config[key] = value - elif key == "chat_destination": - # Expand user home directory for path-based settings - config[key] = str(Path(config[key]).expanduser()) + elif key == "file_categories": + # Merge default categories with user-defined categories + for category, category_data in value.items(): + if category not in config[key]: + config[key][category] = category_data return config def _save_config(self): @@ -97,17 +157,12 @@ def set(self, key, value): """ Sets a configuration value and saves the configuration. - For path-based settings (chat_destination), this method expands the user's home directory. - Args: key (str): The key for the configuration setting to set. value (any): The value to set for the given key. This method updates the configuration with the provided key-value pair and then saves the configuration to the file. """ - if key == "chat_destination": - # Expand user home directory for path-based settings - value = str(Path(value).expanduser()) self.config[key] = value self._save_config() @@ -128,3 +183,33 @@ def get_session_key(self): return None return session_key + + def add_file_category(self, category_name, description, patterns): + if "file_categories" not in self.config: + self.config["file_categories"] = {} + self.config["file_categories"][category_name] = { + "description": description, + "patterns": patterns, + } + self._save_config() + + def remove_file_category(self, category_name): + if ( + "file_categories" in self.config + and category_name in self.config["file_categories"] + ): + del self.config["file_categories"][category_name] + self._save_config() + + def update_file_category(self, category_name, description=None, patterns=None): + if ( + "file_categories" in self.config + and category_name in self.config["file_categories"] + ): + if description is not None: + self.config["file_categories"][category_name][ + "description" + ] = description + if patterns is not None: + self.config["file_categories"][category_name]["patterns"] = patterns + self._save_config() diff --git a/src/claudesync/providers/base_claude_ai.py b/src/claudesync/providers/base_claude_ai.py index 4ea6934..12e4439 100644 --- a/src/claudesync/providers/base_claude_ai.py +++ b/src/claudesync/providers/base_claude_ai.py @@ -1,6 +1,8 @@ import datetime +import json import logging import urllib +import sseclient import click from .base_provider import BaseProvider @@ -198,3 +200,61 @@ def delete_chat(self, organization_id, conversation_uuids): def _make_request(self, method, endpoint, data=None): raise NotImplementedError("This method should be implemented by subclasses") + + def create_chat(self, organization_id, chat_name="", project_uuid=None): + """ + Create a new chat conversation in the specified organization. + + Args: + organization_id (str): The UUID of the organization. + chat_name (str, optional): The name of the chat. Defaults to an empty string. + project_uuid (str, optional): The UUID of the project to associate the chat with. Defaults to None. + + Returns: + dict: The created chat conversation data. + + Raises: + ProviderError: If the chat creation fails. + """ + data = { + "uuid": self._generate_uuid(), + "name": chat_name, + "project_uuid": project_uuid, + } + return self._make_request( + "POST", f"/organizations/{organization_id}/chat_conversations", data + ) + + def _generate_uuid(self): + """Generate a UUID for the chat conversation.""" + import uuid + + return str(uuid.uuid4()) + + def _make_request_stream(self, method, endpoint, data=None): + # This method should be implemented by subclasses to return a response object + # that can be used with sseclient + raise NotImplementedError("This method should be implemented by subclasses") + + def send_message(self, organization_id, chat_id, prompt, timezone="UTC"): + endpoint = ( + f"/organizations/{organization_id}/chat_conversations/{chat_id}/completion" + ) + data = { + "prompt": prompt, + "timezone": timezone, + "attachments": [], + "files": [], + } + response = self._make_request_stream("POST", endpoint, data) + client = sseclient.SSEClient(response) + for event in client.events(): + if event.data: + try: + yield json.loads(event.data) + except json.JSONDecodeError: + yield {"error": "Failed to parse JSON"} + if event.event == "error": + yield {"error": event.data} + if event.event == "done": + break diff --git a/src/claudesync/providers/base_provider.py b/src/claudesync/providers/base_provider.py index b83e156..2509073 100644 --- a/src/claudesync/providers/base_provider.py +++ b/src/claudesync/providers/base_provider.py @@ -68,3 +68,13 @@ def get_artifact_content(self, organization_id, artifact_uuid): def delete_chat(self, organization_id, conversation_uuids): """Delete specified chats for a given organization.""" pass + + @abstractmethod + def create_chat(self, organization_id, chat_name="", project_uuid=None): + """Create a new chat conversation in the specified organization.""" + pass + + @abstractmethod + def send_message(self, organization_id, chat_id, prompt, timezone="UTC"): + """Send a message to a specified chat conversation.""" + pass diff --git a/src/claudesync/providers/claude_ai.py b/src/claudesync/providers/claude_ai.py index 53b3d56..d32c8a7 100644 --- a/src/claudesync/providers/claude_ai.py +++ b/src/claudesync/providers/claude_ai.py @@ -3,6 +3,8 @@ import urllib.parse import json import gzip +from datetime import datetime, timezone + from .base_claude_ai import BaseClaudeAIProvider from ..exceptions import ProviderError @@ -71,11 +73,11 @@ def _make_request(self, method, endpoint, data=None): raise ProviderError(f"Invalid JSON response from API: {str(json_err)}") def handle_http_error(self, e): - self.logger.error(f"Request failed: {str(e)}") - self.logger.error(f"Response status code: {e.code}") - self.logger.error(f"Response headers: {e.headers}") + 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.error(f"Response content: {content}") + self.logger.debug(f"Response content: {content}") if e.code == 403: error_msg = ( "Received a 403 Forbidden error. Your session key might be invalid. " @@ -86,4 +88,36 @@ def handle_http_error(self, e): ) self.logger.error(error_msg) raise ProviderError(error_msg) + if e.code == 429: + try: + error_data = json.loads(content) + 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}") + 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)}") + + def _make_request_stream(self, method, endpoint, data=None): + url = f"{self.BASE_URL}{endpoint}" + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko/20100101 Firefox/129.0", + "Content-Type": "application/json", + "Accept": "text/event-stream", + "Cookie": f"sessionKey={self.session_key}", + } + + req = urllib.request.Request(url, method=method, headers=headers) + if data: + req.data = json.dumps(data).encode("utf-8") + + try: + return urllib.request.urlopen(req) + except urllib.error.HTTPError as e: + self.handle_http_error(e) + except urllib.error.URLError as e: + raise ProviderError(f"API request failed: {str(e)}") diff --git a/src/claudesync/providers/claude_ai_curl.py b/src/claudesync/providers/claude_ai_curl.py index bbe8769..06f2b72 100644 --- a/src/claudesync/providers/claude_ai_curl.py +++ b/src/claudesync/providers/claude_ai_curl.py @@ -1,3 +1,4 @@ +import io import json import subprocess import tempfile @@ -127,3 +128,28 @@ def _handle_unicode_decode_error(self, e, headers): ) self.logger.error(error_message) raise ProviderError(error_message) + + def _make_request_stream(self, method, endpoint, data=None): + url = f"{self.BASE_URL}{endpoint}" + headers = [ + "-H", + "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko/20100101 Firefox/129.0", + "-H", + f"Cookie: sessionKey={self.session_key}", + "-H", + "Content-Type: application/json", + "-H", + "Accept: text/event-stream", + ] + + command = ["curl", "-N", "-s", url, "-X", method] + headers + if data: + command.extend(["-d", json.dumps(data)]) + + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + ) + return io.TextIOWrapper(process.stdout) diff --git a/src/claudesync/utils.py b/src/claudesync/utils.py index 5de1230..173d9e3 100644 --- a/src/claudesync/utils.py +++ b/src/claudesync/utils.py @@ -163,7 +163,7 @@ def process_file(file_path): return None -def get_local_files(local_path): +def get_local_files(local_path, category=None): """ Retrieves a dictionary of local files within a specified path, applying various filters. @@ -183,16 +183,26 @@ def get_local_files(local_path): Returns: dict: A dictionary where keys are relative file paths, and values are MD5 hashes of the file contents. """ + config = ConfigManager() gitignore = load_gitignore(local_path) claudeignore = load_claudeignore(local_path) files = {} exclude_dirs = {".git", ".svn", ".hg", ".bzr", "_darcs", "CVS", "claude_chats"} + categories = config.get("file_categories", {}) + if category and category not in categories: + raise ValueError(f"Invalid category: {category}") + + patterns = ["*"] # Default to all files + if category: + patterns = categories[category]["patterns"] + + spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns) + for root, dirs, filenames in os.walk(local_path, topdown=True): rel_root = os.path.relpath(root, local_path) rel_root = "" if rel_root == "." else rel_root - # Filter out directories before traversing dirs[:] = [ d for d in dirs @@ -207,7 +217,7 @@ def get_local_files(local_path): rel_path = os.path.join(rel_root, filename) full_path = os.path.join(root, filename) - if should_process_file( + if spec.match_file(rel_path) and should_process_file( full_path, filename, gitignore, local_path, claudeignore ): file_hash = process_file(full_path) @@ -268,14 +278,14 @@ def validate_and_get_provider(config, require_org=True, require_project=False): """ active_provider = config.get("active_provider") session_key = config.get_session_key() - if not session_key: - raise ProviderError( - f"Session key has expired. Please run `claudesync api login {active_provider}` again." - ) if not active_provider or not session_key: raise ConfigurationError( "No active provider or session key. Please login first." ) + if not session_key: + raise ProviderError( + f"Session key has expired. Please run `claudesync api login {active_provider}` again." + ) if require_org and not config.get("active_organization_id"): raise ConfigurationError( "No active organization set. Please select an organization." @@ -342,3 +352,32 @@ def load_claudeignore(base_path): with open(claudeignore_path, "r") as f: return pathspec.PathSpec.from_lines("gitwildmatch", f) return None + + +def detect_submodules(base_path, submodule_detect_filenames): + """ + Detects submodules within a project based on specific filenames. + + This function walks through the directory tree starting from base_path, + looking for files that indicate a submodule (e.g., pom.xml, build.gradle). + It returns a list of tuples containing the relative path to the submodule + and the filename that caused it to be identified as a submodule. + + Args: + base_path (str): The base directory path to start the search from. + submodule_detect_filenames (list): List of filenames that indicate a submodule. + + Returns: + list: A list of tuples (relative_path, detected_filename) for detected submodules, + excluding the root directory. + """ + submodules = [] + for root, dirs, files in os.walk(base_path): + for filename in submodule_detect_filenames: + if filename in files: + relative_path = os.path.relpath(root, base_path) + # Exclude the root directory (represented by an empty string or '.') + if relative_path not in ("", "."): + submodules.append((relative_path, filename)) + break # Stop searching this directory once a submodule is found + return submodules diff --git a/tests/cli/test_project.py b/tests/cli/test_project.py index 8af1486..0ee044c 100644 --- a/tests/cli/test_project.py +++ b/tests/cli/test_project.py @@ -1,149 +1,296 @@ -import unittest -from unittest.mock import patch, MagicMock, ANY +import pytest +from unittest.mock import patch, MagicMock, call from click.testing import CliRunner -from claudesync.cli.main import cli +from claudesync.cli.project import sync from claudesync.exceptions import ProviderError -class TestProjectCLI(unittest.TestCase): - def setUp(self): - self.runner = CliRunner() +@pytest.fixture +def mock_config(): + config = MagicMock() + config.get.side_effect = lambda key, default=None: { + "active_organization_id": "org123", + "active_project_id": "proj456", + "active_project_name": "MainProject", + "local_path": "/path/to/project", + "submodule_detect_filenames": ["pom.xml", "build.gradle"], + }.get(key, default) + return config - @patch("claudesync.cli.project.validate_and_get_provider") - @patch("claudesync.cli.project.click.prompt") - @patch("claudesync.cli.project.validate_and_store_local_path") - def test_project_create( - self, mock_validate_path, mock_prompt, mock_validate_and_get_provider - ): - mock_provider = MagicMock() - mock_provider.create_project.return_value = { - "uuid": "proj1", - "name": "New Project", - } - mock_validate_and_get_provider.return_value = mock_provider - mock_prompt.side_effect = ["New Project", "Project Description"] +@pytest.fixture +def mock_provider(): + return MagicMock() - result = self.runner.invoke(cli, ["project", "create"]) - self.assertEqual(result.exit_code, 0) - self.assertIn( - "Project 'New Project' (uuid: proj1) has been created successfully.", - result.output, - ) - self.assertIn("Active project set to: New Project (uuid: proj1)", result.output) - mock_validate_path.assert_called_once() - mock_provider.create_project.assert_called_once_with( - "org1", "New Project", "Project Description" - ) +@pytest.fixture +def mock_sync_manager(): + return MagicMock() - @patch("claudesync.cli.project.validate_and_get_provider") - @patch("claudesync.cli.project.click.prompt") - @patch("claudesync.cli.project.click.confirm") - def test_project_archive( - self, mock_confirm, mock_prompt, mock_validate_and_get_provider - ): - mock_provider = MagicMock() - mock_provider.get_projects.return_value = [ - {"id": "proj1", "name": "Project 1"}, - {"id": "proj2", "name": "Project 2"}, - ] - mock_validate_and_get_provider.return_value = mock_provider - mock_prompt.return_value = 1 - mock_confirm.return_value = True +@pytest.fixture +def mock_get_local_files(): + with patch("claudesync.cli.project.get_local_files") as mock: + yield mock + - result = self.runner.invoke(cli, ["project", "archive"]) +@pytest.fixture +def mock_detect_submodules(): + with patch("claudesync.cli.project.detect_submodules") as mock: + yield mock - self.assertEqual(result.exit_code, 0) - self.assertIn("Project 'Project 1' has been archived.", result.output) - mock_provider.archive_project.assert_called_once_with("org1", "proj1") +class TestProjectCLI: @patch("claudesync.cli.project.validate_and_get_provider") - @patch("claudesync.cli.project.click.prompt") - @patch("claudesync.cli.project.validate_and_store_local_path") - def test_project_select( - self, mock_validate_path, mock_prompt, mock_validate_and_get_provider + @patch("claudesync.cli.project.SyncManager") + @patch("os.path.abspath") + @patch("os.path.join") + @patch("os.makedirs") + def test_project_sync( + self, + mock_makedirs, + mock_path_join, + mock_path_abspath, + mock_sync_manager_class, + mock_validate_provider, + mock_config, + mock_provider, + mock_sync_manager, + mock_get_local_files, + mock_detect_submodules, ): - mock_provider = MagicMock() + # Setup + runner = CliRunner() + mock_validate_provider.return_value = mock_provider + mock_sync_manager_class.return_value = mock_sync_manager + mock_provider.get_projects.return_value = [ - {"id": "proj1", "name": "Project 1"}, - {"id": "proj2", "name": "Project 2"}, + {"id": "proj456", "name": "MainProject"}, + {"id": "sub789", "name": "MainProject-SubModule-SubA"}, + ] + mock_provider.list_files.side_effect = [ + [ + { + "uuid": "file1", + "file_name": "main.py", + "content": "print('main')", + "created_at": "2023-01-01T00:00:00Z", + } + ], + [ + { + "uuid": "file2", + "file_name": "sub.py", + "content": "print('sub')", + "created_at": "2023-01-01T00:00:00Z", + } + ], ] - mock_validate_and_get_provider.return_value = mock_provider - mock_prompt.return_value = 1 + mock_get_local_files.side_effect = [{"main.py": "hash1"}, {"sub.py": "hash2"}] + + mock_detect_submodules.return_value = [("SubA", "pom.xml")] - result = self.runner.invoke(cli, ["project", "select"]) + mock_path_abspath.side_effect = lambda x: x + mock_path_join.side_effect = lambda *args: "/".join(args) - self.assertEqual(result.exit_code, 0) - self.assertIn("Selected project: Project 1 (ID: proj1)", result.output) - mock_validate_path.assert_called_once() + # Execute + result = runner.invoke(sync, obj=mock_config) + + # Assert + assert ( + result.exit_code == 0 + ), f"Exit code was {result.exit_code}, expected 0. Exception: {result.exception}" + assert "Main project 'MainProject' synced successfully." in result.output + assert "Syncing submodule 'SubA'..." in result.output + assert "Submodule 'SubA' synced successfully." in result.output + assert ( + "Project sync completed successfully, including available submodules." + in result.output + ) + + # Verify method calls + mock_validate_provider.assert_called_once_with( + mock_config, require_project=True + ) + mock_provider.get_projects.assert_called_once_with( + "org123", include_archived=False + ) + mock_detect_submodules.assert_called_once_with( + "/path/to/project", ["pom.xml", "build.gradle"] + ) + + assert mock_provider.list_files.call_count == 2 + mock_provider.list_files.assert_has_calls( + [call("org123", "proj456"), call("org123", "sub789")] + ) + + assert mock_get_local_files.call_count == 2 + mock_get_local_files.assert_has_calls( + [call("/path/to/project", None), call("/path/to/project/SubA", None)] + ) + + assert mock_sync_manager.sync.call_count == 2 + mock_sync_manager.sync.assert_has_calls( + [ + call( + {"main.py": "hash1"}, + [ + { + "uuid": "file1", + "file_name": "main.py", + "content": "print('main')", + "created_at": "2023-01-01T00:00:00Z", + } + ], + ), + call( + {"sub.py": "hash2"}, + [ + { + "uuid": "file2", + "file_name": "sub.py", + "content": "print('sub')", + "created_at": "2023-01-01T00:00:00Z", + } + ], + ), + ] + ) @patch("claudesync.cli.project.validate_and_get_provider") - def test_project_list(self, mock_validate_and_get_provider): - mock_provider = MagicMock() - mock_provider.get_projects.return_value = [ - {"id": "proj1", "name": "Project 1", "archived_at": None}, - {"id": "proj2", "name": "Project 2", "archived_at": "2023-01-01"}, - ] - mock_validate_and_get_provider.return_value = mock_provider + def test_project_sync_no_local_path(self, mock_validate_provider, mock_config): + runner = CliRunner() + mock_config.get.side_effect = lambda key, default=None: ( + None if key == "local_path" else default + ) + mock_validate_provider.return_value = MagicMock() - result = self.runner.invoke(cli, ["project", "ls", "--all"]) + result = runner.invoke(sync, obj=mock_config) - self.assertEqual(result.exit_code, 0) - self.assertIn("Project 1 (ID: proj1)", result.output) - self.assertIn("Project 2 (ID: proj2) (Archived)", result.output) + assert result.exit_code == 0 + assert ( + "No local path set. Please select or create a project first." + in result.output + ) @patch("claudesync.cli.project.validate_and_get_provider") - def test_project_list_no_projects(self, mock_validate_and_get_provider): - mock_provider = MagicMock() - mock_provider.get_projects.return_value = [] - mock_validate_and_get_provider.return_value = mock_provider + def test_project_sync_provider_error(self, mock_validate_provider, mock_config): + runner = CliRunner() + mock_validate_provider.side_effect = ProviderError("API Error") - result = self.runner.invoke(cli, ["project", "ls"]) + result = runner.invoke(sync, obj=mock_config) - self.assertEqual(result.exit_code, 0) - self.assertIn("No projects found.", result.output) + assert result.exit_code == 0 + assert "Error: API Error" in result.output @patch("claudesync.cli.project.validate_and_get_provider") - def test_project_create_error(self, mock_validate_and_get_provider): - mock_provider = MagicMock() - mock_provider.create_project.side_effect = ProviderError( - "Failed to create project" - ) - mock_validate_and_get_provider.return_value = mock_provider + @patch("claudesync.cli.project.SyncManager") + def test_project_sync_no_submodules( + self, + mock_sync_manager_class, + mock_validate_provider, + mock_config, + mock_provider, + mock_sync_manager, + mock_get_local_files, + mock_detect_submodules, + ): + runner = CliRunner() + mock_validate_provider.return_value = mock_provider + mock_sync_manager_class.return_value = mock_sync_manager - result = self.runner.invoke( - cli, ["project", "create"], input="New Project\nProject Description\n" + mock_provider.get_projects.return_value = [ + {"id": "proj456", "name": "MainProject"} + ] + mock_provider.list_files.return_value = [ + { + "uuid": "file1", + "file_name": "main.py", + "content": "print('main')", + "created_at": "2023-01-01T00:00:00Z", + } + ] + mock_get_local_files.return_value = {"main.py": "hash1"} + mock_detect_submodules.return_value = [] + + result = runner.invoke(sync, obj=mock_config) + + assert result.exit_code == 0 + assert "Main project 'MainProject' synced successfully." in result.output + assert ( + "Project sync completed successfully, including available submodules." + in result.output ) + assert "Syncing submodule" not in result.output + + mock_sync_manager.sync.assert_called_once() + + @patch("claudesync.cli.project.validate_and_get_provider") + @patch("claudesync.cli.project.SyncManager") + def test_project_sync_with_category( + self, + mock_sync_manager_class, + mock_validate_provider, + mock_config, + mock_provider, + mock_sync_manager, + mock_get_local_files, + mock_detect_submodules, + ): + runner = CliRunner() + mock_validate_provider.return_value = mock_provider + mock_sync_manager_class.return_value = mock_sync_manager + + mock_provider.get_projects.return_value = [ + {"id": "proj456", "name": "MainProject"} + ] + mock_provider.list_files.return_value = [ + { + "uuid": "file1", + "file_name": "main.py", + "content": "print('main')", + "created_at": "2023-01-01T00:00:00Z", + } + ] + mock_get_local_files.return_value = {"main.py": "hash1"} + mock_detect_submodules.return_value = [] - self.assertEqual(result.exit_code, 0) - self.assertIn( - "Failed to create project: Failed to create project", result.output + result = runner.invoke(sync, ["--category", "production_code"], obj=mock_config) + + assert result.exit_code == 0 + assert "Main project 'MainProject' synced successfully." in result.output + + mock_get_local_files.assert_called_once_with( + "/path/to/project", "production_code" ) + mock_sync_manager.sync.assert_called_once() @patch("claudesync.cli.project.validate_and_get_provider") @patch("claudesync.cli.project.SyncManager") - @patch("claudesync.cli.project.get_local_files") - def test_project_sync( - self, mock_get_local_files, mock_sync_manager, mock_validate_and_get_provider + def test_project_sync_with_invalid_category( + self, + mock_sync_manager_class, + mock_validate_provider, + mock_config, + mock_provider, + mock_sync_manager, + mock_get_local_files, + mock_detect_submodules, ): - mock_provider = MagicMock() - mock_validate_and_get_provider.return_value = mock_provider - mock_get_local_files.return_value = {"file1.txt": "hash1"} - mock_sync_manager_instance = MagicMock() - mock_sync_manager.return_value = mock_sync_manager_instance - - result = self.runner.invoke(cli, ["project", "sync"]) - - self.assertEqual(result.exit_code, 0) - self.assertIn("Project sync completed successfully.", result.output) - mock_validate_and_get_provider.assert_called_once_with( - ANY, require_project=True + runner = CliRunner() + mock_validate_provider.return_value = mock_provider + mock_sync_manager_class.return_value = mock_sync_manager + + mock_get_local_files.side_effect = ValueError( + "Invalid category: invalid_category" + ) + + result = runner.invoke( + sync, ["--category", "invalid_category"], obj=mock_config ) - mock_sync_manager_instance.sync.assert_called_once() + assert result.exit_code == 1 + assert "Invalid category: invalid_category" in result.exception.args[0] -if __name__ == "__main__": - unittest.main() + mock_sync_manager.sync.assert_not_called() diff --git a/tests/cli/test_sync.py b/tests/cli/test_sync.py deleted file mode 100644 index c019586..0000000 --- a/tests/cli/test_sync.py +++ /dev/null @@ -1,108 +0,0 @@ -import unittest -from unittest.mock import patch, MagicMock, ANY -from click.testing import CliRunner -from claudesync.cli.main import cli - - -class TestSyncCLI(unittest.TestCase): - def setUp(self): - self.runner = CliRunner() - - @patch("claudesync.cli.sync.validate_and_get_provider") - def test_ls_command(self, mock_validate_and_get_provider): - mock_provider = MagicMock() - mock_provider.list_files.return_value = [ - {"file_name": "file1.txt", "uuid": "uuid1", "created_at": "2023-01-01"}, - {"file_name": "file2.py", "uuid": "uuid2", "created_at": "2023-01-02"}, - ] - mock_validate_and_get_provider.return_value = mock_provider - - result = self.runner.invoke(cli, ["ls"]) - - self.assertEqual(result.exit_code, 0) - self.assertIn("file1.txt", result.output) - self.assertIn("file2.py", result.output) - mock_provider.list_files.assert_called_once() - mock_validate_and_get_provider.assert_called_once_with( - ANY, require_project=True - ) - - @patch("claudesync.cli.sync.validate_and_get_provider") - @patch("claudesync.cli.sync.SyncManager") - @patch("claudesync.cli.sync.get_local_files") - @patch("claudesync.cli.sync.sync_chats") - def test_sync_command( - self, - mock_sync_chats, - mock_get_local_files, - mock_sync_manager, - mock_validate_and_get_provider, - ): - mock_provider = MagicMock() - mock_validate_and_get_provider.return_value = mock_provider - mock_get_local_files.return_value = {"file1.txt": "hash1"} - mock_sync_manager_instance = MagicMock() - mock_sync_manager.return_value = mock_sync_manager_instance - - result = self.runner.invoke(cli, ["sync"]) - - self.assertEqual(result.exit_code, 0) - self.assertIn("Project and chat sync completed successfully.", result.output) - mock_validate_and_get_provider.assert_called_once_with( - ANY, require_project=True - ) - mock_sync_manager_instance.sync.assert_called_once() - mock_sync_chats.assert_called_once() - - @patch("claudesync.cli.sync.validate_and_get_provider") - def test_ls_command_no_files(self, mock_validate_and_get_provider): - mock_provider = MagicMock() - mock_provider.list_files.return_value = [] - mock_validate_and_get_provider.return_value = mock_provider - - result = self.runner.invoke(cli, ["ls"]) - - self.assertEqual(result.exit_code, 0) - self.assertIn("No files found in the active project.", result.output) - - @patch("claudesync.cli.sync.shutil.which") - @patch("claudesync.cli.sync.sys.platform", "linux") - @patch("claudesync.cli.sync.CronTab") - def test_schedule_command_unix(self, mock_crontab, mock_which): - mock_which.return_value = "/usr/local/bin/claudesync" - mock_cron = MagicMock() - mock_crontab.return_value = mock_cron - - result = self.runner.invoke(cli, ["schedule"], input="10\n") - - self.assertEqual(result.exit_code, 0) - self.assertIn("Cron job created successfully!", result.output) - mock_cron.new.assert_called_once_with(command="/usr/local/bin/claudesync sync") - mock_cron.write.assert_called_once() - - @patch("claudesync.cli.sync.shutil.which") - @patch("claudesync.cli.sync.sys.platform", "win32") - def test_schedule_command_windows(self, mock_which): - mock_which.return_value = "C:\\Program Files\\claudesync\\claudesync.exe" - - result = self.runner.invoke(cli, ["schedule"], input="10\n") - - self.assertEqual(result.exit_code, 0) - self.assertIn("Windows Task Scheduler setup:", result.output) - self.assertIn( - 'schtasks /create /tn "ClaudeSync" /tr "C:\\Program Files\\claudesync\\claudesync.exe sync" /sc minute /mo 10', - result.output, - ) - - @patch("claudesync.cli.sync.shutil.which") - def test_schedule_command_claudesync_not_found(self, mock_which): - mock_which.return_value = None - - result = self.runner.invoke(cli, ["schedule"], input="10\n") - - self.assertEqual(result.exit_code, 1) - self.assertIn("Error: claudesync not found in PATH.", result.output) - - -if __name__ == "__main__": - unittest.main()