From 1c36cbdb23990146946a3be345bc01ca2d7605ec Mon Sep 17 00:00:00 2001 From: Niv vaknin <122722245+nivcertora@users.noreply.github.com> Date: Wed, 8 Jan 2025 11:43:00 +0200 Subject: [PATCH] Restore CLI cmd and Refactor Quorum configuration management (#72) * Resotre CLI cmd and Refactor Quorum configuration managment * Clean leftover * Add setter for tests --- Quorum/apis/git_api/git_manager.py | 7 +- Quorum/checks/check.py | 4 +- Quorum/checks/new_listing.py | 1 - Quorum/checks/proposal_check.py | 4 +- .../implementations/ipfs_validator.py | 4 +- Quorum/entry_points/quorum_cli.py | 62 +++---- Quorum/llm/chains/cached_llm.py | 6 +- Quorum/tests/conftest.py | 13 +- Quorum/utils/config.py | 27 --- Quorum/utils/config_loader.py | 53 ------ Quorum/utils/quorum_configuration.py | 172 ++++++++++++++++++ version | 2 +- 12 files changed, 217 insertions(+), 138 deletions(-) delete mode 100644 Quorum/utils/config.py delete mode 100644 Quorum/utils/config_loader.py create mode 100644 Quorum/utils/quorum_configuration.py diff --git a/Quorum/apis/git_api/git_manager.py b/Quorum/apis/git_api/git_manager.py index 9e21d61..29e7e37 100644 --- a/Quorum/apis/git_api/git_manager.py +++ b/Quorum/apis/git_api/git_manager.py @@ -1,7 +1,7 @@ from pathlib import Path from git import Repo -import Quorum.utils.config as config +from Quorum.utils.quorum_configuration import QuorumConfiguration import Quorum.utils.pretty_printer as pp class GitManager: @@ -22,11 +22,12 @@ def __init__(self, customer: str, gt_config: dict[str, any]) -> None: gt_config (dict[str, any]): The ground truth configuration data. """ self.customer = customer + self.config = QuorumConfiguration() - self.modules_path = config.MAIN_PATH / self.customer / "modules" + self.modules_path = self.config.main_path / self.customer / "modules" self.modules_path.mkdir(parents=True, exist_ok=True) - self.review_module_path = config.MAIN_PATH / self.customer / "review_module" + self.review_module_path = self.config.main_path / self.customer / "review_module" self.review_module_path.mkdir(parents=True, exist_ok=True) self.repos, self.review_repo = self._load_repos_from_file(gt_config) diff --git a/Quorum/checks/check.py b/Quorum/checks/check.py index 4eb4504..582853d 100644 --- a/Quorum/checks/check.py +++ b/Quorum/checks/check.py @@ -3,7 +3,7 @@ import json5 as json from pathlib import Path -import Quorum.utils.config as config +from Quorum.utils.quorum_configuration import QuorumConfiguration from Quorum.apis.block_explorers.source_code import SourceCode from Quorum.utils.chain_enum import Chain @@ -14,7 +14,7 @@ def __init__(self, customer: str, chain: Chain, proposal_address: str, source_co self.chain = chain self.proposal_address = proposal_address self.source_codes = source_codes - self.customer_folder = config.MAIN_PATH / customer + self.customer_folder = QuorumConfiguration().main_path / customer self.check_folder = self.customer_folder / "checks" / chain / proposal_address / f"{self.__class__.__name__}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" self.check_folder.mkdir(parents=True, exist_ok=True) diff --git a/Quorum/checks/new_listing.py b/Quorum/checks/new_listing.py index faa7e8a..0e55650 100644 --- a/Quorum/checks/new_listing.py +++ b/Quorum/checks/new_listing.py @@ -1,6 +1,5 @@ from Quorum.checks.check import Check import Quorum.utils.pretty_printer as pp -import Quorum.utils.config as config from Quorum.llm.chains.first_deposit_chain import FirstDepositChain, ListingArray diff --git a/Quorum/checks/proposal_check.py b/Quorum/checks/proposal_check.py index 298b556..654b6aa 100644 --- a/Quorum/checks/proposal_check.py +++ b/Quorum/checks/proposal_check.py @@ -7,7 +7,7 @@ from Quorum.apis.price_feeds.price_feed_utils import PriceFeedProviderBase from Quorum.apis.governance.data_models import PayloadAddresses from Quorum.apis.git_api.git_manager import GitManager -import Quorum.utils.config_loader as ConfigLoader +from Quorum.utils.quorum_configuration import QuorumConfiguration class CustomerConfig(BaseModel): @@ -49,7 +49,7 @@ def run_customer_proposal_validation(prop_config: ProposalConfig) -> None: """ for config in prop_config.customers_config: pp.pprint('Run Preparation', pp.Colors.INFO, pp.Heading.HEADING_1) - ground_truth_config = ConfigLoader.load_customer_config(config.customer) + ground_truth_config = QuorumConfiguration().load_customer_config(config.customer) git_manager = GitManager(config.customer, ground_truth_config) git_manager.clone_or_update() price_feed_providers = ground_truth_config.get("price_feed_providers", []) diff --git a/Quorum/entry_points/implementations/ipfs_validator.py b/Quorum/entry_points/implementations/ipfs_validator.py index 7a7d4b6..cea11dd 100644 --- a/Quorum/entry_points/implementations/ipfs_validator.py +++ b/Quorum/entry_points/implementations/ipfs_validator.py @@ -4,7 +4,7 @@ from Quorum.apis.block_explorers.chains_api import ChainAPI from Quorum.llm.chains.ipfs_validation_chain import IPFSValidationChain -import Quorum.utils.config as config +from Quorum.utils.quorum_configuration import QuorumConfiguration import Quorum.utils.pretty_printer as pp @@ -54,7 +54,7 @@ def run_ipfs_validator(args: argparse.Namespace): run_ipfs_validator(args) """ # Check if the Anthropic API key is set in environment variables - if not config.ANTHROPIC_API_KEY: + if not QuorumConfiguration().anthropic_api_key: raise ValueError("ANTHROPIC_API_KEY environment variable is not set. Please set it to use this functionality.") # Initialize Chain API and fetch source codes diff --git a/Quorum/entry_points/quorum_cli.py b/Quorum/entry_points/quorum_cli.py index 13a3232..cd7bb65 100644 --- a/Quorum/entry_points/quorum_cli.py +++ b/Quorum/entry_points/quorum_cli.py @@ -1,13 +1,23 @@ +# Quorum/entry_points/quorum_cli.py + import argparse from pydantic import BaseModel +from typing import Callable import Quorum.entry_points.cli_arguments as cli_args +from Quorum.entry_points.implementations.check_proposal import run_single +from Quorum.entry_points.implementations.check_proposal_config import run_config +from Quorum.entry_points.implementations.check_proposal_id import run_proposal_id +from Quorum.entry_points.implementations.create_report import run_create_report +from Quorum.entry_points.implementations.ipfs_validator import run_ipfs_validator +from Quorum.entry_points.implementations.setup_quorum import run_setup_quorum class Command(BaseModel): name: str help: str arguments: list[cli_args.Argument] + func: Callable[[argparse.Namespace], None] COMMAND_REGISTRY = [ @@ -18,12 +28,14 @@ class Command(BaseModel): cli_args.CUSTOMER_ARGUMENT, cli_args.CHAIN_ARGUMENT, cli_args.PROPOSAL_ADDRESS_ARGUMENT - ] + ], + func=run_single ), Command( name="validate-batch", help="Run a batch check from a JSON config file.", - arguments=[cli_args.CONFIG_ARGUMENT] + arguments=[cli_args.CONFIG_ARGUMENT], + func=run_config ), Command( name="validate-by-id", @@ -31,7 +43,8 @@ class Command(BaseModel): arguments=[ cli_args.CUSTOMER_ARGUMENT, cli_args.PROPOSAL_ID_ARGUMENT - ] + ], + func=run_proposal_id ), Command( name="create-report", @@ -40,7 +53,8 @@ class Command(BaseModel): cli_args.PROPOSAL_ID_ARGUMENT, cli_args.TEMPLATE_ARGUMENT, cli_args.GENERATE_REPORT_PATH_ARGUMENT - ] + ], + func=run_create_report ), Command( name="validate-ipfs", @@ -50,12 +64,14 @@ class Command(BaseModel): cli_args.CHAIN_ARGUMENT, cli_args.PROPOSAL_ADDRESS_ARGUMENT, cli_args.PROMPT_TEMPLATES_ARGUMENT - ] + ], + func=run_ipfs_validator ), Command( name="setup", help="Initial Quorum environment setup.", - arguments=[cli_args.WORKING_DIR_ARGUMENT] + arguments=[cli_args.WORKING_DIR_ARGUMENT], + func=run_setup_quorum ) ] @@ -89,37 +105,7 @@ def main(): help=subcmd.help ) add_arguments(subparser, subcmd.arguments) - - if subcmd.name == "validate-address": - def run(args): - from Quorum.entry_points.implementations.check_proposal import run_single - run_single(args) - subparser.set_defaults(func=run) - elif subcmd.name == "validate-batch": - def run(args): - from Quorum.entry_points.implementations.check_proposal_config import run_config - run_config(args) - subparser.set_defaults(func=run) - elif subcmd.name == "validate-by-id": - def run(args): - from Quorum.entry_points.implementations.check_proposal_id import run_proposal_id - run_proposal_id(args) - subparser.set_defaults(func=run) - elif subcmd.name == "create-report": - def run(args): - from Quorum.entry_points.implementations.create_report import run_create_report - run_create_report(args) - subparser.set_defaults(func=run) - elif subcmd.name == "validate-ipfs": - def run(args): - from Quorum.entry_points.implementations.ipfs_validator import run_ipfs_validator - run_ipfs_validator(args) - subparser.set_defaults(func=run) - elif subcmd.name == "setup": - def run(args): - from Quorum.entry_points.implementations.setup_quorum import run_setup_quorum - run_setup_quorum(args) - subparser.set_defaults(func=run) + subparser.set_defaults(func=subcmd.func) args = parser.parse_args() @@ -128,4 +114,4 @@ def run(args): if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/Quorum/llm/chains/cached_llm.py b/Quorum/llm/chains/cached_llm.py index f2458c7..e0b74a9 100644 --- a/Quorum/llm/chains/cached_llm.py +++ b/Quorum/llm/chains/cached_llm.py @@ -1,6 +1,6 @@ from pathlib import Path -from Quorum.utils.config import ANTHROPIC_MODEL, ANTHROPIC_API_KEY +from Quorum.utils.quorum_configuration import QuorumConfiguration from langchain_anthropic import ChatAnthropic from langchain_community.cache import SQLiteCache @@ -26,9 +26,9 @@ def __init__(self): #Initialize the Anthropic LLM with the specified model and configurations self.llm = ChatAnthropic( - model=ANTHROPIC_MODEL, + model=QuorumConfiguration().anthropic_model, cache=True, max_retries=3, temperature=0.0, - api_key=ANTHROPIC_API_KEY + api_key=QuorumConfiguration().anthropic_api_key ) diff --git a/Quorum/tests/conftest.py b/Quorum/tests/conftest.py index fe8627b..34c74b9 100644 --- a/Quorum/tests/conftest.py +++ b/Quorum/tests/conftest.py @@ -5,7 +5,7 @@ from typing import Generator from Quorum.apis.block_explorers.source_code import SourceCode -import Quorum.utils.config as config +from Quorum.utils.quorum_configuration import QuorumConfiguration RESOURCES_DIR = Path(__file__).parent / 'resources' @@ -24,11 +24,12 @@ def source_codes(request: pytest.FixtureRequest) -> list[SourceCode]: @pytest.fixture def tmp_output_path() -> Generator[Path, None, None]: - og_path = config.MAIN_PATH - config.MAIN_PATH = Path(__file__).parent / 'tmp' - yield config.MAIN_PATH # Provide the temporary path to the test - shutil.rmtree(config.MAIN_PATH) - config.MAIN_PATH = og_path + config = QuorumConfiguration() + og_path = config.main_path + config.main_path = Path(__file__).parent / 'tmp' + yield config.main_path # Provide the temporary path to the test + shutil.rmtree(config.main_path) + config.main_path = og_path @pytest.fixture diff --git a/Quorum/utils/config.py b/Quorum/utils/config.py deleted file mode 100644 index 1910ee5..0000000 --- a/Quorum/utils/config.py +++ /dev/null @@ -1,27 +0,0 @@ -import os -from pathlib import Path - -import Quorum.utils.pretty_printer as pp -from Quorum.utils.load_env import load_env_variables - -load_env_variables() - -main_path = os.getenv("QUORUM_PATH") -if not main_path: - raise ValueError("QUORUM_PATH environment variable not set") - -MAIN_PATH = Path(main_path).absolute() - -if not MAIN_PATH.exists(): - MAIN_PATH.mkdir(parents=True) - -GROUND_TRUTH_PATH = MAIN_PATH / "ground_truth.json" - -ANTHROPIC_API_KEY = os.getenv('ANTHROPIC_API_KEY') -if not ANTHROPIC_API_KEY: - pp.pprint( - "Warning: ANTHROPIC_API_KEY environment variable is not set. All dependent checks will be skipped.", - pp.Colors.WARNING - ) - -ANTHROPIC_MODEL = os.getenv('ANTROPIC_MDOEL', 'claude-3-5-sonnet-20241022') diff --git a/Quorum/utils/config_loader.py b/Quorum/utils/config_loader.py deleted file mode 100644 index d19b6cc..0000000 --- a/Quorum/utils/config_loader.py +++ /dev/null @@ -1,53 +0,0 @@ -import json5 as json -from typing import Dict -import Quorum.utils.config as config -import Quorum.utils.pretty_printer as pp -import Quorum.apis.price_feeds as price_feeds - - -SUPPORTED_PROVIDERS = set(price_feeds.PriceFeedProvider.__members__.values()) - - -def load_customer_config(customer: str) -> Dict[str, any]: - """ - Load the customer ground truth configuration data from the ground truth file, - and validate the price feed providers. - - Args: - customer (str): The name or identifier of the customer. - - Returns: - Dict[str, any]: The customer configuration data. - """ - if not config.GROUND_TRUTH_PATH.exists(): - raise FileNotFoundError(f"Ground truth file not found at {config.GROUND_TRUTH_PATH}") - - with open(config.GROUND_TRUTH_PATH) as f: - config_data = json.load(f) - - customer_config = config_data.get(customer) - if not customer_config: - pp.pprint(f"Customer {customer} not found in ground truth data.", pp.Colors.FAILURE) - raise ValueError(f"Customer {customer} not found in ground truth data.") - price_feed_providers = customer_config.get("price_feed_providers", []) - token_providers = customer_config.get("token_validation_providers", []) - unsupported = set(price_feed_providers).union(token_providers) - SUPPORTED_PROVIDERS - if unsupported: - pp.pprint(f"Unsupported providers for {customer}: {', '.join(unsupported)}", pp.Colors.FAILURE) - price_feed_providers = list(set(price_feed_providers) & SUPPORTED_PROVIDERS) - token_providers = list(set(token_providers) & SUPPORTED_PROVIDERS) - - # Replace the provider names with the actual API objects - for i, provider in enumerate(price_feed_providers): - if provider == price_feeds.PriceFeedProvider.CHAINLINK: - price_feed_providers[i] = price_feeds.ChainLinkAPI() - elif provider == price_feeds.PriceFeedProvider.CHRONICLE: - price_feed_providers[i] = price_feeds.ChronicleAPI() - - for i, provider in enumerate(token_providers): - if provider == price_feeds.PriceFeedProvider.COINGECKO: - token_providers[i] = price_feeds.CoinGeckoAPI() - - customer_config["price_feed_providers"] = price_feed_providers - customer_config["token_validation_providers"] = token_providers - return customer_config diff --git a/Quorum/utils/quorum_configuration.py b/Quorum/utils/quorum_configuration.py new file mode 100644 index 0000000..a0eb0b1 --- /dev/null +++ b/Quorum/utils/quorum_configuration.py @@ -0,0 +1,172 @@ +import os +from pathlib import Path +import json5 as json +from typing import Dict, Any + +from Quorum.utils.load_env import load_env_variables +import Quorum.utils.pretty_printer as pp +from Quorum.utils.singleton import singleton +import Quorum.apis.price_feeds as price_feeds + + +@singleton +class QuorumConfiguration: + def __init__(self): + """ + Initialize the QuorumConfiguration instance. This constructor should do the + minimal amount of work to prepare for lazy loading. For instance: + - Environment variables are loaded immediately + - Other data (like ground truth configs) is loaded only on demand + """ + self.__env_loaded = False + self.__main_path: Path | None = None + self.__ground_truth_path: Path | None = None + self.__anthropic_api_key: str | None = None + self.__anthropic_model: str | None = None + + # This dictionary will cache customer configs after loading them from ground_truth.json + self.__customer_configs: Dict[str, Any] = {} + + # Price feed providers must be validated on-the-fly + self.__supported_providers = set(price_feeds.PriceFeedProvider.__members__.values()) + + # We only load environment variables once + self.__load_env() + + def __load_env(self) -> None: + """ + Load environment variables and set up main paths. + This is called once in __init__ to ensure we have a minimal environment loaded. + """ + if not self.__env_loaded: + # 1. Load .env variables + load_env_variables() + + # 2. Main path + main_path = os.getenv("QUORUM_PATH") + if not main_path: + raise ValueError("QUORUM_PATH environment variable not set") + + self.__main_path = Path(main_path).absolute() + if not self.__main_path.exists(): + self.__main_path.mkdir(parents=True) + self.__ground_truth_path = self.__main_path / "ground_truth.json" + + # 3. Anthropic Key + self.__anthropic_api_key = os.getenv('ANTHROPIC_API_KEY') + if not self.__anthropic_api_key: + pp.pprint( + "Warning: ANTHROPIC_API_KEY environment variable is not set. " + "All dependent checks will be skipped.", + pp.Colors.WARNING + ) + + # 4. Anthropic Model + self.__anthropic_model = os.getenv('ANTROPIC_MODEL', 'claude-3-5-sonnet-20241022') + + self.__env_loaded = True + + @property + def main_path(self) -> Path: + """ + Returns the main path for Quorum, as determined by QUORUM_PATH. + """ + return self.__main_path + + @main_path.setter + def main_path(self, value: Path) -> None: + self.__main_path = value + + @property + def ground_truth_path(self) -> Path: + """ + Returns the ground truth JSON path. + """ + return self.__ground_truth_path + + @property + def anthropic_api_key(self) -> str | None: + """ + Returns the Anthropic API key, or None if not set. + """ + return self.__anthropic_api_key + + @property + def anthropic_model(self) -> str: + """ + Returns the Anthropic Model or the default one if not set. + """ + return self.__anthropic_model + + def load_customer_config(self, customer: str) -> Dict[str, Any]: + """ + Load and validate the configuration data for a given customer. + + If the config for this customer has already been loaded, + return it from the cache (self._customer_configs). + Otherwise, read from ground_truth.json, validate the price feed providers, + and store it in the cache. + + Args: + customer (str): The name or identifier of the customer. + + Returns: + dict[str, any]: The customer configuration data. + + Raises: + FileNotFoundError: If the ground truth file does not exist. + ValueError: If the requested customer is not found or invalid. + """ + # 1. Check if we already loaded this config + if customer in self.__customer_configs: + return self.__customer_configs[customer] + + # 2. Check that ground_truth.json exists + if not self.ground_truth_path.exists(): + raise FileNotFoundError(f"Ground truth file not found at {self.ground_truth_path}") + + # 3. Load the entire ground truth + with open(self.ground_truth_path, 'r') as f: + all_customers_config = json.load(f) + + # 4. Retrieve the config for the specific customer + customer_config = all_customers_config.get(customer) + if not customer_config: + pp.pprint(f"Customer {customer} not found in ground truth data.", pp.Colors.FAILURE) + raise ValueError(f"Customer {customer} not found in ground truth data.") + + # 5. Validate and transform the providers + price_feed_providers = customer_config.get("price_feed_providers", []) + token_providers = customer_config.get("token_validation_providers", []) + + unsupported = set(price_feed_providers).union(token_providers) - self.__supported_providers + if unsupported: + pp.pprint(f"Unsupported providers for {customer}: {', '.join(unsupported)}", pp.Colors.FAILURE) + # Filter out unsupported ones + price_feed_providers = list(set(price_feed_providers) & self.__supported_providers) + token_providers = list(set(token_providers) & self.__supported_providers) + + # 6. Replace provider strings with actual API objects + self._replace_providers_with_objects(price_feed_providers, token_providers) + + customer_config["price_feed_providers"] = price_feed_providers + customer_config["token_validation_providers"] = token_providers + + # 7. Cache it + self.__customer_configs[customer] = customer_config + return customer_config + + def _replace_providers_with_objects(self, price_feed_providers: list, token_providers: list) -> None: + """ + Helper method to replace string provider references with actual API objects. + """ + for i, provider in enumerate(price_feed_providers): + if provider == price_feeds.PriceFeedProvider.CHAINLINK: + price_feed_providers[i] = price_feeds.ChainLinkAPI() + elif provider == price_feeds.PriceFeedProvider.CHRONICLE: + price_feed_providers[i] = price_feeds.ChronicleAPI() + + for i, provider in enumerate(token_providers): + if provider == price_feeds.PriceFeedProvider.COINGECKO: + token_providers[i] = price_feeds.CoinGeckoAPI() + diff --git a/version b/version index 0d1881a..d545ba0 100644 --- a/version +++ b/version @@ -1 +1 @@ -20250108.105422.494484 +20250108.112835.691155