diff --git a/.gitignore b/.gitignore index 880101f..a78f7fa 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__ .env duckington_cli.egg-info .coverage +.vscode diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a77ed1d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +default_language_version: + python: python3.11 +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-added-large-files + - id: check-toml + - id: check-yaml + args: + - --unsafe + - id: end-of-file-fixer + - id: trailing-whitespace +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.7.4 + hooks: + - id: ruff + args: + - --fix + - id: ruff-format +- repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.41.0 + hooks: + - id: markdownlint + args: [ --disable=MD013, --disable=MD002,--disable=MD032,--disable=MD005,--disable=MD009 ] + + +ci: + autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks + autoupdate_commit_msg: ⬆ [pre-commit.ci] pre-commit autoupdate diff --git a/README.md b/README.md index 63849e6..83decea 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,21 @@ -# Command Line Interface (CLI) +# Duckington CLI -The CLI is built using [click](https://click.palletsprojects.com/en/8.1.x/). Use the following command to get started with the CLI: + +
+ Le Duck +
+ +The CLI is built using [click](https://click.palletsprojects.com/en/8.1.x/). + +Use the following command to get started with the CLI: ```bash quack ``` +## Contributing + +To get started contributing to this project see the [setup](docs/setup.md) page ## Testing @@ -20,51 +30,3 @@ or ```bash python3 -m pytest ``` - -## Setup - -Follow these steps to set up a Python virtual environment using `venv` - -### 1. Create Virtual Environment - -```bash -python3 -m venv .venv -``` - -### 2. Activate Virtual Environment - -- Linux / macOS: - -```bash -source .venv/bin/activate -``` - -- Windows: - -```bash -.venv\Scripts\activate -``` - -- Git Bash: - -```bash -source .venv/Scripts/activate -``` - -### 3. Install Dependencies - -```bash -pip install -r requirements.txt -``` - -### 4. Install CLI application in editable mode - -```bash -pip install --editable . -``` - -### 5. To deactivate the venv - -```bash -deactivate -``` diff --git a/docs/assets/duckington.png b/docs/assets/duckington.png new file mode 100644 index 0000000..468367b Binary files /dev/null and b/docs/assets/duckington.png differ diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..bc7db2e --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,61 @@ +# Setup + +Follow these steps to get started with `quack`. + +## TL;DR + +1. Set up a Python virtual environment using `venv` +2. Install Pre-commit hooks +3. Install `quack` as an editable module + +## Environment Setup + +### 1. Create Virtual Environment + +```bash +python3 -m venv .venv +``` + +### 2. Activate Virtual Environment + +- **Linux / macOS:** + + ```bash + source .venv/bin/activate + ``` + +- **Windows:** + + ```bash + . .venv\Scripts\activate + ``` + +- **Git Bash:** + + ```bash + source .venv/Scripts/activate + ``` + +### 3. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### 4. Activate Pre-commit Hooks + +```bash +pre-commit install +``` + +### 5. Install CLI Application in Editable Mode + +```bash +pip install --editable . +``` + +### 6. Deactivate the Virtual Environment + +```bash +deactivate +``` diff --git a/requirements.txt b/requirements.txt index ba5e5f9..f251454 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/src/api/api_client.py b/src/api/api_client.py index 1c3ce26..d28f06e 100644 --- a/src/api/api_client.py +++ b/src/api/api_client.py @@ -1,3 +1,4 @@ +from io import BufferedReader import requests from typing import Optional, Dict, Any from src.config.settings import API_BASE_URL @@ -21,6 +22,7 @@ def _make_request( data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, + files: Optional[Dict[str, BufferedReader]] = None, ) -> Dict[str, Any]: url: str = f"{self.base_url}/{endpoint}" headers = headers or {} @@ -37,6 +39,7 @@ def _make_request( data=data if endpoint == "auth" else None, params=params, headers=headers, + files=files, ) response.raise_for_status() return response.json() @@ -55,9 +58,10 @@ def post( data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, + files: Optional[Dict[str, BufferedReader]] = None, ) -> Dict[str, Any]: return self._make_request( - "POST", endpoint, data=data, params=params, headers=headers + "POST", endpoint, data=data, params=params, headers=headers, files=files ) def put( @@ -66,9 +70,10 @@ def put( data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, + files: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: return self._make_request( - "PUT", endpoint, data=data, params=params, headers=headers + "PUT", endpoint, data=data, params=params, headers=headers, files=files ) def delete( diff --git a/src/api/model_file_api.py b/src/api/model_file_api.py new file mode 100644 index 0000000..fcb4f18 --- /dev/null +++ b/src/api/model_file_api.py @@ -0,0 +1,52 @@ +import os +from typing import Dict + +from src.api.api_client import APIClient +from src.utils.helpers.handle_api_errors import handle_api_errors + + +class ModelFileAPI: + def __init__(self, client: APIClient): + self.client = client + + @handle_api_errors + def upload_model_file( + self, model_name: str, model_id: str, file_path: str + ) -> Dict[str, str]: + with open(file_path, "rb") as file: + files = { + "file": (os.path.basename(file_path), file, "application/octet-stream") + } + params = {"model_name": model_name, "model_id": model_id} + return self.client.post("models", params=params, files=files) + + @handle_api_errors + def get_model(self, model_id: str): + return self.client.get(f"models/{model_id}") + + @handle_api_errors + def get_all_models(self): + return self.client.get("models") + + @handle_api_errors + def read_model_file(self, model_id: str, file_name: str): + return self.client.get(f"models/{model_id}/{file_name}") + + @handle_api_errors + def update_model_file( + self, model_name: str, model_id: str, file_path: str + ) -> Dict[str, str]: + with open(file_path, "rb") as file: + files = { + "file": (os.path.basename(file_path), file, "application/octet-stream") + } + params = {"model_name": model_name, "model_id": model_id} + return self.client.put("models", params=params, files=files) + + @handle_api_errors + def delete_model_file(self, model_id: str, file_name: str): + return self.client.delete(f"models/{model_id}/{file_name}") + + @handle_api_errors + def delete_model(self, model_id: str): + return self.client.delete(f"models/{model_id}") diff --git a/src/commands/__init__.py b/src/commands/__init__.py index e69de29..05dc52b 100644 --- a/src/commands/__init__.py +++ b/src/commands/__init__.py @@ -0,0 +1,7 @@ +from src.commands import key +from src.commands import user_auth +from src.commands import machine +from src.commands import metrics +from src.commands import model_file + +__all__ = [key, user_auth, machine, metrics, model_file] diff --git a/src/commands/auth_commands.py b/src/commands/auth_commands.py deleted file mode 100644 index bb1beee..0000000 --- a/src/commands/auth_commands.py +++ /dev/null @@ -1,147 +0,0 @@ -import click -from src.commands.base_command import BaseCommand -from src.api.api_client import APIClient -from src.api.auth_api import AuthAPI -from src.utils.helpers.validity_enum import ValidityEnum - - -class LoginCommand(BaseCommand): - def execute(self, email, password): - client = APIClient() - endpoint = AuthAPI(client) - result = endpoint.login(email, password) - if result["success"]: - print(f"Successfully logged in. {result['data']}") - else: - print(f"Login failed. {result['message']}") - - -@click.command() -@click.option("--email", prompt=True) -@click.option("--password", prompt=True, hide_input=True) -@click.pass_context -def login(ctx, email, password): - cmd = LoginCommand() - cmd.execute(email, password) - - -class LogoutCommand(BaseCommand): - def execute(self): - client = APIClient() - endpoint = AuthAPI(client) - logout_result = endpoint.logout() - - if logout_result: - print("Successfully logged out.") - else: - print("No action taken. You were not logged in.") - - return logout_result - - -@click.command() -@click.pass_context -def logout(ctx): - cmd = LogoutCommand() - cmd.execute() - - -class CreateAPIKeyCommand(BaseCommand): - def execute(self, validity): - client = APIClient() - endpoint = AuthAPI(client) - result = endpoint.create_api_key(ValidityEnum[validity]) - if result["success"]: - print(f"API key created successfully: {result['data']}") - else: - print(f"Failed to create API key. {result['message']}") - - -@click.command() -@click.option( - "--validity", - type=click.Choice(["ONE_HOUR", "ONE_DAY", "ONE_WEEK", "ONE_MONTH", "ONE_YEAR"]), - default="ONE_DAY", - show_default=True, - prompt="Choose validity period", -) -@click.pass_context -def create_api_key(ctx, validity): - cmd = CreateAPIKeyCommand() - cmd.execute(validity) - - -class ListAPIKeysCommand(BaseCommand): - def execute(self): - client = APIClient() - endpoint = AuthAPI(client) - result = endpoint.list_api_keys() - if result["success"]: - print("API Keys:") - for key in result["data"]: - print( - f"Token: {key['token']}, Created At: {key['created_at']}, Validity: {key['validity']}" - ) - else: - print(f"Failed to retrieve API keys. {result['message']}") - - -@click.command() -@click.pass_context -def list_api_keys(ctx): - cmd = ListAPIKeysCommand() - cmd.execute() - - -class DeleteAPIKeyCommand(BaseCommand): - def execute(self, token): - client = APIClient() - endpoint = AuthAPI(client) - result = endpoint.delete_api_key(token) - if result["success"]: - print(f"API key deleted successfully. {result['data']}") - else: - print(f"Failed to delete API key. {result['message']}") - - -@click.command() -@click.option("--token", prompt=True) -@click.pass_context -def delete_api_key(ctx, token): - cmd = DeleteAPIKeyCommand() - cmd.execute(token) - - -class SetAPIKeyCommand(BaseCommand): - def execute(self, api_key): - client = APIClient() - endpoint = AuthAPI(client) - result = endpoint.set_api_key(api_key) - if result: - print("API key set successfully.") - - -@click.command() -@click.option("--api-key", prompt=True, hide_input=True) -@click.pass_context -def set_api_key(ctx, api_key): - cmd = SetAPIKeyCommand() - cmd.execute(api_key) - - -class RemoveAPIKeyCommand(BaseCommand): - def execute(self): - client = APIClient() - endpoint = AuthAPI(client) - result = endpoint.clear_api_key() - if result: - print("API key removed successfully.") - else: - print("No API key was set.") - - -@click.command() -@click.pass_context -def remove_api_key(ctx): - cmd = RemoveAPIKeyCommand() - cmd.execute() diff --git a/src/commands/base_command.py b/src/commands/base_command.py deleted file mode 100644 index b1f0887..0000000 --- a/src/commands/base_command.py +++ /dev/null @@ -1,8 +0,0 @@ -from abc import ABC, abstractmethod - -class BaseCommand(ABC): - @abstractmethod - def execute(self, *args, **kwargs): - """Execute the command""" - pass - diff --git a/src/commands/key.py b/src/commands/key.py new file mode 100644 index 0000000..ba549d3 --- /dev/null +++ b/src/commands/key.py @@ -0,0 +1,100 @@ +import click + +from src.api.api_client import APIClient +from src.api.auth_api import AuthAPI +from src.utils.groups.subcommand_group import SubCommandGroup +from src.utils.helpers.validity_enum import ValidityEnum + + +class KeyCommands: + def __init__(self): + self.client = APIClient() + self.endpoint = AuthAPI(self.client) + + def create_api_key(self, validity): + result = self.endpoint.create_api_key(ValidityEnum[validity]) + if result["success"]: + click.echo(f"API key created successfully: {result['data']}") + else: + click.echo(f"Failed to create API key. {result['message']}") + + def list_api_keys(self): + result = self.endpoint.list_api_keys() + if result["success"]: + click.echo("API Keys:") + for key in result["data"]: + click.echo( + f"Token: {key['token']}, Created At: {key['created_at']}, Validity: {key['validity']}" + ) + else: + click.echo(f"Failed to retrieve API keys. {result['message']}") + + def delete_api_key(self, token): + result = self.endpoint.delete_api_key(token) + if result["success"]: + click.echo(f"API key deleted successfully. {result['data']}") + else: + click.echo(f"Failed to delete API key. {result['message']}") + + def set_api_key(self, api_key): + result = self.endpoint.set_api_key(api_key) + if result: + click.echo("API key set successfully.") + + def remove_api_key(self): + result = self.endpoint.clear_api_key() + if result: + click.echo("API key removed successfully.") + else: + click.echo("No API key was set.") + + +@click.group(cls=SubCommandGroup) +@click.pass_context +def key(ctx): + """API key management commands.""" + ctx.obj = KeyCommands() + + +@key.command() +@click.option( + "--validity", + type=click.Choice(["ONE_HOUR", "ONE_DAY", "ONE_WEEK", "ONE_MONTH", "ONE_YEAR"]), + default="ONE_DAY", + show_default=True, + prompt="Choose validity period", +) +@click.pass_context +def create(ctx, validity): + """Create a new API key.""" + ctx.obj.create_api_key(validity) + + +@key.command() +@click.pass_context +def list(ctx): + """List all API keys.""" + ctx.obj.list_api_keys() + + +@key.command() +@click.option("--token", prompt=True) +@click.pass_context +def delete(ctx, token): + """Delete an API key.""" + ctx.obj.delete_api_key(token) + + +@key.command() +@click.option("--api-key", prompt=True, hide_input=True) +@click.pass_context +def set(ctx, api_key): + """Set an API key.""" + ctx.obj.set_api_key(api_key) + + +@key.command() +@click.pass_context +def unset(ctx): + """Remove the set API key.""" + ctx.obj.remove_api_key() diff --git a/src/commands/machine_commands.py b/src/commands/machine.py similarity index 60% rename from src/commands/machine_commands.py rename to src/commands/machine.py index c131658..3f83d97 100644 --- a/src/commands/machine_commands.py +++ b/src/commands/machine.py @@ -1,14 +1,16 @@ import click from src.api.machine_api import MachineAPI -from src.commands.base_command import BaseCommand +from src.utils.groups.subcommand_group import SubCommandGroup -class CreateMachineCommand(BaseCommand): - def execute(self, machine_name, machine_type): - endpoint = MachineAPI() +class MachineCommands: + def __init__(self): + self.endpoint = MachineAPI() + + def create(self, machine_name, machine_type): click.echo("\nAttempting to create machine...\n") - result = endpoint.create_machine(machine_name, machine_type) + result = self.endpoint.create_machine(machine_name, machine_type) if result["success"]: click.echo(f"Machine created successfully. Details: {result['data']}") else: @@ -16,35 +18,12 @@ def execute(self, machine_name, machine_type): "Failed to create machine. Check machine name and type and try again." ) - -@click.command() -@click.pass_context -@click.option("--machine-name", "-n", required=True, prompt="Enter machine name") -@click.option( - "--machine-type", - "-t", - required=True, - prompt="Choose machine type", - type=click.Choice(["t2.micro", "f1.2xlarge", "f1.4xlarge", "f1.16xlarge"]), - default="f1.2xlarge", - show_default=True, -) -def create_machine(ctx, machine_name, machine_type): - """Create a machine with name and type.""" - cmd = CreateMachineCommand() - cmd.execute(machine_name=machine_name, machine_type=machine_type) - - -class ListMachinesCommand(BaseCommand): - def execute(self): - endpoint = MachineAPI() - result = endpoint.list_user_machines() - + def list(self): + result = self.endpoint.list_user_machines() if result["success"]: if not result["data"]: click.echo("No machines to list.") return - click.echo("Machines:") for machine in result["data"]: click.echo(machine) @@ -52,99 +31,98 @@ def execute(self): else: click.echo("Failed to retrieve list of machines.") - -@click.command() -@click.pass_context -def list_machines(ctx): - """List all machines assigned to user.""" - cmd = ListMachinesCommand() - cmd.execute() - - -class StopMachine(BaseCommand): - def execute(self, machine_id): - endpoint = MachineAPI() - + def stop(self, machine_id): click.echo("\nAttempting to stop machine...\n") - result = endpoint.stop_machine(machine_id) - + result = self.endpoint.stop_machine(machine_id) if result["success"]: click.echo(f"{result['data']['message']}") else: click.echo("Failed to stop machine. Check machine ID and try again.") - -@click.command() -@click.option("--machine-id", "-id", required=True, prompt="Enter machine ID") -@click.pass_context -def stop_machine(ctx, machine_id): - """Stop machine with machine ID.""" - cmd = StopMachine() - cmd.execute(machine_id) - - -class StartMachine(BaseCommand): - def execute(self, machine_id): - endpoint = MachineAPI() - + def start(self, machine_id): click.echo("\nAttempting to start machine...\n") - result = endpoint.start_machine(machine_id) - + result = self.endpoint.start_machine(machine_id) if result["success"]: click.echo(f"{result['data']['message']}") else: click.echo("Failed to start machine. Check machine ID and try again.") + def terminate(self, machine_id): + click.echo("\nAttempting to terminate machine...\n") + result = self.endpoint.terminate_machine(machine_id) + if result["success"]: + click.echo(f"{result['data']['message']}") + else: + click.echo("Failed to terminate machine. Check machine ID and try again.") -@click.command() -@click.option("--machine-id", "-id", required=True, prompt="Enter machine ID") + def get_details(self, machine_id): + result = self.endpoint.get_machine(machine_id) + if result["success"]: + click.echo(f"Machine {machine_id} details:") + click.echo(result["data"]) + return result["data"] + else: + click.echo("Failed to get machine. Check machine ID and try again.") + + +@click.group(cls=SubCommandGroup) @click.pass_context -def start_machine(ctx, machine_id): - """Start machine with machine ID.""" - cmd = StartMachine() - cmd.execute(machine_id) +def machine(ctx): + """Machine management commands.""" + ctx.obj = MachineCommands() -class TerminateMachine(BaseCommand): - def execute(self, machine_id): - endpoint = MachineAPI() +@machine.command() +@click.pass_context +@click.option("--machine-name", "-n", required=True, prompt="Enter machine name") +@click.option( + "--machine-type", + "-t", + required=True, + prompt="Choose machine type", + type=click.Choice(["t2.micro", "f1.2xlarge", "f1.4xlarge", "f1.16xlarge"]), + default="f1.2xlarge", + show_default=True, +) +def create(ctx, machine_name, machine_type): + """Create a machine with name and type.""" + ctx.obj.create(machine_name=machine_name, machine_type=machine_type) - click.echo("\nAttempting to terminate machine...\n") - result = endpoint.terminate_machine(machine_id) - if result["success"]: - click.echo(f"{result['data']['message']}") - else: - click.echo("Failed to terminate machine. Check machine ID and try again.") +@machine.command() +@click.pass_context +def list(ctx): + """List all machines assigned to user.""" + ctx.obj.list() -@click.command() +@machine.command() @click.option("--machine-id", "-id", required=True, prompt="Enter machine ID") @click.pass_context -def terminate_machine(ctx, machine_id): - """Terminate machine with machine ID.""" - cmd = TerminateMachine() - cmd.execute(machine_id) +def stop(ctx, machine_id): + """Stop machine with machine ID.""" + ctx.obj.stop(machine_id) -class GetMachineDetails(BaseCommand): - def execute(self, machine_id): - endpoint = MachineAPI() +@machine.command() +@click.option("--machine-id", "-id", required=True, prompt="Enter machine ID") +@click.pass_context +def start(ctx, machine_id): + """Start machine with machine ID.""" + ctx.obj.start(machine_id) - result = endpoint.get_machine(machine_id) - if result["success"]: - click.echo(f"Machine {machine_id} details:") - click.echo(result["data"]) - return result["data"] - else: - click.echo("Failed to Get machine. Check machine ID and try again.") +@machine.command() +@click.option("--machine-id", "-id", required=True, prompt="Enter machine ID") +@click.pass_context +def terminate(ctx, machine_id): + """Terminate machine with machine ID.""" + ctx.obj.terminate(machine_id) -@click.command() +@machine.command() @click.option("--machine-id", "-id", required=True, prompt="Enter machine ID") @click.pass_context -def get_machine_details(ctx, machine_id): +def details(ctx, machine_id): """Get machine details with machine ID.""" - cmd = GetMachineDetails() - cmd.execute(machine_id) + ctx.obj.get_details(machine_id) diff --git a/src/commands/metrics.py b/src/commands/metrics.py new file mode 100644 index 0000000..80b7955 --- /dev/null +++ b/src/commands/metrics.py @@ -0,0 +1,16 @@ +import click + +from src.utils.groups.subcommand_group import SubCommandGroup + + +class MachineMetricsCommand: + """Commands for machine metrics.""" + + pass + + +@click.group(cls=SubCommandGroup) +@click.pass_context +def metrics(ctx): + """Function not yet supported.""" + ctx.obj = MachineMetricsCommand() diff --git a/src/commands/metrics_commands.py b/src/commands/metrics_commands.py deleted file mode 100644 index 88cdd73..0000000 --- a/src/commands/metrics_commands.py +++ /dev/null @@ -1,17 +0,0 @@ -import click -from src.commands.base_command import BaseCommand - - -class MachineMetricsCommand(BaseCommand): - def execute(self): - print("Function not yet supported.") - - -@click.command() -@click.pass_context -def machine_metrics(ctx): - """ - This command is not implemented yet. - """ - cmd = MachineMetricsCommand() - cmd.execute() diff --git a/src/commands/model_file.py b/src/commands/model_file.py new file mode 100644 index 0000000..2f4dac0 --- /dev/null +++ b/src/commands/model_file.py @@ -0,0 +1,207 @@ +from datetime import datetime + +import click + +from src.api.api_client import APIClient +from src.api.model_file_api import ModelFileAPI +from src.utils.groups.subcommand_group import SubCommandGroup + + +class ModelFileCommands: + def __init__(self): + self.client = APIClient() + self.endpoint = ModelFileAPI(self.client) + + def upload( + self, + file_path: str, + model_name: str | None = None, + model_id: str | None = None, + ): + click.echo("\nAttempting to upload model file...\n") + result = self.endpoint.upload_model_file(model_name, model_id, file_path) + if result["success"]: + upload_date = datetime.strptime( + result["data"]["upload_date"], "%Y-%m-%dT%H:%M:%S.%f%z" + ) + formatted_date = upload_date.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") + click.echo("Model file uploaded successfully:") + click.echo(f" Model Name: {result['data']['model_name']}") + click.echo(f" Model ID: {result['data']['model_id']}") + click.echo(f" Upload Date: {formatted_date}") + else: + click.echo(f"Failed to upload model file: {result['response']['detail']}") + + def list(self): + result = self.endpoint.get_all_models() + if result["success"]: + if not result["data"]: + click.echo("No models to list.") + return + click.echo("Models:") + for model in result["data"]: + click.echo(f" Name: {model['model_name']}") + click.echo(f" ID: {model['model_id']}") + click.echo(" Files:") + for file in model["files"]: + last_modified = datetime.strptime( + file["last_modified"], "%Y-%m-%dT%H:%M:%SZ" + ) + last_modified = last_modified.astimezone().strftime( + "%Y-%m-%d %H:%M:%S %Z" + ) + click.echo( + f" • {file['file_name']} ({file['file_size']} bytes)\n\tlast modified: {last_modified}" + ) + else: + click.echo(f"Failed to list models: {result['response']['detail']}") + + def get_model(self, model_id): + result = self.endpoint.get_model(model_id) + if result["success"]: + click.echo(f"Name: {result['data']['model_name']}") + click.echo(f"ID: {result['data']['model_id']}") + click.echo("Files:") + for file in result["data"]["files"]: + last_modified = datetime.strptime( + file["last_modified"], "%Y-%m-%dT%H:%M:%SZ" + ) + last_modified = last_modified.astimezone().strftime( + "%Y-%m-%d %H:%M:%S %Z" + ) + click.echo(f" • {file['file_name']} ({file['file_size']} bytes)") + click.echo(f" last modified: {last_modified}") + else: + click.echo(f"Failed to get model. {result['response']['detail']}") + + def read_file(self, model_id, file_name): + result = self.endpoint.read_model_file(model_id, file_name) + if result["success"]: + click.echo(f"File contents for {file_name}:") + click.echo(result["data"]["content"]) + else: + click.echo(f"Failed to read model file. {result['response']['detail']}") + + def update( + self, + file_path: str, + model_name: str | None = None, + model_id: str | None = None, + ): + click.echo("\nAttempting to update model file...\n") + result = self.endpoint.update_model_file( + model_name=model_name, model_id=model_id, file_path=file_path + ) + if result["success"]: + upload_date = datetime.strptime( + result["data"]["upload_date"], "%Y-%m-%dT%H:%M:%S.%f%z" + ) + formatted_date = upload_date.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") + click.echo("Model file uploaded successfully:") + click.echo(f" Model Name: {result['data']['model_name']}") + click.echo(f" Model ID: {result['data']['model_id']}") + click.echo(f" Upload Date: {formatted_date}") + else: + click.echo(f"Failed to upload model file: {result['response']['detail']}") + + def delete_file(self, model_id, file_name): + click.echo("\nAttempting to delete model file...\n") + result = self.endpoint.delete_model_file(model_id, file_name) + if result["success"] and result["data"] is None: + click.echo(f"Model file {file_name} deleted successfully") + else: + click.echo(f"Failed to delete model file. {result['response']['detail']}") + + def delete_model(self, model_id): + click.echo("\nAttempting to delete model...\n") + result = self.endpoint.delete_model(model_id) + if result["success"] and result["data"] is None: + click.echo(f"Model {model_id} deleted successfully") + else: + click.echo(f"Failed to delete model. {result['response']['detail']}") + + +@click.group(cls=SubCommandGroup) +@click.pass_context +def model(ctx): + """Model file management commands.""" + ctx.obj = ModelFileCommands() + + +@model.command() +@click.pass_context +@click.option("--model-name", "-n", required=False) +@click.option("--model-id", "-id", required=False) +@click.option( + "--file-path", + "-f", + required=True, + prompt="Enter file path", + type=click.Path(exists=True), + shell_complete=click.Path().shell_complete, +) +def upload(ctx, model_name, model_id, file_path): + """Upload a model file.""" + if not model_name and not model_id: + model_name = click.prompt("Enter model name") + ctx.obj.upload(model_name=model_name, model_id=model_id, file_path=file_path) + + +@model.command() +@click.pass_context +def list(ctx): + """List all models.""" + ctx.obj.list() + + +@model.command() +@click.pass_context +@click.option("--model-id", "-id", required=True, prompt="Enter model ID") +def get(ctx, model_id): + """Get model details with model ID.""" + ctx.obj.get_model(model_id) + + +@model.command() +@click.pass_context +@click.option("--model-id", "-id", required=True, prompt="Enter model ID") +@click.option("--file-name", "-f", required=True, prompt="Enter file name") +def read(ctx, model_id, file_name): + """Read contents of a model file.""" + ctx.obj.read_file(model_id, file_name) + + +@model.command() +@click.pass_context +@click.option("--model-name", "-n", required=False) +@click.option("--model-id", "-id", required=False) +@click.option( + "--file-path", + "-f", + required=True, + prompt="Enter file path", + type=click.Path(exists=True), + shell_complete=click.Path().shell_complete, +) +def update(ctx, model_name, model_id, file_path): + """Update a model file.""" + if not model_name and not model_id: + model_name = click.prompt("Enter model name") + ctx.obj.update(model_name=model_name, model_id=model_id, file_path=file_path) + + +@model.command() +@click.pass_context +@click.option("--model-id", "-id", required=True, prompt="Enter model ID") +@click.option("--file-name", "-f", required=True, prompt="Enter file name") +def delete_file(ctx, model_id, file_name): + """Delete a model file.""" + ctx.obj.delete_file(model_id, file_name) + + +@model.command() +@click.pass_context +@click.option("--model-id", "-id", required=True, prompt="Enter model ID") +def delete(ctx, model_id): + """Delete a model.""" + ctx.obj.delete_model(model_id) diff --git a/src/commands/user_auth.py b/src/commands/user_auth.py new file mode 100644 index 0000000..a4652f7 --- /dev/null +++ b/src/commands/user_auth.py @@ -0,0 +1,29 @@ +import click + +from src.api.api_client import APIClient +from src.api.auth_api import AuthAPI + +endpoint = AuthAPI(APIClient()) + + +@click.command() +@click.option("--email", prompt=True) +@click.option("--password", prompt=True, hide_input=True) +def login(email, password): + """Login to the application.""" + result = endpoint.login(email, password) + if result["success"]: + click.echo(f"Successfully logged in. {result['data']}") + else: + click.echo(f"Login failed. {result['message']}") + + +@click.command() +@click.pass_context +def logout(ctx): + """Logout from the application.""" + result = endpoint.logout() + if result: + click.echo("Successfully logged out.") + else: + click.echo("No action taken. You were not logged in.") diff --git a/src/main.py b/src/main.py index 92e0c0f..c1a5f78 100644 --- a/src/main.py +++ b/src/main.py @@ -1,27 +1,40 @@ import click -from types import ModuleType -from typing import List -import src.commands.auth_commands as auth_commands -import src.commands.metrics_commands as metrics_commands -import src.commands.machine_commands as machine_commands - -@click.group() -def quack(): - """Quack CLI tool""" - pass +import src.commands as commands +from src.utils.groups.quack_group import QuackGroup -def add_commands_to_cli(modules: List[ModuleType]) -> None: - for mod in modules: - for cmd in dir(mod): - if isinstance(getattr(mod, cmd), click.core.Command): - quack.add_command(getattr(mod, cmd)) - +@click.group(cls=QuackGroup) +@click.version_option(version="0.1.0") +@click.pass_context +def quack(ctx): + """A simple CLI tool to help manage your ducks.""" + pass -command_modules = [auth_commands, metrics_commands, machine_commands] -add_commands_to_cli(command_modules) +cmds = {} +# register all groups and only standalone commands +for mod in commands.__all__: + groups = [ + getattr(mod, attr) + for attr in dir(mod) + if isinstance(getattr(mod, attr), click.core.Group) + ] + all_cmds = [ + getattr(mod, attr) + for attr in dir(mod) + if isinstance(getattr(mod, attr), click.core.Command) + ] + + # Register groups and track their commands + for group in groups: + cmds.update({sub_cmd.name: sub_cmd for sub_cmd in group.commands.values()}) + quack.add_command(group) + + # Register standalone commands not part of any group + for cmd in all_cmds: + if cmd.name not in cmds: + quack.add_command(cmd) if __name__ == "__main__": quack() diff --git a/src/utils/formatters/__init__.py b/src/utils/formatters/__init__.py new file mode 100644 index 0000000..ee57a07 --- /dev/null +++ b/src/utils/formatters/__init__.py @@ -0,0 +1,3 @@ +""" +Utility module for formatting and displaying data in the CLI for use in custom groups. +""" diff --git a/src/utils/formatters/help_formatter_base.py b/src/utils/formatters/help_formatter_base.py new file mode 100644 index 0000000..00f6338 --- /dev/null +++ b/src/utils/formatters/help_formatter_base.py @@ -0,0 +1,53 @@ +from abc import ABC, abstractmethod + +import rich.box as rBox +from rich.align import Align as rAlign +from rich.console import Console as rConsole +from rich.table import Table as rTable + + +class HelpFormatterBase(ABC): + item_padding = " " * 4 + + @abstractmethod + def __init__(self, console: rConsole) -> None: + pass + + @abstractmethod + def format_header(self, console: rConsole) -> None: + pass + + @abstractmethod + def format_usage(self, console: rConsole, usage_text: str) -> None: + pass + + @abstractmethod + def format_options(self, console: rConsole, options: list) -> None: + pass + + @abstractmethod + def format_commands(self, console: rConsole, commands: dict, title: str) -> None: + pass + + @abstractmethod + def render(self, console: rConsole) -> None: + pass + + def create_main_table(self): + self.main_table = rTable( + show_header=False, + box=rBox.SIMPLE, + padding=(0, 0, 0, 0), + width=self.width, + border_style="cyan", + show_edge=True, + pad_edge=False, + ) + self.main_table.add_column("Content", style="bright_white") + + def add_section_header(self, title: str): + title = f"━━━ {title.upper()}" + right_sep = "━" * (self.width - len(title) - 3) + self.main_table.add_row( + rAlign(f"[bold cyan]{title} {right_sep}[/]"), style="on black" + ) diff --git a/src/utils/formatters/quack_formatter.py b/src/utils/formatters/quack_formatter.py new file mode 100644 index 0000000..e5ddc3a --- /dev/null +++ b/src/utils/formatters/quack_formatter.py @@ -0,0 +1,62 @@ +from pyfiglet import Figlet, FontNotFound +from rich.align import Align as rAlign +from rich.console import Console as rConsole +from rich.panel import Panel as rPanel + +from src.utils.formatters.help_formatter_base import HelpFormatterBase + + +class MainframeFormatter(HelpFormatterBase): + def __init__(self, console: rConsole, format: str = None) -> None: + self.console = console + self.format = format if format else "cricket" + self.width = max(60, self.console.width - 4) + self.main_table = None + self.create_main_table() + + def format_header(self, title: str, help: str) -> None: + # Keep ASCII art header separate + try: + fig = Figlet(font=self.format, width=self.width - 1) + except FontNotFound: + self.console.print(f"Font {self.format} not found. Using default.") + fig = Figlet(font="cricket", width=self.width - 1) + ascii_art = fig.renderText(title) + styled_art = rAlign(f"[bold yellow]{ascii_art}[/]", align="center") + styled_help = rPanel( + rAlign(f"{help}", align="center"), + title="by Duckington Labs", + border_style="bright_yellow", + ) + self.main_table.add_row(styled_art) + self.main_table.add_row(styled_help) + + def format_usage(self, usage_text: str) -> None: + self.add_section_header("USAGE") + self.main_table.add_row(f"{self.item_padding}{usage_text}\n") + + def format_options(self, options: list) -> None: + self.add_section_header("OPTIONS") + for opt, desc in options: + self.main_table.add_row(f"{self.item_padding}[magenta]{opt:<15}[/] {desc}") + self.main_table.add_row() + + def format_commands(self, commands: dict, title: str) -> None: + self.add_section_header(title) + if title == "COMMANDS": + prefix = "❯" + print_newline = True + else: + prefix = "▶" + print_newline = False + + for name, cmd in sorted(commands.items()): + self.main_table.add_row( + f"{self.item_padding}{prefix} [green]{name:<13}[/] {cmd.help or ''}" + ) + if print_newline: + self.main_table.add_row() + + def render(self) -> None: + if self.main_table: + self.console.print(self.main_table) diff --git a/src/utils/formatters/subcommand_formatter.py b/src/utils/formatters/subcommand_formatter.py new file mode 100644 index 0000000..1ba2107 --- /dev/null +++ b/src/utils/formatters/subcommand_formatter.py @@ -0,0 +1,45 @@ +from rich.align import Align as rAlign +from rich.console import Console as rConsole +from rich.panel import Panel as rPanel + +from src.utils.formatters.help_formatter_base import HelpFormatterBase + + +class SubFrameFormatter(HelpFormatterBase): + def __init__(self, console: rConsole) -> None: + self.console = console + self.width = max(60, console.width - 4) + self.main_table = None + self.create_main_table() + + def format_header(self, title: str, help: str) -> None: + styled_help = rPanel( + rAlign(f"{help}", align="center"), + title=f"quack {title}", + border_style="yellow", + ) + self.main_table.add_row(styled_help) + + def format_usage(self, usage_text: str) -> None: + self.add_section_header("USAGE") + self.main_table.add_row(f"{self.item_padding}{usage_text}") + self.main_table.add_row() + + def format_options(self, options: list) -> None: + self.add_section_header("OPTIONS") + for opt, desc in options: + self.main_table.add_row(f"{self.item_padding}[magenta]{opt:<15}[/] {desc}") + self.main_table.add_row() + + def format_commands(self, commands: dict, title: str) -> None: + self.add_section_header(title) + prefix = "❯" if title == "COMMANDS" else "▶" + + for name, cmd in sorted(commands.items()): + self.main_table.add_row( + f"{self.item_padding}{prefix} [green]{name:<13}[/] {cmd.help or ''}" + ) + + def render(self) -> None: + if self.main_table: + self.console.print(self.main_table) diff --git a/src/utils/groups/__init__.py b/src/utils/groups/__init__.py new file mode 100644 index 0000000..b497680 --- /dev/null +++ b/src/utils/groups/__init__.py @@ -0,0 +1,3 @@ +""" +Utility module for extending click.Group using custom formatters. +""" diff --git a/src/utils/groups/quack_group.py b/src/utils/groups/quack_group.py new file mode 100644 index 0000000..148afb0 --- /dev/null +++ b/src/utils/groups/quack_group.py @@ -0,0 +1,48 @@ +import io + +import click +from rich.console import Console as rConsole + +from src.utils.formatters.quack_formatter import MainframeFormatter + + +class QuackGroup(click.Group): + def format_help(self, ctx, formatter): + sio = io.StringIO() + console = rConsole(file=sio, force_terminal=True) + help_formatter = MainframeFormatter(console) + + # Format header and initialize table + help_formatter.format_header("Quack CLI", self.help) + + help_formatter.format_usage(ctx.get_usage().removeprefix("Usage: ")) + # Get options from the context's command + options = [] + for param in ctx.command.get_params(ctx): + if isinstance(param, click.Option): + opt_names = "/".join(param.opts) + opt_help = param.help or "" + options.append((opt_names, opt_help)) + + help_formatter.format_options(options) + + if self.commands: + commands = { + name: cmd + for name, cmd in self.commands.items() + if not isinstance(cmd, click.Group) + } + subcommands = { + name: cmd + for name, cmd in self.commands.items() + if isinstance(cmd, click.Group) + } + + if commands: + help_formatter.format_commands(commands, "COMMANDS") + if subcommands: + help_formatter.format_commands(subcommands, "SUBCOMMANDS") + + # Render the final table + help_formatter.render() + formatter.write(sio.getvalue()) diff --git a/src/utils/groups/subcommand_group.py b/src/utils/groups/subcommand_group.py new file mode 100644 index 0000000..59b0207 --- /dev/null +++ b/src/utils/groups/subcommand_group.py @@ -0,0 +1,48 @@ +import io + +import click +from rich.console import Console as rConsole + +from src.utils.formatters.subcommand_formatter import SubFrameFormatter + + +class SubCommandGroup(click.Group): + def format_help(self, ctx, formatter): + sio = io.StringIO() + console = rConsole(file=sio, force_terminal=True) + help_formatter = SubFrameFormatter(console) + cmd_name = ctx.command.name or ctx.info_name + + help_formatter.format_header(cmd_name, self.help) + + help_formatter.format_usage(ctx.get_usage().removeprefix("Usage: ")) + # Get options from the context's command + options = [] + for param in ctx.command.get_params(ctx): + if isinstance(param, click.Option): + opt_names = "/".join(param.opts) + opt_help = param.help or "" + options.append((opt_names, opt_help)) + + help_formatter.format_options(options) + + if self.commands: + commands = { + name: cmd + for name, cmd in self.commands.items() + if not isinstance(cmd, click.Group) + } + subcommands = { + name: cmd + for name, cmd in self.commands.items() + if isinstance(cmd, click.Group) + } + + if commands: + help_formatter.format_commands(commands, "COMMANDS") + if subcommands: + help_formatter.format_commands(subcommands, "SUBCOMMANDS") + + # Render the final table + help_formatter.render() + formatter.write(sio.getvalue()) diff --git a/src/utils/helpers/handle_api_errors.py b/src/utils/helpers/handle_api_errors.py index 2f16c79..abe8dcc 100644 --- a/src/utils/helpers/handle_api_errors.py +++ b/src/utils/helpers/handle_api_errors.py @@ -1,6 +1,6 @@ from typing import Callable from functools import wraps -from requests.exceptions import HTTPError +from requests.exceptions import HTTPError, ConnectionError def handle_api_errors(func: Callable) -> Callable: @@ -9,7 +9,20 @@ def wrapper(*args, **kwargs): try: return {"success": True, "data": func(*args, **kwargs)} except HTTPError as http_err: - return {"success": False, "error": "HTTP error", "message": str(http_err)} + return { + "success": False, + "error": "HTTP error", + "message": str(http_err), + "status_code": http_err.response.status_code, + "response": http_err.response.json(), + } + except ConnectionError as conn_err: + return { + "success": False, + "error": "Connection refused", + "message": str(conn_err), + "response": {"detail": "Connection refused"}, + } except Exception as err: return {"success": False, "error": "Unexpected error", "message": str(err)} diff --git a/test/api/test_client.py b/test/api/test_client.py index 34c4025..19d8b94 100644 --- a/test/api/test_client.py +++ b/test/api/test_client.py @@ -29,6 +29,7 @@ def test_make_request_with_api_key(api_client): json=None, data=None, params=None, + files=None, headers={"X-API-Key": "test_api_key"}, ) assert result == {"data": "test"} @@ -47,6 +48,7 @@ def test_make_request_with_access_token(api_client): json={"key": "value"}, data=None, params=None, + files=None, headers={"Authorization": "Bearer test_access_token"}, ) assert result == {"data": "test"} @@ -102,6 +104,7 @@ def test_get_method(api_client): data=None, params={"param": "value"}, headers={"Custom-Header": "Value", "X-API-Key": ANY}, + files=None, ) assert result == {"data": "get_test"} @@ -116,6 +119,7 @@ def test_post_method(api_client): data=None, params=None, headers={"X-API-Key": ANY}, + files=None, ) assert result == {"data": "post_test"} @@ -130,6 +134,7 @@ def test_put_method(api_client): data=None, params=None, headers={"X-API-Key": ANY}, + files=None, ) assert result == {"data": "put_test"} @@ -144,6 +149,7 @@ def test_delete_method(api_client): data=None, params=None, headers={"X-API-Key": ANY}, + files=None, ) assert result == {"data": "delete_test"} diff --git a/test/api/test_model_file_api.py b/test/api/test_model_file_api.py new file mode 100644 index 0000000..c6270d1 --- /dev/null +++ b/test/api/test_model_file_api.py @@ -0,0 +1,64 @@ +import unittest +from unittest.mock import patch, mock_open +from src.api.api_client import APIClient +from src.api.model_file_api import ModelFileAPI + + +class TestModelFileAPI(unittest.TestCase): + def setUp(self): + self.api = ModelFileAPI(APIClient()) + + @patch("builtins.open", new_callable=mock_open, read_data="test data") + @patch.object(APIClient, "post") + def test_upload_model_file(self, mock_post, mock_file): + mock_post.return_value = {"status": "uploaded"} + response = self.api.upload_model_file("test_model", "123", "test.file") + self.assertEqual(response, {"success": True, "data": {"status": "uploaded"}}) + mock_post.assert_called_once() + mock_file.assert_called_with("test.file", "rb") + + @patch.object(APIClient, "get") + def test_get_model(self, mock_get): + mock_get.return_value = {"model_id": "123"} + response = self.api.get_model("123") + self.assertEqual(response, {"success": True, "data": {"model_id": "123"}}) + mock_get.assert_called_with("models/123") + + @patch.object(APIClient, "get") + def test_get_all_models(self, mock_get): + mock_get.return_value = {"models": []} + response = self.api.get_all_models() + self.assertEqual(response, {"success": True, "data": {"models": []}}) + mock_get.assert_called_with("models") + + @patch.object(APIClient, "get") + def test_read_model_file(self, mock_get): + mock_get.return_value = {"content": "file_content"} + response = self.api.read_model_file("123", "test.file") + self.assertEqual( + response, {"success": True, "data": {"content": "file_content"}} + ) + mock_get.assert_called_with("models/123/test.file") + + @patch("builtins.open", new_callable=mock_open, read_data="test data") + @patch.object(APIClient, "put") + def test_update_model_file(self, mock_put, mock_file): + mock_put.return_value = {"status": "updated"} + response = self.api.update_model_file("test_model", "123", "test.file") + self.assertEqual(response, {"success": True, "data": {"status": "updated"}}) + mock_put.assert_called_once() + mock_file.assert_called_with("test.file", "rb") + + @patch.object(APIClient, "delete") + def test_delete_model_file(self, mock_delete): + mock_delete.return_value = {"status": "deleted"} + response = self.api.delete_model_file("123", "test.file") + self.assertEqual(response, {"success": True, "data": {"status": "deleted"}}) + mock_delete.assert_called_with("models/123/test.file") + + @patch.object(APIClient, "delete") + def test_delete_model(self, mock_delete): + mock_delete.return_value = {"status": "deleted"} + response = self.api.delete_model("123") + self.assertEqual(response, {"success": True, "data": {"status": "deleted"}}) + mock_delete.assert_called_with("models/123") diff --git a/test/commands/test_auth_commands.py b/test/commands/test_auth_commands.py deleted file mode 100644 index b645a3a..0000000 --- a/test/commands/test_auth_commands.py +++ /dev/null @@ -1,151 +0,0 @@ -import pytest -from unittest.mock import patch -from click.testing import CliRunner -from src.commands.auth_commands import ( - login, - logout, - create_api_key, - list_api_keys, - delete_api_key, - set_api_key, - remove_api_key, -) - - -@pytest.fixture -def mock_auth_api(): - with patch("src.commands.auth_commands.AuthAPI") as mock: - yield mock.return_value - - -@pytest.fixture -def runner(): - return CliRunner() - - -def test_login_command_success(runner, mock_auth_api): - mock_auth_api.login.return_value = {"success": True, "data": "Login successful"} - result = runner.invoke( - login, ["--email", "test@example.com", "--password", "password"] - ) - assert result.exit_code == 0 - assert "Successfully logged in" in result.output - mock_auth_api.login.assert_called_once_with("test@example.com", "password") - - -def test_login_command_failure(runner, mock_auth_api): - mock_auth_api.login.return_value = { - "success": False, - "message": "Invalid credentials", - } - result = runner.invoke( - login, ["--email", "test@example.com", "--password", "wrong_password"] - ) - assert result.exit_code == 0 - assert "Login failed. Invalid credentials" in result.output - mock_auth_api.login.assert_called_once_with("test@example.com", "wrong_password") - - -def test_logout_command_success(runner, mock_auth_api): - mock_auth_api.logout.return_value = True - result = runner.invoke(logout) - assert result.exit_code == 0 - assert "Successfully logged out" in result.output - mock_auth_api.logout.assert_called_once() - - -def test_logout_command_not_logged_in(runner, mock_auth_api): - mock_auth_api.logout.return_value = False - result = runner.invoke(logout) - assert result.exit_code == 0 - assert "No action taken. You were not logged in." in result.output - mock_auth_api.logout.assert_called_once() - - -def test_create_api_key_command_success(runner, mock_auth_api): - mock_auth_api.create_api_key.return_value = {"success": True, "data": "new_api_key"} - result = runner.invoke(create_api_key, ["--validity", "ONE_DAY"]) - assert result.exit_code == 0 - assert "API key created successfully: new_api_key" in result.output - mock_auth_api.create_api_key.assert_called_once() - - -def test_create_api_key_command_failure(runner, mock_auth_api): - mock_auth_api.create_api_key.return_value = { - "success": False, - "message": "Failed to create API key", - } - result = runner.invoke(create_api_key, ["--validity", "ONE_DAY"]) - assert result.exit_code == 0 - assert "Failed to create API key. Failed to create API key" in result.output - mock_auth_api.create_api_key.assert_called_once() - - -def test_list_api_keys_command_success(runner, mock_auth_api): - mock_api_keys = [ - {"token": "key1", "created_at": "2023-01-01", "validity": "ONE_DAY"}, - {"token": "key2", "created_at": "2023-01-02", "validity": "ONE_WEEK"}, - ] - mock_auth_api.list_api_keys.return_value = {"success": True, "data": mock_api_keys} - result = runner.invoke(list_api_keys) - assert result.exit_code == 0 - assert "Token: key1" in result.output - assert "Token: key2" in result.output - mock_auth_api.list_api_keys.assert_called_once() - - -def test_list_api_keys_command_failure(runner, mock_auth_api): - mock_auth_api.list_api_keys.return_value = { - "success": False, - "message": "Failed to retrieve API keys", - } - result = runner.invoke(list_api_keys) - assert result.exit_code == 0 - assert "Failed to retrieve API keys. Failed to retrieve API keys" in result.output - mock_auth_api.list_api_keys.assert_called_once() - - -def test_delete_api_key_command_success(runner, mock_auth_api): - mock_auth_api.delete_api_key.return_value = { - "success": True, - "data": "API key deleted", - } - result = runner.invoke(delete_api_key, ["--token", "test_token"]) - assert result.exit_code == 0 - assert "API key deleted successfully" in result.output - mock_auth_api.delete_api_key.assert_called_once_with("test_token") - - -def test_delete_api_key_command_failure(runner, mock_auth_api): - mock_auth_api.delete_api_key.return_value = { - "success": False, - "message": "API key not found", - } - result = runner.invoke(delete_api_key, ["--token", "invalid_token"]) - assert result.exit_code == 0 - assert "Failed to delete API key. API key not found" in result.output - mock_auth_api.delete_api_key.assert_called_once_with("invalid_token") - - -def test_set_api_key_command_success(runner, mock_auth_api): - mock_auth_api.set_api_key.return_value = True - result = runner.invoke(set_api_key, ["--api-key", "new_api_key"]) - assert result.exit_code == 0 - assert "API key set successfully" in result.output - mock_auth_api.set_api_key.assert_called_once_with("new_api_key") - - -def test_remove_api_key_command_success(runner, mock_auth_api): - mock_auth_api.clear_api_key.return_value = True - result = runner.invoke(remove_api_key) - assert result.exit_code == 0 - assert "API key removed successfully" in result.output - mock_auth_api.clear_api_key.assert_called_once() - - -def test_remove_api_key_command_no_key_set(runner, mock_auth_api): - mock_auth_api.clear_api_key.return_value = False - result = runner.invoke(remove_api_key) - assert result.exit_code == 0 - assert "No API key was set" in result.output - mock_auth_api.clear_api_key.assert_called_once() diff --git a/test/commands/test_key.py b/test/commands/test_key.py new file mode 100644 index 0000000..25211cb --- /dev/null +++ b/test/commands/test_key.py @@ -0,0 +1,146 @@ +import unittest +from unittest.mock import MagicMock, patch +from click.testing import CliRunner +from src.commands.key import key, KeyCommands + + +class TestKeyCommands(unittest.TestCase): + def setUp(self): + """Set up test fixtures before each test method.""" + self.runner = CliRunner() + + # Create and setup mock_auth_api + self.patcher = patch("src.api.auth_api.AuthAPI") + self.mock_auth_api = self.patcher.start() + self.mock_instance = self.mock_auth_api.return_value + + # Set up default successful responses + self.mock_instance.create_api_key.return_value = { + "success": True, + "data": "test_key", + } + self.mock_instance.list_api_keys.return_value = {"success": True, "data": []} + self.mock_instance.delete_api_key.return_value = { + "success": True, + "data": "Deleted", + } + self.mock_instance.set_api_key.return_value = True + self.mock_instance.clear_api_key.return_value = True + + # Setup key_commands + self.key_commands = KeyCommands() + self.key_commands.client = MagicMock() + self.key_commands.endpoint = self.mock_instance + + def tearDown(self): + """Clean up after each test method.""" + self.patcher.stop() + + def test_create_api_key_success(self): + self.mock_instance.create_api_key.return_value = { + "success": True, + "data": "new_api_key", + } + + with patch("click.echo") as mock_print: + self.key_commands.create_api_key("ONE_DAY") + mock_print.assert_called_with("API key created successfully: new_api_key") + + def test_create_api_key_failure(self): + self.mock_instance.create_api_key.return_value = { + "success": False, + "message": "Failed to create key", + } + + with patch("click.echo") as mock_print: + self.key_commands.create_api_key("ONE_DAY") + mock_print.assert_called_with( + "Failed to create API key. Failed to create key" + ) + + def test_list_api_keys_success(self): + mock_keys = [ + {"token": "key1", "created_at": "2023-01-01", "validity": "ONE_DAY"}, + {"token": "key2", "created_at": "2023-01-02", "validity": "ONE_WEEK"}, + ] + self.mock_instance.list_api_keys.return_value = { + "success": True, + "data": mock_keys, + } + + with patch("click.echo") as mock_print: + self.key_commands.list_api_keys() + self.assertEqual(mock_print.call_count, 3) # Header + 2 keys + + def test_list_api_keys_empty(self): + self.mock_instance.list_api_keys.return_value = {"success": True, "data": []} + + with patch("click.echo") as mock_print: + self.key_commands.list_api_keys() + mock_print.assert_called_with("API Keys:") + + def test_delete_api_key_success(self): + self.mock_instance.delete_api_key.return_value = { + "success": True, + "data": "Key deleted", + } + + with patch("click.echo") as mock_print: + self.key_commands.delete_api_key("test_token") + mock_print.assert_called_with("API key deleted successfully. Key deleted") + + def test_delete_api_key_failure(self): + self.mock_instance.delete_api_key.return_value = { + "success": False, + "message": "Key not found", + } + + with patch("click.echo") as mock_print: + self.key_commands.delete_api_key("invalid_token") + mock_print.assert_called_with("Failed to delete API key. Key not found") + + def test_set_api_key_success(self): + self.mock_instance.set_api_key.return_value = True + + with patch("click.echo") as mock_print: + self.key_commands.set_api_key("test_key") + mock_print.assert_called_with("API key set successfully.") + + def test_remove_api_key_success(self): + self.mock_instance.clear_api_key.return_value = True + + with patch("click.echo") as mock_print: + self.key_commands.remove_api_key() + mock_print.assert_called_with("API key removed successfully.") + + def test_remove_api_key_no_key(self): + self.mock_instance.clear_api_key.return_value = False + + with patch("click.echo") as mock_print: + self.key_commands.remove_api_key() + mock_print.assert_called_with("No API key was set.") + + @patch("src.commands.key.KeyCommands", autospec=True) + def test_cli_create_command(self, key_commands): + result = self.runner.invoke(key, ["create", "--validity", "ONE_DAY"]) + self.assertEqual(result.exit_code, 0) + + @patch("src.commands.key.KeyCommands", autospec=True) + def test_cli_list_command(self, key_commands): + result = self.runner.invoke(key, ["list"]) + self.assertEqual(result.exit_code, 0) + + @patch("src.commands.key.KeyCommands", autospec=True) + def test_cli_delete_command(self, key_commands): + result = self.runner.invoke(key, ["delete", "--token", "test_token"]) + self.assertEqual(result.exit_code, 0) + + @patch("src.commands.key.KeyCommands", autospec=True) + def test_cli_set_command(self, key_commands): + result = self.runner.invoke(key, ["set", "--api-key", "test_key"]) + self.assertEqual(result.exit_code, 0) + + @patch("src.commands.key.KeyCommands", autospec=True) + def test_cli_unset_command(self, key_commands): + result = self.runner.invoke(key, ["unset"]) + self.assertEqual(result.exit_code, 0) diff --git a/test/commands/test_machine.py b/test/commands/test_machine.py new file mode 100644 index 0000000..772acd0 --- /dev/null +++ b/test/commands/test_machine.py @@ -0,0 +1,151 @@ +import unittest +from unittest.mock import MagicMock, patch +from click.testing import CliRunner +from src.commands.machine import MachineCommands, machine + + +class TestMachineCommands(unittest.TestCase): + def setUp(self): + """Set up test fixtures before each test method.""" + self.runner = CliRunner() + + # Create and setup mock_machine_api + self.patcher = patch("src.api.machine_api.MachineAPI") + self.mock_machine_api = self.patcher.start() + self.mock_instance = self.mock_machine_api.return_value + + # Set up default successful responses + self.mock_instance.create_machine.return_value = { + "success": True, + "data": { + "machine_id": "test123", + "machine_name": "test-machine", + "machine_type": "f1.2xlarge", + }, + } + self.mock_instance.list_user_machines.return_value = { + "success": True, + "data": [], + } + self.mock_instance.start_machine.return_value = { + "success": True, + "data": {"message": "Machine started successfully"}, + } + self.mock_instance.stop_machine.return_value = { + "success": True, + "data": {"message": "Machine stopped successfully"}, + } + self.mock_instance.terminate_machine.return_value = { + "success": True, + "data": {"message": "Machine terminated successfully"}, + } + + # Setup machine_commands + self.machine_commands = MachineCommands() + self.machine_commands.client = MagicMock() + self.machine_commands.endpoint = self.mock_instance + + def tearDown(self): + """Clean up after each test method.""" + self.patcher.stop() + + def test_create_machine_success(self): + with patch("click.echo") as mock_print: + self.machine_commands.create("test-machine", "f1.2xlarge") + mock_print.assert_called_with( + f"Machine created successfully. Details: {self.mock_instance.create_machine.return_value['data']}" + ) + + def test_create_machine_failure(self): + self.mock_instance.create_machine.return_value = { + "success": False, + "message": "Failed to create machine", + } + + with patch("click.echo") as mock_print: + self.machine_commands.create("test-machine", "f1.2xlarge") + mock_print.assert_called_with( + "Failed to create machine. Check machine name and type and try again." + ) + + def test_list_machines_with_data(self): + mock_machines = [ + {"machine_id": "test123", "machine_name": "test-machine-1"}, + {"machine_id": "test456", "machine_name": "test-machine-2"}, + ] + self.mock_instance.list_user_machines.return_value = { + "success": True, + "data": mock_machines, + } + + with patch("click.echo") as mock_print: + self.machine_commands.list() + self.assertEqual(mock_print.call_count, 3) # Header + 2 machines + + def test_list_machines_empty(self): + with patch("click.echo") as mock_print: + self.machine_commands.list() + mock_print.assert_called_with("No machines to list.") + + def test_start_machine_success(self): + with patch("click.echo") as mock_print: + self.machine_commands.start("test123") + mock_print.assert_called_with("Machine started successfully") + + def test_stop_machine_success(self): + with patch("click.echo") as mock_print: + self.machine_commands.stop("test123") + mock_print.assert_called_with("Machine stopped successfully") + + def test_terminate_machine_success(self): + with patch("click.echo") as mock_print: + self.machine_commands.terminate("test123") + mock_print.assert_called_with("Machine terminated successfully") + + def test_get_machine_details_success(self): + self.mock_instance.get_machine.return_value = { + "success": True, + "data": { + "machine_id": "test123", + "machine_name": "test-machine", + "machine_type": "f1.2xlarge", + }, + } + + with patch("click.echo") as mock_print: + self.machine_commands.get_details("test123") + self.assertEqual(mock_print.call_count, 2) + + # CLI command tests + @patch("src.commands.machine.MachineCommands", autospec=True) + def test_cli_create_command(self, machine_commands): + result = self.runner.invoke( + machine, + ["create", "-n", "test-machine", "-t", "f1.2xlarge"], + ) + self.assertEqual(result.exit_code, 0) + + @patch("src.commands.machine.MachineCommands", autospec=True) + def test_cli_list_command(self, machine_commands): + result = self.runner.invoke(machine, ["list"]) + self.assertEqual(result.exit_code, 0) + + @patch("src.commands.machine.MachineCommands", autospec=True) + def test_cli_start_command(self, machine_commands): + result = self.runner.invoke(machine, ["start", "--machine-id", "test123"]) + self.assertEqual(result.exit_code, 0) + + @patch("src.commands.machine.MachineCommands", autospec=True) + def test_cli_stop_command(self, machine_commands): + result = self.runner.invoke(machine, ["stop", "--machine-id", "test123"]) + self.assertEqual(result.exit_code, 0) + + @patch("src.commands.machine.MachineCommands", autospec=True) + def test_cli_terminate_command(self, machine_commands): + result = self.runner.invoke(machine, ["terminate", "--machine-id", "test123"]) + self.assertEqual(result.exit_code, 0) + + @patch("src.commands.machine.MachineCommands", autospec=True) + def test_cli_details_command(self, machine_commands): + result = self.runner.invoke(machine, ["details", "--machine-id", "test123"]) + self.assertEqual(result.exit_code, 0) diff --git a/test/commands/test_machine_commands.py b/test/commands/test_machine_commands.py deleted file mode 100644 index 0c01a6f..0000000 --- a/test/commands/test_machine_commands.py +++ /dev/null @@ -1,217 +0,0 @@ -import unittest -from unittest.mock import patch -from click.testing import CliRunner - -from src.api.machine_api import MachineAPI -from src.commands.machine_commands import ( - list_machines, - create_machine, - start_machine, - stop_machine, - terminate_machine, - get_machine_details, -) - - -class TestMachineCommands(unittest.TestCase): - - def setUp(self): - self.runner = CliRunner() - - @patch.object(MachineAPI, "list_user_machines") - def test_list_user_machines_with_no_machines(self, mock_list_user_machines): - mock_response = {"success": True, "data": []} - mock_list_user_machines.return_value = mock_response - - result = self.runner.invoke(list_machines) - - self.assertEqual("No machines to list.\n", result.output) - self.assertEqual(result.exit_code, 0) - - @patch.object(MachineAPI, "list_user_machines") - def test_list_user_machines_with_some_machines(self, mock_list_user_machines): - mock_list = [ - { - "machine_id": "i-0dfa0b805940358fb", - "machine_name": "test-cli-machine", - "machine_type": "f1.2xlarge", - "machine_status": "running", - "hourly_price": 0.0116, - "machine_desc": [ - {"Key": "Name", "Value": "test-cli-machine-create"}, - {"Key": "assigned", "Value": "true"}, - {"Key": "user_id", "Value": "user_1234"}, - ], - } - ] - mock_response = {"success": True, "data": mock_list} - mock_list_user_machines.return_value = mock_response - - result = self.runner.invoke(list_machines) - - self.assertIn("Machines:\n" + str(mock_list[0]) + "\n", result.output) - self.assertEqual(result.exit_code, 0) - - @patch.object(MachineAPI, "list_user_machines") - def test_list_user_machines_fail(self, mock_list_user_machines): - mock_response = {"success": False, "data": []} - mock_list_user_machines.return_value = mock_response - - result = self.runner.invoke(list_machines) - - self.assertEqual("Failed to retrieve list of machines.\n", result.output) - self.assertEqual(result.exit_code, 0) - - @patch.object(MachineAPI, "create_machine") - def test_create_machine_success(self, mock_create_machine): - mock_machine = { - "machine_id": "i-0dfa0b805940358fb", - "machine_name": "test-cli-machine", - "machine_type": "f1.2xlarge", - "machine_status": "running", - "hourly_price": 0.0116, - "machine_desc": [ - {"Key": "Name", "Value": "test-cli-machine-create"}, - {"Key": "assigned", "Value": "true"}, - {"Key": "user_id", "Value": "user_1234"}, - ], - } - mock_response = {"success": True, "data": mock_machine} - mock_create_machine.return_value = mock_response - - result = self.runner.invoke( - create_machine, - ["--machine-name", "test-cli-machine", "--machine-type", "f1.2xlarge"], - ) - - self.assertIn( - f"Machine created successfully. Details: {mock_machine}\n", result.output - ) - self.assertEqual(result.exit_code, 0) - - @patch.object(MachineAPI, "create_machine") - def test_create_machine_fail(self, mock_create_machine): - mock_machine = {} - mock_response = {"success": False, "data": mock_machine} - mock_create_machine.return_value = mock_response - - result = self.runner.invoke( - create_machine, - ["--machine-name", "test-cli-machine", "--machine-type", "f1.2xlarge"], - ) - - self.assertIn( - "Failed to create machine. Check machine name and type and try again.\n", - result.output, - ) - self.assertEqual(result.exit_code, 0) - - @patch.object(MachineAPI, "start_machine") - def test_start_machine_success(self, mock_start_machine): - mock_message = {"message": "Machine test1234 started successfully"} - mock_response = {"success": True, "data": mock_message} - mock_start_machine.return_value = mock_response - - result = self.runner.invoke(start_machine, ["--machine-id", "test1234"]) - - self.assertIn("Machine test1234 started successfully\n", result.output) - self.assertEqual(result.exit_code, 0) - - @patch.object(MachineAPI, "start_machine") - def test_start_machine_fail(self, mock_start_machine): - mock_message = {"message": "Failed"} - mock_response = {"success": False, "data": mock_message} - mock_start_machine.return_value = mock_response - - result = self.runner.invoke(start_machine, ["--machine-id", "test1234"]) - - self.assertIn( - "Failed to start machine. Check machine ID and try again.\n", result.output - ) - self.assertEqual(result.exit_code, 0) - - @patch.object(MachineAPI, "stop_machine") - def test_stop_machine_success(self, mock_stop_machine): - mock_message = {"message": "Machine test1234 stopped successfully"} - mock_response = {"success": True, "data": mock_message} - mock_stop_machine.return_value = mock_response - - result = self.runner.invoke(stop_machine, ["--machine-id", "test1234"]) - - self.assertIn("Machine test1234 stopped successfully\n", result.output) - self.assertEqual(result.exit_code, 0) - - @patch.object(MachineAPI, "stop_machine") - def test_stop_machine_fail(self, mock_stop_machine): - mock_message = {"message": "Failed"} - mock_response = {"success": False, "data": mock_message} - mock_stop_machine.return_value = mock_response - - result = self.runner.invoke(stop_machine, ["--machine-id", "test1234"]) - - self.assertIn( - "Failed to stop machine. Check machine ID and try again.\n", result.output - ) - self.assertEqual(result.exit_code, 0) - - @patch.object(MachineAPI, "terminate_machine") - def test_terminate_machine_success(self, mock_terminate_machine): - mock_message = {"message": "Machine test1234 terminated successfully"} - mock_response = {"success": True, "data": mock_message} - mock_terminate_machine.return_value = mock_response - - result = self.runner.invoke(terminate_machine, ["--machine-id", "test1234"]) - - self.assertIn("Machine test1234 terminated successfully\n", result.output) - self.assertEqual(result.exit_code, 0) - - @patch.object(MachineAPI, "terminate_machine") - def test_terminate_machine_fail(self, mock_terminate_machine): - mock_message = {"message": "Failed"} - mock_response = {"success": False, "data": mock_message} - mock_terminate_machine.return_value = mock_response - - result = self.runner.invoke(terminate_machine, ["--machine-id", "test1234"]) - - self.assertIn( - "Failed to terminate machine. Check machine ID and try again.\n", - result.output, - ) - self.assertEqual(result.exit_code, 0) - - @patch.object(MachineAPI, "get_machine") - def test_get_machine_details_success(self, mock_get_machine): - mock_machine = { - "machine_id": "test1234", - "machine_name": "test-cli-2", - "machine_type": "f1.2xlarge", - "machine_status": "running", - "hourly_price": 0.0116, - "machine_desc": [ - {"Key": "Name", "Value": "test-cli-2"}, - {"Key": "assigned", "Value": "true"}, - {"Key": "user_id", "Value": "user_1234"}, - ], - } - mock_response = {"success": True, "data": mock_machine} - mock_get_machine.return_value = mock_response - - result = self.runner.invoke(get_machine_details, ["--machine-id", "test1234"]) - - self.assertIn( - "Machine test1234 details:\n" + str(mock_machine) + "\n", result.output - ) - self.assertEqual(result.exit_code, 0) - - @patch.object(MachineAPI, "get_machine") - def test_get_machine_details_fail(self, mock_get_machine): - mock_machine = {"failed"} - mock_response = {"success": False, "data": mock_machine} - mock_get_machine.return_value = mock_response - - result = self.runner.invoke(get_machine_details, ["--machine-id", "test1234"]) - - self.assertIn( - "Failed to Get machine. Check machine ID and try again.\n", result.output - ) - self.assertEqual(result.exit_code, 0) diff --git a/test/commands/test_metrics.py b/test/commands/test_metrics.py new file mode 100644 index 0000000..3fdca40 --- /dev/null +++ b/test/commands/test_metrics.py @@ -0,0 +1,14 @@ +import unittest +from click.testing import CliRunner +from src.commands.metrics import metrics + + +class TestMachineMetricsCommand(unittest.TestCase): + def setUp(self): + """Set up test fixtures before each test method.""" + self.runner = CliRunner() + + def test_cli_machine_metrics(self): + result = self.runner.invoke(metrics) + assert result.exit_code == 0 + assert "Function not yet supported." in result.output diff --git a/test/commands/test_metrics_commands.py b/test/commands/test_metrics_commands.py deleted file mode 100644 index 610af28..0000000 --- a/test/commands/test_metrics_commands.py +++ /dev/null @@ -1,12 +0,0 @@ -import pytest -from click.testing import CliRunner -from src.commands.metrics_commands import machine_metrics - -@pytest.fixture -def runner(): - return CliRunner() - -def test_machine_metrics(runner): - result = runner.invoke(machine_metrics) - assert result.exit_code == 0 - assert "Function not yet supported." in result.output diff --git a/test/commands/test_model_file.py b/test/commands/test_model_file.py new file mode 100644 index 0000000..1d5ded1 --- /dev/null +++ b/test/commands/test_model_file.py @@ -0,0 +1,145 @@ +import unittest +from unittest.mock import MagicMock, patch +from click.testing import CliRunner +from src.commands.model_file import model, ModelFileCommands +import traceback + + +class TestModelFileCommands(unittest.TestCase): + def setUp(self): + """Set up test fixtures before each test method.""" + self.runner = CliRunner() + + # Create and setup mock_model_api + self.patcher = patch("src.api.model_file_api.ModelFileAPI") + self.mock_model_api = self.patcher.start() + self.mock_instance = self.mock_model_api.return_value + + # Set up default successful responses + self.mock_instance.upload_model_file.return_value = { + "success": True, + "data": { + "model_name": "test_model", + "model_id": "123", + "upload_date": "2023-01-01T12:00:00.000+0000", + }, + } + self.mock_instance.get_all_models.return_value = { + "success": True, + "data": [], + "response": {"detail": None}, + } + self.mock_instance.get_model.return_value = { + "success": True, + "data": {"model_name": "test_model", "model_id": "123", "files": []}, + } + self.mock_instance.read_model_file.return_value = { + "success": True, + "data": {"content": "test content"}, + } + self.mock_instance.delete_model_file.return_value = { + "success": True, + "data": None, + } + self.mock_instance.delete_model.return_value = {"success": True, "data": None} + + # Setup model_commands + self.model_commands = ModelFileCommands() + self.model_commands.client = MagicMock() + self.model_commands.endpoint = self.mock_instance + + def tearDown(self): + """Clean up after each test method.""" + self.patcher.stop() + + def test_upload_model_file_success(self): + with patch("click.echo") as mock_print: + self.model_commands.upload("test.txt", "test_model") + mock_print.assert_any_call("\nAttempting to upload model file...\n") + mock_print.assert_any_call("Model file uploaded successfully:") + + def test_upload_model_file_failure(self): + self.mock_instance.upload_model_file.return_value = { + "success": False, + "response": {"detail": "Upload failed"}, + } + with patch("click.echo") as mock_print: + self.model_commands.upload("test.txt", "test_model") + mock_print.assert_called_with("Failed to upload model file: Upload failed") + + def test_list_models_success(self): + mock_models = [ + { + "model_name": "model1", + "model_id": "123", + "files": [ + { + "file_name": "test.txt", + "file_size": 100, + "last_modified": "2023-01-01T12:00:00Z", + } + ], + } + ] + self.mock_instance.get_all_models.return_value = { + "success": True, + "data": mock_models, + } + with patch("click.echo") as mock_print: + self.model_commands.list() + mock_print.assert_any_call("Models:") + + def test_get_model_success(self): + with patch("click.echo") as mock_print: + self.model_commands.get_model("123") + mock_print.assert_any_call("Name: test_model") + mock_print.assert_any_call("ID: 123") + + def test_read_file_success(self): + with patch("click.echo") as mock_print: + self.model_commands.read_file("123", "test.txt") + mock_print.assert_any_call("File contents for test.txt:") + mock_print.assert_any_call("test content") + + def test_delete_file_success(self): + with patch("click.echo") as mock_print: + self.model_commands.delete_file("123", "test.txt") + mock_print.assert_called_with("Model file test.txt deleted successfully") + + def test_delete_model_success(self): + with patch("click.echo") as mock_print: + self.model_commands.delete_model("123") + mock_print.assert_called_with("Model 123 deleted successfully") + + @patch("src.commands.model_file.ModelFileCommands", autospec=True) + def test_cli_upload_command(self, model_commands): + with self.runner.isolated_filesystem(): + with open("test.txt", "w") as f: + f.write("test") + result = self.runner.invoke( + model, ["upload", "-n", "test_model", "-f", "test.txt"] + ) + self.assertEqual(result.exit_code, 0) + + @patch("src.commands.model_file.ModelFileCommands", autospec=True) + def test_cli_list_command(self, model_commands): + result = self.runner.invoke(model, ["list"]) + if result.exit_code != 0: + print(f"Exit code: {result.exit_code}") + print(f"Output: {result.output}") + print(f"Exception: {result.exception}") + if result.exc_info: + print("Full traceback:") + print("".join(traceback.format_exception(*result.exc_info))) + self.assertEqual(result.exit_code, 0) + + @patch("src.commands.model_file.ModelFileCommands", autospec=True) + def test_cli_get_command(self, model_commands): + result = self.runner.invoke(model, ["get", "--model-id", "123"]) + print(result.output) + self.assertEqual(result.exit_code, 0) + + @patch("src.commands.model_file.ModelFileCommands", autospec=True) + def test_cli_delete_command(self, model_commands): + result = self.runner.invoke(model, ["delete", "--model-id", "123"]) + self.assertEqual(result.exit_code, 0) diff --git a/test/commands/test_user_auth.py b/test/commands/test_user_auth.py new file mode 100644 index 0000000..1accdae --- /dev/null +++ b/test/commands/test_user_auth.py @@ -0,0 +1,70 @@ +import unittest +from unittest.mock import patch +from click.testing import CliRunner +from src.commands.user_auth import login, logout + + +class TestUserAuth(unittest.TestCase): + def setUp(self): + """Set up test fixtures before each test method.""" + self.runner = CliRunner() + + # Create and setup mock_auth_api + self.patcher = patch("src.api.auth_api.AuthAPI") + self.mock_auth_api = self.patcher.start() + self.mock_instance = self.mock_auth_api.return_value + + self.endpoint = patch( + "src.commands.user_auth.endpoint", self.mock_instance + ).start() + + def tearDown(self): + """Clean up after each test method.""" + self.patcher.stop() + self.endpoint.stop() + + def test_login_success(self): + """Test successful login.""" + self.mock_instance.login.return_value = { + "success": True, + "data": "Login successful", + } + + with patch("click.echo") as mock_print: + result = self.runner.invoke( + login, ["--email", "test@test.com", "--password", "password"] + ) + mock_print.assert_called_with("Successfully logged in. Login successful") + self.assertEqual(result.exit_code, 0) + + def test_login_failure(self): + """Test failed login.""" + self.mock_instance.login.return_value = { + "success": False, + "message": "Invalid credentials", + } + + with patch("click.echo") as mock_print: + result = self.runner.invoke( + login, ["--email", "test@test.com", "--password", "wrong"] + ) + mock_print.assert_called_with("Login failed. Invalid credentials") + self.assertEqual(result.exit_code, 0) + + def test_logout_success(self): + """Test successful logout.""" + self.mock_instance.logout.return_value = True + + with patch("click.echo") as mock_print: + result = self.runner.invoke(logout) + mock_print.assert_called_with("Successfully logged out.") + self.assertEqual(result.exit_code, 0) + + def test_logout_not_logged_in(self): + """Test logout when not logged in.""" + self.mock_instance.logout.return_value = False + + with patch("click.echo") as mock_print: + result = self.runner.invoke(logout) + mock_print.assert_called_with("No action taken. You were not logged in.") + self.assertEqual(result.exit_code, 0)