diff --git a/README.md b/README.md index 446632d..0c04569 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ # croco-cli -[![Croco Logo](https://i.ibb.co/G5Pjt6M/logo.png)](https://t.me/crocofactory) +[![Croco Logo](https://i.ibb.co/G5Pjt6M/logo.png)](https://t.me/crocofactory) + +[![PyPi version](https://img.shields.io/pypi/v/croco-cli)](https://pypi.org/project/croco-cli/) +[![PyPI Downloads](https://img.shields.io/pypi/dm/croco-cli)](https://pypi.org/project/croco-cli/) +[![License](https://img.shields.io/github/license/blnkoff/croco-cli.svg)](https://pypi.org/project/croco-cli/) +[![Last Commit](https://img.shields.io/github/last-commit/blnkoff/croco-cli.svg)](https://pypi.org/project/croco-cli/) +[![Development Status](https://img.shields.io/pypi/status/croco-cli)](https://pypi.org/project/croco-cli/) + The CLI for developing Web3-based projects in Croco Factory @@ -19,6 +26,8 @@ Command `croco` display this in your console. Let`s learn them: - `change` - if you already have set multiple accounts, using `set` you can change current +- `export` - Export cli configuration +- `import` - Import cli configuration - `init` - if you created you project or pacakge just now, you can initialize it, using template structure - `install` - install Croco Factory packages. If package is placed in private GitHub repository, you need to have access token with permission of downloading this package diff --git a/croco_cli/database.py b/croco_cli/_database.py similarity index 82% rename from croco_cli/database.py rename to croco_cli/_database.py index 49e62b0..865b431 100644 --- a/croco_cli/database.py +++ b/croco_cli/_database.py @@ -2,46 +2,61 @@ This module provides a database interface """ import json +import sys import os import pickle import getpass from eth_account import Account -from github import Auth -from github import Github +from github import Auth, BadCredentialsException, Github from typing import Type, Optional, ClassVar -from croco_cli.types import GithubUser, Wallet, CustomAccount, EnvVariable +from eth_utils.exceptions import ValidationError +from croco_cli.exceptions import InvalidToken, InvalidMnemonic +from croco_cli.types import GithubUser, Wallet, CustomAccount, EnvVar from peewee import Model, CharField, BlobField, SqliteDatabase, BooleanField -class _DatabaseMeta(type): - _instance = None - - def __call__(cls, *args, **kwargs): - if not isinstance(cls._instance, cls): - cls._instance = super().__call__(*args, **kwargs) - - return cls._instance - - def _get_cache_folder() -> str: + """ + Get the cache folder path based on the operating system. + + :return: Cache folder path. + """ username = getpass.getuser() os_name = os.name + venv_path = sys.prefix + + parent_path = venv_path[:venv_path.rfind('/')] + folder = os.path.basename(parent_path) + if os_name == "posix": - cache_path = f'/Users/{username}/.cache/croco_cli' + cache_folder = f'/Users/{username}/.croco_cli' elif os_name == "nt": - cache_path = f'C:\\Users\\{username}\\AppData\\Local\\croco_cli' + cache_folder = f'C:/Users/{username}/AppData/Local/croco_cli' else: raise OSError(f"Unsupported Operating System {os_name}") - try: + if not os.path.exists(cache_folder): + os.mkdir(cache_folder) + + cache_path = os.path.join(cache_folder, folder) + + if not os.path.exists(cache_path): os.mkdir(cache_path) - except FileExistsError: - pass return cache_path +class _DatabaseMeta(type): + _instance = None + + def __call__(cls, *args, **kwargs): + if not isinstance(cls._instance, cls): + cls._instance = super().__call__(*args, **kwargs) + + return cls._instance + + class Database(metaclass=_DatabaseMeta): _path: ClassVar[str] = os.path.join(_get_cache_folder(), 'user.db') interface: ClassVar[SqliteDatabase] = SqliteDatabase(_path) @@ -129,15 +144,19 @@ def drop_database(self) -> None: """ self.interface.drop_tables([self.github_users, self.wallets, self.custom_accounts, self.env_variables]) - def get_wallets(self) -> list[Wallet] | None: + def get_wallets(self, current: bool = False) -> list[Wallet] | None: """ Returns a list of all ethereum wallets of the user :return: a list of all ethereum wallets of the user """ - query = self.wallets.select() if not self.wallets.table_exists(): return None + if not current: + query = self.wallets.select() + else: + query = self.wallets.select().where(self._wallets.current) + wallets = [ Wallet( public_key=wallet.public_key, @@ -148,7 +167,7 @@ def get_wallets(self) -> list[Wallet] | None: ) for wallet in query ] - return wallets + return wallets if wallets else None def get_github_user(self) -> GithubUser | None: """ @@ -183,13 +202,16 @@ def set_github_user(self, token: str) -> None: _auth = Auth.Token(token) with Github(auth=_auth) as github_api: - user = github_api.get_user() - _emails = user.get_emails() + try: + user = github_api.get_user() + _emails = user.get_emails() - for email in _emails: - if email.primary: - user_email = email.email - break + for email in _emails: + if email.primary: + user_email = email.email + break + except BadCredentialsException: + raise InvalidToken github_users.create( data=pickle.dumps(user), @@ -218,6 +240,16 @@ def get_public_key(private_key: str) -> str: public_key = account.address return public_key + @staticmethod + def _get_private_key(mnemonic: str) -> str: + try: + Account.enable_unaudited_hdwallet_features() + account = Account.from_mnemonic(mnemonic) + private_key = account.key.hex() + return private_key + except ValidationError: + raise InvalidMnemonic + def set_wallet( self, private_key: str, @@ -234,8 +266,14 @@ def set_wallet( wallets = self._wallets database = self.interface + if label == 'None': + label = None + database.create_tables([wallets]) + if mnemonic and private_key != self._get_private_key(mnemonic): + raise InvalidMnemonic + existing_wallets = wallets.select().where(wallets.private_key == private_key) current_wallets = wallets.select().where(wallets.current) @@ -291,11 +329,12 @@ def get_custom_accounts( current=account.current, email=account.email, email_password=account.email_password, - data=account.data + data=json.loads(account.data) ) for account in query ] - return accounts + + return accounts if accounts else None def set_custom_account( self, @@ -344,7 +383,7 @@ def set_custom_account( data=json.dumps(data) ) - def delete_custom_accounts(self, account: str, email: str) -> None: + def delete_custom_accounts(self, account: str, email: Optional[str] = None) -> None: """ Delete custom user accounts :param account: A name of accounts @@ -352,11 +391,17 @@ def delete_custom_accounts(self, account: str, email: str) -> None: :return: None """ custom_accounts = self._custom_accounts - custom_accounts.delete().where( - custom_accounts.account == account and custom_accounts.email == email - ).execute() - def set_env_variable( + if email: + custom_accounts.delete().where( + custom_accounts.account == account and custom_accounts.email == email + ).execute() + else: + custom_accounts.delete().where( + custom_accounts.account == account + ).execute() + + def set_envar( self, key: str, value: str @@ -375,7 +420,7 @@ def set_env_variable( value=value ) - def get_env_variables(self) -> list[EnvVariable] | None: + def get_env_variables(self) -> list[EnvVar] | None: env_variables = self._env_variables if not env_variables.table_exists(): return @@ -383,7 +428,7 @@ def get_env_variables(self) -> list[EnvVariable] | None: query = env_variables.select() return [ - EnvVariable( + EnvVar( key=env_variable.key, value=env_variable.value ) diff --git a/croco_cli/cli/__init__.py b/croco_cli/cli/__init__.py index 8600bcc..1b114e3 100644 --- a/croco_cli/cli/__init__.py +++ b/croco_cli/cli/__init__.py @@ -2,4 +2,4 @@ This sub-package provides command-line interface """ -from .cli import cli +from ._cli import cli diff --git a/croco_cli/cli/change.py b/croco_cli/cli/_change.py similarity index 56% rename from croco_cli/cli/change.py rename to croco_cli/cli/_change.py index 51eff3e..441d485 100644 --- a/croco_cli/cli/change.py +++ b/croco_cli/cli/_change.py @@ -1,7 +1,11 @@ import click -from croco_cli.database import Database -from croco_cli.types import Option, Wallet, CustomAccount -from croco_cli.utils import show_key_mode, sort_wallets, echo_error, make_screen_option +from croco_cli._database import Database +from croco_cli.tools.keymode import KeyMode +from croco_cli.tools.option import Option +from croco_cli.types import CustomAccount +from croco_cli.utils import Wallet +from croco_cli.utils import sort_wallets +from croco_cli.croco_echo import CrocoEcho @click.group() @@ -19,6 +23,26 @@ def _handler(): def _deleting_handler(): database.delete_wallet(wallet['private_key']) + wallets = database.get_wallets() + + if wallets: + try: + last_wallet = database.get_wallets()[-1] + current_wallet = filter(lambda x: x['current'], wallets) + + try: + current_wallet = next(current_wallet) + except StopIteration: + current_wallet = None + + if not current_wallet: + database.set_wallet( + last_wallet['private_key'], + last_wallet['label'], + last_wallet['mnemonic'] + ) + except IndexError: + pass if wallet["current"]: label = f'{label} (Current)' @@ -45,6 +69,24 @@ def _handler(): def _deleting_handler(): database.delete_custom_accounts(account['account'], account['email']) + custom_accounts = database.get_custom_accounts(account['account']) + + if custom_accounts: + try: + last_custom_account = custom_accounts[-1] + current_account = filter(lambda x: x['current'], custom_accounts) + + try: + current_account = next(current_account) + except StopIteration: + current_account = None + + if not current_account: + last_custom_account.pop('current') + database.set_custom_account(**last_custom_account) + except IndexError: + pass + if account["current"]: label = f'{label} (Current)' @@ -67,13 +109,14 @@ def _wallet(): wallets = database.get_wallets() if len(wallets) < 2: - echo_error('There are no wallets in the database to change.') + CrocoEcho.error('There are no wallets in the database to change.') return wallets = sort_wallets(wallets) options = [_make_wallet_option(wallet) for wallet in wallets] - show_key_mode(options, 'Change wallet for unit tests') + keymode = KeyMode(options, 'Change wallet for unit tests') + keymode() @change.command() @@ -84,7 +127,7 @@ def custom(): custom_accounts = database.get_custom_accounts() if not custom_accounts or not len(custom_accounts): - echo_error('There are no custom accounts in the database to change.') + CrocoEcho.error('There are no custom accounts in the database to change.') return for account in custom_accounts: @@ -103,11 +146,10 @@ def custom(): description = f'Change {key.capitalize()} account' def deleting_handler(): - custom_accounts = database.custom_accounts - custom_accounts.delete().where(custom_accounts.account == key).execute() + database.delete_custom_accounts(key) screen_options.append( - make_screen_option( + KeyMode.screen_option( key.capitalize(), description, account_options, @@ -116,7 +158,8 @@ def deleting_handler(): ) if not len(screen_options): - echo_error('There are no custom accounts in the database to change.') + CrocoEcho.error('There are no custom accounts in the database to change.') return - show_key_mode(screen_options, 'Change custom account') + keymode = KeyMode(screen_options, 'Change custom account') + keymode() diff --git a/croco_cli/cli/cli.py b/croco_cli/cli/_cli.py similarity index 77% rename from croco_cli/cli/cli.py rename to croco_cli/cli/_cli.py index 990f5a5..18b7116 100644 --- a/croco_cli/cli/cli.py +++ b/croco_cli/cli/_cli.py @@ -4,17 +4,20 @@ import click from typing import cast -from .change import change -from .init import init -from .install import install -from .user import user -from .set import _set -from .make import make -from .reset import reset +from ._change import change +from ._init import init +from ._install import install +from ._user import user +from ._set import _set +from ._make import make +from ._reset import reset +from ._export import export +from ._import import _import from croco_cli.types import ClickGroup @click.group() +@click.version_option(prog_name='croco-cli', package_name='croco-cli') def cli(): """ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ @@ -27,6 +30,8 @@ def cli(): """ +cli.add_command(cast(ClickGroup, _import)) +cli.add_command(cast(ClickGroup, export)) cli.add_command(cast(ClickGroup, change)) cli.add_command(cast(ClickGroup, init)) cli.add_command(cast(ClickGroup, install)) diff --git a/croco_cli/cli/_export.py b/croco_cli/cli/_export.py new file mode 100644 index 0000000..2675078 --- /dev/null +++ b/croco_cli/cli/_export.py @@ -0,0 +1,41 @@ +""" +This module contains functions to export cli settings and accounts +""" +import click +import json +from croco_cli._database import Database +from typing import Optional +from croco_cli.croco_echo import CrocoEcho + + +@click.command() +@click.option('-i', '--indent', 'indent', is_flag=True, default=False, show_default=True, help='Export using indentations') +@click.argument('path', default=None, type=click.Path(file_okay=True), required=False) +def export(path: Optional[str] = None, indent: bool = True) -> None: + """Export cli configuration""" + path = 'croco_config.json' if not path else path + database = Database() + + env_vars = database.get_env_variables() + wallets = database.get_wallets() + github_user = database.get_github_user() + custom_accounts = database.get_custom_accounts() + + if env_vars: + env_vars = {var['key']: var['value'] for var in env_vars} + + config = { + 'user': { + 'wallets': [wallet.pop('public_key') and wallet for wallet in wallets] if wallets else None, + 'custom': custom_accounts if custom_accounts else None, + 'github': github_user['access_token'] if github_user else None, + 'env': env_vars if env_vars else None + } + } + + try: + with open(path, 'w') as file: + indent = 2 if indent else None + json.dump(config, file, indent=indent) + except (FileNotFoundError, NotADirectoryError): + CrocoEcho.error('All folders in path must exist') diff --git a/croco_cli/cli/_import.py b/croco_cli/cli/_import.py new file mode 100644 index 0000000..c465d96 --- /dev/null +++ b/croco_cli/cli/_import.py @@ -0,0 +1,50 @@ +""" +This module contains functions to import cli settings and accounts +""" + +import click +import json +from croco_cli._database import Database +from croco_cli.utils import catch_github_errors, catch_wallet_errors + + +@click.command(name='import') +@click.argument('path', type=click.Path(exists=True)) +@catch_wallet_errors +@catch_github_errors +def _import(path: str) -> None: + """Import cli configuration""" + database = Database() + + with open(path, 'r') as file: + config = json.load(file) + + user = config['user'] + + if token := user['github']: + database.set_github_user(token) + + if wallets := user['wallets']: + current_wallet = None + for wallet in wallets: + if not wallet.pop('current'): + database.set_wallet(**wallet) + else: + current_wallet = wallet + + database.set_wallet(**current_wallet) + + if custom_accounts := user['custom']: + current_accounts = [] + for account in custom_accounts: + if not account.pop('current'): + database.set_custom_account(**account) + else: + current_accounts.append(account) + + for account in current_accounts: + database.set_custom_account(**account) + + if env := user['env']: + for key, value in env.items(): + database.set_envar(key, value) diff --git a/croco_cli/cli/init.py b/croco_cli/cli/_init.py similarity index 84% rename from croco_cli/cli/init.py rename to croco_cli/cli/_init.py index b1b980c..073d2be 100644 --- a/croco_cli/cli/init.py +++ b/croco_cli/cli/_init.py @@ -1,11 +1,12 @@ """ This module contains functions to initialize python packages and projects """ -import datetime import os -import click -from croco_cli.utils import snake_case, require_github -from croco_cli.database import Database +import click +import datetime +from importlib import metadata +from croco_cli._database import Database +from croco_cli.utils import snake_case, require_github, run_poetry_command, check_poetry @click.group() @@ -58,6 +59,9 @@ def _add_poetry( [tool.poetry.dependencies] python = '^3.11' +[tool.poetry.group.dev.dependencies] +croco-cli = '^{metadata.version('croco-cli')}' + [build-system] requires = ['poetry-core'] build-backend = 'poetry.core.masonry.api' @@ -73,15 +77,15 @@ def _add_packages(open_source: bool, is_package: bool) -> None: :param is_package: Whether packages should be installed for the developing a Python package :return: None """ - os.system('poetry add -D pytest') - os.system('poetry add -D python-dotenv') + run_poetry_command('poetry add -D pytest') + run_poetry_command('poetry add -D python-dotenv') if not is_package: - os.system('poetry add loguru') + run_poetry_command('poetry add loguru') return elif open_source: - os.system('poetry add -D build') - os.system('poetry add -D twine') + run_poetry_command('poetry add -D build') + run_poetry_command('poetry add -D twine') def _initialize_folders( @@ -118,7 +122,7 @@ def _initialize_folders( ~~~~~~~~~~~~~~ {description} -:copyright: (c) 2023 by {github_user['name']} +:copyright: (c) {datetime.datetime.now().year} by {github_user['name']} :license: MIT, see LICENSE for more details. \"\"\" """) @@ -161,8 +165,7 @@ def _initialize_folders( pass with open('main.py', 'w') as main_file: - main_file.write("""import loguru -import asyncio + main_file.write("""import asyncio async def main(): @@ -203,11 +206,19 @@ def _add_readme( database = Database() github_user = database.get_github_user() - content = (f"""# {project_name} -[![Croco Logo](https://i.ibb.co/G5Pjt6M/logo.png)](https://t.me/crocofactory) + content = f"""# {project_name} -{description} +[![Croco Logo](https://i.ibb.co/G5Pjt6M/logo.png)](https://t.me/crocofactory)""" + + if is_package: + content += (f"""\n\n[![PyPi Version](https://img.shields.io/pypi/v/{project_name})](https://pypi.org/project/{project_name}/) +[![PyPI Downloads](https://img.shields.io/pypi/dm/{project_name}?label=downloads)](https://pypi.org/project/{project_name}/) +[![License](https://img.shields.io/github/license/{github_user['login']}/{project_name}.svg)](https://pypi.org/project/{project_name}/) +[![Last Commit](https://img.shields.io/github/last-commit/{github_user['login']}/{project_name}.svg)](https://pypi.org/project/{project_name}/) +[![Development Status](https://img.shields.io/pypi/status/{project_name})](https://pypi.org/project/{project_name}/)""") + + content += (f"""\n\n{description} - **[Telegram channel](https://t.me/crocofactory)** - **[Bug reports](https://github.com/{github_user['login']}/{project_name}/issues)** @@ -245,6 +256,7 @@ def _add_readme( @init.command() @require_github +@check_poetry def package() -> None: """Initialize the package directory""" repo_name = os.path.basename(os.getcwd()) @@ -262,6 +274,7 @@ def package() -> None: @init.command() @require_github +@check_poetry def project() -> None: """Initialize the project directory""" repo_name = os.path.basename(os.getcwd()) diff --git a/croco_cli/cli/install.py b/croco_cli/cli/_install.py similarity index 85% rename from croco_cli/cli/install.py rename to croco_cli/cli/_install.py index 50c0060..9709f76 100644 --- a/croco_cli/cli/install.py +++ b/croco_cli/cli/_install.py @@ -2,12 +2,13 @@ This module contains functions to install Croco Factory packages """ -import os import click from functools import partial -from croco_cli.database import Database -from croco_cli.types import Option, Package, GithubPackage, PackageSet -from croco_cli.utils import show_key_mode, require_github, is_github_package +from croco_cli._database import Database +from croco_cli.tools.keymode import KeyMode +from croco_cli.tools.option import Option +from croco_cli.types import Package, GithubPackage, PackageSet +from croco_cli.utils import require_github, is_github_package, run_poetry_command, check_poetry from croco_cli.globals import PYPI_PACKAGES, GITHUB_PACKAGES, PACKAGE_SETS _DESCRIPTION = "Install Croco Factory packages" @@ -36,7 +37,7 @@ def _install_package( if branch: command += f"@{branch}" - os.system(command) + run_poetry_command(command) def _make_install_option( @@ -81,14 +82,14 @@ def _make_set_install_option( set_map = PACKAGE_SETS[package_set] handlers = [partial(_install_package, package) for package in set_map['packages']] - def handler(): + def set_handler(): for handler in handlers: handler() return Option( name=package_set, description=set_map['description'], - handler=handler + handler=set_handler ) @@ -107,15 +108,6 @@ def _get_options(set_mode: bool) -> list[Option]: return options -def _show_install_screen(set_mode: bool) -> None: - """ - Shows the installation packages screen - :return: None - """ - options = _get_options(set_mode) - show_key_mode(options, _DESCRIPTION) - - @click.command(help=_DESCRIPTION) @click.option( '-s', @@ -127,5 +119,8 @@ def _show_install_screen(set_mode: bool) -> None: default=False ) @require_github +@check_poetry def install(set_: bool): - _show_install_screen(set_) + options = _get_options(set_) + keymode = KeyMode(options, _DESCRIPTION) + keymode() diff --git a/croco_cli/cli/_make.py b/croco_cli/cli/_make.py new file mode 100644 index 0000000..7126a25 --- /dev/null +++ b/croco_cli/cli/_make.py @@ -0,0 +1,52 @@ +import click +from croco_cli._database import Database +from croco_cli.croco_echo import CrocoEcho +from croco_cli.utils import constant_case + + +@click.group() +def make(): + """Make some files for project""" + + +@make.command() +@click.argument('path', default='.env', required=False, type=click.Path(dir_okay=True)) +def dotenv(path: str = '.env'): + """Make file with environment variables. Use with python-dotenv""" + database = Database() + + current_wallet = database.get_wallets(current=True)[0] + custom_accounts = database.get_custom_accounts(current=True) + env_variables = database.get_env_variables() + + try: + with open(path, 'w') as file: + if current_wallet: + file.write('# Wallet credential\n') + file.write(f"TEST_PRIVATE_KEY='{current_wallet['private_key']}'\n") + file.write(f"TEST_MNEMONIC='{current_wallet['mnemonic']}'\n") + file.write("\n") + + if env_variables: + file.write('# Environment variables\n') + for env_var in env_variables: + file.write(f"{env_var['key']}='{env_var['value']}'\n") + file.write("\n") + + if custom_accounts: + file.write('# Custom account credentials\n') + for custom_account in custom_accounts: + account = custom_account.pop('account') + custom_account.pop('current') + custom_data = custom_account.pop('data') + for key, value in custom_account.items(): + key = constant_case(f'{account}_{key}') + file.write(f"{key}='{value}'\n") + + for key, value in custom_data.items(): + key = constant_case(f'{account}_{key}') + file.write(f"{key}='{value}'\n") + + file.write('\n') + except (FileNotFoundError, NotADirectoryError): + CrocoEcho.error('All folders in path must exist') diff --git a/croco_cli/cli/reset.py b/croco_cli/cli/_reset.py similarity index 59% rename from croco_cli/cli/reset.py rename to croco_cli/cli/_reset.py index 34e36a9..200d2ae 100644 --- a/croco_cli/cli/reset.py +++ b/croco_cli/cli/_reset.py @@ -1,61 +1,61 @@ import click -from croco_cli.database import Database +from croco_cli._database import Database @click.command() @click.option( '-u', '--user', + 'info', help='Reset all user data', show_default=True, - is_flag=True, + flag_value='user', default=True ) @click.option( - '-g', '--git', + '-g', + 'info', help='Reset GitHub user data', - show_default=True, - is_flag=True, - default=False + flag_value='git', + default=True ) @click.option( - '-w', '--wallets', + '-w', + 'info', help='Reset wallet user data', - show_default=True, - is_flag=True, + flag_value='wallets', default=False ) @click.option( - '-c', '--custom', + '-c', + 'info', help='Reset custom user accounts', - show_default=True, - is_flag=True, + flag_value='custom', default=False ) @click.option( + '--env', '-e', - '--envar', + 'info', help='Reset environment variables accounts', - show_default=True, - is_flag=True, + flag_value='env', default=False ) -def reset(user: bool, git: bool, wallets: bool, custom: bool, envar: bool): +def reset(info: str): """Reset user accounts""" database = Database() - if git or wallets or custom or envar: - if git: + match info: + case 'git': database.github_users.drop_table() - if wallets: + case 'wallets': database.wallets.drop_table() - if custom: + case 'custom': database.custom_accounts.drop_table() - if envar: + case 'envar': database.env_variables.drop_table() - return - elif user: - database.drop_database() + case 'user': + database.drop_database() diff --git a/croco_cli/cli/set.py b/croco_cli/cli/_set.py similarity index 86% rename from croco_cli/cli/set.py rename to croco_cli/cli/_set.py index 43ab767..4b958ef 100644 --- a/croco_cli/cli/set.py +++ b/croco_cli/cli/_set.py @@ -1,9 +1,9 @@ import click from typing import Optional from croco_cli.types import CustomAccount, Wallet -from .user import _show_github, _show_custom_account -from croco_cli.utils import show_wallet, show_detail, constant_case -from croco_cli.database import Database +from croco_cli.utils import constant_case, catch_github_errors, catch_wallet_errors +from croco_cli._database import Database +from croco_cli.croco_echo import CrocoEcho @click.group(name='set') @@ -15,11 +15,13 @@ def _set(): @click.argument('private_key', default=None, type=click.STRING) @click.argument('label', default=None, required=False, type=click.STRING) @click.argument('mnemonic', default=None, required=False, type=click.STRING) +@catch_wallet_errors def wallet(private_key: str, label: Optional[str] = None, mnemonic: Optional[str] = None) -> None: """Set wallet for unit tests using its private key""" database = Database() database.set_wallet(private_key, label, mnemonic) + public_key = database.get_public_key(private_key) current_wallet = Wallet( private_key=private_key, @@ -28,11 +30,12 @@ def wallet(private_key: str, label: Optional[str] = None, mnemonic: Optional[str current=True, label=label ) - show_wallet(current_wallet) + CrocoEcho.wallet(current_wallet) @_set.command() @click.argument('access_token', default=None, required=False, type=click.STRING) +@catch_github_errors def git(access_token: str): """Set GitHub user account, using access token""" database = Database() @@ -41,7 +44,7 @@ def git(access_token: str): access_token = click.prompt('Please enter the access token of new account', hide_input=True) database.set_github_user(access_token) - _show_github() + CrocoEcho.github() @_set.command() @@ -83,7 +86,7 @@ def custom( data=data ) - _show_custom_account(custom_account) + CrocoEcho.custom_account(custom_account) @_set.command() @@ -94,5 +97,5 @@ def envar(key: str, value: str) -> None: database = Database() key = constant_case(key) - database.set_env_variable(key, value) - show_detail(key, value, 0) + database.set_envar(key, value) + CrocoEcho.detail(key, value, 0) diff --git a/croco_cli/cli/_user.py b/croco_cli/cli/_user.py new file mode 100644 index 0000000..d53f52c --- /dev/null +++ b/croco_cli/cli/_user.py @@ -0,0 +1,48 @@ +import click +from croco_cli.croco_echo import CrocoEcho + + +@click.command() +@click.option( + '--git', + '-g', + 'info', + help='Show GitHub user account', + flag_value='git', + default=True +) +@click.option( + '--wallets', + '-w', + 'info', + help='Show wallets of user', + flag_value='wallets', + default=False +) +@click.option( + '--custom', + '-c', + 'info', + help='Show custom accounts of user', + flag_value='custom', + default=False +) +@click.option( + '--env', + '-e', + 'info', + help='Show environment variables of user', + flag_value='env', + default=False +) +def user(info: str) -> None: + """Show user accounts""" + match info: + case 'wallets': + CrocoEcho.wallets() + case 'custom': + CrocoEcho.custom_accounts() + case 'git': + CrocoEcho.github() + case 'env': + CrocoEcho.envars() diff --git a/croco_cli/cli/make.py b/croco_cli/cli/make.py deleted file mode 100644 index d0b789f..0000000 --- a/croco_cli/cli/make.py +++ /dev/null @@ -1,50 +0,0 @@ -import json -import click -from croco_cli.database import Database -from croco_cli.utils import require_wallet, constant_case - - -@click.group() -def make(): - """Make some files for project""" - - -@make.command() -@require_wallet -def dotenv(): - """Make file with environment variables. Use with python-dotenv""" - database = Database() - - wallets = database.get_wallets() - custom_accounts = database.get_custom_accounts(current=True) - env_variables = database.get_env_variables() - - with open('.env', 'w') as file: - if wallets: - current_wallet = next(filter(lambda wallet: wallet['current'], wallets)) - file.write('# Wallet credential\n') - file.write(f"TEST_PRIVATE_KEY='{current_wallet['private_key']}'\n") - file.write(f"TEST_MNEMONIC='{current_wallet['mnemonic']}'\n") - file.write("\n") - - if env_variables: - file.write('# Environment variables\n') - for env_var in env_variables: - file.write(f"{env_var['key']}='{env_var['value']}'\n") - file.write("\n") - - if custom_accounts: - file.write('# Custom account credentials\n') - for custom_account in custom_accounts: - account = custom_account.pop('account') - custom_account.pop('current') - custom_data = json.loads(custom_account.pop('data')) - for key, value in custom_account.items(): - key = constant_case(f'{account}_{key}') - file.write(f"{key}='{value}'\n") - - for key, value in custom_data.items(): - key = constant_case(f'{account}_{key}') - file.write(f"{key}='{value}'\n") - - file.write('\n') diff --git a/croco_cli/cli/user.py b/croco_cli/cli/user.py deleted file mode 100644 index 07db056..0000000 --- a/croco_cli/cli/user.py +++ /dev/null @@ -1,83 +0,0 @@ -import json -import click -from croco_cli.database import Database -from croco_cli.types import CustomAccount -from croco_cli.utils import require_github, show_detail, show_label, hide_value, show_account_dict, show_wallets, echo_error - - -@click.command() -@click.option( - '-g', - '--git', - help='Show GitHub user account', - show_default=True, - is_flag=True, - default=True -) -@click.option( - '-w', - '--wallets', - help='Show wallets of user', - show_default=True, - is_flag=True, - default=False -) -@click.option( - '-c', - '--custom', - help='Show custom accounts of user', - show_default=True, - is_flag=True, - default=False -) -@require_github -def user(git: bool, wallets: bool, custom: bool) -> None: - """Show user accounts""" - if wallets: - show_wallets() - elif custom: - _show_custom_accounts() - elif git: - _show_github() - - -def _show_github() -> None: - """Show GitHub user account""" - database = Database() - - github_user = database.get_github_user() - access_token = hide_value(github_user['access_token'], 10) - show_label('GitHub') - show_detail('Login', github_user["login"]) - show_detail('Email', github_user["email"]) - show_detail('Access token', access_token) - - -def _show_custom_account(custom_account: CustomAccount) -> None: - """Show custom accounts of user""" - custom_data = custom_account.pop('data') - current = custom_account.pop('current') - if isinstance(custom_data, str): - custom_data = json.loads(custom_data) - - label = f'{custom_account.pop("account").capitalize()} (Current)' if current else custom_account.pop('account').capitalize() - show_account_dict(custom_account, label) - - custom_data and show_account_dict(custom_data) - - -def _show_custom_accounts() -> None: - """Show custom accounts of user""" - database = Database() - - if not database.custom_accounts.table_exists(): - echo_error('There are no custom accounts to show') - return - - custom_accounts = database.get_custom_accounts() - if not custom_accounts: - echo_error('There are no custom accounts to show') - return - - for custom_account in custom_accounts: - _show_custom_account(custom_account) diff --git a/croco_cli/croco_echo.py b/croco_cli/croco_echo.py new file mode 100644 index 0000000..2be6348 --- /dev/null +++ b/croco_cli/croco_echo.py @@ -0,0 +1,123 @@ +from typing import Optional +from ._database import Database +from .tools.echo import Echo +from .types import Wallet, CustomAccount, EnvVar +from croco_cli.utils import hide_value, require_wallet, sort_wallets, require_github + + +class CrocoEcho(Echo): + @classmethod + def wallet(cls, wallet: Wallet) -> None: + """ + Echo details of a wallet on the screen. + + :param wallet: The wallet to display. + :return: None + """ + label = wallet['label'] if wallet['label'] else 'Wallet' + label = f'{label} (Current)' if wallet["current"] else label + + private_key = hide_value(wallet["private_key"], 5, 5) + Echo.label(f'{label}') + cls.detail('Public Key', wallet['public_key']) + cls.detail('Private Key', private_key) + if mnemonic := wallet.get('mnemonic'): + first_word_len = len(mnemonic.split()[0]) + last_word_len = len(mnemonic.split()[-1]) + cls.detail('Mnemonic', hide_value(mnemonic, first_word_len, last_word_len)) + + @classmethod + @require_wallet + def wallets(cls) -> None: + """ + Echo wallets of the user. + Retrieves the wallets from the database, sorts them, and displays details for each wallet on the screen. + + :return: None + """ + database = Database() + + wallets = database.get_wallets() + wallets = sort_wallets(wallets) + for wallet in wallets: + cls.wallet(wallet) + + @classmethod + def account_dict(cls, __dict: dict[str, str], label: Optional[str] = None) -> None: + """ + Echo an account represented as a dictionary on the screen. + + :param __dict: The dictionary representing the account. + :param label: Optional label to display. + :return: None + """ + label and cls.label(f'{label}') + for key, value in __dict.items(): + if 'password' in key or 'cookie' in key: + continue + + if 'token'.lower() in key.lower() or 'secret' in key.lower() or 'private' in key.lower(): + value = hide_value(value, len(value) // 5, len(value) // 5) + + key = ' '.join([word.capitalize() for word in key.replace("_", " ").split()]) + cls.detail(f'{key}', value) + + @classmethod + @require_github + def github(cls) -> None: + """Echo GitHub user account""" + database = Database() + + github_user = database.get_github_user() + + if not github_user: + cls.error('There is no GitHub to show') + return + + access_token = hide_value(github_user['access_token'], 10) + CrocoEcho.label('GitHub') + CrocoEcho.detail('Login', github_user["login"]) + CrocoEcho.detail('Email', github_user["email"]) + CrocoEcho.detail('Access token', access_token) + + @classmethod + def custom_account(cls, custom_account: CustomAccount) -> None: + """Echo custom accounts of user""" + custom_data = custom_account.pop('data') + current = custom_account.pop('current') + + label = f'{custom_account.pop("account").capitalize()} (Current)' if current else custom_account.pop( + 'account').capitalize() + cls.account_dict(custom_account, label) + + custom_data and cls.account_dict(custom_data) + + @classmethod + def custom_accounts(cls) -> None: + """Echo custom accounts of user. Retrieves the accounts from the database""" + database = Database() + + custom_accounts = database.get_custom_accounts() + if not custom_accounts: + cls.error('There are no custom accounts to show') + return + + for custom_account in custom_accounts: + cls.custom_account(custom_account) + + @classmethod + def envar(cls, envar: EnvVar) -> None: + """Echo an environment variable.""" + CrocoEcho.detail(envar['key'], envar['value'], 0) + + @classmethod + def envars(cls) -> None: + database = Database() + + envars = database.get_env_variables() + if not envars: + cls.error('There are no environment variables to show') + return + + for envar in envars: + cls.envar(envar) diff --git a/croco_cli/exceptions.py b/croco_cli/exceptions.py index cd52a48..83c6a5e 100644 --- a/croco_cli/exceptions.py +++ b/croco_cli/exceptions.py @@ -1,3 +1,29 @@ """ This module contains exceptions used by the croco-cli -""" \ No newline at end of file +""" + + +class PoetryNotFoundException(OSError): + """Raised when poetry is not installed""" + + def __init__(self) -> None: + super().__init__( + 'To run this command you have to install poetry. Run "pip install poetry" or "pipx install poetry"' + ) + + +class InvalidToken(ValueError): + """Raised when GitHub access token is invalid""" + + def __init__(self) -> None: + super().__init__( + 'Invalid GitHub access token. Maybe you forgot enable permission of getting email or downloading some of ' + 'private repositories' + ) + + +class InvalidMnemonic(ValueError): + """Raised when mnemonic of a wallet is invalid""" + + def __init__(self) -> None: + super().__init__('Invalid mnemonic. Mnemonic must be related to the private key') \ No newline at end of file diff --git a/croco_cli/tools/__init__.py b/croco_cli/tools/__init__.py new file mode 100644 index 0000000..4f75f6c --- /dev/null +++ b/croco_cli/tools/__init__.py @@ -0,0 +1,7 @@ +""" +Standalone tools to develop CLI +""" + +from .echo import Echo +from .option import Option +from .keymode import KeyMode diff --git a/croco_cli/tools/echo.py b/croco_cli/tools/echo.py new file mode 100644 index 0000000..e5160d3 --- /dev/null +++ b/croco_cli/tools/echo.py @@ -0,0 +1,78 @@ +""" +Class for echoing messages +""" +import click +from typing import Optional, Any, IO + + +class Echo: + @staticmethod + def warning(text: str) -> None: + """ + Echo warning on the screen. + + :param text: The warning message to display. + :return: None + """ + click.echo(click.style(' ! ', bg='yellow'), nl=False) + click.echo(' ', nl=False) + click.echo(click.style(text, fg='yellow')) + + @staticmethod + def error(text: str) -> None: + """ + Echo error on the screen. + + :param text: The error message to display. + :return: None + """ + click.echo(click.style(' x ', bg='red'), nl=False, err=True) + click.echo(' ', nl=False, err=True) + click.echo(click.style(text, fg='red'), err=True) + + @staticmethod + def label(label: str, padding: Optional[int] = 0) -> None: + """ + Echo label on the screen. + + :param label: The label to display. + :param padding: Optional padding for indentation. + :return: None + """ + padding = ' ' * padding + click.echo(click.style(f'{padding}[{label}]', fg='blue', bold=True)) + + @staticmethod + def detail(key: str, value: str, padding: Optional[int] = 1) -> None: + """ + Echo detail on the screen. + + :param key: The detail key. + :param value: The detail value. + :param padding: Optional padding for indentation. + :return: None + """ + padding = ' ' * padding + click.echo(click.style(f'{padding}{key}: ', fg='magenta'), nl=False) + click.echo(click.style(f'{value}', fg='green')) + + @staticmethod + def text( + message: Optional[Any] = None, + file: Optional[IO[Any]] = None, + nl: bool = True, + err: bool = False, + color: Optional[bool] = None + ) -> None: + click.echo(message, file=file, err=err, color=color, nl=nl) + + @staticmethod + def stext( + message: Optional[Any] = None, + file: Optional[IO[bytes | str]] = None, + nl: bool = True, + err: bool = False, + color: Optional[bool] = None, + **styles: Any, + ) -> None: + click.secho(message, file=file, err=err, color=color, nl=nl, **styles) diff --git a/croco_cli/tools/keymode.py b/croco_cli/tools/keymode.py new file mode 100644 index 0000000..823b377 --- /dev/null +++ b/croco_cli/tools/keymode.py @@ -0,0 +1,147 @@ +""" +Class to showing keyboard-interactive mode. +""" +from typing import Optional +import blessed +from .option import Option +from .types import AnyCallable + + +class KeyMode: + def __init__( + self, + options: list[Option], + description: str, + term: blessed.Terminal = blessed.Terminal() + ): + """ + Class to showing keyboard-interactive mode. + + :param options: Options to be shown on screen + :param description: Description of the screen to be shown on screen + :param term: Terminal to be interacted with + """ + self.__options = options + self.__description = description + self.__term = term + + @property + def options(self) -> list[Option]: + """Options to be shown on screen""" + return self.__options.copy() + + @property + def description(self) -> str: + """Description of the screen to be shown on screen""" + return self.__description + + def __call__(self): + """Shows keyboard interaction mode for the given options""" + + term = self.__term + options = self.options + + exit_option = Option( + name='Exit', + description='Return to the term', + handler=term.clear() + ) + + options.append(exit_option) + + current_option = 0 + padded_name_len = max([len(option.name) for option in options]) + 2 + + use_description = True + padded_description_lengths = [] + for option in options: + description = option.get('description', []) + padded_description_lengths.append(len(option.get('description', []))) + if not description: + use_description = False + break + + padded_description_len = None + if use_description: + padded_description_len = max(padded_description_lengths) + 2 + + with term.fullscreen(), term.cbreak(), term.hidden_cursor(): + while True: + print(term.move_yx(0, 0) + term.clear()) + + if self.description: + print(term.bold_green(self.description + '\n')) + + for i, option in enumerate(options): + name = option.name.ljust(padded_name_len) + + if use_description: + description = option.description.ljust(padded_description_len) + option_text = f'{name} | {description}' + else: + option_text = name + + if i == current_option: + print(term.green_reverse(f"> {option_text}")) + else: + print(f" {option_text}") + + key = term.inkey() + + last_option_idx = len(options) - 1 + if key.name == 'KEY_UP': + if current_option > 0: + current_option -= 1 + else: + current_option = last_option_idx + elif key.name == 'KEY_DOWN': + if current_option < last_option_idx: + current_option += 1 + else: + current_option = 0 + elif (key.name in ('KEY_BACKSPACE', 'KEY_DELETE') and + (deleting_handler := options[current_option].get('deleting_handler'))): + deleting_handler() + options.pop(current_option) + + if len(options) > 1: + if current_option > 0: + current_option -= 1 + else: + current_option += 1 + else: + return + elif key == '\n' or key.name == 'KEY_ENTER': + selected_option = options[current_option] + break + + return selected_option.handler() + + @staticmethod + def screen_option( + label: str, + description: Optional[str], + options: list[Option], + deleting_handler: Optional[AnyCallable] = None + ) -> Option: + """ + Returns an option navigating to a new screen. + + :param label: The label for the option. + :param description: The description for the option. + :param options: List of options for the new screen. + :param deleting_handler: Optional handler for deleting the option. + :return: The created Option instance. + """ + + def _handler(): + keymode = KeyMode(options, description) + keymode() + + option = Option( + name=label, + handler=_handler, + deleting_handler=deleting_handler + ) + + return option diff --git a/croco_cli/tools/option.py b/croco_cli/tools/option.py new file mode 100644 index 0000000..75d3760 --- /dev/null +++ b/croco_cli/tools/option.py @@ -0,0 +1,32 @@ +""" +Class for making options to be shown on screen during KeyMode +""" +from typing import Any +from dataclasses import dataclass +from .types import AnyCallable + + +@dataclass(frozen=True) +class Option: + """ + Class for making options to be shown on screen during KeyMode + + :param name: Name of the option + :param description: Description of the option + :param handler: Action to be performed on this option + :param deleting_handler: Action to be performed on the deleting of this option + """ + + name: str + handler: AnyCallable + description: str | None = None + deleting_handler: AnyCallable = lambda: None + + def get(self, attr: str, default: Any = None) -> Any: + """ + Gets an attribute in option + :param attr: Attribute name + :param default: Default value to be returned if the attribute is not found. + :return: The attribute value or default value if the attribute is not found. + """ + return res if (res := getattr(self, attr)) else default diff --git a/croco_cli/tools/types.py b/croco_cli/tools/types.py new file mode 100644 index 0000000..1a5da22 --- /dev/null +++ b/croco_cli/tools/types.py @@ -0,0 +1,3 @@ +from typing import Callable, Any + +AnyCallable = Callable[..., Any] \ No newline at end of file diff --git a/croco_cli/types.py b/croco_cli/types.py index 5d521f3..c429ce6 100644 --- a/croco_cli/types.py +++ b/croco_cli/types.py @@ -14,13 +14,6 @@ ClickCommand = Union[Callable[[Callable[..., Any]], Command], Command] -class Option(TypedDict): - name: str - description: NotRequired[str] - handler: AnyCallable - deleting_handler: NotRequired[AnyCallable] - - class Package(TypedDict): name: str description: str @@ -60,6 +53,6 @@ class CustomAccount(TypedDict): data: NotRequired[dict[str, str]] -class EnvVariable(TypedDict): +class EnvVar(TypedDict): key: str value: str diff --git a/croco_cli/utils.py b/croco_cli/utils.py index 8c29cf4..40ef496 100644 --- a/croco_cli/utils.py +++ b/croco_cli/utils.py @@ -3,13 +3,16 @@ """ import os import re +import subprocess import blessed -import getpass -from typing import Any, Callable, Optional import click -from croco_cli.database import Database -from croco_cli.types import Option, Wallet, Package, GithubPackage, AnyCallable -from functools import partial, wraps +from requests.adapters import ConnectionError +from typing import Callable +from croco_cli._database import Database +from croco_cli.exceptions import PoetryNotFoundException, InvalidToken, InvalidMnemonic +from croco_cli.types import Wallet, Package, GithubPackage +from functools import wraps +from .tools import Echo _term = blessed.Terminal() @@ -44,117 +47,6 @@ def is_github_package(package: Package | GithubPackage) -> bool: return bool(package.get('branch')) -def _show_key_mode( - options: list[Option], - command_description: str, - terminal: blessed.Terminal -) -> Any: - """ - Shouldn't be used directly, instead use show_key_mode - """ - - exit_option = Option( - name='Exit', - description='Return to the terminal', - handler=_term.clear() - ) - - options.append(exit_option) - - current_option = 0 - padded_name_len = max([len(option['name']) for option in options]) + 2 - - use_description = True - padded_description_lengths = [] - for option in options: - description = option.get('description', []) - padded_description_lengths.append(len(option.get('description', []))) - if not description: - use_description = False - break - - padded_description_len = None - if use_description: - padded_description_len = max(padded_description_lengths) + 2 - - with terminal.fullscreen(), terminal.cbreak(), terminal.hidden_cursor(): - while True: - print(terminal.move_yx(0, 0) + _term.clear()) - print(terminal.bold_green(command_description + '\n')) - - for i, option in enumerate(options): - name = option["name"].ljust(padded_name_len) - - if use_description: - description = option['description'].ljust(padded_description_len) - option_text = f'{name} | {description}' - else: - option_text = name - - if i == current_option: - print(_term.green_reverse(f"> {option_text}")) - else: - print(f" {option_text}") - - key = terminal.inkey() - - last_option_idx = len(options) - 1 - if key.name == 'KEY_UP': - if current_option > 0: - current_option -= 1 - else: - current_option = last_option_idx - elif key.name == 'KEY_DOWN': - if current_option < last_option_idx: - current_option += 1 - else: - current_option = 0 - elif key == '\n' or key.name == 'KEY_ENTER': - selected_option = options[current_option] - break - - return selected_option['handler']() - - -def show_key_mode( - options: list[Option], - command_description: str, -) -> None: - """ - Shows keyboard interaction mode for the given options - - :param options: list of options to display on the screen - :param command_description: description of the command - :return: None - """ - handler = partial(_show_key_mode, options, command_description, _term) - handler() - - -def echo_error(text: str) -> None: - """ - Echo error on the screen. - - :param text: The error message to display. - :return: None - """ - click.echo(click.style(' x ', bg='red'), nl=False) - click.echo(' ', nl=False) - click.echo(click.style(text, fg='red')) - - -def echo_warning(text: str) -> None: - """ - Echo warning on the screen. - - :param text: The warning message to display. - :return: None - """ - click.echo(click.style(' ! ', bg='yellow'), nl=False) - click.echo(' ', nl=False) - click.echo(click.style(text, fg='yellow')) - - def require_github(func: Callable): """ Decorator to require a GitHub API token in order to run the command. @@ -165,13 +57,15 @@ def require_github(func: Callable): database = Database() @wraps(func) + @catch_github_errors def wrapper(*args, **kwargs): - if not database.github_users.table_exists(): - env_token = os.environ.get("GITHUB_ACCESS_TOKEN") + if not database.get_github_user(): + env_token = os.environ.get("CROCO_GIT_TOKEN") + if env_token: database.set_github_user(env_token) else: - echo_warning('GitHub access token is missing. Set it to continue') + Echo.warning('GitHub access token is missing. Set it to continue') token = click.prompt('', hide_input=True) database.set_github_user(token) @@ -192,12 +86,12 @@ def require_wallet(func: Callable): @wraps(func) def wrapper(*args, **kwargs): if not database.wallets.table_exists(): - env_token = os.environ.get("TEST_PRIVATE_KEY") + env_token = os.environ.get("CROCO_WALLET_KEY") if env_token: database.set_wallet(env_token) return func(*args, **kwargs) else: - echo_warning('Wallet private key is missing. Set it to continue (croco set wallet).') + Echo.warning('Wallet private key is missing. Set it to continue (croco set wallet).') else: return func(*args, **kwargs) @@ -230,56 +124,6 @@ def sort_by_key(item: Wallet) -> str: return wallets -def get_cache_folder() -> str: - """ - Get the cache folder path based on the operating system. - - :return: Cache folder path. - """ - username = getpass.getuser() - os_name = os.name - - if os_name == "posix": - cache_path = f'/Users/{username}/.cache/croco_cli' - elif os_name == "nt": - cache_path = f'C:\\Users\\{username}\\AppData\\Local\\croco_cli' - else: - raise OSError(f"Unsupported Operating System {os_name}") - - try: - os.chdir(cache_path) - except FileNotFoundError: - os.mkdir(cache_path) - - return cache_path - - -def show_label(label: str, padding: Optional[int] = 0) -> None: - """ - Echo label on the screen. - - :param label: The label to display. - :param padding: Optional padding for indentation. - :return: None - """ - padding = ' ' * padding - click.echo(click.style(f'{padding}[{label}]', fg='blue', bold=True)) - - -def show_detail(key: str, value: str, padding: Optional[int] = 1) -> None: - """ - Echo detail on the screen. - - :param key: The detail key. - :param value: The detail value. - :param padding: Optional padding for indentation. - :return: None - """ - padding = ' ' * padding - click.echo(click.style(f'{padding}{key}: ', fg='magenta'), nl=False) - click.echo(click.style(f'{value}', fg='green')) - - def hide_value(value: str, begin_part: int, end_part: int = 8) -> str: """ Hide part of the value, replacing it with *. @@ -293,95 +137,59 @@ def hide_value(value: str, begin_part: int, end_part: int = 8) -> str: return value -def show_account_dict(__dict: dict[str, str], label: Optional[str] = None) -> None: - """ - Echo an account represented as a dictionary on the screen. - - :param __dict: The dictionary representing the account. - :param label: Optional label to display. - :return: None - """ - label and show_label(f'{label}') - for key, value in __dict.items(): - if 'password' in key or 'cookie' in key: - continue - - if 'token'.lower() in key.lower() or 'secret' in key.lower() or 'private' in key.lower(): - value = hide_value(value, len(value) // 5, len(value) // 5) - - key = ' '.join([word.capitalize() for word in key.replace("_", " ").split()]) - show_detail(f'{key}', value) - - -def make_screen_option( - label: str, - description: Optional[str], - options: list[Option], - deleting_handler: Optional[AnyCallable] = None -) -> Option: - """ - Returns an option navigating to a new screen. - - :param label: The label for the option. - :param description: The description for the option. - :param options: List of options for the new screen. - :param deleting_handler: Optional handler for deleting the option. - :return: The created Option instance. - """ - def _handler(): - show_key_mode(options, description) - - option = Option( - name=label, - handler=_handler, - deleting_handler=deleting_handler - ) - - return option +def get_poetry_version() -> str: + result = subprocess.run('poetry --version', shell=True, capture_output=True, text=True) + if result.returncode != 0 or 'is not recognized' in result.stderr or 'is not recognized' in result.stdout: + raise PoetryNotFoundException + else: + version = result.stdout.split('version')[1].split(')')[0].strip() + return version -def get_back_option( - options: list[Option] -): - """ - Returns an option navigating to the previous screen. +def check_poetry(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + try: + get_poetry_version() + except PoetryNotFoundException as ex: + Echo.error(str(ex)) + else: + return func(*args, **kwargs) - :param options: List of options for the previous screen. - :return: The created Option instance for going back. - """ - return make_screen_option('Back', f'Return to the previous screen', options) + return wrapper -def show_wallet(wallet: Wallet) -> None: - """ - Echo details of a wallet on the screen. +def catch_github_errors(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + try: + result = func(*args, **kwargs) + except InvalidToken as err: + Echo.error(str(err)) + return + except ConnectionError: + Echo.error('Unable to connect to GitHub account') + return + else: + return result - :param wallet: The wallet to display. - :return: None - """ - label = f'{wallet["label"]} (Current)' if wallet["current"] else wallet['label'] + return wrapper - private_key = hide_value(wallet["private_key"], 5, 5) - show_label(f'{label}') - show_detail('Public Key', wallet['public_key']) - show_detail('Private Key', private_key) - if mnemonic := wallet.get('mnemonic'): - first_word_len = len(mnemonic.split()[0]) - last_word_len = len(mnemonic.split()[-1]) - show_detail('Mnemonic', hide_value(mnemonic, first_word_len, last_word_len)) +def catch_wallet_errors(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + try: + result = func(*args, **kwargs) + except InvalidMnemonic as err: + Echo.error(str(err)) + return + else: + return result -@require_wallet -def show_wallets() -> None: - """ - Echo wallets of the user. - Retrieves the wallets from the database, sorts them, and displays details for each wallet on the screen. + return wrapper - :return: None - """ - database = Database() - wallets = database.get_wallets() - wallets = sort_wallets(wallets) - for wallet in wallets: - show_wallet(wallet) +@check_poetry +def run_poetry_command(command: str) -> None: + os.system(command) diff --git a/pyproject.toml b/pyproject.toml index 37bad60..db678e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = 'croco_cli' -version = '0.2.1' +version = '0.3.0' description = 'The CLI for developing Web3-based projects in Croco Factory' authors = ['Alexey '] license = 'MIT'