From ad47122943cb37a27554b62b97d7db4715617b5f Mon Sep 17 00:00:00 2001 From: Jacob Elias Date: Wed, 21 Aug 2024 18:47:16 -0500 Subject: [PATCH] feat: add op-conductor-ops script with updated readme and example config file (#44) --- op-conductor-ops/README.md | 46 ++++ op-conductor-ops/config.py | 29 +++ op-conductor-ops/example.config.toml | 62 +++++ op-conductor-ops/network.py | 29 +++ op-conductor-ops/op-conductor-ops | 3 + op-conductor-ops/op-conductor-ops.py | 326 +++++++++++++++++++++++++++ op-conductor-ops/poetry.lock | 307 +++++++++++++++++++++++++ op-conductor-ops/pyproject.toml | 17 ++ op-conductor-ops/sequencer.py | 105 +++++++++ op-conductor-ops/utils.py | 26 +++ 10 files changed, 950 insertions(+) create mode 100644 op-conductor-ops/README.md create mode 100644 op-conductor-ops/config.py create mode 100644 op-conductor-ops/example.config.toml create mode 100644 op-conductor-ops/network.py create mode 100755 op-conductor-ops/op-conductor-ops create mode 100755 op-conductor-ops/op-conductor-ops.py create mode 100644 op-conductor-ops/poetry.lock create mode 100644 op-conductor-ops/pyproject.toml create mode 100644 op-conductor-ops/sequencer.py create mode 100644 op-conductor-ops/utils.py diff --git a/op-conductor-ops/README.md b/op-conductor-ops/README.md new file mode 100644 index 0000000..100f2b8 --- /dev/null +++ b/op-conductor-ops/README.md @@ -0,0 +1,46 @@ +# op-conductor-ops + +op-conductor-ops is a CLI tool for managing op-conductor sequencer clusters. + +**WARNING!!! This tool can cause a network outage if used improperly. Please consult #pod-devinfra before using.** + +## Setup + +Requires [poetry](https://github.com/python-poetry/poetry). + +Install python dependencies with `poetry install`. + +## Usage + +After installing dependencies with `poetry`, the tool can be invoked with `./op-conductor-ops`, +which just calls `poetry run python main.py` and passes on any arguments. + +### Example Usage + +* Example usage with implicit config file with lookup at ./config.toml +```./op-conductor-ops status ``` + +* Usage with explicit path to config and certificate +```./op-conductor-ops -c .//config.toml --cert .//cacert.pem ``` + +## Example Configuration File: example.config.toml + +This configuration file is used to set up the networks and sequencers for your application. + +### Structure + +The configuration file is divided into two main sections: + +1. **Networks**: This section defines the networks that your application will use. There is an example network configuration (`op-network-1`) and a blank network configuration (`op-network-N`) for you to fill out. + +2. **Sequencers**: This section defines the sequencers for each network. Again, there is an example sequencer configuration for `op-network-1` and a blank sequencer configuration for `op-network-N`. + +Is is recommended to update the network name and sequencer names for your specifc configuration in the toml object declaration + +### Config Usage + +1. Copy this file to `config.toml` in your application's root directory. +2. Modify the example configurations or fill out the blank configurations as needed for your application. +3. Save the `config.toml` file and use it to configure your application's networks and sequencers. + +Remember, the example configurations are provided for your convenience, but you should review and update them to match your specific requirements. diff --git a/op-conductor-ops/config.py b/op-conductor-ops/config.py new file mode 100644 index 0000000..7affb1e --- /dev/null +++ b/op-conductor-ops/config.py @@ -0,0 +1,29 @@ +from network import Network +from sequencer import Sequencer +import toml + + +def read_config(config_path: str) -> tuple[dict[str, Sequencer], str]: + config = toml.load(config_path) + + cert_path = config.get('cert_path', "") + + # load sequencers into a map + sequencers = {} + for name, seq_config in config['sequencers'].items(): + sequencers[name] = Sequencer( + sequencer_id=name, + raft_addr=seq_config['raft_addr'], + conductor_rpc_url=seq_config['conductor_rpc_url'], + node_rpc_url=seq_config['node_rpc_url'], + voting=seq_config['voting'] + ) + + # Initialize network, with list of sequencers + networks = {} + for network_name, network_config in config['networks'].items(): + network_sequencers = [sequencers[seq_name] + for seq_name in network_config['sequencers']] + networks[network_name] = Network(network_name, network_sequencers) + + return networks, cert_path diff --git a/op-conductor-ops/example.config.toml b/op-conductor-ops/example.config.toml new file mode 100644 index 0000000..1754ca2 --- /dev/null +++ b/op-conductor-ops/example.config.toml @@ -0,0 +1,62 @@ +# Path to the SSL/TLS certificate file +cert_path = "./cacert.pem" + +# Network configurations +[networks] + +# Example network configuration +[networks.op-network-1] +sequencers = [ + "op-network-1-sequencer-0", + "op-network-1-sequencer-1", + "op-network-1-sequencer-2", +] + +# Blank network configuration +[networks.op-network-N] +sequencers = [ + "op-network-N-sequencer-0", + "op-network-N-sequencer-1", + "op-network-N-sequencer-2", +] + +# Sequencer configurations +[sequencers] + +# Example sequencer configuration for op-network-1 with three sequencers +[sequencers.op-network-1-sequencer-0] +raft_addr = "op-network-1-sequencer-0-op-conductor-raft:50050" +conductor_rpc_url = "https://op-network-1-sequencer-0-op-conductor" +node_rpc_url = "https://op-network-1-sequencer-0-op-node" +voting = true + +[sequencers.op-network-1-sequencer-1] +raft_addr = "op-network-1-sequencer-1-op-conductor-raft.50050" +conductor_rpc_url = "https://op-network-1-sequencer-1-op-conductor" +node_rpc_url = "https://op-network-1-sequencer-1-op-node" +voting = false + +[sequencers.op-network-1-sequencer-2] +raft_addr = "op-network-1-sequencer-2-op-conductor-raft:50050" +conductor_rpc_url = "https://op-network-1-sequencer-2-op-conductor" +node_rpc_url = "https://op-network-1-sequencer-2-op-node" +voting = true + +# Blank sequencer configuration for op-network-N with three blank sequencers +[sequencers.op-network-N-sequencer-0] +raft_addr = "" +conductor_rpc_url = "" +node_rpc_url = "" +voting = true + +[sequencers.op-network-N-sequencer-1] +raft_addr = "" +conductor_rpc_url = "" +node_rpc_url = "" +voting = true + +[sequencers.op-network-N-sequencer-2] +raft_addr = "" +conductor_rpc_url = "" +node_rpc_url = "" +voting = true diff --git a/op-conductor-ops/network.py b/op-conductor-ops/network.py new file mode 100644 index 0000000..4f1a007 --- /dev/null +++ b/op-conductor-ops/network.py @@ -0,0 +1,29 @@ +import concurrent.futures + + +class Network: + def __init__(self, name, sequencers): + self.name = name + self.sequencers = sequencers + + def update(self): + def _update(sequencer): + sequencer.update() + with concurrent.futures.ThreadPoolExecutor() as executor: + list(executor.map(_update, self.sequencers)) + + def get_sequencer_by_id(self, sequencer_id: str): + return next( + ( + sequencer + for sequencer in self.sequencers + if sequencer.sequencer_id == sequencer_id + ), + None, + ) + + def find_conductor_leader(self): + return next( + (sequencer for sequencer in self.sequencers if sequencer.conductor_leader), + None, + ) diff --git a/op-conductor-ops/op-conductor-ops b/op-conductor-ops/op-conductor-ops new file mode 100755 index 0000000..5b0367f --- /dev/null +++ b/op-conductor-ops/op-conductor-ops @@ -0,0 +1,3 @@ +#!/bin/sh + +poetry run python op-conductor-ops.py "${@}" diff --git a/op-conductor-ops/op-conductor-ops.py b/op-conductor-ops/op-conductor-ops.py new file mode 100755 index 0000000..db50a56 --- /dev/null +++ b/op-conductor-ops/op-conductor-ops.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python + +import os +import requests +from rich.console import Console +from rich.table import Table +import typer +from typing_extensions import Annotated + +from config import read_config +from utils import make_rpc_payload, print_boolean, print_warn, print_error + + +app = typer.Typer( + help="CLI for managing OP Conductor sequencers. WARNING: This tool can cause a network outage if used improperly. Please consult #pod-devinfra before using." +) + +console = Console() + + +@app.callback() +def load_config( + cert: Annotated[str, typer.Option( + "--cert", + help="[Optional] Certificate file path for https. Takes precedece over cert_path config", + envvar="CONDUCTOR_CERT", + )] = "", + config_path: Annotated[str, typer.Option( + "--config", "-c", + help="Path to config file.", + envvar="CONDUCTOR_CONFIG", + )] = "./config.toml", +): + networks, config_cert_path = read_config(config_path) + global NETWORKS + NETWORKS = networks + + # Use the cert path from the command line if provided, + # otherwise use the one from the config + # Export the certificate for https connections + cert_path = cert or config_cert_path + if cert_path: + os.environ["REQUESTS_CA_BUNDLE"] = cert_path + os.environ["SSL_CERT_FILE"] = cert_path + + +def get_network(network: str): + if network not in NETWORKS: + typer.echo(f"Network must be one of {', '.join(NETWORKS.keys())}") + raise typer.Exit(code=1) + network_obj = NETWORKS[network] + network_obj.update() + return network_obj + + +@app.command() +def status(network: str): + """Print the status of all sequencers in a network.""" + network_obj = get_network(network) + sequencers = network_obj.sequencers + table = Table( + "Sequencer ID", + "Conductor Active", + "Sequencer Healthy", + "Conductor Leader", + "Active Sequencer", + "Unsafe Number", + "Unsafe Hash", + ) + for sequencer in sequencers: + table.add_row( + sequencer.sequencer_id, + print_boolean(sequencer.conductor_active), + print_boolean(sequencer.sequencer_healthy), + print_boolean(sequencer.conductor_leader), + print_boolean(sequencer.sequencer_active), + str(sequencer.unsafe_l2_number), + str(sequencer.unsafe_l2_hash), + ) + console.print(table) + + leader = network_obj.find_conductor_leader() + if leader is None: + print_warn(f"Could not find current leader in network {network}") + else: + display_correction = False + membership = {x["id"]: x for x in leader.cluster_membership()} + for sequencer in sequencers: + if sequencer.sequencer_id in membership: + if ( + int(not sequencer.voting) + != membership[sequencer.sequencer_id]["suffrage"] + ): + print_error( + f": {sequencer.sequencer_id} does not have the correct voting status.") + display_correction = True + else: + print_warn( + f": {sequencer.sequencer_id} is not in the cluster") + display_correction = True + if display_correction: + print_warn( + "Run 'update-cluster-membership' to correct membership issues") + + +@app.command() +def transfer_leader(network: str, sequencer_id: str): + """Transfer leadership to a specific sequencer.""" + network_obj = get_network(network) + + sequencer = network_obj.get_sequencer_by_id(sequencer_id) + if sequencer is None: + print_error( + f"Sequencer ID {sequencer_id} not found in network {network}") + raise typer.Exit(code=1) + if sequencer.voting is False: + print_error(f"Sequencer {sequencer_id} is not a voter") + raise typer.Exit(code=1) + + healthy = sequencer.sequencer_healthy + if not healthy: + print_error(f"Target sequencer {sequencer_id} is not healthy") + raise typer.Exit(code=1) + + leader = network_obj.find_conductor_leader() + if leader is None: + print_error(f"Could not find current leader in network {network}") + raise typer.Exit(code=1) + + resp = requests.post( + leader.conductor_rpc_url, + json=make_rpc_payload( + "conductor_transferLeaderToServer", + params=[sequencer.sequencer_id, sequencer.raft_addr], + ), + ) + resp.raise_for_status() + if "error" in resp.json(): + print_error( + f"Failed to transfer leader to {sequencer_id}: {resp.json()['error']}" + ) + raise typer.Exit(code=1) + + typer.echo(f"Successfully transferred leader to {sequencer_id}") + + +@app.command() +def pause(network: str, sequencer_id: str = None): + """Pause all conductors. + If --sequencer-id is provided, only pause conductor for that sequencer. + """ + network_obj = get_network(network) + sequencers = network_obj.sequencers + + if sequencer_id is not None: + sequencer = network_obj.get_sequencer_by_id(sequencer_id) + if sequencer is None: + print_error( + f"Sequencer ID {sequencer_id} not found in network {network}") + raise typer.Exit(code=1) + sequencers = [sequencer] + + error = False + for sequencer in sequencers: + resp = requests.post( + sequencer.conductor_rpc_url, + json=make_rpc_payload("conductor_pause"), + ) + try: + resp.raise_for_status() + if "error" in resp.json(): + raise Exception(resp.json()["error"]) + typer.echo(f"Successfully paused {sequencer.sequencer_id}") + except Exception as e: + typer.echo(f"Failed to pause {sequencer.sequencer_id}: {e}") + if error: + raise typer.Exit(code=1) + + +@app.command() +def resume(network: str, sequencer_id: str = None): + """Resume all conductors. + If --sequencer-id is provided, only resume conductor for that sequencer. + """ + network_obj = get_network(network) + sequencers = network_obj.sequencers + + if sequencer_id is not None: + sequencer = network_obj.get_sequencer_by_id(sequencer_id) + if sequencer is None: + print_error( + f"sequencer ID {sequencer_id} not found in network {network}") + raise typer.Exit(code=1) + sequencers = [sequencer] + + error = False + for sequencer in sequencers: + resp = requests.post( + sequencer.conductor_rpc_url, + json=make_rpc_payload("conductor_resume"), + ) + try: + resp.raise_for_status() + if "error" in resp.json(): + raise Exception(resp.json()["error"]) + typer.echo(f"Successfully resumed {sequencer.sequencer_id}") + except Exception as e: + print_error(f"Failed to resume {sequencer.sequencer_id}: {e}") + if error: + raise typer.Exit(code=1) + + +@app.command() +def override_leader(network: str, sequencer_id: str): + """Override the conductor_leader response for a sequencer to True. + Note that this does not affect consensus and it should only be used for disaster recovery purposes. + """ + network_obj = get_network(network) + sequencer = network_obj.get_sequencer_by_id(sequencer_id) + if sequencer is None: + print_error( + f"sequencer ID {sequencer_id} not found in network {network}") + raise typer.Exit(code=1) + + resp = requests.post( + sequencer.conductor_rpc_url, + json=make_rpc_payload("conductor_overrideLeader"), + ) + resp.raise_for_status() + if "error" in resp.json(): + print_error( + f"Failed to override conductor leader status for {sequencer_id}: {resp.json()['error']}" + ) + raise typer.Exit(code=1) + + resp = requests.post( + sequencer.node_rpc_url, + json=make_rpc_payload("admin_overrideLeader"), + ) + resp.raise_for_status() + if "error" in resp.json(): + print_error( + f"Failed to override sequencer leader status for {sequencer_id}: {resp.json()['error']}" + ) + raise typer.Exit(code=1) + + typer.echo(f"Successfully overrode leader for {sequencer_id}") + + +@app.command() +def remove_server(network: str, sequencer_id: str): + """Remove a sequencer from the cluster.""" + network_obj = get_network(network) + sequencer = network_obj.get_sequencer_by_id(sequencer_id) + if sequencer is None: + print_error( + f"sequencer ID {sequencer_id} not found in network {network}") + raise typer.Exit(code=1) + + leader = network_obj.find_conductor_leader() + + resp = requests.post( + leader.conductor_rpc_url, + json=make_rpc_payload("conductor_removeServer", + params=[sequencer_id, 0]), + ) + resp.raise_for_status() + if "error" in resp.json(): + print_error(f"Failed to remove {sequencer_id}: {resp.json()['error']}") + raise typer.Exit(code=1) + + typer.echo(f"Successfully removed {sequencer_id}") + + +@app.command() +def update_cluster_membership(network: str): + """Update the cluster membership to match the sequencer configuration.""" + network_obj = get_network(network) + + sequencers = network_obj.sequencers + + leader = network_obj.find_conductor_leader() + if leader is None: + print_error(f"Could not find current leader in network {network}") + raise typer.Exit(code=1) + + membership = {x["id"]: x for x in leader.cluster_membership()} + + error = False + for sequencer in sequencers: + if sequencer.sequencer_id in membership: + if ( + int(not sequencer.voting) + != membership[sequencer.sequencer_id]["suffrage"] + ): + typer.echo( + f"Removing {sequencer.sequencer_id} from cluster to update voting status" + ) + remove_server(network, sequencer.sequencer_id) + method = ( + "conductor_addServerAsVoter" + if sequencer.voting + else "conductor_addServerAsNonvoter" + ) + resp = requests.post( + leader.conductor_rpc_url, + json=make_rpc_payload( + method, + params=[sequencer.sequencer_id, sequencer.raft_addr, 0], + ), + ) + try: + resp.raise_for_status() + if "error" in resp.json(): + raise Exception(resp.json()["error"]) + typer.echo( + f"Successfully added {sequencer.sequencer_id} as {'voter' if sequencer.voting else 'non-voter'}" + ) + except Exception as e: + print_warn(f"Failed to add {sequencer.sequencer_id} as voter: {e}") + if error: + raise typer.Exit(code=1) + + +if __name__ == "__main__": + app() diff --git a/op-conductor-ops/poetry.lock b/op-conductor-ops/poetry.lock new file mode 100644 index 0000000..0d6f82e --- /dev/null +++ b/op-conductor-ops/poetry.lock @@ -0,0 +1,307 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "certifi" +version = "2024.6.2" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"}, + {file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "idna" +version = "3.7" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rich" +version = "13.7.1" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, + {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] + +[[package]] +name = "typer" +version = "0.12.3" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.7" +files = [ + {file = "typer-0.12.3-py3-none-any.whl", hash = "sha256:070d7ca53f785acbccba8e7d28b08dcd88f79f1fbda035ade0aecec71ca5c914"}, + {file = "typer-0.12.3.tar.gz", hash = "sha256:49e73131481d804288ef62598d97a1ceef3058905aa536a1134f90891ba35482"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "urllib3" +version = "2.2.2" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, + {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "b56021bf3dca52508e895f8746030fedc8d5d4a7b5e91cd9e25874ed1970f580" diff --git a/op-conductor-ops/pyproject.toml b/op-conductor-ops/pyproject.toml new file mode 100644 index 0000000..826e394 --- /dev/null +++ b/op-conductor-ops/pyproject.toml @@ -0,0 +1,17 @@ +[tool.poetry] +name = "op-conductor-ops" +description = "CLI tool for managing op-conductor sequencer clusters" +authors = ["Zach Howard "] +license = "MIT" +readme = "README.md" +package-mode = false + +[tool.poetry.dependencies] +python = "^3.12" +typer = "^0.12.3" +requests = "^2.31.0" +toml = "^0.10.2" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/op-conductor-ops/sequencer.py b/op-conductor-ops/sequencer.py new file mode 100644 index 0000000..a0f0dfb --- /dev/null +++ b/op-conductor-ops/sequencer.py @@ -0,0 +1,105 @@ +import concurrent.futures + +import requests +import typer + +from utils import make_rpc_payload + + +class Sequencer: + def __init__( + self, sequencer_id, raft_addr, conductor_rpc_url, node_rpc_url, voting + ): + self.sequencer_id = sequencer_id + self.raft_addr = raft_addr + self.conductor_rpc_url = conductor_rpc_url + self.node_rpc_url = node_rpc_url + self.voting = voting + self.conductor_active = None + self.conductor_leader = None + self.sequencer_healthy = None + self.sequencer_active = None + self.unsafe_l2_hash = None + self.unsafe_l2_number = None + + def _get_sequencer_active(self): + resp = requests.post( + self.node_rpc_url, + json=make_rpc_payload("admin_sequencerActive"), + ) + try: + resp.raise_for_status() + except Exception as e: + return None + self.sequencer_active = resp.json()["result"] + + def _get_sequencer_healthy(self): + resp = requests.post( + self.conductor_rpc_url, + json=make_rpc_payload("conductor_sequencerHealthy"), + ) + try: + resp.raise_for_status() + except Exception as e: + return None + self.sequencer_healthy = resp.json()["result"] + + def _get_conductor_active(self): + resp = requests.post( + self.conductor_rpc_url, + json=make_rpc_payload("conductor_active"), + ) + try: + resp.raise_for_status() + except Exception as e: + return None + self.conductor_active = resp.json()["result"] + + def _get_conductor_leader(self): + resp = requests.post( + self.conductor_rpc_url, + json=make_rpc_payload("conductor_leader"), + ) + try: + resp.raise_for_status() + except Exception as e: + return None + self.conductor_leader = resp.json()["result"] + + def _get_unsafe_l2(self): + resp = requests.post( + self.node_rpc_url, + json=make_rpc_payload("optimism_syncStatus"), + ) + try: + resp.raise_for_status() + except Exception as e: + return None + result = resp.json()["result"] + self.unsafe_l2_number = result["unsafe_l2"]["number"] + self.unsafe_l2_hash = result["unsafe_l2"]["hash"] + + def cluster_membership(self): + resp = requests.post( + self.conductor_rpc_url, + json=make_rpc_payload("conductor_clusterMembership"), + ) + resp.raise_for_status() + return resp.json()["result"]["servers"] + + def update(self): + functions = [ + self._get_conductor_active, + self._get_conductor_leader, + self._get_sequencer_healthy, + self._get_sequencer_active, + self._get_unsafe_l2, + ] + with concurrent.futures.ThreadPoolExecutor() as executor: + futures = {executor.submit(func): func for func in functions} + for future in concurrent.futures.as_completed(futures): + func = futures[future] + try: + result = future.result() + except Exception as e: + typer.echo(f"{func.__name__} raised an exception: {e}") diff --git a/op-conductor-ops/utils.py b/op-conductor-ops/utils.py new file mode 100644 index 0000000..c1447f6 --- /dev/null +++ b/op-conductor-ops/utils.py @@ -0,0 +1,26 @@ +from rich import print + + +def print_boolean(value): + if value is None: + return "❓" + return "✅" if value else "❌" + + +def make_rpc_payload(method: str, params: list = None): + if params is None: + params = [] + return { + "id": 1, + "jsonrpc": "2.0", + "method": method, + "params": params, + } + + +def print_error(msg: str): + print(f"[bold red]ERROR![/bold red] {msg}") + + +def print_warn(msg: str): + print(f"[bold yellow]WARNING![/bold yellow] {msg}")