From 067ba58cea5c884cf7bc073607afa35c0812c617 Mon Sep 17 00:00:00 2001 From: K-rolls Date: Sat, 7 Dec 2024 22:20:13 -0500 Subject: [PATCH 01/16] TEST --- Refactor made, all tests fail --- .gitignore | 1 + .pre-commit-config.yaml | 32 ++++ README.md | 3 +- requirements.txt | Bin 962 -> 455 bytes src/commands/__init__.py | 6 + src/commands/auth_commands.py | 147 --------------- src/commands/base.py | 29 +++ src/commands/base_command.py | 8 - src/commands/key.py | 98 ++++++++++ .../{machine_commands.py => machine.py} | 168 ++++++++---------- src/commands/metrics.py | 17 ++ src/commands/metrics_commands.py | 17 -- src/main.py | 40 +++-- test/commands/test_auth_commands.py | 14 +- test/commands/test_machine_commands.py | 3 +- test/commands/test_metrics_commands.py | 4 +- 16 files changed, 292 insertions(+), 295 deletions(-) create mode 100644 .pre-commit-config.yaml delete mode 100644 src/commands/auth_commands.py create mode 100644 src/commands/base.py delete mode 100644 src/commands/base_command.py create mode 100644 src/commands/key.py rename src/commands/{machine_commands.py => machine.py} (59%) create mode 100644 src/commands/metrics.py delete mode 100644 src/commands/metrics_commands.py 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..4164b55 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ The CLI is built using [click](https://click.palletsprojects.com/en/8.1.x/). Use quack ``` - ## Testing `pytest` docs can be found [here](https://docs.pytest.org/en/stable/). See basic run instructions below, which collects all tests and runs them, with default verbosity (should work from root dir or test dir) @@ -44,7 +43,7 @@ source .venv/bin/activate ```bash .venv\Scripts\activate ``` - + - Git Bash: ```bash diff --git a/requirements.txt b/requirements.txt index ba5e5f93234ce9f1a70d47fafd34237997877606..a63984ce22b408a53ce3b16caf4294c8b58a0861 100644 GIT binary patch literal 455 zcmYjO%W}gY47~F%(O}~|df;0+gvgE>j3}1VIA33pOlNxB)vmNG=MZm&t1KX)j+D^* z4tlUAVig+6``-2goWLn{4Rt};Wv+8b^u+4D0|&i{iQ>)s6LjEA92DQN zhDkRcb@S2SBVrNaJXK!H`(AJ8lGWj$vxerDDADVZnTFl6JprKa^d+i?WrL)oL5gRQz7m lsaVmASjVvq5Ab1x=hVJH{k>Uh(oS51P4}dt<_!acD^djOw1wsef@36;d2K)i6L18(h1E%?qxR=dWQ zcNN@cXHj?RJyHnls>+jU;*)xMsqN^M)6B#p#DuDpX9MEk%$XJJLd>bk{oE0`9esiZ zPbFM6xIKGM213VKgPYQpAcB&7PA{{S=*P_DcVX|m(VQ6_neWm6Gkqbs5-!aqRpnc_ zVn{Y(KIztcLXBA~o?EV*Ta0p{c?08rw3s`vM^>R{j+Ad@dyif*bB#iWl5!3F;#LNM ze*@PEKXM-3YMy`+=a$|*s1X<`>npnDQ(|MgX3+R-8`k2E^~%`brKeh;98cu7|KvI? ztExKPa58e!(9_u(2Q`kC?DACGVPKM*kcP0|?jhy-7TJTdry0NSrK*$(Fgwp)at@b5CZK*A{3+-Ye^wu7*n$O{%{w{w;rpYL9{d diff --git a/src/commands/__init__.py b/src/commands/__init__.py index e69de29..d7323d8 100644 --- a/src/commands/__init__.py +++ b/src/commands/__init__.py @@ -0,0 +1,6 @@ +from src.commands import key +from src.commands import base +from src.commands import machine +from src.commands import metrics + +__all__ = [key, base, machine, metrics] 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.py b/src/commands/base.py new file mode 100644 index 0000000..5d98fc7 --- /dev/null +++ b/src/commands/base.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"]: + print(f"Successfully logged in. {result['data']}") + else: + print(f"Login failed. {result['message']}") + + +@click.command() +@click.pass_context +def logout(ctx): + """Logout from the application.""" + result = endpoint.logout() + if result: + print("Successfully logged out.") + else: + print("No action taken. You were not logged in.") 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..6afd309 --- /dev/null +++ b/src/commands/key.py @@ -0,0 +1,98 @@ +import click +from src.api.api_client import APIClient +from src.api.auth_api import AuthAPI +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"]: + print(f"API key created successfully: {result['data']}") + else: + print(f"Failed to create API key. {result['message']}") + + def list_api_keys(self): + result = self.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']}") + + def delete_api_key(self, token): + result = self.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']}") + + def set_api_key(self, api_key): + result = self.endpoint.set_api_key(api_key) + if result: + print("API key set successfully.") + + def remove_api_key(self): + result = self.endpoint.clear_api_key() + if result: + print("API key removed successfully.") + else: + print("No API key was set.") + + +@click.group() +@click.pass_context +def key(ctx): + """API key management.""" + 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 59% rename from src/commands/machine_commands.py rename to src/commands/machine.py index c131658..798b631 100644 --- a/src/commands/machine_commands.py +++ b/src/commands/machine.py @@ -1,14 +1,14 @@ import click - from src.api.machine_api import MachineAPI -from src.commands.base_command import BaseCommand -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 +16,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 +29,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() @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..54dda62 --- /dev/null +++ b/src/commands/metrics.py @@ -0,0 +1,17 @@ +import click + + +class MachineMetricsCommand: + """Commands for machine metrics.""" + + pass + + +@click.group() +@click.pass_context +def metrics(ctx): + """ + No implementation yet. + Get information about machine metrics. + """ + 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/main.py b/src/main.py index 92e0c0f..0cb6150 100644 --- a/src/main.py +++ b/src/main.py @@ -1,9 +1,6 @@ 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 + +import src.commands as commands @click.group() @@ -12,16 +9,29 @@ def quack(): pass -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)) - - -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/test/commands/test_auth_commands.py b/test/commands/test_auth_commands.py index b645a3a..42745f8 100644 --- a/test/commands/test_auth_commands.py +++ b/test/commands/test_auth_commands.py @@ -1,11 +1,11 @@ import pytest from unittest.mock import patch from click.testing import CliRunner -from src.commands.auth_commands import ( +from src.commands.key import ( login, logout, - create_api_key, - list_api_keys, + create, + list, delete_api_key, set_api_key, remove_api_key, @@ -64,7 +64,7 @@ def test_logout_command_not_logged_in(runner, mock_auth_api): 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"]) + result = runner.invoke(create, ["--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() @@ -75,7 +75,7 @@ def test_create_api_key_command_failure(runner, mock_auth_api): "success": False, "message": "Failed to create API key", } - result = runner.invoke(create_api_key, ["--validity", "ONE_DAY"]) + result = runner.invoke(create, ["--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() @@ -87,7 +87,7 @@ def test_list_api_keys_command_success(runner, mock_auth_api): {"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) + result = runner.invoke(list) assert result.exit_code == 0 assert "Token: key1" in result.output assert "Token: key2" in result.output @@ -99,7 +99,7 @@ def test_list_api_keys_command_failure(runner, mock_auth_api): "success": False, "message": "Failed to retrieve API keys", } - result = runner.invoke(list_api_keys) + result = runner.invoke(list) 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() diff --git a/test/commands/test_machine_commands.py b/test/commands/test_machine_commands.py index 0c01a6f..6d0bd21 100644 --- a/test/commands/test_machine_commands.py +++ b/test/commands/test_machine_commands.py @@ -3,7 +3,7 @@ from click.testing import CliRunner from src.api.machine_api import MachineAPI -from src.commands.machine_commands import ( +from src.commands.machine import ( list_machines, create_machine, start_machine, @@ -14,7 +14,6 @@ class TestMachineCommands(unittest.TestCase): - def setUp(self): self.runner = CliRunner() diff --git a/test/commands/test_metrics_commands.py b/test/commands/test_metrics_commands.py index 610af28..b47424c 100644 --- a/test/commands/test_metrics_commands.py +++ b/test/commands/test_metrics_commands.py @@ -1,11 +1,13 @@ import pytest from click.testing import CliRunner -from src.commands.metrics_commands import machine_metrics +from src.commands.metrics import machine_metrics + @pytest.fixture def runner(): return CliRunner() + def test_machine_metrics(runner): result = runner.invoke(machine_metrics) assert result.exit_code == 0 From 1289b6fb4504748e87fabc30c93d5e475b128eee Mon Sep 17 00:00:00 2001 From: K-rolls Date: Sun, 8 Dec 2024 14:35:45 -0500 Subject: [PATCH 02/16] Update Documentation --- README.md | 65 ++++++++----------------------------- docs/assets/duckington.png | Bin 0 -> 41163 bytes docs/setup.md | 65 +++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 4 files changed, 80 insertions(+), 51 deletions(-) create mode 100644 docs/assets/duckington.png create mode 100644 docs/setup.md diff --git a/README.md b/README.md index 4164b55..0c88cf6 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,22 @@ -# 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 +quack --help ``` +## Contributing + +To get started contributing to this project see the [setup](docs/setup.md) page + ## Testing `pytest` docs can be found [here](https://docs.pytest.org/en/stable/). See basic run instructions below, which collects all tests and runs them, with default verbosity (should work from root dir or test dir) @@ -19,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 0000000000000000000000000000000000000000..468367bc7a4fd3dd32b972f341a259963a161b83 GIT binary patch literal 41163 zcmXtfbyS4?(X`g z-}n6fNSBNQ>~viJk=f~BD3tohi8^6hiYkqjOM6A?Zuf!WTTBe^sN-i# zj3O3w%|aP@I(iyj~$Mc9Y=o6nLbB9w|za2{f79{qH^+r zO7zeU+Zy&J>Q$THR2|8*_eY;!WlQ+gAy8LZBq~7cm!~T=eU`o0hEyv>IKEq>Sx3BH zdoQRQ9X*fp9MI&hj#pei#-A2qP>tsnDDry{GT<5f)xc z!~Rl>Mz#Ii$yz>XeTmog@yc*Z?wNg|trdI>u8|+)eQD$IP7D@%e_oM1ve4nP50rd# zbF!5mC=wqmJN1zr3H|ty6I1rnf2pP>boB&@ChUXEg6vq~+{tsW55t!`PQ%kD)WE6B ziey;EEW5&>v*H?|gPH6~7?<3q5RAxF{QiEqV`1mB{#nUq-~Rk)@>ws{41w|P-Xe|u zl25&T_GDySe5<7x7`S_jUTZzvyq$M8$HQ_lzMbM9HYNg)gXzhu!~lPiQe_bll3Ry| zM>iPY;@25OzO4QHIasG!E|fLA)qk4o4XF)oC$U|J+b#4`aSYvdZkm;Vl!#H#gqB`_ zV=rbYDz>R_xjmaG#|gQyr+t5S$Y)XgCH-9bX9Mof`|u_1)-yTu7swq73h*a|R>t6o zQ@2%%~YhAgJ!S~2VGH@Y@-+KDD_p8{rIGRq~A0xwF z{eH`h8ET00hvejTG}?6waRSS^=NaYZGqPI6nh3lGBdXdw>~-J1dDE`khk_LB}Y+X;9e;@mC~G_%ZZ_t$J9 zk^JKNJ-)(wcheL(zGvCT1Mx>3s&RMGy8ou4t|p@%cw8U7NX=Kk3wVrr8aB5PUf{0 z17k1tuCaYQ#>v{99-*4cvSEOzg|d7EE+IEni@*3ow3h!cF{oT~)JkV$|GPj8FW-rf ztr+Cl-*?)FKw8(^pa0Ha+#(l(K4%|iPd$A1P`txjA*d`=oUd48Y8 zMiIXT+VW6j&^-8R+=7n>3i|PWsx<#MP+G$Uxv4{2=LR!%>coFBme#IDKhCF0$jz((-qp{$?jUO3#wa#DMnA0*RGn=I61tT5ZB0=FAoqBS$*~!h;e-TB$-y3Q9aWv=I zew*w1mqBdzInSAu)oR(a)Oz2gK3npIpcO9Ljs%FzI}jBm%v*_?LSpy7A%SJ-9|lL@ zk8ZEnS3!zEGfnWSw&9^)qe>Q8R?b3~(Gn~#NKPLu;BF`OCwJ`|IftH4Z@-4Bg=_;T zP2?>{pURDb`9q6B76(1xNz3J?yD=s1b{=nI?|6C7ERhSDf|q{D zmKZtnqGP0uat=U$^@0e$+j}CJkeG;TZDE`9fumpbp;V7wPYsZiKvuo<1w*{PGw{4$ zPd|@%aPDwRz-dE6hdL@lfbXQ#CifUl$#H+>cmFv=SeQ?oPC-WuIpE-kqSi8Y;9Fv% z6O;D5-t;OHU{4A(Auq5maBD?1N3)jq+w%eI&I1E|A9#1V)AhP(*8B3<;#IQ0CQfyH zb%`okwH@Sm*bW03*t+3_;$aRrU=YkVkV$y6_#5G@#N9c~e1~Pm0oELcB$X)C1Ox`d zVjI*^gEx=xO}dtcxtLf5PYPmJ630zH%irTw7CI+-t9N{6uwE`sQ_AG~++@E3rH;77+)&AU zD3dzJcokmqc(#uKyBeo(Jvn#W<(%i zqAVgVC4Z7>B00iuzXhQbWyto zGxL|anv0`JWdol_^Xb2dDmjH^l{6-Ldc2akIHffkJ1)+P{I4FjJy#BPXRAZUZEoB& z->h>`G2o75io5%(o%Et1BbGQYxL0NtwUuZi6ewRobwbTi{0p;Zhh8xSJ;fpGZD~Zx z*Ux)16*Rxo1vQ_8K!+mJg^BFP=%H2x4LAG54241v-R#D~ zUHWy~Cz6dU2CtYX>~-?+Uxoo4o|YL`>`?|MvFgSGXJ=-%xwCNwqlV9yh-wDLehf6G zJ{%Q{&k9H80odrPH6A=}AfET2;P-qBW>0uyB4`tuL=xb;`p;hy~;AWh2rHePTJv(U>2b_^9g*C&wRF z@L;TP9w55Rw*eny2BYKq@c=)^@2XnD!C@cM2d11M;#hoo*e#c?e9xKHojo$Gun|4XxbW0Uf5(8#37GBkxxEkW zLnn7VF_e67clYi;Vv_ZySGZzgxNf_XMZ=?#$6wing*j3cW3)apV1l6<%;$7g%H~WI zQnCz~As2cji!T7E>LR68mCPnOupqD+%nxd-9d8E;)gtEQ`{4Pm02m^Gz4Jv~k(4UJumH6dYN#<_n`i@ctw{03HmNBMz42bgN_W%jAZ zx@3C@(3~2uQ3;WmIv50Yf%kneLXn<$0V^E5yQ0Wp2}?-H*UC`*4H7SeJQAxn=<%27 z7T8$y3$Ryzz1V}fwl?p#D^&SKmswMSXauY7&8%{hTq`_|{m+ho@q%=mB9OepEY%WV zE2q&RfV1zV`flyk4iG(a=e^=EwKFp=SKw5g7NPZAX^aR45IcALK8r?FD$-RR4VJ*j z8#=L@wEu9o$ldtc;0Zt>g`S%kx`bC=ku072+n;(^d~7&dr4p78{>ooIo8n?#A-{ZP zUZ{0Qwx-?^b^$f$s_Uz?ANV;-afwxETrCiN1Cb&Vf5j{UMG5S0tf1pbQTjniK; zRGvWD*_S53!1$OfKxo5mE#mTElj>c(+-Ns{pQ0(n$zL+6Tjx$xDygN1T}&3v4IGkW zG2x_YMt2**WDKHVU=eL_q33V)I-4o#Ti@vy`y>@i=>ZSjYpemMB z22_-n`h=Qou5)u*oW+r8QYD5r-k$K<@$=o{ktR^FxC1+j>b_A_UW)2#JYyUjd*WXf zUPb9ZE!CBessg|# zX4wzD2%`PvQ6>Ef*et&=EILDZ%FM<(Dgz*aqxphC#~sg25k>L%YgX_?3{Zo&C4biP&}B z;%}bG;W*Ts-eG>G#`)=7*{^{{#}sve^$LF}gJu_90-c8%CTu;N!!lvX_9s=|rBV-*b#KV51H@7m%3o*E)m16}k3K9$Zz{Sr3p74B zvjaj{!y@J5{3@SqguhzlC}LJ%3lhQsG7zB_;#q*yRbVhb`c9Z`Egm%R!|u2{N;AGt zJXMt{H2UFnH)_6vtnyimt*h|!w+ubKeGY9BzWA3bTM0ai3bcJh1Ul+EW(GRp8N2jf zG!zFnNiR3{zF82o);06n9OjNyYr`m?Oa|dux?5K(H-8GN&L8AFPEIH$=61LZ$CBXk z&!^+?A8Co787%1iDcyM|9D_i5bz_ujZr3w!%DNS@3>?C$|;$HBDI1dxTd51`iiG7)9m3b|dq6 zL3l7ftf)FMQ*TaJ-C&RNFHltep}v>i)d4+zo-sb0F`k(*ep`vwRhbqi`^{-3qs}>t z;>AI_%V7(P%VqD?%ox8t?`WkB)lH=1`Qvliv`N1vehdi6@Q1FsSb5iCsByu&O;D3#|xvf)jl-OFwXp;c}wT`P#{0^8` zm1K3Ei?p+Wm63X$o%L2DwvHp$1`Q6QH(xkM-}Bnsx~?aUcsUGxFguzWltP{*k=%j2;J&KaC!x>3sFpWbe{MMS4dc$OnJbQ1mgR4LYlZ5}>!EC25IwxoBb zBHNiL(yy4d1woY&@**;9g9gF=OV{h)POv-R!!hBOoB9#oEQH z+uRROe8l50VL6}=W|>N|(Xs_@D)JY)ZYE3=DKhUG^DukW1HWK-n>Q4WRP4l=jg);5 z=t<`;$!9QC=^cp@Ci*_PttpntU9z@4&tUN-2@y~JQ|s!FJ!x~p-Jc9#c|f+`;2nj| zoSoyrw_+c2>EMn!*xB})u0{0-Ywh*W(5PtFqYpH|ahlE4Zo_Yicm0R&gp}NN)8pd< z$Tu!N{_Qy*0C-)2suBo%?pI%bQhmJY2wVE0H24GI;m3$8>Q15&KVjlhPRFn7k#x4n z0(|sVE|dv2Q6Dx-kPcR^Ytm-FH3n%5|8xtKsX_G+FiI-%nv+7YoA@7cM}+$nQPB>|AE|$f7}|< zc9S+lMbJTdee4Xg+rVU(x#paQx&`HLDIW1*)8aWVjTjJR9Lc-AJz|YDsg^_}r|t>4 z{Nfv3=lTR&-j_>aWrl4uw;Fd*ny+w|3NS>3n?28U>rHHt#pclD3pOaLQ824~Cl~57 zo8jGL=tPra0C$pm!ccU&;v~qv=#vhUGmBkx;cVjHn#FQTGvS6mMf!zp4t~S*8PEF+dCaR_MNZ( zLvbqmOjo=i1k0xUym$ysTsJodLEqo$+s#Nctw;hFj;bkz4Fu}yGzd(5gV|n#n7`CF zy)A?2E6zgcw8#V#(=W*sTNJg5KBv)6rCP(bUk337#L{yLk`~GZKf_PGF>{_jvOW#CL!dv{~(iPpzd`E z7WiU1UxxW8=jcjGlI!Ppd3+Cz@H6#N6I^DEmhvCvV4Xc}7KX}wt)!n?$`fUU3Qt@$ zSRd;y?WMi$?sO0dlfrMpG2!77N>q+HW)1I3 zn8-EJ*VP8#>_-Is4vY*M8F>Og-%$-@>PDg48IIFau%s0Nza+wba1Gw6FbcP~r}W&5 zR(w^NXlMBbwoLj$%d0rZ&P>NsFe1}jW7k`KR(HI2v6fJmACjpnIoIx}lJSU$rQ|yH zA4+nL$qPaqM`nX;Mn!GRV=IcWT0Im!d-<|)%qFQiC}8eesu~jsk3LL~DjRk2xva=E zv)f#67bk0c;nObamez*gPt^|9Mwj$xtlkE~we53o@4kT5B+mXBfA_o9n7*4|sAXQC z^RJ6gNK!^ARn=eNd_ZABMm{<`Olv$kNP4Wg^E4v;03Xb)^(yfg`9+AzQ(?^+viO#N zu=R>jxS=qyuzgCWirp3p8L9JS4I5$c1v>Os)mdo0p?CN2V?Y;69y>aDe}2HS7WQA~ z7>M>!wD3@$d(CkFb3?kbVepX9;Fy%OHNdD+uft9b30k+tT<-{tt;42xrC+;YLSTU- zW{Qqioqp#<>2i_Q#;MLI6%o6}sR+dYxDP2@4lLZ)XL4JsmUYH-jb-siY8x-YRv=St5E1 z2VDmI#V*AR4cQ~Za*KnE`PC%{ggVtI*~Iue&aa(GLqgT7d}ED1mI$qid03D zHn!CsKL9Ik)p5X$O8aM6St=0+H6ef}fM_P^=?w4L=W37TucOaE_;%Lj(!(9W&(_Mq z8&JYnI3u^Iex-}IdXTwth6V$>y~=cbn;6jVq$zUnhWhg_EnzxyIli%iYf_Ne~l}-#IwW;*$)gSUiQY+6awXx&?GJrki@Qs%f)k?vOFbH9_5-90o_i_ z!y_V+r>5NIAAffina@g?us}XX%-T_Fk{x|{+tnb zRTay)m>MfPe_Gp`O*(LK_1BkP+g?s66m~~fXklOInjAhMLZf%HHVjXjl&?ZSIeCJg zjC&oF-wPq=dA4VwN}TI~iWA>lT)Z0VFMl}hj~ymL0-pPF%lGxWB$bw2#SggXs4i*~aAT1X} z8mGup+J(JrA-_YjTpMV^Z%gl9o>u7gCDE&IN0y!5%bArwpnZhH-4f95{c-`WY7pX6&BRxdEeA<>8SGOs7 zL8}5-M%!Dc+vu{_7L<*wqONk233e7$yR`8V3xl)hO?3oAjnEI?2N7-m!3Eh3W3fC+ zibBDNR5_B!h=d0{EQL&nl6tXt!B)f2MJ0V*duvbUSRXQ_Vl5)a2LU#0_E5;c|40v`B6TK$(|R{@ag}po%b>QIp#3 zyk)nZs7_2(OfA%0R-9<}@?$pq3FBiiOOSA988k{%U`!Sm?99xfpP$t-lO}m{4596g zh)^nHKaEtz3m@SNm*J_bk@rY50KZ7{(sl5xs2O2r3-kKJKA36h5e(D(UGIZ;u0ddt;c5f|U}eM(9ig|%%M z)2(4Tn4xfX9QKQ3OiyRo%2|0*RurtDDPGvzNGmTYa_bmU zuT{ph{XZ>01&EhzX7MYb*x5;Aw|X{pl7sG6iA&vzR)4QMROH z1A@34f;9kwj}H-G!sOQ+nI7wP{f%{KIN&#N2&~e~=UvNQXD}o%(yNlTpbMHI`3M(r zn*&q~mchg{8lgX+nu-zHA86dmf5au@R>&W-Fj{Mm&^un1aY)|a5$@mpa&fiR9(-^3 z*$iSHKPda@^T{%t1uvY^_G{-bG>sp9Fx4HXzBToBW9vbeU{hBkuBND&-pWf^nxh)V zK!Y=^xRdX4HayWcV;Z>^>R>pxKX$Jn+Rp8mu-{eo=kb-=rtWWU0`Cw1eA_&fuqyp& z6)pL-6AG9NLIx%Sd7&gX^`-i99_$hAN2i8OQ3Z&+-sTC6I;+f-Kd`RZ6Qmgo(ST9f?$MJZ zjwOQ3JKFOZO~NlLtnt#dMQ$=`8SwoVRxbL!9WjuMx-#1Y?&VE!tmohUb7%^&89Q)2 z-S6~tO1HxqOchrM|NJu=5%fKj3EnNzG3Y$}(%;gesC@qQyH3lbSC>H=&+h&_)@R@} zUe8uGu#oQ4U9%)BFycIjD2@)7LWt>WjCr5`k{qspQSYkx(#KIk?$9d3P7A~3JAMfk zji#GfB9>%f{TAQghH@fS9hPKvYjztR1?$AYg}SOl_iR;?;n4kJou|5Sxxt?)KH&KE z{S0iEh0fy3h5%*Y$ljN&eMGEMG=Hw#PS9iUF zjwgdgRAStUCfn1o@@i#g+ZLm8TOgX|t$}IO3#xJqkybZ1!}?#2KI#1Y?3{2^dZj>2 zy}(x*c;__gxm^NK7Rb@l#V@KUrmeKP;Q7}q+RWA7>7rU6DlO#Si52G9E&kL&QT-kc zZ)wT}?@9Syr4j|c03+FQGmL{}=Uj~vf&FwoYpzo1?+b)knTj3Y4ws~WnM$2~-*xZC z1hVPMwDDr;qA2NQND+I^64PY|2_ut(!3?MId7b}h4ySeY#cjeC^9o1#kEg?YMeF-* zlFqsS|5G%{*xj1_ogKVI{|kraTe;(=jMyl<`$1KSLkUrl*6x!2KDUR1IPHqxm~stI z2Z7qds`yb>jkv-)(=Pp$<`su1rWuYc48H}ADP|w)JJun-t)6=4mCZ95j*rKW2ewyO z_o#gWi_j}vdl-X}{CE4dQ2x1ATdRUYU43#p8Ho!!*d|?dNX}0QXkUMI+sw_0RxBjI zd%9yZbYIM-D!>?=bjym7a;?Kt6V-fNlWq_h0uq!keO9wgay~_!gm*x|fN<4r(_5>G z2t<$RsgV{+j_1=!tJ}fU=jdtEsxkrwoQhf>GDvR(iMp$rR)xVA9fMa{zsxr(M&K_! z@ol4ph;~ugC`z~ARneK?Az7!V;-lrZrLs&qIlN<`fudu#W{K}JK6vjh(zfBhM8z>K zA7)(Ts{>-rj;>+{*YErUmMTw6j=ISM){b<{BirPXDk&+8JimG;|2Y!QpR1rWklpr) z-rw95t}3|?a~2PcNuBd7y}mI(Dxa=BHalliDZ7_Gdy=n7^qJ4G?+PsWDK-D4EYK4R ziS=-nD}Hsj7okM9CuVk5Guj!tdqUNS20SHy~Qt)`m3#zz>VHaJ}e zzWuh31#KvjU66_tnwxj+)R~+h#E&80u3vSy%M`FMY1=L6Z&RV-{o}g#4zX+6fg!mq z09|efdQ6Aier+cVhy^&psVFY zpQ`!QW?tm66;G0)hK4=++V*7)fNV`gx5*z0=y79j+}G5or{ZGH&Zfo};@+O(4R-tm zHGIzYT-`e2)d7N@yXlK%ssGSwZlR# z>eY+CNNE!)1gtI6)cd^1@wW&a$D(toQrFV-L06iDfX)v|O+;WE5rN65!H}MG0Ld?x zZwEaU|ETHU?3T?vek-u9^51XfTCgw$<&~~NulL`JWZ5DQ-^-0uxMx^+nrJQ5p@Lq{ z%7oc-Gx;dlnJrZ5>vp>Cj$7XqP0X6%g_?2(zZ!`6i1w6i4Fbo(Q9=NQJRm4<;QzIy@rKBbWzB({1{fm4 zgq=>;ZprU)kE};T4d9zS@_&#!kr%|lL9zTeduxvvQB2^6siO>^Ff;2KlR~kD&$o1B zJd1Qzrs}WX%v38*Zh`=1_QIVq(qIN!Jjn9s@A> z2cmPXXyU~;Jq=wSSQs2fDxFv3hr8$92|9A6flirG7qXLeO#O1`m|CoL3&f&UK*%S=|2bj_LH}BZ!(r?(PK{>AZ#$z& zoc1Ty-D9Kwap)T=7#5_5M(sjjzr2jsT44`3hkOd#+?!^lA>quL7dP#z@6(RJscC>oB#q)xaL$(vQ!G)Y{#TMaj7~+@Hl50hr!Jb`qpzbdEazT*Lst1 z=F2pg%(aZAQP^@{LSe-4wN^JTfEg?j5cRMWW6ccQKTA4qvlvMgTgzjahQa>HozM$V z(4p@BIp!8cjK;<&sCwKl)Br5GK|4}nL5^@~I0Xr=k>9OWpv@^AJmx+Wz)l^k6>Oxn z3lMWK>hQk&K`vj@p;ZB@Gx~)(?UOA%*;5+$hM4$vESEmurI&0Vu>;F6QpQwCyS19* zFVbMUIb@E}fL%QBu8fTA>sPV?Yh&wu1|}rvc>*6A--o}7Gr~pTT`b5ZM;6Q2c5uQf&dgxaWTpqM+ci+Z$U;SsP^u-&dd#B@_3rw*Kv- zc)T6Us=|>1q5-?HjcO}>Z_ls|9x%c0+nm=wOSl!lU$7y8XUtP>qfegL6rAhn@6qOJ zE&@sK2hhBXKX&$Pz~X>G+gEl=u+c!!6A3w;tPC2y9Gbq5n}_~p^Fe*XMGrmv2g$D? z6tV^JVZ5ft`|II>csg`J{N~rQ)z*{6djY!>MM*ewFlW=tPOI_!a{QU*75@q&_rG1i z&scY^l|FB%J=Qi$^9t9e?*HXO&f)sf=DRsO)>MwiP8cBG(X(5tpes4qF#{d`N=oLyE{ zcJThVvwBU74BS&4rknKP%SRnh3a4T6$cR#SWMrc4Y}Mf9{wxIs@exgZwu$w)Dk~3* zQX}P)+r?^jIH4SQ^2~QmlRhF`+>IQJ%r;LK-OXk@TZp&1qCWuV8qYR-rPj1xlhZ>= zt5}KD+6$RqFQtMOtW0ZG3!u2!hlHIu?R!O|aEOvV%qCbF%_P;}jJY6XN1t zqd>5*u<+1ukdc}7~i%Sp>Ccn9uLf_w7KW8%xlaP)LmGGF+n_8^~{lmu2beqK4#ktuf1C!Fz z%i-S}FNQfgOQ}Uc#NAF{9$Oitk4)R)_Qz7CkPAAL8i|5HRy)uBq<0QM?z)RFnmF8v5Gyst`;aI@<1!syVm4J8zDy zqfN(0ze5%{cU)%fOL>9->5 zWFZ(w+W*FM8>R{Jf)HZ$^)I(OM2APe!o|nOPDN_rQ7Z4*mHNCwFt8#2`(G5U+OCc= zB1=;CKuHI(&6)j&*M8ws!DUS@PImmyyrBDW_#_6KxLSq-{!2i&teKaOL}lEHzi3lG z!m$N87_(ZRFmf<;G?tyGx4kt^=vXkz<2$KH#*VdcM}1s;^~z8h?Y91t=X=+d{*?z;GWu1!yA43f*fMMrYgBmT|xrWk1;t9)`)T~V6+b9QGp(5 z$vd(SuW?ris;&%M*kvX(x~oFV740g}nCq$4=4;h&%*6U9mhg2{K~&GCI$45 zBBr)GZ#2bos<*$F`5Gm0N}oGVKVm*r#uVic&EW2GGgChYMKp!%)Q$$|H9s6Ax;Hn} zG{jIyc76S1Zk;9KZa2FzXd1nnj8k|)8jgh1vKQ6qanDgI~Gk72&1= zIA`2f7`;%5Z8|aD z*34A@N#L*^{Kr$Xvq5G61jNKi>DYK&eC=Qd??Z;&=|x$;>}&+WROd8aOF|dXj8mcM zu0FQ*e1lc6fkb2u@xtU&_|j%0+*&AeX~9)!TenCU#QHC#$*waMbR{a*BIYKyTk>dS zbA8=c zvU&9P3WYWIKdmBmR=tmUJ;>sSH7l4NEQ_i0+M>JGP)cR2VB`G#G3E!aU?yJne z!Q*E=LN1HuJpuw#wprSbAN|GMeVb2>BimEh*X3o`Vh5;jZ^f#JvJI@4|q*L3GYTShCTVmO}7}B^;z9 zvyQ0HU$tP2eH_sCM`N+{hQ9&tRbMy-G|%pq5V`COT(?CFubp?iBSZ2g0~r{ZKJG5M z=M(9bWywE&z$;P?Az)1_J-%d^BUqxL{n7WJW+X#WR?Tlgmc-z^IMlseHGON7V=Ws3t9zf ztQcDPd8@Ac{IM1O-FB7OjfUTzp2VXyQI1v2!OAk!`=znA0>r`4GMPU^N`daFR=a|87pV#T^iduLNL`P(TD& zkjmb?Mn6GS_ccPc54GA{eUa*Wf2@=x7VwLWzl_S7=Mg9QJyjCC?w4#ggE`ecEVfxP z-MfE{P8EPN99Cq&v?4Y0=W%`!U)~y5)?__Db(6;I`PcJ-PK!%83cFA6?>R>+_!nHa z{Vg$*sFqrd-g$+I?qq8#<82r6L;C9}*ybYCl&6H3oj8hzq4gR7OK%7hj{F&Wd zi>xU$sfqzsU;~5-X?5-=Z7emFh-Qdu zhK3*K+XCQNZ-w#TOpLwdl&5k55)F%i$puB@$X1;|3S1#k&-bVK&n)O{`s4Omkfm~s z&Kxyv25}C2t2f;&dOd}!FMMyFsFOj{HLyVK7MIN_WCzFGYYB7wshif(h;5RJ zhRfF!0EIi)cR1=x6l!>{6D5V=+B*~L1%mGUH6eC*l&RgauECW)>?@=(0u^`$f9@#+ z^UFQlo|hPSx*yI?#|%X;|Epe?sxd@Fn#unnB@0K$Drlr#6Abt$7Cwak_97+O8DaI< zjr&3c@KP`q;f+#uwmHnIhQi)tZHp|%y81!4Lg?!_yPpA8fv;xBT{}&G128EfQB|a( zpWow@I#IM>j>-&B5hG=1x$BwjO9hpt^0Bri<9{jhap3N}5@bMD_~PQS6Yg;{7uhx` zSxrfh2Ze5|UZc%!HgYL!ERB^zLlQA406^pr_J`7Tx0?&Hzs0%T+wB0&D=@JX?Ms9} zGA!~v(lryF4;P!YUw5ToW-bYI^K}Cc+Nn}K3C**e6&0e6CbQVYZUA|DSfk>p zS7+Ds&`nD6)AZJpgW{NcltLK}ooVmLIsQS+4k^9S8qb$aG7laO)eVykHV+1{N$4X4 z^kib}ms=ZsPkv!yqXxc|w zP4fZC)hDA70dUORz(sU}6L6g(U(*2u3($7^0TVfcRS5pVg+RLU;aC$hd|2-xOg zsIPxC2^yb9Nwp%0e^_Xf^to5iZvK?O2^BmGFJdsk^WB`jBrj3Pni~(bK6vfVY62x`h_oNVlU26?df~!g6+F zqf6MSMBVYd<8({UOyDc_a6lhroRhdPUNe3m8q-DYy5&JP149lt?LbZv78X}=t&E8rK? za#@_F%boBSulej02NcWE%=rD;>R}Q?Uz+UfY;1Wq^PTa6P_gq7-U+AZJF>=*UF)_p2WdRwj;Z#{ad6o=xh_xG%U^WCx9W$*8?nYj8F<*2nCcn=u-1R{i4CMr5mlHPV!eM_U3ryMUS3)qh8K zw+hj>pi}ySCUn=IR$xv2nMq`Eac{|A6H)w}HBnduPL{EUtHNq$lM9O(4jLJpPse>6 zfl_+D7tiu`b{pB`wectr~fUV z?n*_vWp+7|pWmHCNkHIGdgda0vh>HaKjuADeVTa=Nz?^1pq9S-8b#i#go8XF{#5w^ zZ#x5gD^Ukw@&KIX&=zDP&CSV)Q+QO=kDVQ>@YvWC&p%#ry;V*qz#kv-P!H@Eo3v(# zQEpfK@0F|c+lr}E5)$&+grV>Cs{Zj5L#oT({8OvD7`V2J=*bcxx6P?oYb6DkT7R6m z!};fj1Fq&srSDP#+qJu(6xbx+<=56T?Oi!t)q>{8aistFN4@Se; z#E{47nyfkZ_!0o7_@Q11u<8F&Ld3#HO#niWzG zau*SGUb6~7e95*yQ{Se#H^B?9=@w!=WS7jaupJ5%#Tn5ruF%`L$UC z!&?#EkI=5<)y4BBT1F`e7_l?n=wIW|PYcA1;q?rKwm`dOAA_s;R>3Q7$;AP>gSdFq*7%|;Fs_qQt33}5cgIr-}`03a?IQD9Q?8f=;WZi z7ZM%YTrf62^|@a5Jcj*O32AJdEDCb|-cti0I^|&F{dUywNj=61s}_1e;t60SPGkqy z;={O#aJ{xEi&Zj8&norXm(D7i3#hzDct5>7iKhhqcM+IGjLUB1A!<0oqLt;K;^Q#U z-yMQW_AuA|L1X9&_5ZX0oPsrOyWiqdP!Wf3xGhZ%^&(%66DOX8tOkkD@|`XaJb38l zQ?WT?oc+oOTen6973>sz{4bVtgYix6Y)^7r6??7cansqTgsycx=R=6831`60aPr)6 zPNnO7|D_j=b2W6|^0&xl=#YHT!~V(>l%*;AkdPJS1ArBZ&LVzff^10CTwxr&y=*r1 z&@Z2x0~5((O7zqt%LXVL>yq@HKEL(ECwF`JLKnZ7^V)wdG$`+@XgG0KQES3v{+6S@ zyfffVnX%{EXx)%Nip5dsvkj`s`Hu%;?~{^BhaDY&M~SzaY1~9$n``_mqhO2r;vd<7 zM|;oLSvs~;&`d7VnIFH=Ft5lGQtqDSs3wv0&bI5eN(V8;cYB*AO3BlLBm{1r{N1;W z;+=Mf{)T1-FXjFjXH5~7uqr)heYC5ceuiJu#P8vs-&7)OBDy&aSJ`BLvb2r3iqjgsDdI5=RKdgMv+##Po1 z4bSMYT&({RwXnbv)8tO5=e17X=*kna6U4{W_kMy4@3~2l;*!#(lfsxRphf_-f#gAc z`ak4&BxmuCF*tJ}GTi#x%I7B^>3)jcFn4dJ{k=#kD+u*Qcb_pHzHE_lu#SDvQo0zKfv*xBvHQJY@QMCO*e}2_DD20zj|g zlHyHPEE0Fn5k=K7TmZmsKwQ&CP3b$W%c*IkwFJ${`qt(vgfA|P8#oSF_X&Qdvm`I_onuXCL%r73qd(4TjM^^kAc z^G#Xsk;9e08&A9HbeYGxGTI&$sKqVM<_WRh)U~I4+Te5G+)R`Sb!(K{i_2b`p1N?y z_ZlJ2ms3u0J{dOOghw!3tjCUY&QMeSw7}# z_PV^fQ@?&AhA)f>BMgz__DbKp(W-O6`uUKD>9WCWIkooA(guLTK@MxWnZf@lx*PS6 z;AF$dx<`b_YaoTbNg){ir*VgwRpw949=!;Ci6B9IvNt7;n^`DHkNe6~T)+lxD9Ki` z#Jd}PO4`g2#mLOXeb@sE5ncC{<^UU;UXdHnMmzP5spce4Zba_)%^U_8C_r%cD=YQ*g zWcmFUf>ah}wWfOX5D6eAJ%c7Z!r7~5&6+&V=`p2^1wJZCR12OKLsz41-AVhJUU>&V zj#euobG?A%d+wa3^7)XCg&&t5&*8s{z0Y$t$~1z-Fr=IR{;YS@8r%~8r*KuawUZ&Il%;9$y`VRM1V2ZXH$L&5`!t;GSVJ^}fc1P2fv%OC=(7aGOvTn3m1 zxU&R%AB70Kh@pU}H4%qDh4n8j;EvyZdqD7zva2zUgiaU&3H)zieBsTaq*D!=lRPqQ z9VdJLM!WusOX=6p?J&yVkTU|XU;Xj$m}rT*pO9~kx+vu|Im{IKyHIA$@n=P?kIcff zlU_vNBwwE|#-;+s07>Zm5&jdR=)E}F7bu+siiN+LD_iqaS9_05>i+T2^I2VTSg#3D z!P==1dIGVVDSA5b%hmDC_zmi@-$lVv*OlId)tX+>`8XGGs{rXx%LwnLx@7A{{qLSK zdEbM))|RI3Sb~O09M;pdjp{_8I)0%L)z1%xs4BvB0|`gKVC${uJD$LkKXhsB=xZ?$ z%ep-m%;F-;fle6Kp1>gZcFHE)k${m3WkLcGJFN8rNzS6~&zHI@JYE|cHy4MyOg&$k zWa4cHV>}hvazX|%C)ZaGvFiB$vUy00(bfYO?E6^R+l-oe~n`cYY&aN12HrfQJ|S(Kj868JLD{X63Lpo3ENQ zm*;Lr^PxcXszB9DmgX1Ugv?I#0Zf}g1_y~4|Kv-^MdG(9`YGfLc?tB81>rzJ-!xJe&PjrZRcOol-m>3%aKTP;T z5oC7th8P|Dl`0!aTC&`@P)#xiVpz3uC4<0c93KkX>_Yxd;+J2|P5 z);DQa)`S$QwV2aGsaR%Q>n}b*X9mLXisJI~`}oY?OHaC3sq2-=CeF382@wY{5`FfZ zJ)=D1x!+6f+eHW#2TWEXQQ`v`B?Lg~@`o&B)tL;sOov?WkE0#p|1fiF)6UrwX)A3e z?`tkmhKdR7p6l_M*XlWovG{)RG+%>jOvAh^7-%txe9j~xd) zykvy|P!||kTfZ-wdpGI#N*Oj4hL!IX7|Vru&vK#>!WKYE8q`iO%yq4tJC)aDa9$|y zJXXj)zLCW%3!ITs&vgkDR0{PwU56ZnJf7G5Wvw{v!Hp^P0E4ilNMz*Mxv_mV$yZDD zc#N}sp6rE=M^JFyUf$V^xef^HKyu5vu^_A-Q%nFfmipuu}_ssY+^WU1wbs zKx^KVDt%$EI*D^9iedTCG|q`a;Q;$2qN(z3rb{=gT4$6{M})r`$}w2h8XVOE*yNGs z-G8V#8=J;=fag)`!1EK9G5ZQ%aw}l-0WYT_v>LSm~4g$~eI-<-vt+24$2qwQ7`fSKo zQbIYT?>B?;8tg(=vMn{973FKq8?C4Bfr{wlWI0-C@nd@BtKu}d_ku+`g6gne$#|U< ziGB&wh?aKdOrQ|gdH;8{@(lOhtLG=Afd1_MRmqzYnP@!)7^K+3->~AwUbyZ&FUa4;J!t?^_AOoo%mXMW@}Elka1nFvrjA7Fv|BEO_CL5i+ae(Mdtg>9Qt!dYjy zCWtWibuZahQ|17_I!$NH&Hp{v$_XSRNsawgj%Z}_P(Q22NvxKb9~QrJ${CRg!gW=C z5VDcHlL~MUY0De#eeihl8dUH>qB5QsiUEb3v{hd&sm!(ZPEI54|5H%_3Yxpojvu)} zOA>YuRX{Z4Pj2pEa(@NiB>0-kxg}6~e2X^gglh*GTodo2jQ{TgT`#`C*@xQD@Y`}J znExslJ$6TXo0r~+625@4n==R0Jns7~Vw>=BiRjEl6F_}kO(mr;nqIt_O(wl^HZ=m4 zjPf4THcRZbhfOD($~PUaT)CHKaw~o}Z=e=wvyhD!Zceh(UW1cGMuXr~N}&j*D>g<4 zo&5hQcA)5xXgRe|X4^EX@{9Lu%mdomLsV%A7tm%O=c^^u2ppCaB>O>;({mWc-*pjU z6&;kARZB!`QI~PgraC8HO^74S69-QF!$P2Fdj#)G|vlH)4cj&u_ZqZs6c}I zGD;JB=lcwx=$2L|HwBhl`L7zBdEJ^%%9h-wSIV{^KV|n3{4{RJ@PH~LO0txnea;f1 zA|Ckm$%oE4*tJcTg^H3~(xLHgqAYlkGMoB&;P#n=>f7b&Js)7Y(1akO{8fi2bde|k zLisyS`-GTu8SQh5-^l6qrn-`BBNMd}eQwL_f3%!6Y_z&%=wf?Ubw>j?_m1aiABfa| z3--rZliLlJ{5Z*Yl!(e+Bx4T?u07dH{^*biAX+e-wQVs-0Xtb*=AjwE z3ukJqBMD5K62|#hw?*vvCE_26xNVHBDVZ7xD(Rd0dE2%qzy3_$^>pTJ<(h~;GcR>= z|Ee1~T|vT@x-y&ZZnhCWA>>=~J~3;P|Lotf^s!L9=w?O(OEp@rcQ^H_o*n@bAaMt-a@g)R9&-xAh`d zR6<6PQ`$Up%g3kThNv9QgatQh9TAvt>t20K=VWj;Ct3z;q3AJE=PhNwsx8jjv7W!4 zKp0viJ}{>^Y{j#8g8R7twi3~j58of_*{>SFQ5WB(FU_e(PW#G{7*ObIe?H-LLJ0+D zcT3p?B5XYT>~o4m8Gkf6*3|DDUJPT-4(!LM%a}x`8QnR8gTwp*CdDjwHj0h(cDB;Y`}fa^jYRV!Kc=(Y*wxiP z!o6Uf86r9U(V@Wh6Ae3&Ws!A5Vl>ngoZS6OxlVgC+6RUL;+kn7AVVf@xdENea^45z5ap8C*9CjbR~oSh355lC|rj4QYzWP_ZID~;UjJanc5xti;;U@?g-OwclD zNj^lbw*pw;Nx=v?tS?}n?P`$6#nue3^pQ!kgd;%#J|5+?op`VJk*#-~WZC?Tv`vnH zkq5^Cs=9$!*kN%^x=1P6_tutjj6MJ8{qoXr_aD#uO-&$*-W1KJMX8%Tk8VI3TcbM>DiN>Y%9fL!tjl$Bxi94O;)F_h~u<c8#92D@PHED$|#c$j<&ac1~5?i?@NX-JnrLrW{4x9VVXgkId? zwAA9BbNw;z8CtR3OICY_uPV*GD3(zLdB+rQ>?Q68FFT}Qf!lm|TFqNMy{IhMm9_H+ zY3#DjxGnofIw3@hQk5PX0|}92AwL_VLHd7tSGYaNr%qwj*RyDvZtUsm!p=2B7K{^P z&mK8k8>-k9QwQRf09kq5+eur3X%SZxR9l5s{}QvMuv zS}tCF9M4&r7}3T{?7l7xH8elH7WMga-T7jh2af_f9Gw`FEld#^FPCVqJkRzsp{Mu7 zI^!2TdNwkiLLRMCo^HV}Zg*aP^26%z9TPxG1zVnL#42p5!C$RHcOQkTHl{xbW7jk# zIc*(lW00mLr<=09x*o&L8y$p0Ld^kUv*24{L$vK3Db08Ept>0YazE3tZGzF+2kXeMxSwUiwgCeMAWuBwR%)}llw zhOWXoVp$2`31NM_P1yW*F~XfbG3 z17d^KCOsMB0XG~VTjZxWHQ6o7MLsmWdluIgovX9!a$w1bo7Inx4Cg*#Q$jl$2WyiUwXa%A zn-I{msww9*C!ZLuV z9#6z0zn*dxW)+a_#Z!fwOnjVTXfc6S%n)N_vV`LY9W9TMjMc|-FwQ}0hm|OZ$eglj zLyXrnb~xb?klrL$z6>=ruB~dwvv#NQCZLKb(9Q4XXL7CyT`ezmn(-fYHkw5ClDzD` zrL70YcbtK2-blSF0>qP1*d*QSayen4f{DpaJ-2Y@?%!MTbQD3;NSa2nrOd~b%o=(A zfMjj<6mnif0bkdRAe`8|TFAm)nj!)%<@t0Pt6SifsRwl*jW5nAsogx7D-oSwZMu$Q zO##wd-SfG3vG=k7G`&~?9^}aCB*HvH2Y=a2l=#GgEeOd7%jB09eUjcs%7tJ;as$wq z7b#zCe?K8I6<-m?dH4h3OO4(0%i_j)uvEA7wye{-0g^3+Qp)5flZi1pUko-%?J}2j zc|0l&q+%o~UW+-KOUV%RfHs-Dn(HD=nSZ^L9aD!z4&I{*+y{$b2(}K{r8b-wmj3|KMo|P zSi`*y@!f9ln||2&A^>Zmj}<;N?i_Dy+A z3%4Fy*d?)P6S(6B$F^cvj-g+@n8%ZoO$ZVjm-^q+FR{CcCWlNuP0`(=>+L2D4|H0+ z@dyD#d0UyFhzd@g{P zgX2t@g5ftbM#AJGe?{L58PWvi&W{Xk@j-K3B9z5wiV(9@MMMo@Y;D`hT@(aAv{4;4orrqxx9peoOZ+5$wpxx_eB;yRkZns{m`Fx+bcF_!JOH;Bsa2|8a0g!6 zqrSbRsx24hHvk|DC)axcY^yC!MLeF=x}|Qkk!ivdb3ffN0&W6+hL&La2VT&?Ve0ux zN4gjretue^736SqNw+6Bbtex2c}tOW74+gqyv+4fZ(M;Ojy<8>%b@X*_z{aq(*KaU zGo1ONp^CJAQ8M5aMBa|(0@~ZniS+sUuK8#e=U8-W7u8%0KO$a}tc)7N+L|A@MOVm; z!ADK0J1uW6b(J{0#CE?Gl>(VpFFS?~fwE*gN&Nz)Jb#7(T+_%fq#TllKEH~cvN}FM z`ga8zp{oHKMH;rxN0dSRmvLeQ^^iBbpSCT3^04~i9LQaSsG2VU$Sua&B%p7${c$DUy|M!j@`JC|_`Qo@u z!T|?|+%tJ`di~~HZHmgT;X3f|-?ENHq6;Tg-y6P_<`~iQ2mIBAl@337#^5c?4`&`n zrOm%jTFI%2-TwZ#oQ&@=tm>JoQSl1=LWIo&36EAOD0or#Ho%;NCuwRxn*oRynFx9# zcHDKHUf<|@XU(AHR-uP`53xcfkYnY+rX!Ob!Akk%?8FZ4H0g~^+8;!u>>#7ZPhy&_ zoacA`qBDID=%!G$4)RP7h}nxkm$()Jch0u#wa*8>&%t9;8A*g^PLnK8em83#X9;k~ zL_8A-8Nny!i5R9MmIIj`lrKYH9?K@ub21hkXKvl5#6o8?wu5H|Z6Y&+lR_WFi%R6_qvk z&*=R5XDSl`vLvFTSp%XQFh=(FNKB% z4u%Eff0^PzUgGGy5^hq+_`RIo%<`r2-tM=wbx_9Wo!fWJWZ9%y#f^K3cd))v!@zO# zu9jM za5`H`k!SFFwS(BcDX9KNv+{*I7koM`ws8m}GI=-(;ll2EQ>Piu+H4bS+GjwCe0=~i zv-a~i9<9blAawi)0l~Kpdj_JnK6QDdW-b@zM{aL$D4|dX3AH1~yg}SItFHdRI&iFb2!q^9oNkLRlyWCmRV#0!Y1J zx;nA8^Rd3ZQJIQhKUj1VFXMGPABt}@NrzA%Yw?m`=g@5Xe8ObQMkS`0aS;#3gqk{G zzz(Sd7gbx|L2El=F5(rX#UFHJ=5GEHp=u~5!{U{@d>!@*lsOh$Ro&QW2&>dhZu1;Wvk*x#p1+OvRUbmk|$t6`A=&{0>xj1y5T! zo2-FdF}dS?-VVhhi4q;ELw^+kHP+)}OFOzkirr;Sql*A_*oe~7nAX60P%J-SW#|3U zQr^SkQIpGYpwjg@bqm+tNnnfJQJx)|y*@ExW~Q0VVp<- z=vfC25mdsEgEjZ^$?>?O? z%s7W**0xL$$%#JhFiGRCwO89`S0z=<@hLqn4RS7EM2?p4!iAu zu4D1zQ3fVAa-n`1V7^LFh?#cl|9S!Nu$)W8tS}Qq?8=?SwUy4Vs=XMM@FeL-_|Hp_ zNZz31&aw)_RbjkQlSZBki>I6GqfgTy8%a(|PBrutU}?W0^&0Q&pSJ^lf{_v9`V@$l z;YCH3R@0W4lqHP%w%|aB&2BgvPx{c7|CdHU9vYY03o)vk1?WR%z@rPj{teXoT==~- zGyRS}eG9x=!&enrh&D6o9bj8CxHD}fk z#<#1i^ad#)ElMJ2IYX=D2kk#szTJ!r43F;D)!<5shhRK_F^k4y2a7N_3-}4$!mMlk zpU`|a867FTK1KPi>GaED{*!P(*CBPZ12Dp?E#kX>Zh1~98Y$;U(K#3HSh2aS2D*2)F>F;Y& zVxUnKK8C`%oBEkQLn#`s2N?ON_|e^Z8J228xJyoZaqWL1xH5r72|OHP6y8M~GHK5^ zMrtcA{{PUcr?H4_?OT(k)5X za?ru4yx?+oWUoMp(|$y(VvcXw&tz)^w>)S~UkNvKs`CrKJ7ep$EZJ!!6?LPdynB5Qi`wsipdjxnION zg*aY)-HgBx08p|}J`>Mhq4`V_rLX;HSFqcSv+j`Jb6ocyng)=Kj?$$TdA zsZ92f9Dvph^|f+=yz<9^&2O7MbHTmewMq&mwH%hc1Sr0#TbIgC4;pqfs#!k&=kiY* z$o#A-JdhO(=))i26{|Z`_L=?0_4oWs8ZbWTfd@!>{P#*Z8>Dg%Ah8|8WGVW=p%9}m z5Wzl>CHzJq#P_>^;=IsHg-b)Ff7v%b9iF4H9()^W<1lN39@Fu(Il)cxX8Kkzs_Fjj zh~7S4iwO=$R{?#l8G?2uG=2Y^%wcGd5wA{y(5LS|RAJM%>;4mC^r9RGzJ^B&;4Ngx zFoni@>hRd{sM~+%dyn}Z{$@}r$=5*$WM0M8UoWsZQdyF!-U=sRy|H5Jsd?#Ht_twp zX&CeY_xZc*ty_fB()1?0%eJhuV&gzZhor1F-f)&kt}#ZEoIet_K9T@yGHU`;3H-V$ zQxCo}{=*!idN>WW&X(53D&REwcH5uA_!@6zZG&GY(dDUcP$yittsS3|pFeQ8^m_Go zkkEmLzbLnEJsFl9QA}~({`dwDGx@U@L6&F6jCJEbOf?i0%*ywgA54gDW`E8-X}=tTJK2jOEt;Hs41 z{MOH^uSKC)DSaJ551q6AiL~yAu8sy|=Pa}`fV}5YoaR**S(9&G7KQxdHlmEo7M={3;1H-b-6~yG)|=&Ny^N(pegr+_J$IyMl)~z{lI|kTwN9>6 zyPW-uK~1n_cG-?w_x=UT~eY7RHzB9PMABV!6>(dmy@j~xf6ZvDw z6Bzk697GM^hsToPkL^&_RC7e|)1L*O$pF|W@e)5dXA*Ydi5fnGTbYQh^Ln4m@JMvo z9K4Pkmfry?0dN4Ds;Vs8U(F0dRirWkU#}8AFDmKAe;sGt-UR%5rcCdIW1trB(HVG^ zsQmu>xv~(1eB@le#MFYOfP-9`^-|1lzq^gykiYx%ilU+>K)=sd_wQlArAO{^yX%C{ z!+j_BUBck;kKM4LfWpM0>NTn z=4fh{QlJ5CRH?hiXeL0;G=9zQ;~amgnw@o?C!)OT!8A2!_!f1&Q=B9NQApo2Y<<7|Upmoh~it`%Fb)r;X>9njgFHYr| zg!3QBn1D*gREQQ)-usPhl4^p*zAhk&4cqaK^xcuk(RkwLp8@q|?dj5si$xS_duORHuNyT6+_R^>R#J20JQ}k>sS1 zab|7dhn`KI2HZ%6aZ7CB^s5RJxSXwezbinJ>%2c=i&l&-pM8wl`o@jMkEcHB4d!GK zx0>9S@4bM7z zk{&uYCA$@F6-+rwA&kkdXB+TpxIkmN0B5E!<&BaA>|!falZ2lT&?gWPJBe5*Jyliu z$}3Y|Sv0uu<4E|u`8K0$Q4NqZAyX)v$G#VU)ukTjl6fk~`A-<{O~ot19lr5>oDm$A ziQ?P^I$A1PqdqgWvBzM?d}}4D<0fa5j$id@d#4&X;_0XvneO|15V8``8~LjLB+n<-vguNHM&j z{W*hJzxbVZhSf&i`;ms`zQGa4F;ds<(iJz702G7VNqBMbM_^FHHgg%JaD~O}a#bDe z+Q&!CT~w_$0-$F90YMGL@507n&LoXe;481x8MYLm?&*LhYSY@lO6iy5YjLjnACDxL z(zu2Dwlitr=&a0)r3`RIT=IPH|I^+TK& z{nRXKhpd^n9s&Z7$RuoM!Mn!q2n9=kb6t`@pvK=;V2f2#Z^o=Y>yYvlO)((Y;e~wA zsaNdbBzV&V0qfKnf%3hmBC4#kW+S{$XQJo`%hLKW?9zKS9M5n<=26+Gn$u2zPVqn0 zqkdLyU`gdqa(W7J?(^!ea7gA!L6P%`EaMiV(lAEMwWD%EEAimW2dyNAsh@MZ>L%H_ ze}&5@;vY920_OBAbk2ihD9HuNk241_u9r3scD(%!{^|W~+4G^&6=iTv zZtvrhi7l|?A8{?f#{aYcJZcIV`F?D72)wV*uyK<``eEs9340o5`^TD1h9#L@W|0) zQm$tvu2?nR4>j>XfJFC3d@qp%2xXZHHbJlB&Mzr9v-+s;h5YVLGMJ2YtitCi59v{8O5s$cTgd2A%wA6NYf@9J6QX;Lxi$(jJqd%2k3+di}1nV+?pd>JLQazbdVxbXaeT$& zWSyG7bBEpMR#(#Ch7^&SLGDc{Ea&)%p(^^9p9T_FTn{@&%W6QR+jOvg@lWu`A&q}% z$3x^W{p&#e(fup;PBXiu)VdTo!`I&xE<)To-*E1Q+QSA3n!8jpq-audgI#m&t%JSk<^*XlNfplgX=N?``+csI^(Ug^{(!BlsaSzpzi0br3p;ddq5Y}x ztQgRbWXEXFliL8K5#?@)3{TH@03G|7!5aJcBI`RZH4-0cR;axVX8*?h(4cCU_VU<0 z#F!5KDfdnZ%x=Je_b@Sa?1NJsh-&cX+`Y6eS%=?{ZxoRum-Gtjug?frm_SPa~&F|m;M6Ynhg+hXB<1~!ZK}{zl{Rd$-Vra`p3pQU6B1Q>aGnbfPh3QyH>MQ}II065UcX1D z!G5iXEdBD}g6njI0h^-VLs-zQJD2wp;{Daqfi&)LNc3M zY_r&!jzaQ^7Wv^3_P7v|&R6W+@p`+8SudDMZrYX#xmm|D&JMf?#i|(!T%yn{h#uw7 zU_j|iCi=*3%*je;WG`CZ%MHns9{3*}JLtq7|3E4}I$dIAG28DyowNgGDpaA0J7lJF z7VYV2x5F0Zi`z$10&Af@q~DOy@?h8QPA&m)acfO&cNg?W+Idpe#7{r0loNP~<*z-G z>htg5_-gSLqM5<)=ggQIhEOtFq9Q#Ji#?GD2_D7GYVX>;Iago#Zp)2ql5C-F{U(QV zlN&0bF28pVQNqt^%&r%Y#*V9g>l>}E)-$x$J1yTEoaNLz%q*Jeygy#4qgpsWN|Fq? za*^(^J5KUM{or0{@QDtdSfyEC>1Lryy_TM5#~lhdKGsg(xw(^m&#K`Lz_x|RfXBb# zL4Se~VV8wIuYLS9JGk$Ye~`bV>)gkLLv+X5zSK;#_%a0j?(#_)NW{So7V%FqjyIk` zE?zoXY350~w=`De6F)Dhudu=Ns>FeE;K*Sh_n!Kaf3qzW|7+xtcWbmtUyHy%y7j(D zC_LQ~b}-faUF-lh)18ST8T*`_D&V-fYpgr-I#0lP5oT1xd@27~(2+Q>Ou%9F73Lq` z6z0qDqZibM#lb=9%AHZ4chT_#Mh=801E=#txAh*-Kd;~735dZ{wb|vvJQqIzz5{@O zG0!@@2CKJtJMaJ7+mVl3@9H={j*1U(LwqSHC)cR+4QN6y`EOd)OrVc{eWH^U`bTxZ z3XXV8w1r_oL7lgO7gGUsH;}n!7Q-qNYa_2)!Wj_jrrtmt&lu@@n7Pc|7F+4>zyPh- z;!=6lrciFhB=V_`yQ6zCL_63Dq|4wxUW%vK( zGzpqqExe4qBCU>JG-;gG?y6(BTk=lzeo$W=`DFxT#nQ?QLnxzD01%7l9LRLBH%Ke5 z^b?F#N%-3YNeXM&kMRx7v61dl?pH1DmVHse=I_1#*8DJ8sf~Il-`NoT(RsZ1&Cx1y zl1-|b?&%>1m8r0k-6(eNiTJl)$Nhj4B~#r7NAC+W`xAkID9(DtvT1D8JY!<~?U#}9 zw`v5!Np~96Jrd7&1IMwkvC4-THP7?(&Z_Dq^@jR)%|8~~R+3Q>yT-_EL!2N&9@6fO&UL`sDG!WM^b@ zh4#OD-{=mq8kVhxPb|l2A{;W<4XGYLwSBnX@a9Fbw4X&qv=^Bc#%sq`Iu>zf&=awr zw(w$eazw&F|Z;yicq3YSQ zQ6;6h+A=s#M2UnJDNYIke3WbhxF~EaYNZOCAnHP~aS9km@@%ZcypODET~|s)3mt#8 z87Yn!l=gEP3ytyeJD$m&rze&@ng42h_p3@Nezr8X+F0fcL`%_=cg7)S<&ZbfPpO!!747gtQ;5Dt+?|7ZUk zKwN;qSA)9-M8e+Smb>Bg3Jr)1bON}XBj5j!f+Zl#bUxRruYS99(~7BWi`zYRo(w5H zm~-}(eEpSEA~hqVS}!=!0V*IznS)lh4Yw(Jg+tU+mj5$ z#KpgQn23+mKWidY6rx$&nfJ1P;-<=euems%*}i-r-q6(Gc0AeRQBz&r??@vCb=ikG zwswbC_q($nyl+KKH+FT^n30Du-`Bg|8klrKZx5xVE)OL@xgwV3-|GTD6R7x}LF?zG%{r{-7={eqi0yly2eWg;;Ppwr}X^>9NfUmyae72sq#g zP|EfV7fwjxxoYIe z?OQslU1yggevi^oA4$F1_7O6cpkO2@#sr++lHFg}abhKygg5!G5Oe0cdIZ_?tF*QzRr zb+OFO2;o_4W!5XKUrxRDqiGPn(*LFKo_WF@QF+%$2tQ1RqRN-Fn9u(7^dp$P_{)x~ zrhN1>32D#m)C{>FcVA1_@+jbsgAJN_g8i;@xqnT#Y)f;yvjV=!iK)9MpcXF7IVwVg zb5sjSn36gkdJ|d^<7p^n&#PX1ZgBe@07{Y^eUTS$XM8%%7n#M=7Trg~e1Y^KP4{cxhWQbz9$@q1ncM@`d3g zZQ|>V4SqJ*@2(IhPOc%OJsS>S#NDj$Hk__rw?>u1$wH%^{1?s~&ou$xZY(2{Yh>X}K%|Cp)XL7Wgi zB~Rfs%*z~9WIbF)t#edDi%=b6!-@w00FqE#Thg_jQNWFb+|dSYE)D^+^bv+vOrMq4 zr+|_@^h|o{-`9VBr+t(;+PE2v-q~qgDCJbk^%_S63hWYZkfO>dt^ViAxxLvgi>|puKd&lp`;^6JdrVWkskp+sFgB%6%Gj|ShC1i0k&0- zyI4R1!|Gsd3YxbUxakqK80nX<_&FSi&Tu0faB2BuZdwPs@Skd=->=*E$EHY=9VxGR z@CgN9iTCsbH}v&_^bwWs@%8!tC8CpZsbSl02WB<#a0+uPCBKPaMf-~M3JsEo5#h9k zYptA1kR^QF2@?fsAy`n~jvkx)9asV<9>qZs=YUSjU^oSP-T}euIfEL1S$8?>t{!Ec z5n68~$cq=lQZL=W`4YVB-+=a^26Yl+h!C>*9esRc(Ry~XWw{al>sxj3%*aphwLTa% zI&#lwSM$E4c(SUSm@D{(_Ofj*^FW7Ip!!?ZKg7J8^P;tTN&&>9W@-vneXvh_qafXn zg0+Bm^JF_MqzHb4kWBJk`=t@~Ji`|^mW;U98ykK)FI(mLhd0pA7gX6RbgHVd&D53@b7 zo#6J*qG!+uHY5U-Is9n^ZDPGRrSPsSf9d)(kk@s6R%Mj(YiLyeisMot(Sbhj>H=mv zgNk;lL#Tsa5m?36L-<2w;HZ{eF!?wy5%w#~Bi<)Gi<#!%G(>goABXM9Y?uD-Ix!K4 z()D+ptb4AHfb-apHis0&H7DVO(M(0l?t1(xC>I^a}qwCl>G*K zNDwQZ@0YwbHMPV`WkKsA%9E>0%-f5GAd_KlCc9d*Zd2yD=1N=*pCzH$0>?WEnaI89 zz`7^vE$%svWejB?o7&eNFKa4(jj*{v?Y`7JLGrKCNL2QdBaD^K9XTy5>^O?%9yI5Y zoo_`zO9*ykTuB0u5D@#v@AYYnUl@dfC_QjH z_}=(Q^tKbEmKo0@Jj|Snja>gc_xoS`CuxqaXSI!kdG$QJt6h)yQcRg)d>L{=8wc_uPBLTn z`(?vX);?PssYs7jmaoV?3bYq=)yJ?M(XX6ckQ^>AA4 z@FF51S7r^{zvfZ4`&Hcn_M!3)bMYLsZAFZ<@x9OJFRgE8_5d9Fo_6=d1HkYi=?8kA zOSh<*EG++GepS-9n22Tcs~bVkmkt0s9GwP0`xAfiQ)rPICA0K45r~NIcoD>*wmJ+~ zgs~!-cRYh{E`!~L9uh#NEaP-!HieDL+&`Pkji_d_nQifEMa0Ad7mF;COXQbVZdW}S zb-48ePx1v%_F4MSJpR`U@Rd8#tovg%2Ph^lN&_D@HNUWfGq#(}D^sy|4j{b*Rl!11 zG5S6YoEZJ74Kt%hkjg|rS+SU-bO5_4n$lY2#QDk;B3ld0u9+l7@dAB|#h zjyKy6JOEM{9*kE8xdsB@Le1LJD*&@7bkXh%R~f0)AJKcVhNdxTM18ZbXXXyVh*nCo zyaw`uku+DsCWU0P7XuJV_~8j?QBc zoQYB@r(3v?(gmltb@ zZbsv?&8As4T=53M0l<-_UBi}FP|A#WdfZZSXWJ@n`IKGF&A~0PL3P+LHNEN)7hO9r z$SZ68Xh&23`*++=aXmZ<8PrGZcv&yHyYGK%grWVnk)F+fsClB@?B@i+`O>A=&|U!z zOmRMV;A%zQ1pb?nCWiG=fA7(Vdy0Ezng!H{K&hj%-q=Z8Gp1d;ucrv#xKrnYs9BY^ zO-$hZ@Gvxv{m!QmP2%&kWIPVut!aq+S~j<(!L3IDCnuSWg#xoelr)mLl(gjC+T%NV zws*FZ85mM4#-*GRUuQDjx!;~e{_u0e)p~c^fyN;d?=1F$RgK{Ma$$#*Rke#pf_n#P zSu^G5o}`LOvD$2DWs;xj!wKeqDw%Qrkb@cJ?=#F-h_`7%{UdUlJQj*f0(t=kSE*&CVN*XVCLAtxU zLEsXX?(ULK>4tawt+&?u|NSv1W}UUpIeX9S+54M4->k?RQy^Se$I&PRo*}p$bvMs{ zqQpKRbkcTQZ!|=3SvNO~i+ZLv^K#5{Xl5^wE!xMp;H<@jBIxPW4{%Wv zzlSa?S~mz5Ad9H-J@5w-`CJi|W{SkFnYQFMHaR(Inz5g9nTU&?bhHT?&(=w8Md4xn z^duDJk|h4ChsyFM5>0ot6}(Pp;*0uu6BRG&6&y{Mc)LE>289FVsRLRy3eibS3s)`C zfUGY;c@d`xYVI!r%L-q5-KQ%c)0Z8@QxqC^iiL0^p z*Dt}o1pC250DG(!7c&Z;Nn0^`cJ?*$UH4~=JFHl*Y?CF%{SH!8{29i*&&PXQ12TQ^ zO(Rz&$r&nFrq+<29R-2VCvVPVu8UWZq}evppuLvct<^DrOo}uy@!jinG%+Kl+;%p}xqty6OW^6R=@qV{vPj8uwbg4(c`Jz5G5G4VJ z5z5cgQ^n$^un8OfaYD|KT2@YbCxn7B5DfpYQ->BW(B*?cDgBFEn<3*ta)|ZGudf)n ztlVbUBQctWx?P(<&IJ1l*(($icQK{*K3}A&f?geNtBD!?bEeui?eTFw%D2de$l+Qm zi&}sm^qaw(l6gCNp}S*<1cNu|2Q_(iqA!8_rMiud0kHP%+XMnYURC=VyssydJf>Uv z+3Qo`#tudV)F^Vy^WO+r81`y90=U#IWCyk4!_N3n)y5u~??p~%GO!Psg4ixU&#fgf zfp!U+qR=r17@kP6eVM-)RCw^E#e-EhvM@xr&uAA-4W7ZPEVj=xm9Cf9YWCtr^F(j^ zq%~3q=hmnjX7hk;6D^`kO3Y02N}siOONgQ#(skS=(CZsOBsg>?5lmTrj(_|yz3+B_ zl%@N#`->t$y{&M?69w##0SPO^j4?|2xnuJ!v`D?dU6KI7Gy0W6fi6_^8NyA*=$Oya zTpY=##Sr9fwlA5eK&-O4ycx}cKntp|FYb#cJ@IMXhLU&2@iFm1zEkulZ$vIlO8e9$ z2Xwrz2n9Vc5ZO#YuYu4Y&e)-u5lEpsSnqxGb{5?@tHn>65^Xi@MT?Oa;SrH4+5S-A z7ZPIiK<|CFq&PdfGanvi=j{&6Lv(HI<3W}Ev^Km@$zlT;NX2i`pLv}pfEiB|-6RPq zq^0ienvt7z)32V{D!p%J(-g>#5pA5vg6dX%U9MKC15ki6dI2w zE6nvMN?2RWSZHsFw*^=uHC413PvKjrZ=R$_v2dJcc;8)9BBCOkW{6htJ57n^k>^|@ zyS@2zw+#&qj3OdN**Q5oiV6zVoSdA}DZ=iYT)e#O6P9nKsbDNRS)(+-kKJ-{aXDB? zN=i<2hLWH!EG}xdPGn8)PtJ+%QpE$5^VE-WUVA0eTX)N&^q(fJarJz{%R|om+o^HB z67it0jW$pYS#%PlGny%LVoS#8MVe}513G_?`NE0ddMR8kJqq#C4+whIA`BgW!DVO2 zPz#l2Kc^959>P)B+b$*wG;j9zZJSl{#$#e)$OHrg?lb^ls#5V;pGD;4Y)o+)HQG|W z|N6HoBO^nTRXK6uRNZIG`d%A<0vkUdSe2^Yw6}d^Flm|be0u;t0U}7&f&D4B>wLeSg zBwY!E9bjL!V2?y8H$S%o(`tWxx*V7!%$otdjL&hgm4rF=WL6yV}7&dgl% zCtUkGS){W}`POp8Sw%&KcX4sCTS`jm>gM*=Gm@IWR43(Y@9u|us{C*&3Ng`6$ZDoX zM%ja_nTEM}AN#bu=@BOgG}pg?#4(be156D&7n#r9rMrR>v7@{%B^qwWt7o^80$m;m z1)kTvv2sOduRRthOw=O>6bo)L*9;e%d{n3S_#pW<<5No4q398JUCO(ARBtCNZ_f_& z5>p*pw6Gw(g9}AlAmd&EryZx8LtJvs{jUInnenFbT1T*F7y)% z#4t2WUX+135rjsB<9RwSCU!Cz`!opgp0<}(8lYS(#;_;s=#-l8#uGnrhUX@}U1W|f z^rBhucg@L7(VhwfL)gAZvY|S8x@gAc^bhtIu)S&2DsBOMR){K%{$T;L(Zi!5H~>cQ3>Jz_U{t%mQ(j~9 z_`KG6YpVWoY5smJ!I`xdm?Mz~qQB(5IQNasjs2k}4%@BGPm3{0o-eO!_Xh@>PsE&F zZfR69>Tn(m45=)`V^Uf~czGcP=H|QeDh#Nd+ibUgO-|t z#{E*{t2?DQsiU0rcC2cVdw8>go&Z;R8XsiUT5$zFMU#XF_s zv(BHEJA!j|*<^mo`1giFRBiM^3@!Y8DmA|J|8Ip~uray_4b2U{i{tSn&EJ5sBQ+48 z?&+$JnKiyP{@wLklO4<1>EBt>)!zdr6>cwL=Y{L47RMS=4sfX1yT97t-i}E4`7wM4 z$Yn7xilW0Fy-Gen!P@8qaEv*9{fiwFv*%yFjP4f=ndiPz8BB~7;)keDWa$+-r_AYj zE#@>&rhYTeZKAHRYG|$~*-(Te)BZ^|GU`~sz{kO-5aM~;xyWuhqE?6EYPbC1Ok=VT z=<-EW4m-=;W#L)Kcd||IkV7Q~VJd^l($igo((dE2nVQ15qm%9W%Z1--N^@Y^1)(L# z>!5d=vijAKrs-nsJ&`Hc;Ds*VyLpI>MRRl3_k*z1dqecy!qkZ^4M2`73?sfimTG@# z9z~nXBX{+QMmr<}boq!|U~j%w0G53Y+Fuh&FH-w z6|Uo+bhla5nyzZjtxhZH6&&_fG04NfBlw0zr&!|uPh+m?=;Ms$L3dxw7D^8kJJ@Tx ze~^_a|0OJv!6zMlw$A{@a3O<3lLNw)nWTk;-jc|%Ha!ok*RLfGkrsh%&uC>GEf*MB z_vCxecLl?a%fcHq(&T}*07O!V^#tAzD#ds+&^t39qb}Yo>0Pj@U)E{GSbKWMNL zh3Y6#XO-$`_m_R-_%tcLI4(YueJpWHa{H2Aj!Nypxj0E6mP^i5N~E-Bw!RygFypt@ zStx$IOxChi74xumZp z?elTbhI5ysfGXbz|LQ5`rIUUK-5Q)*R{I9uapo8!x@n(c7Mt9FB!mIaiQv%{=H9}tHN-9lYaqBU478{`H z$|KOK71s3GqIg?#9)#<%pUH6G6t;;VpPY?zR&4MR_HTcx}sX|hB zDOy3L%kZPvZ`!2DOJaV;CLHdDKtKREL`%9vM%AI>ec%DoSCO{tgcagU3Yl*ic`E{Y z48Tk+Q1f~qMfh10lb4~S^>xL>nl@m9fPpxk$oilO8a6)lA$&1hk=QeQfo;pcm#?IT zkVu(ywEOJ?S-C=15-voFS{mgIJew$PtM<7d?jTYuu5nK+)Lrns``yo(#(ehkgnu?I z-wgjOO`ym1;n!!-@9X^u-hy!dGmA|a(e-#!h(D!KU`VBAHuI1^X}5mpZ%bh~wHf3miIXPna2-B1Rz5=NDX|;- za26URBhqZ)I)o}}@j^jLK**u)OOuVk%GQlPiSD@NW9F8ft*N`9p=O>&mqksIT2*TE zGMVP--lMJjU`7E1%LI`Q6?rX+OKD1Xj z(^&8kwe2V+DfxjfYkf5Aj!?f6Z=W@UeO=Hj%hbu2e=vd?pBgzISO0kL5$L#O{$$IQ zvp3}rbLWJNPXRN5y?5)DrP9#vb!7)N?H91?TNJK zP}tH7F@AnAeb3%?ZF1!hyshu`rpEH$AhTPDv37UUb@w*oAftVYZaVZjLXv-Wg3To6 zWi^qUbaDY(xZN8Z;<=DD!OgXa@$a3-c?&~B$Iei0>tC8jjfK4D4gcx%CBD+Ji=!tf z{a}|jJWY;7^_P!T9kv^ z;L=Dht(CsqV{NeYY#Z+@i=~t2`1sHAH5U7osKzsUQ;*&oK>V{>m}|4t5wGR;8ST1+ zUi-cz9tcF~X{RTdb>5e1Beq!dbQXyyzNm{V>KD>XQ~u9%jv*=;ApTQTdbq%Y##N$G1ut+uhdd0O{X5s2#R84^X78aIbElz(Tf4iOKt9_)}h$g$-bPB z5R+hX$|FHtmdq}>VE3`l9MsYL{Y`dKJEdSa2N>1GIjmo_b@RC`pQQ#za^U9IlR{WI zyXV4BW~L;`hb>JE%!EOWuzJC!n0nG_9eCL6!4pPS#qinlCs7YB#`nG1c3V11+m9Oc z2J~NqzE&GQ4s-7L5o_C4DxE|I~?ROvaRBKjYEdPxeOG@1luZ zY)R;NCu5TV^rI46?_E2O<%I)l>ud*&SLQ;>Z*Y*HhYUfDd&9Ivq}M_&>$J=>XSK0> zRvOQ8kmd;vz4Z}~jhyUJNSj+0Q_#!}V-;|a#yebz9Jq8nR-_vLqBODn$$A^v{Q6U~ z$|RyA*RM6=h@{EA7onp@e620Awih}nH6aIzSwk@!zAUaXyDay}oT>|+eq9nPA{BE zm-3I3p9{s*_MAHa6(T(wTNrmAW1k6lQF_=Rbwy>V%A@RrbN=G?6?Nat&+ zV2iY7EbSI%OE<$SFXcF7t=w!1sgg%0ZFpA~SKp8x$p))=?y(9%g19f-!ye)&ZjCpz zz!uF6f^=K?{LGKALpD5lh3uGm3L93Q=Zzu7Na&eGDrN4iSw_-DbVG;6Qn>4_x${{3 zy7o9mN%D@^3+qe^Ro9Uf2FoOCGA;ZCZFD&5Jy><{2-r^JZb*nIi>Pl-ry^I9eq75C z=1Qysra{88&BI8$r^)5?Y(GfbbsFHT??$6b$z+DtQ_Sj_4jnRfzWK-l;q26f5oS;^ z5W}$cAe2#p=K);I*h)k1UO2t7oGR^pjJ0IxOpaaKT^nND+Tpx8BFATYbdy;A#o-)8 z_tiD*G;9`C#d_kw$RPZi8el$bd<%qFH-$=2!fr>f=ja264VW9}zZ7 z0CCke-cPDowH)Rhz6)s!tL>7O#&u8A*1xPOlv3gvzm5SdQ+i?=v> zWoMNZ34yMc_$9{l)4o;5l{wSa(Y{wMQYQf1ikJ9r=yygQ>E$2TodG()s0x#xEv7P> z4B$B|B287S=SNASBjpsR)eBca?LSGG`JM-5TYh%_EO)ZSM;5eh?uF}T7;f{@32YEn zd{w)qVMq;sZc!k&OT!8mEvu3EvQ~UEBWcnQc{1{Pr(`^qU(+ggJijdK4Un_K)Vt zk{c=SX7(qHYio5@YooA>wW(7?wP&le{|xC-X-`+GBWeFBC4lu=&U_C0vmAwzxb6w1 z*z`e8TFoex->+dSmasocovt!6S&1Pw#~esl=M+k8PXr|Ac5{$i^$^P9Jv-*`==tnx zNlIOQY@zjvYNlVF`Ss<}>BNB-4Uu?^UOZ)+E@fVyy6aeNgC}I`C9sO4aPh~tWK^< zspaYdg&;F@Q&#u2TzO{mT|!7ZTvwbAZUI6_Tim^~_O=x|dwLwFr3f0l!N#a@0j|3)X()ud{wc>2U8o2)#+9RjhnZcsXct{u&Zue#xJ4e&I!;VL#mV z^TzWT#`OHXhr^#m<0t$}=bwLCodo+SyS31dfwyG;47z*28?+-oX)`UNJ&J%lo`mzZ zGp6BCmZ`YB>2}dPiL%;we^h&LxV4b-CuSV}U}U%eZMs(adx~d|ucZUzV11OA zlF&+C;eT!VyUfBe9w_BN2^r2O6r(RAeB@qgTUuI@V3Li%^8#mEtn`d}`z<|Ww0-gK vdg^%%!~Odgd&j-*Wa7*Jp)~3GR98L$v_~rX7~^k%H=xK%t4Nhfn)v?@*4&Yy literal 0 HcmV?d00001 diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..edb2fc5 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,65 @@ +# 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 + +## Debugging + +See the [debugging](debugging.md) page for debugging help. + +## 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 a63984c..2383c5c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,3 +23,4 @@ ruff==0.6.9 tomli==2.0.2 urllib3==2.2.3 zipp==3.20.2 +pre_commit==4.0.1 From 0e29cc26c70f044d65a97c85b6f9aba0b1484315 Mon Sep 17 00:00:00 2001 From: K-rolls Date: Sun, 8 Dec 2024 15:35:33 -0500 Subject: [PATCH 03/16] Create quack formatter for help --- src/main.py | 9 ++- src/utils/formatters/__init__.py | 3 + src/utils/formatters/help_formatter_base.py | 53 ++++++++++++++++++ src/utils/formatters/quack_formatter.py | 62 +++++++++++++++++++++ src/utils/groups/__init__.py | 3 + src/utils/groups/quack_group.py | 48 ++++++++++++++++ 6 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 src/utils/formatters/__init__.py create mode 100644 src/utils/formatters/help_formatter_base.py create mode 100644 src/utils/formatters/quack_formatter.py create mode 100644 src/utils/groups/__init__.py create mode 100644 src/utils/groups/quack_group.py diff --git a/src/main.py b/src/main.py index 0cb6150..c1a5f78 100644 --- a/src/main.py +++ b/src/main.py @@ -1,11 +1,14 @@ import click import src.commands as commands +from src.utils.groups.quack_group import QuackGroup -@click.group() -def quack(): - """Quack CLI tool""" +@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 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/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()) From 79c52a33fbcb8fab49bf0a1489aae1b90649344e Mon Sep 17 00:00:00 2001 From: K-rolls Date: Sun, 8 Dec 2024 15:36:04 -0500 Subject: [PATCH 04/16] Extends to subcommand groups --- src/commands/key.py | 6 ++- src/commands/machine.py | 4 +- src/commands/metrics.py | 9 ++-- src/utils/formatters/subcommand_formatter.py | 45 ++++++++++++++++++ src/utils/groups/subcommand_group.py | 48 ++++++++++++++++++++ 5 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 src/utils/formatters/subcommand_formatter.py create mode 100644 src/utils/groups/subcommand_group.py diff --git a/src/commands/key.py b/src/commands/key.py index 6afd309..8945f9b 100644 --- a/src/commands/key.py +++ b/src/commands/key.py @@ -1,6 +1,8 @@ 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 @@ -47,10 +49,10 @@ def remove_api_key(self): print("No API key was set.") -@click.group() +@click.group(cls=SubCommandGroup) @click.pass_context def key(ctx): - """API key management.""" + """API key management commands.""" ctx.obj = KeyCommands() diff --git a/src/commands/machine.py b/src/commands/machine.py index 798b631..3f83d97 100644 --- a/src/commands/machine.py +++ b/src/commands/machine.py @@ -1,5 +1,7 @@ import click + from src.api.machine_api import MachineAPI +from src.utils.groups.subcommand_group import SubCommandGroup class MachineCommands: @@ -63,7 +65,7 @@ def get_details(self, machine_id): click.echo("Failed to get machine. Check machine ID and try again.") -@click.group() +@click.group(cls=SubCommandGroup) @click.pass_context def machine(ctx): """Machine management commands.""" diff --git a/src/commands/metrics.py b/src/commands/metrics.py index 54dda62..5cd9b0f 100644 --- a/src/commands/metrics.py +++ b/src/commands/metrics.py @@ -1,5 +1,7 @@ import click +from src.utils.groups.subcommand_group import SubCommandGroup + class MachineMetricsCommand: """Commands for machine metrics.""" @@ -7,11 +9,8 @@ class MachineMetricsCommand: pass -@click.group() +@click.group(cls=SubCommandGroup) @click.pass_context def metrics(ctx): - """ - No implementation yet. - Get information about machine metrics. - """ + """No implementation yet.""" ctx.obj = MachineMetricsCommand() 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/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()) From 40f098be66a719c8b534acd50943161d44b94ec3 Mon Sep 17 00:00:00 2001 From: K-rolls Date: Tue, 17 Dec 2024 16:08:54 -0500 Subject: [PATCH 05/16] Updates testing --- src/commands/base.py | 8 +- src/commands/key.py | 20 +-- src/commands/metrics.py | 3 +- test/commands/test_auth_commands.py | 151 ----------------- test/commands/test_base.py | 68 ++++++++ test/commands/test_key.py | 145 +++++++++++++++++ test/commands/test_machine.py | 149 +++++++++++++++++ test/commands/test_machine_commands.py | 216 ------------------------- test/commands/test_metrics.py | 14 ++ test/commands/test_metrics_commands.py | 14 -- 10 files changed, 392 insertions(+), 396 deletions(-) delete mode 100644 test/commands/test_auth_commands.py create mode 100644 test/commands/test_base.py create mode 100644 test/commands/test_key.py create mode 100644 test/commands/test_machine.py delete mode 100644 test/commands/test_machine_commands.py create mode 100644 test/commands/test_metrics.py delete mode 100644 test/commands/test_metrics_commands.py diff --git a/src/commands/base.py b/src/commands/base.py index 5d98fc7..a4652f7 100644 --- a/src/commands/base.py +++ b/src/commands/base.py @@ -13,9 +13,9 @@ def login(email, password): """Login to the application.""" result = endpoint.login(email, password) if result["success"]: - print(f"Successfully logged in. {result['data']}") + click.echo(f"Successfully logged in. {result['data']}") else: - print(f"Login failed. {result['message']}") + click.echo(f"Login failed. {result['message']}") @click.command() @@ -24,6 +24,6 @@ def logout(ctx): """Logout from the application.""" result = endpoint.logout() if result: - print("Successfully logged out.") + click.echo("Successfully logged out.") else: - print("No action taken. You were not logged in.") + click.echo("No action taken. You were not logged in.") diff --git a/src/commands/key.py b/src/commands/key.py index 8945f9b..ba549d3 100644 --- a/src/commands/key.py +++ b/src/commands/key.py @@ -14,39 +14,39 @@ def __init__(self): def create_api_key(self, validity): result = self.endpoint.create_api_key(ValidityEnum[validity]) if result["success"]: - print(f"API key created successfully: {result['data']}") + click.echo(f"API key created successfully: {result['data']}") else: - print(f"Failed to create API key. {result['message']}") + click.echo(f"Failed to create API key. {result['message']}") def list_api_keys(self): result = self.endpoint.list_api_keys() if result["success"]: - print("API Keys:") + click.echo("API Keys:") for key in result["data"]: - print( + click.echo( f"Token: {key['token']}, Created At: {key['created_at']}, Validity: {key['validity']}" ) else: - print(f"Failed to retrieve API keys. {result['message']}") + 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"]: - print(f"API key deleted successfully. {result['data']}") + click.echo(f"API key deleted successfully. {result['data']}") else: - print(f"Failed to delete API key. {result['message']}") + 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: - print("API key set successfully.") + click.echo("API key set successfully.") def remove_api_key(self): result = self.endpoint.clear_api_key() if result: - print("API key removed successfully.") + click.echo("API key removed successfully.") else: - print("No API key was set.") + click.echo("No API key was set.") @click.group(cls=SubCommandGroup) diff --git a/src/commands/metrics.py b/src/commands/metrics.py index 5cd9b0f..9de3c3c 100644 --- a/src/commands/metrics.py +++ b/src/commands/metrics.py @@ -6,11 +6,12 @@ class MachineMetricsCommand: """Commands for machine metrics.""" + click.echo("Function not yet supported.") pass @click.group(cls=SubCommandGroup) @click.pass_context def metrics(ctx): - """No implementation yet.""" + """Function not yet supported.""" ctx.obj = MachineMetricsCommand() diff --git a/test/commands/test_auth_commands.py b/test/commands/test_auth_commands.py deleted file mode 100644 index 42745f8..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.key import ( - login, - logout, - create, - list, - 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, ["--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, ["--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) - 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) - 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_base.py b/test/commands/test_base.py new file mode 100644 index 0000000..954d5e8 --- /dev/null +++ b/test/commands/test_base.py @@ -0,0 +1,68 @@ +import unittest +from unittest.mock import patch +from click.testing import CliRunner +from src.commands.base import login, logout + + +class TestBaseCommands(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.base.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) diff --git a/test/commands/test_key.py b/test/commands/test_key.py new file mode 100644 index 0000000..62c579a --- /dev/null +++ b/test/commands/test_key.py @@ -0,0 +1,145 @@ +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.") + + def test_cli_create_command(self): + result = self.runner.invoke(key, ["create", "--validity", "ONE_DAY"]) + self.assertEqual(result.exit_code, 0) + + def test_cli_list_command(self): + result = self.runner.invoke(key, ["list"]) + self.assertEqual(result.exit_code, 0) + + def test_cli_delete_command(self): + result = self.runner.invoke(key, ["delete", "--token", "test_token"]) + self.assertEqual(result.exit_code, 0) + + def test_cli_set_command(self): + result = self.runner.invoke(key, ["set", "--api-key", "test_key"]) + self.assertEqual(result.exit_code, 0) + + def test_cli_unset_command(self): + result = self.runner.invoke(key, ["unset"]) + self.assertEqual(result.exit_code, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/commands/test_machine.py b/test/commands/test_machine.py new file mode 100644 index 0000000..aa8ab74 --- /dev/null +++ b/test/commands/test_machine.py @@ -0,0 +1,149 @@ +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 + def test_cli_create_command(self): + result = self.runner.invoke( + machine, + ["create", "-n", "test-machine", "-t", "f1.2xlarge"], + ) + self.assertEqual(result.exit_code, 0) + + def test_cli_list_command(self): + result = self.runner.invoke(machine, ["list"]) + self.assertEqual(result.exit_code, 0) + + def test_cli_start_command(self): + result = self.runner.invoke(machine, ["start", "--machine-id", "test123"]) + self.assertEqual(result.exit_code, 0) + + def test_cli_stop_command(self): + result = self.runner.invoke(machine, ["stop", "--machine-id", "test123"]) + self.assertEqual(result.exit_code, 0) + + def test_cli_terminate_command(self): + result = self.runner.invoke(machine, ["terminate", "--machine-id", "test123"]) + self.assertEqual(result.exit_code, 0) + + def test_cli_details_command(self): + result = self.runner.invoke(machine, ["details", "--machine-id", "test123"]) + self.assertEqual(result.exit_code, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/commands/test_machine_commands.py b/test/commands/test_machine_commands.py deleted file mode 100644 index 6d0bd21..0000000 --- a/test/commands/test_machine_commands.py +++ /dev/null @@ -1,216 +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 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 b47424c..0000000 --- a/test/commands/test_metrics_commands.py +++ /dev/null @@ -1,14 +0,0 @@ -import pytest -from click.testing import CliRunner -from src.commands.metrics 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 From 2a36f74425c13af16d29011a76faa1ce5b362329 Mon Sep 17 00:00:00 2001 From: K-rolls Date: Tue, 17 Dec 2024 16:16:34 -0500 Subject: [PATCH 06/16] removes errant echo --- src/commands/metrics.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/commands/metrics.py b/src/commands/metrics.py index 9de3c3c..80b7955 100644 --- a/src/commands/metrics.py +++ b/src/commands/metrics.py @@ -6,7 +6,6 @@ class MachineMetricsCommand: """Commands for machine metrics.""" - click.echo("Function not yet supported.") pass From 6bce68543530b9e61ebd0f1e48bd26295ea753e5 Mon Sep 17 00:00:00 2001 From: K-rolls Date: Tue, 17 Dec 2024 18:06:20 -0500 Subject: [PATCH 07/16] Adds model file commands --- src/api/api_client.py | 9 +- src/api/model_file_api.py | 52 +++++++ src/commands/__init__.py | 3 +- src/commands/model_file.py | 203 +++++++++++++++++++++++++ src/utils/helpers/handle_api_errors.py | 8 +- 5 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 src/api/model_file_api.py create mode 100644 src/commands/model_file.py 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..ee5ac54 --- /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): + self.client = APIClient() + + @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 d7323d8..edeae54 100644 --- a/src/commands/__init__.py +++ b/src/commands/__init__.py @@ -2,5 +2,6 @@ from src.commands import base from src.commands import machine from src.commands import metrics +from src.commands import model_file -__all__ = [key, base, machine, metrics] +__all__ = [key, base, machine, metrics, model_file] diff --git a/src/commands/model_file.py b/src/commands/model_file.py new file mode 100644 index 0000000..ea3185c --- /dev/null +++ b/src/commands/model_file.py @@ -0,0 +1,203 @@ +import click +from src.api.model_file_api import ModelFileAPI +from src.utils.groups.subcommand_group import SubCommandGroup +from datetime import datetime + + +class ModelFileCommands: + def __init__(self): + self.endpoint = ModelFileAPI() + + 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/utils/helpers/handle_api_errors.py b/src/utils/helpers/handle_api_errors.py index 2f16c79..3e42134 100644 --- a/src/utils/helpers/handle_api_errors.py +++ b/src/utils/helpers/handle_api_errors.py @@ -9,7 +9,13 @@ 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 Exception as err: return {"success": False, "error": "Unexpected error", "message": str(err)} From df192c86eaca443df0ccdfb04d2dc3572d74a8db Mon Sep 17 00:00:00 2001 From: K-rolls Date: Tue, 17 Dec 2024 18:21:39 -0500 Subject: [PATCH 08/16] adds api testing --- test/api/test_client.py | 6 ++++ test/api/test_model_file_api.py | 64 +++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 test/api/test_model_file_api.py 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..37832c6 --- /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() + + @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") From 37e96f2f23a1cc0c552f185a731e6189e0e7a2d3 Mon Sep 17 00:00:00 2001 From: K-rolls Date: Tue, 17 Dec 2024 19:04:10 -0500 Subject: [PATCH 09/16] updates testing --- src/api/model_file_api.py | 4 +- src/commands/model_file.py | 9 +- test/api/test_model_file_api.py | 2 +- test/commands/test_key.py | 19 ++-- test/commands/test_machine.py | 22 ++--- test/commands/test_model_file.py | 145 +++++++++++++++++++++++++++++++ 6 files changed, 177 insertions(+), 24 deletions(-) create mode 100644 test/commands/test_model_file.py diff --git a/src/api/model_file_api.py b/src/api/model_file_api.py index ee5ac54..fcb4f18 100644 --- a/src/api/model_file_api.py +++ b/src/api/model_file_api.py @@ -6,8 +6,8 @@ class ModelFileAPI: - def __init__(self): - self.client = APIClient() + def __init__(self, client: APIClient): + self.client = client @handle_api_errors def upload_model_file( diff --git a/src/commands/model_file.py b/src/commands/model_file.py index ea3185c..c29068d 100644 --- a/src/commands/model_file.py +++ b/src/commands/model_file.py @@ -1,12 +1,16 @@ +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 -from datetime import datetime class ModelFileCommands: def __init__(self): - self.endpoint = ModelFileAPI() + self.client = APIClient() + self.endpoint = ModelFileAPI(self.client) def upload( self, @@ -30,6 +34,7 @@ def upload( def list(self): result = self.endpoint.get_all_models() + print(result) if result["success"]: if not result["data"]: click.echo("No models to list.") diff --git a/test/api/test_model_file_api.py b/test/api/test_model_file_api.py index 37832c6..c6270d1 100644 --- a/test/api/test_model_file_api.py +++ b/test/api/test_model_file_api.py @@ -6,7 +6,7 @@ class TestModelFileAPI(unittest.TestCase): def setUp(self): - self.api = ModelFileAPI() + self.api = ModelFileAPI(APIClient()) @patch("builtins.open", new_callable=mock_open, read_data="test data") @patch.object(APIClient, "post") diff --git a/test/commands/test_key.py b/test/commands/test_key.py index 62c579a..25211cb 100644 --- a/test/commands/test_key.py +++ b/test/commands/test_key.py @@ -120,26 +120,27 @@ def test_remove_api_key_no_key(self): self.key_commands.remove_api_key() mock_print.assert_called_with("No API key was set.") - def test_cli_create_command(self): + @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) - def test_cli_list_command(self): + @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) - def test_cli_delete_command(self): + @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) - def test_cli_set_command(self): + @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) - def test_cli_unset_command(self): + @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) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/commands/test_machine.py b/test/commands/test_machine.py index aa8ab74..772acd0 100644 --- a/test/commands/test_machine.py +++ b/test/commands/test_machine.py @@ -117,33 +117,35 @@ def test_get_machine_details_success(self): self.assertEqual(mock_print.call_count, 2) # CLI command tests - def test_cli_create_command(self): + @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) - def test_cli_list_command(self): + @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) - def test_cli_start_command(self): + @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) - def test_cli_stop_command(self): + @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) - def test_cli_terminate_command(self): + @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) - def test_cli_details_command(self): + @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) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/commands/test_model_file.py b/test/commands/test_model_file.py new file mode 100644 index 0000000..ea8afe1 --- /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": "KYS"}, + } + 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) From dd0a12149cc0705a2b899d4cd734f26fc8319b04 Mon Sep 17 00:00:00 2001 From: K-rolls Date: Tue, 17 Dec 2024 19:16:26 -0500 Subject: [PATCH 10/16] updates requirements --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index 2383c5c..f251454 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,3 +24,5 @@ tomli==2.0.2 urllib3==2.2.3 zipp==3.20.2 pre_commit==4.0.1 +rich==13.9.4 +pyfiglet==1.0.2 From 7ee1a0a220ee938b0885870e70a162a1e7470275 Mon Sep 17 00:00:00 2001 From: K-rolls Date: Tue, 17 Dec 2024 19:19:01 -0500 Subject: [PATCH 11/16] update whoopsie --- test/commands/test_model_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/commands/test_model_file.py b/test/commands/test_model_file.py index ea8afe1..1d5ded1 100644 --- a/test/commands/test_model_file.py +++ b/test/commands/test_model_file.py @@ -27,7 +27,7 @@ def setUp(self): self.mock_instance.get_all_models.return_value = { "success": True, "data": [], - "response": {"detail": "KYS"}, + "response": {"detail": None}, } self.mock_instance.get_model.return_value = { "success": True, From 2d37020bfab3cdc28d22909ee51dcb76d81c363b Mon Sep 17 00:00:00 2001 From: K-rolls Date: Sat, 11 Jan 2025 11:59:56 -0330 Subject: [PATCH 12/16] Update name of base.py -> user_auth.py --- src/commands/__init__.py | 4 ++-- src/commands/{base.py => user_auth.py} | 0 test/commands/{test_base.py => test_user_auth.py} | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) rename src/commands/{base.py => user_auth.py} (100%) rename test/commands/{test_base.py => test_user_auth.py} (93%) diff --git a/src/commands/__init__.py b/src/commands/__init__.py index edeae54..05dc52b 100644 --- a/src/commands/__init__.py +++ b/src/commands/__init__.py @@ -1,7 +1,7 @@ from src.commands import key -from src.commands import base +from src.commands import user_auth from src.commands import machine from src.commands import metrics from src.commands import model_file -__all__ = [key, base, machine, metrics, model_file] +__all__ = [key, user_auth, machine, metrics, model_file] diff --git a/src/commands/base.py b/src/commands/user_auth.py similarity index 100% rename from src/commands/base.py rename to src/commands/user_auth.py diff --git a/test/commands/test_base.py b/test/commands/test_user_auth.py similarity index 93% rename from test/commands/test_base.py rename to test/commands/test_user_auth.py index 954d5e8..1140c96 100644 --- a/test/commands/test_base.py +++ b/test/commands/test_user_auth.py @@ -1,7 +1,7 @@ import unittest from unittest.mock import patch from click.testing import CliRunner -from src.commands.base import login, logout +from src.commands.user_auth import login, logout class TestBaseCommands(unittest.TestCase): @@ -14,7 +14,9 @@ def setUp(self): self.mock_auth_api = self.patcher.start() self.mock_instance = self.mock_auth_api.return_value - self.endpoint = patch("src.commands.base.endpoint", self.mock_instance).start() + self.endpoint = patch( + "src.commands.user_auth.endpoint", self.mock_instance + ).start() def tearDown(self): """Clean up after each test method.""" From f11de669c86f829337330e06868b2159d06a5b79 Mon Sep 17 00:00:00 2001 From: K-rolls Date: Sat, 11 Jan 2025 12:33:30 -0330 Subject: [PATCH 13/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0c88cf6..83decea 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 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 --help +quack ``` ## Contributing From ea14b45466a591faa0d79b9cfcd65dffb004669a Mon Sep 17 00:00:00 2001 From: K-rolls Date: Sat, 11 Jan 2025 14:27:58 -0330 Subject: [PATCH 14/16] fixes no connection error --- src/commands/model_file.py | 1 - src/utils/helpers/handle_api_errors.py | 9 ++++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/commands/model_file.py b/src/commands/model_file.py index c29068d..2f4dac0 100644 --- a/src/commands/model_file.py +++ b/src/commands/model_file.py @@ -34,7 +34,6 @@ def upload( def list(self): result = self.endpoint.get_all_models() - print(result) if result["success"]: if not result["data"]: click.echo("No models to list.") diff --git a/src/utils/helpers/handle_api_errors.py b/src/utils/helpers/handle_api_errors.py index 3e42134..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: @@ -16,6 +16,13 @@ def wrapper(*args, **kwargs): "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)} From b65d19f14eeee025ccaa1516cfd91a5c3d1fb17f Mon Sep 17 00:00:00 2001 From: K-rolls Date: Sat, 11 Jan 2025 14:34:29 -0330 Subject: [PATCH 15/16] update documentation --- docs/setup.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/setup.md b/docs/setup.md index edb2fc5..bc7db2e 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -8,10 +8,6 @@ Follow these steps to get started with `quack`. 2. Install Pre-commit hooks 3. Install `quack` as an editable module -## Debugging - -See the [debugging](debugging.md) page for debugging help. - ## Environment Setup ### 1. Create Virtual Environment From a13161cf0ed4e5130bc5a30738dce0a17b87967f Mon Sep 17 00:00:00 2001 From: K-rolls Date: Sat, 11 Jan 2025 14:39:06 -0330 Subject: [PATCH 16/16] Update name of test --- test/commands/test_user_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/commands/test_user_auth.py b/test/commands/test_user_auth.py index 1140c96..1accdae 100644 --- a/test/commands/test_user_auth.py +++ b/test/commands/test_user_auth.py @@ -4,7 +4,7 @@ from src.commands.user_auth import login, logout -class TestBaseCommands(unittest.TestCase): +class TestUserAuth(unittest.TestCase): def setUp(self): """Set up test fixtures before each test method.""" self.runner = CliRunner()