diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 09a4a92a..a5b191fe 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "zetaforge", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zetaforge", - "version": "0.4.1", + "version": "0.4.2", "license": "AGPL-3.0-only", "dependencies": { "@aws-sdk/client-s3": "^3.525.0", diff --git a/frontend/package.json b/frontend/package.json index 64261047..cb1ccdb0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "zetaforge", - "version": "0.4.1", + "version": "0.4.2", "main": "dist-electron/main/index.js", "description": "ZetaForge", "author": "Zetane ", diff --git a/pyproject.toml b/pyproject.toml index 61cef7fb..47c307f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,34 +1,28 @@ [build-system] -requires = [ - "setuptools>=42", - "wheel" -] +requires = ["setuptools>=42", "wheel"] build-backend = "setuptools.build_meta" - [project] name = "zetaforge" -authors = [ - {name="Zetane", email="info@zetane.com"} -] +authors = [{ name = "Zetane", email = "info@zetane.com" }] dynamic = ["version"] description = "zetaforge installer" readme = "README_PYPI.md" dependencies = [ - "setuptools==69.0.2", - "requests==2.31.0", - "boto3==1.34.79", - "colorama==0.4.6", - "mixpanel==4.10.1", - "langchain==0.1.15", - "langchain-openai==0.1.2", - "sentry-sdk===2.0.1", - "yaspin" - ] + "setuptools==69.0.2", + "requests==2.31.0", + "boto3==1.34.79", + "colorama==0.4.6", + "mixpanel==4.10.1", + "langchain==0.1.15", + "langchain-openai==0.1.2", + "sentry-sdk===2.0.1", + "yaspin", + "rich", +] requires-python = ">=3.10" [project.scripts] zetaforge = "zetaforge.forge_cli:main" - diff --git a/uv.lock b/uv.lock index 5ccb4c10..233a8af2 100644 --- a/uv.lock +++ b/uv.lock @@ -607,6 +607,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/bd/314a8a1d06d1c576f7246a35eef5ed9322aa6eac8b87095d356bf1a21880/langsmith-0.1.131-py3-none-any.whl", hash = "sha256:80c106b1c42307195cc0bb3a596472c41ef91b79d15bcee9938307800336c563", size = 294649 }, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + [[package]] name = "marshmallow" version = "3.22.0" @@ -619,6 +631,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/78/c1de55eb3311f2c200a8b91724414b8d6f5ae78891c15d9d936ea43c3dba/marshmallow-3.22.0-py3-none-any.whl", hash = "sha256:71a2dce49ef901c3f97ed296ae5051135fd3febd2bf43afe0ae9a82143a494d9", size = 49334 }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + [[package]] name = "mixpanel" version = "4.10.1" @@ -899,6 +920,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/f9/b6bcaf874f410564a78908739c80861a171788ef4d4f76f5009656672dfe/pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753", size = 1920344 }, ] +[[package]] +name = "pygments" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1051,6 +1081,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 }, ] +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + [[package]] name = "s3transfer" version = "0.10.2" @@ -1149,6 +1193,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165 }, ] +[[package]] +name = "termcolor" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/85/147a0529b4e80b6b9d021ca8db3a820fcac53ec7374b87073d004aaf444c/termcolor-2.3.0.tar.gz", hash = "sha256:b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78bf7075fd95420fd9a5a", size = 12163 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/e1/434566ffce04448192369c1a282931cf4ae593e91907558eaecd2e9f2801/termcolor-2.3.0-py3-none-any.whl", hash = "sha256:3afb05607b89aed0ffe25202399ee0867ad4d3cb4180d98aaf8eefa6a5f7d475", size = 6872 }, +] + [[package]] name = "tiktoken" version = "0.8.0" @@ -1301,9 +1354,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/81/419c24f7c94f56b96d04955482efb5b381635ad265b5b7fbab333a9dfde3/yarl-1.13.1-py3-none-any.whl", hash = "sha256:6a5185ad722ab4dd52d5fb1f30dcc73282eb1ed494906a92d1a228d3f89607b0", size = 39862 }, ] +[[package]] +name = "yaspin" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/3c/70df5034e6712fcc238b76f6afd1871de143a2a124d80ae2c377cde180f3/yaspin-3.1.0.tar.gz", hash = "sha256:7b97c7e257ec598f98cef9878e038bfa619ceb54ac31d61d8ead2b3128f8d7c7", size = 36791 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/78/fa25b385d9f2c406719b5cf574a0980f5ccc6ea1f8411d56249f44acd3c2/yaspin-3.1.0-py3-none-any.whl", hash = "sha256:5e3d4dfb547d942cae6565718123f1ecfa93e745b7e51871ad2bbae839e71b73", size = 18629 }, +] + [[package]] name = "zetaforge" -version = "0.4.0" +version = "0.4.2" source = { editable = "." } dependencies = [ { name = "boto3" }, @@ -1312,8 +1377,10 @@ dependencies = [ { name = "langchain-openai" }, { name = "mixpanel" }, { name = "requests" }, + { name = "rich" }, { name = "sentry-sdk" }, { name = "setuptools" }, + { name = "yaspin" }, ] [package.metadata] @@ -1324,6 +1391,8 @@ requires-dist = [ { name = "langchain-openai", specifier = "==0.1.2" }, { name = "mixpanel", specifier = "==4.10.1" }, { name = "requests", specifier = "==2.31.0" }, + { name = "rich" }, { name = "sentry-sdk", specifier = "===2.0.1" }, { name = "setuptools", specifier = "==69.0.2" }, + { name = "yaspin" }, ] diff --git a/zetaforge/__init__.py b/zetaforge/__init__.py index 4a3139f6..88591c88 100644 --- a/zetaforge/__init__.py +++ b/zetaforge/__init__.py @@ -1,3 +1,3 @@ -__version__ = '0.4.1' +__version__ = '0.4.2' from .block_maker.zetahelper import block_maker from .Zetaforge import Zetaforge diff --git a/zetaforge/check_forge_dependencies.py b/zetaforge/check_forge_dependencies.py index 4b6b62f7..e42f1eec 100644 --- a/zetaforge/check_forge_dependencies.py +++ b/zetaforge/check_forge_dependencies.py @@ -2,8 +2,12 @@ import platform from pathlib import Path import os +from zetaforge.logger import CliLogger + EXECUTABLES_PATH = os.path.join(Path(__file__).parent, 'executables') +logger = CliLogger() + def check_kubectl(): check_ctl = subprocess.run(["kubectl", "version", "--client"], capture_output=True, text=True) return check_ctl.returncode == 0 @@ -11,11 +15,10 @@ def check_kubectl(): def check_running_kube(context): check_kube = subprocess.run(["kubectl", f"--context={context}", "get", "pods"], capture_output=True, text=True) if check_kube.returncode == 0: - print("Kubernetes is running, continuing..") + logger.success("Kubernetes is running, continuing..") return True else: - print("Kubernetes is not running! ", check_kube.returncode) - print(check_kube.stderr) + logger.error(f"Kubernetes is not running! {check_kube.stderr}") return False def check_kube_pod(name): @@ -25,7 +28,7 @@ def check_kube_pod(name): parts = line.split("-") if parts[0] == name: return True - + return False def check_kube_svc(name, namespace="default"): @@ -37,9 +40,9 @@ def check_kube_svc(name, namespace="default"): parts = line.split(" ") if parts[0].strip() == name: return True - + return False - + def check_minikube(): check_ctl = subprocess.run(["minikube", "version"], capture_output=True, text=True) return check_ctl.returncode == 0 @@ -55,4 +58,4 @@ def check_docker_installed(): else: print("https://docs.docker.com/desktop/install/linux-install/") return -1 - return 0 \ No newline at end of file + return 0 diff --git a/zetaforge/forge_cli.py b/zetaforge/forge_cli.py index 97c4ed79..f3b26c30 100644 --- a/zetaforge/forge_cli.py +++ b/zetaforge/forge_cli.py @@ -1,5 +1,6 @@ +from zetaforge.logger import CliLogger from .forge_runner import run_forge, teardown, purge, setup, uninstall -from .install_forge_dependencies import check_version, get_launch_paths, remove_running_services +from .install_forge_dependencies import check_version_exists, install_new_version, get_launch_paths, remove_running_services import argparse, os, json from pathlib import Path from .__init__ import __version__ @@ -9,6 +10,8 @@ EXECUTABLES_PATH = os.path.join(Path(__file__).parent, 'executables') FRONT_END = os.path.join(EXECUTABLES_PATH, "frontend") +logger = CliLogger() + def main(): help_ = """launch:\t sets up containers for zetaforge, and launches the application teardown:\tTears down your docker containers for zetaforge. Use this once you're done using zetaforge, in case your containers are not deleted. @@ -24,29 +27,29 @@ def main(): args = parser.parse_args() init() # Initialize colorama - + server_versions = [__version__] client_versions =[__version__] client_path, server_path = get_launch_paths(server_versions[-1], client_versions[-1]) if args.s2_path is not None: server_path = args.s2_path mixpanel_client.set_env(args.is_dev) - print(server_path) server_dir = os.path.dirname(server_path) - print(server_dir) config_file = os.path.join(server_dir, "config.json") - + if args.command == "launch": - check_version(server_versions[-1], client_versions[-1]) - remove_running_services() - print(f"Checking for config in {config_file}") + logger.show_banner(client_versions[-1]) + success = install_new_version(client_versions[-1], EXECUTABLES_PATH) + + logger.info(f"Checking for config in {config_file}") config = load_config(config_file) + #remove_running_services() if config is None: - print("Config not found! Running setup..") + logger.warning("Config not found! Running setup..") config_file = setup(server_versions[-1], client_versions[-1], args.driver, server_path=args.s2_path, is_dev=args.is_dev) config = load_config(config_file) - if config is not None: + if config is not None and success: run_forge(server_version=server_versions[-1], client_version=client_versions[-1], server_path=args.s2_path, client_path=args.app_path, is_dev=args.is_dev) else: raise Exception("Config failed to load, please re-run `zetaforge setup`.") @@ -55,13 +58,16 @@ def main(): elif args.command == 'purge': purge() elif args.command == 'setup': + current_version_exists = check_version_exists(EXECUTABLES_PATH, client_versions[-1]) + if not current_version_exists: + install_new_version(client_versions[-1], EXECUTABLES_PATH) setup(server_versions[-1], client_versions[-1], args.driver, is_dev=args.is_dev) elif args.command == 'uninstall': uninstall(server_versions[-1], server_path=args.s2_path) else: print('zetaforge:\t' + __version__) - print("client:\t" + client_versions[-1]) - print('server:\t' + server_versions[-1]) + print("client:\t" + client_versions[-1]) + print('server:\t' + server_versions[-1]) def load_config(config_file): if os.path.exists(config_file): @@ -74,4 +80,4 @@ def load_config(config_file): return None if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/zetaforge/forge_runner.py b/zetaforge/forge_runner.py index 6b19f5df..bf89452d 100644 --- a/zetaforge/forge_runner.py +++ b/zetaforge/forge_runner.py @@ -15,7 +15,7 @@ import threading from .mixpanel_client import mixpanel_client import sentry_sdk - +from .logger import CliLogger run_env = mixpanel_client.is_dev env = "production" @@ -35,6 +35,8 @@ EXECUTABLES_PATH = os.path.join(Path(__file__).parent, 'executables') FRONT_END = os.path.join(EXECUTABLES_PATH, "frontend") +logger = CliLogger() + def write_json(server_version, client_version, context, driver, is_dev, s2_path=None): if s2_path: server_path = s2_path @@ -106,44 +108,16 @@ def get_kubectl_contexts(): context_name = context_name[1:] contexts.append(context_name) - # Print the contexts for the user - print("Available kubectl contexts:") - for i, context in enumerate(contexts, start=1): - if context.startswith("*"): - print(f"{i}. {context[1:]} (current)") - else: - print(f"{i}. {context}") - return contexts def select_kubectl_context(): # Get the list of kubectl contexts contexts = get_kubectl_contexts() + selected = logger.show_numbered_menu("Select kubernetes context: ", contexts) - # Prompt the user to select a context - while True: - try: - choice = int(input("Enter the number of the context you want to use: ")) - if 1 <= choice <= len(contexts): - break - else: - print("Invalid choice. Please try again.") - except ValueError: - print("Invalid input. Please enter a valid number.") - - selected_context = contexts[choice - 1].strip("* ") - - # Confirm the selected context with the user - confirmation = input(f"You have selected the context: {selected_context}. Is this correct? (y/n): ") - if confirmation.lower() != "y": - print("Context selection canceled.") - return None - - return selected_context + return contexts[selected] def setup(server_version, client_version, driver, build_flag = True, install_flag = True, is_dev=False, server_path=None): - print("Platform: ", platform.machine()) - print("CWD: ", os.path.abspath(os.getcwd())) mixpanel_client.track_event('Setup Initiated') if driver == "minikube": @@ -151,13 +125,13 @@ def setup(server_version, client_version, driver, build_flag = True, install_fla if not check_minikube(): mixpanel_client.track_event("Setup Failure - Minikube Not Found") time.sleep(0.5) - print("Minikube not found. Please install minikube.") + logger.error("Minikube not found. Please install minikube.") raise Exception("Minikube not found!") minikube = subprocess.run(["minikube", "-p", "zetaforge", "start"], capture_output=True, text=True) if minikube.returncode != 0: mixpanel_client.track_event("Setup Failure - Cannot Start Minikube") time.sleep(0.5) - print(minikube.stderr) + logger.error(minikube.stderr) raise Exception("Error while starting minikube") mixpanel_client.track_event("Setup - Minikube Started") else: @@ -169,7 +143,7 @@ def setup(server_version, client_version, driver, build_flag = True, install_fla switch_context = subprocess.run(["kubectl", "config", "use-context", f"{context}"], capture_output=True, text=True) mixpanel_client.track_event("Setup - Kubectl Found") else: - print("Kubectl not found. Please install docker-desktop, orbstack, or minikube and enable kubernetes, or ensure that kubectl installed and is able to connect to a working kubernetes cluster.") + logger.error("Kubectl not found. Please install docker-desktop, orbstack, or minikube and enable kubernetes, or ensure that kubectl is installed and is able to connect to a working kubernetes cluster.") mixpanel_client.track_event("Setup Failure - Kubectl Not Found") time.sleep(0.5) raise EnvironmentError("Kubectl not found!") @@ -177,22 +151,18 @@ def setup(server_version, client_version, driver, build_flag = True, install_fla in_context = (switch_context.returncode == 0) if not in_context: - print(f"Cannot find the context {context} for kubernetes. Please double check that you have entered the correct context.") - subprocess.run(["kubectl", "config", "get-contexts"], capture_output=True, text=True) + logger.error(f"Cannot find the context {context} for kubernetes. Please double check that you have entered the correct context.") mixpanel_client.track_event("Setup Failure - Context Switch Error") raise Exception("Exception while setting the context") mixpanel_client.track_event("Setup - Context changed") - running_kube = check_running_kube(context) if not running_kube: raise Exception("Kubernetes is not running, please start kubernetes and ensure that you are able to connect to the kube context.") - install_frontend_dependencies(client_version=client_version) - config_path = write_json(server_version, client_version, context, driver, is_dev, s2_path=server_path) - print(f"Setup complete, wrote config to {config_path}.") + logger.success(f"Setup complete, wrote config to {config_path}.") mixpanel_client.track_event("Setup Successful") return config_path @@ -231,7 +201,7 @@ def run_forge(server_version=None, client_version=None, server_path=None, client try: server = None client = None - print(f"Launching execution server {server_path}..") + logger.success(f"Launching execution server {server_path}..") server_executable = os.path.basename(server_path) if platform.system() != 'Windows': server_executable = f"./{server_executable}" @@ -246,7 +216,7 @@ def run_forge(server_version=None, client_version=None, server_path=None, client mixpanel_client.track_event('Launch - Anvil Launched') - print(f"Launching client {client_path}..") + logger.success(f"Launching client {client_path}..") client_executable = os.path.basename(client_path) if platform.system() == 'Darwin': client_executable = [f"./{client_executable}"] @@ -302,9 +272,6 @@ def run_forge(server_version=None, client_version=None, server_path=None, client except: print("Mixpanel cannot track") - client.kill() - - #purge executables, and upload them from the scratch def purge(): shutil.rmtree(EXECUTABLES_PATH) diff --git a/zetaforge/install_forge_dependencies.py b/zetaforge/install_forge_dependencies.py index 37102da0..a870d0f8 100644 --- a/zetaforge/install_forge_dependencies.py +++ b/zetaforge/install_forge_dependencies.py @@ -3,20 +3,22 @@ import subprocess import gzip import boto3 +import hashlib import os import shutil from pathlib import Path from botocore.client import Config from botocore import UNSIGNED -from pathlib import Path import ssl -import os -import platform import sys from zipfile import ZipFile import tarfile from .check_forge_dependencies import check_kube_svc from pkg_resources import resource_filename +from .logger import CliLogger + +import json +from typing import Optional, Tuple, Dict # BACKEND = resource_filename("") EXECUTABLES_PATH = os.path.join(Path(__file__).parent, 'executables') @@ -26,6 +28,7 @@ INSTALL_YAML = resource_filename("zetaforge", os.path.join('utils', 'install.yaml')) s3 = boto3.client('s3', config=Config(signature_version=UNSIGNED), region_name='us-east-2') +logger = CliLogger() def install_kubectl(): try: @@ -124,10 +127,9 @@ def extract_zip(zip_file, target_dir): def extract_tar(tar_file, target_dir): with tarfile.open(tar_file, 'r') as tar: - print(f"Extracting {tar_file} to {target_dir}") tar.extractall(target_dir) - print(f"\nExtraction completed.") + logger.success(f"Extraction complete") def gunzip_file(in_file, out_file): with gzip.open(in_file, 'rb') as f_in: @@ -189,34 +191,6 @@ def get_server_executable(s2_version=None): filename += '-arm64' return filename -def check_and_clean_files(directory, version): - for filename in os.listdir(directory): - file_parts = filename.split('-') - if len(file_parts) >= 2: - file_version = file_parts[1] - if file_version == version: - print(f"Found an existing install of version {version}") - else: - print(f"Found a previous version of forge, uninstalling..") - - - file_path = os.path.join(directory, filename) - if os.path.isfile(file_path): - os.remove(file_path) - print(f"Removed file: {filename}") - elif os.path.isdir(file_path): - shutil.rmtree(file_path) - print(f"Removed directory: {filename}") - - if filename == 'ZetaForge.app' or filename == 'zetaforge.app': - _, server_path = get_launch_paths(version, version) - if os.path.exists(server_path): - print(f"Found existing version {version}") - else: - # did not find the correct version, reinstall it - print(f"Found ZetaForge.app but did not find version {version}, removing previous app") - shutil.rmtree(os.path.join(EXECUTABLES_PATH, filename)) - def remove_running_services(): print(f"Checking for existing kube services to remove..") registry = check_kube_svc("registry") @@ -229,13 +203,6 @@ def remove_running_services(): build = subprocess.run(["kubectl", "delete", "-f", INSTALL_YAML], capture_output=True, text=True) print("Removing build: ", {build.stdout}) -def check_version(server_version, client_version): - try: - check_and_clean_files(EXECUTABLES_PATH, client_version) - check_and_clean_files(EXECUTABLES_PATH, server_version) - except Exception as e: - print("Error removing previous version: ", e) - def get_app_dir(client_version): if platform.system() == 'Darwin': app_dir = os.path.join(EXECUTABLES_PATH, "ZetaForge.app") @@ -264,47 +231,307 @@ def get_launch_paths(server_version, client_version): return client_path, os.path.join(server_dir, server_name) +def get_etag(bucket: str, key: str) -> Optional[str]: + """ + Get ETag from S3 object metadata. + + Args: + bucket: S3 bucket name + key: Object key -def download_binary(bucket_key, destination): - bucket = "forge-executables-test" - print(f"Fetching executable: {bucket_key}") + Returns: + Optional[str]: ETag if available, None if not found + """ try: + meta_data = s3.head_object(Bucket=bucket, Key=key) + etag = meta_data.get('ETag', '').strip('"') # Remove surrounding quotes + return etag + except Exception as e: + logger.error(f"Failed to get ETag: {e}") + return None + +def verify_etag(file_path: str, expected_etag: str) -> bool: + """ + Verify if local file matches S3 ETag. + For multipart uploads, we just check if the file exists + as the ETag calculation for multipart uploads is complex. + + Args: + file_path: Path to local file + expected_etag: Expected ETag from S3 + + Returns: + bool: True if file exists and matches simple ETag + """ + if not os.path.exists(file_path): + return False + + # If it's a multipart ETag (contains a dash), we just verify file exists + if '-' in expected_etag: + return True + + # For simple ETags (no multipart), we can do a direct MD5 comparison + import hashlib + md5_hash = hashlib.md5() + + with open(file_path, 'rb') as f: + for chunk in iter(lambda: f.read(4096), b''): + md5_hash.update(chunk) + + calculated_etag = md5_hash.hexdigest() + return calculated_etag == expected_etag + +def download_with_verification(bucket_key: str, destination: str, bucket: str = "forge-executables-test") -> bool: + """ + Download file if needed and verify integrity. + + Args: + bucket_key: Key in S3 bucket + destination: Local destination path + bucket: S3 bucket name + + Returns: + bool: True if file is available and verified + """ + try: + # Get expected hash from S3 metadata + print(f"Fetching {bucket_key} from {bucket}") + expected_etag = get_etag(bucket, bucket_key) + if not expected_etag: + logger.error("Could not get ETag for verification") + return False + + if os.path.exists(destination): + if verify_etag(destination, expected_etag): + logger.success(f"Using cached version: {os.path.basename(destination)}") + return True + else: + logger.warning("Cache invalid or outdated, downloading fresh copy") + os.remove(destination) + + # Get file size for progress bar meta_data = s3.head_object(Bucket=bucket, Key=bucket_key) + total_length = int(meta_data.get('ContentLength', 0)) + + # Download with progress bar + with logger.create_download_progress() as progress: + task_id = progress.add_task( + description=f"Downloading {os.path.basename(bucket_key)}", + total=total_length + ) + + def progress_callback(chunk): + progress.update(task_id, advance=chunk) + + s3.download_file( + bucket, + bucket_key, + destination, + Callback=progress_callback + ) + + # Verify downloaded file + if verify_etag(destination, expected_etag): + logger.success("Download verified successfully") + return True + else: + logger.error("Download verification failed") + os.remove(destination) + return False + except Exception as e: - raise Exception(f"Executable not found: {bucket_key}! There is no binary build for this version and platform, please log an issue at https://github.com/zetane/zetaforge") + print(f"Error during download: {e}") + if os.path.exists(destination): + os.remove(destination) + return False + + +class VersionInstallError(Exception): + """Custom exception for installation errors""" + pass + +def load_config(config_file: str) -> Optional[Dict]: + """ + Load and validate configuration file. + + Args: + config_file: Path to config file + + Returns: + Optional[Dict]: Configuration dictionary or None if invalid/missing + """ + if os.path.exists(config_file): + try: + with open(config_file, "r") as file: + config = json.load(file) + return config + except (IOError, json.JSONDecodeError) as e: + print(f"Error loading configuration file: {e}") + return None + +def get_config_path(app_dir: str) -> str: + """Get the platform-specific path to config.json""" + if platform.system() == 'Darwin': + return os.path.join(app_dir, "Contents", "Resources", "config.json") + else: + return os.path.join(app_dir, "resources", "config.json") - total_length = int(meta_data.get('ContentLength', 0) / (1024 * 1024)) - downloaded = 0 - def progress(chunk): - nonlocal downloaded - downloaded += chunk - download_mb = int(downloaded / (1024*1024)) - done = int((50 * downloaded / total_length) / (1024 * 1024)) +def backup_config(old_dir: str, new_dir: str) -> bool: + """ + Backup config.json from old installation to new installation and validate it. - sys.stdout.write("\r[%s%s] %s/%sMB" % ('=' * done, ' ' * (50-done), download_mb, total_length) ) - sys.stdout.flush() + Args: + old_dir: Path to old installation + new_dir: Path to new installation - print(f"Downloading app {bucket_key} to {EXECUTABLES_PATH}") - s3.download_file(bucket, bucket_key, destination, Callback=progress) - print("\nCompleted app download..") + Returns: + bool: True if config was copied and validated successfully + """ + old_config_path = get_config_path(old_dir) + new_config_path = get_config_path(new_dir) + # First check if old config exists and is valid + old_config = load_config(old_config_path) + if old_config is None: + print("No valid configuration found in previous installation") + return False -def install_frontend_dependencies(client_version, no_cache=True): - os.makedirs(EXECUTABLES_PATH, exist_ok=True) + try: + # Ensure target directory exists + os.makedirs(os.path.dirname(new_config_path), exist_ok=True) + + # Copy the config file + shutil.copy2(old_config_path, new_config_path) + print("Preserved existing configuration") + + # Validate the copied config + new_config = load_config(new_config_path) + if new_config is None: + print("Warning: Config validation failed after copy") + return False + + return True + except IOError as e: + print(f"Warning: Failed to copy config file: {e}") + return False + +def verify_installation(client_path: str, server_path: str) -> bool: + """ + Verify that the new installation is valid and has required components. + + Args: + client_path: Path to client executable + server_path: Path to server executable + + Returns: + bool: True if installation is valid + """ + if not (os.path.exists(client_path) and os.path.exists(server_path)): + return False - bucket_key = get_download_file(client_version) - app_dir = get_app_dir(client_version) - tar_file = os.path.join(EXECUTABLES_PATH, bucket_key) - if not os.path.exists(tar_file) or no_cache: - download_binary(bucket_key, tar_file) + return True + +def safe_remove(path: str) -> None: + """Safely remove a file or directory""" + try: + if os.path.isfile(path): + os.remove(path) + logger.success(f"Removed file: {os.path.basename(path)}") + elif os.path.isdir(path): + shutil.rmtree(path) + logger.success(f"Removed directory: {os.path.basename(path)}") + except OSError as e: + logger.warning(f"Warning: Failed to remove {path}: {e}") + +def check_version_exists(directory: str, version: str) -> bool: + """ + Check if a specific version is properly installed. + + Args: + directory: Installation directory + version: Version to check for + + Returns: + bool: True if version exists and is valid + """ + client_path, server_path = get_launch_paths(version, version) + return verify_installation(client_path, server_path) + +def clean_old_versions(directory: str, current_version: str): + """ + Remove old versions after confirming new version is installed. + Preserves downloads (.tar.gz) and the current version. + + Args: + directory: Installation directory + current_version: Version to preserve + """ + for filename in os.listdir(directory): + if filename.endswith('.tar.gz'): + continue # Preserve downloads + + file_path = os.path.join(directory, filename) + if not os.path.isdir(file_path): + continue + + # Check if this is a version directory + file_parts = filename.split('-') + if len(file_parts) >= 2: + file_version = file_parts[1] + if file_version != current_version: + logger.info(f"Removing old version: {filename}") + safe_remove(file_path) - print("Unzipping and installing app..") +def install_new_version(version: str, directory: str) -> bool: + """ + Coordinated installation of new version with config management. - if os.path.exists(app_dir): - shutil.rmtree(app_dir) + Args: + version: Version to install + directory: Installation directory - extract_tar(tar_file, EXECUTABLES_PATH) + Returns: + bool: True if installation successful + """ + try: + # Step 1: Find existing installations + if check_version_exists(directory, version): + logger.success(f"Version {version} is already installed") + return True - print(f"Zetaforge extracted and installed at {os.path.join(EXECUTABLES_PATH)}") + # Step 2: Download and install new version if needed + logger.info(f"Installing ZetaForge v{version}", "🔧") + bucket_key = get_download_file(version) + tar_file = os.path.join(directory, bucket_key) - return True + # Download and verify (or use cached copy) + if not download_with_verification(bucket_key, tar_file): + raise VersionInstallError(f"Failed to get verified copy of version {version}") + + # Extract and install + app_dir = get_app_dir(version) + if os.path.exists(app_dir): + shutil.rmtree(app_dir) + logger.info("Extracting files...", "đŸ“Ļ") + extract_tar(tar_file, directory) + + # Verify the new installation works + if not check_version_exists(directory, version): + raise VersionInstallError("Installation verification failed") + + # Only clean up old versions after successful installation + clean_old_versions(directory, version) + logger.success(f"ZetaForge v{version} installed successfully") + + return True + + except Exception as e: + print(f"Error during installation: {e}") + # Clean up partial new installation + try: + app_dir = get_app_dir(version) + if os.path.exists(app_dir): + safe_remove(app_dir) + except Exception as cleanup_error: + print(f"Error during cleanup: {cleanup_error}") + return False diff --git a/zetaforge/logger.py b/zetaforge/logger.py new file mode 100644 index 00000000..f68869e8 --- /dev/null +++ b/zetaforge/logger.py @@ -0,0 +1,266 @@ +import os +import sys +import platform +from datetime import datetime +from typing import Optional, List, Dict, Any, Union +from rich.console import Console +from rich.prompt import Prompt, Confirm +from rich.panel import Panel +from rich.text import Text +from rich.table import Table +from rich.columns import Columns +from rich.markdown import Markdown +from rich.console import Console +from rich.progress import ( + Progress, + SpinnerColumn, + BarColumn, + TextColumn, + DownloadColumn, + TransferSpeedColumn, + TimeRemainingColumn +) +from rich import print as rprint +from enum import Enum + +class LogLevel(Enum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + SUCCESS = "success" + +class CliLogger: + """Enhanced CLI logger with rich formatting and progress bars""" + + BANNER = """ +███████╗███████╗████████╗ █████╗ ███████╗ ██████╗ ██████╗ ██████╗ ███████╗ +╚══███╔╝██╔════╝╚══██╔══╝██╔══██╗██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝ + ███╔╝ █████╗ ██║ ███████║█████╗ ██║ ██║██████╔╝██║ ███╗█████╗ + ███╔╝ ██╔══╝ ██║ ██╔══██║██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ +███████╗███████╗ ██║ ██║ ██║██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗ +╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ + """ + + def __init__(self): + self.console = Console() + self._setup_styles() + + def _setup_styles(self): + """Setup color styles for different message types""" + self.styles = { + LogLevel.INFO: "cyan", + LogLevel.WARNING: "yellow", + LogLevel.ERROR: "red", + LogLevel.SUCCESS: "green" + } + + def _get_system_info(self) -> Table: + """Create a table with system information""" + table = Table(show_header=False, border_style="bright_blue") + + # Get Python version info + py_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + + # Add rows to table + table.add_row("System Time:", datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + table.add_row("Working Dir:", os.getcwd()) + table.add_row("Platform:", platform.system()) + table.add_row("Architecture:", platform.machine()) + table.add_row("Python:", py_version) + + return table + + def show_banner(self, version: str): + """Display the ZetaForge ASCII banner with version""" + banner_panel = Panel( + Text(self.BANNER, style="blue bold"), + subtitle=f"v{version}", + subtitle_align="right" + ) + self.console.print(banner_panel) + self.console.print() # Add spacing after banner + + system_info = self._get_system_info() + self.console.print(Panel( + system_info, + title="[yellow]System Information[/yellow]", + border_style="bright_blue" + )) + self.console.print() + + def log(self, message: str, level: LogLevel = LogLevel.INFO, emoji: str = ""): + """Log a message with appropriate styling""" + style = self.styles[level] + prefix = { + LogLevel.INFO: "ℹī¸ ", + LogLevel.WARNING: "⚠ī¸ ", + LogLevel.ERROR: "❌ ", + LogLevel.SUCCESS: "✅ " + }.get(level, "") + + if emoji: + prefix = f"{emoji} " + + self.console.print(f"{prefix}{message}", style=style) + + def error(self, message: str): + """Log an error message""" + self.log(message, LogLevel.ERROR) + + def warning(self, message: str): + """Log a warning message""" + self.log(message, LogLevel.WARNING) + + def success(self, message: str): + """Log a success message""" + self.log(message, LogLevel.SUCCESS) + + def info(self, message: str, emoji: str = ""): + """Log an info message""" + self.log(message, LogLevel.INFO, emoji) + + def create_download_progress(self) -> Progress: + """Create a rich progress bar for downloads""" + return Progress( + SpinnerColumn(), + TextColumn("[bold blue]{task.description}"), + BarColumn(bar_width=40), + "[progress.percentage]{task.percentage:>3.0f}%", + DownloadColumn(), + TransferSpeedColumn(), + TimeRemainingColumn(), + console=self.console + ) + + def prompt(self, message: str, default: Optional[str] = None, password: bool = False) -> str: + """ + Get user input with optional default value + + Args: + message: Prompt message + default: Default value if user presses enter + password: Whether to hide input + + Returns: + str: User input + """ + if password: + return Prompt.ask(message, password=True) + return Prompt.ask(message, default=default) + + def confirm(self, message: str, default: bool = True) -> bool: + """ + Get yes/no confirmation from user + + Args: + message: Confirmation message + default: Default value if user presses enter + + Returns: + bool: True for yes, False for no + """ + return Confirm.ask(message, default=default) + + def show_menu(self, title: str, options: Dict[str, str]) -> str: + """ + Display a menu and get user selection + + Args: + title: Menu title + options: Dictionary of option keys and descriptions + + Returns: + str: Selected option key + """ + # Create menu table + menu = Table(show_header=False, border_style="bright_blue", box=None) + + # Add options to table + for key, description in options.items(): + menu.add_row(f"[cyan]{key}[/cyan]", description) + + # Show menu in panel + self.console.print(Panel( + menu, + title=f"[yellow]{title}[/yellow]", + border_style="bright_blue" + )) + + # Get valid choice + while True: + choice = self.prompt("Enter your choice").lower() + if choice in options: + return choice + self.error(f"Invalid choice. Please select from: {', '.join(options.keys())}") + + def show_numbered_menu(self, title: str, options: List[str]) -> int: + """ + Display a numbered menu and get user selection + + Args: + title: Menu title + options: List of options + + Returns: + int: Selected option index + """ + # Create menu table + menu = Table(show_header=False, border_style="bright_blue", box=None) + + # Add numbered options + for i, option in enumerate(options, 1): + menu.add_row(f"[cyan]{i}[/cyan]", option) + + # Show menu in panel + self.console.print(Panel( + menu, + title=f"[yellow]{title}[/yellow]", + border_style="bright_blue" + )) + + # Get valid choice + while True: + try: + choice = int(self.prompt("Enter number")) + if 1 <= choice <= len(options): + return choice - 1 + self.error(f"Please enter a number between 1 and {len(options)}") + except ValueError: + self.error("Please enter a valid number") + + def show_multi_select(self, title: str, options: List[str]) -> List[int]: + """ + Display menu for selecting multiple options + + Args: + title: Menu title + options: List of options + + Returns: + List[int]: List of selected indices + """ + # Create menu table + menu = Table(show_header=False, border_style="bright_blue", box=None) + + # Add numbered options + for i, option in enumerate(options, 1): + menu.add_row(f"[cyan]{i}[/cyan]", option) + + # Show menu in panel + self.console.print(Panel( + menu, + title=f"[yellow]{title}[/yellow]", + subtitle="[cyan]Enter numbers separated by commas (e.g., 1,3,4)[/cyan]", + border_style="bright_blue" + )) + + # Get valid choices + while True: + try: + choices = self.prompt("Enter numbers").split(',') + indices = [int(c.strip()) - 1 for c in choices] + if all(0 <= i < len(options) for i in indices): + return indices + self.error(f"Please enter numbers between 1 and {len(options)}") + except ValueError: + self.error("Please enter valid numbers separated by commas")