From 2b251e30a861e5b591016c74731c73464f2af990 Mon Sep 17 00:00:00 2001 From: Daud Ahmed Date: Sat, 12 Oct 2024 19:30:09 +0500 Subject: [PATCH 1/4] Bump rdkit version (#1304) (#1308) * replace deprecated rdkit-pypi with rdkit package * pin rdkit version to 2023.9.1 Co-authored-by: Daud Ahmed --- ersilia/setup/requirements/compound.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ersilia/setup/requirements/compound.py b/ersilia/setup/requirements/compound.py index 5e6733578..6505e1364 100644 --- a/ersilia/setup/requirements/compound.py +++ b/ersilia/setup/requirements/compound.py @@ -11,7 +11,7 @@ def __init__(self): self.install() def install(self): - run_command("python -m pip install rdkit-pypi") + run_command("python -m pip install rdkit==2023.9.1") class ChemblWebResourceClientRequirement(object): From c5b2651341a434503520da5e69604cfbb0a6fab1 Mon Sep 17 00:00:00 2001 From: Jubril Adeyi Date: Sun, 13 Oct 2024 08:47:12 +0100 Subject: [PATCH 2/4] Print ersilia catalog in a table natively (#1292) * as_table function added to the catalogtable class to tabulate catalog output * added None value handling for the data values * set table borders and seperators and padding as constants * added a helper function(method) to to handle seperator line generation for as_table method * re-added the as_json method to the CatalogTable class * added the --as-table flag to output catalog in table format when specified * added an enum class(TableConstants) to manage table formatting constants and simplify imports in catalog.py * added docstring for the as_table method * catalog format selection logic refactored --- ersilia/cli/commands/catalog.py | 15 +++- ersilia/default.py | 17 ++++ ersilia/hub/content/catalog.py | 136 +++++++++++++++++++++++++++++++- 3 files changed, 160 insertions(+), 8 deletions(-) diff --git a/ersilia/cli/commands/catalog.py b/ersilia/cli/commands/catalog.py index 25d64bb20..e20e14824 100644 --- a/ersilia/cli/commands/catalog.py +++ b/ersilia/cli/commands/catalog.py @@ -30,7 +30,7 @@ def catalog_cmd(): help="Show more information than just the EOS identifier", ) @click.option( - "--card", + "--card", is_flag=True, default=False, help="Use this flag to display model card for a given model ID", @@ -40,9 +40,15 @@ def catalog_cmd(): type=click.STRING, required=False, ) + @click.option( + "--as-table", + is_flag=True, + default=False, + help="Show catalog in table format", + ) def catalog( - local=False, file_name=None, browser=False, more=False, card=False, model=None - ): + local=False, file_name=None, browser=False, more=False, card=False, model=None, as_table=False + ): if card and not model: click.echo( click.style("Error: --card option requires a model ID", fg="red"), @@ -80,6 +86,7 @@ def catalog( mc.airtable() return catalog_table = mc.local() if local else mc.hub() + if local and not catalog_table.data: click.echo( click.style( @@ -89,7 +96,7 @@ def catalog( ) return if file_name is None: - catalog = catalog_table.as_json() + catalog = catalog_table.as_table() if as_table else catalog_table.as_json() else: catalog_table.write(file_name) catalog = None diff --git a/ersilia/default.py b/ersilia/default.py index a4e3b3512..1a3fafd0e 100644 --- a/ersilia/default.py +++ b/ersilia/default.py @@ -1,6 +1,7 @@ from pathlib import Path import shutil import os +from enum import Enum # EOS environmental variables EOS = os.path.join(str(Path.home()), "eos") @@ -104,6 +105,22 @@ os.path.join(ROOT, "utils", "supp", _resolve_script), resolve_script ) +# Catalog table border constants +class TableConstants(str, Enum): + TOP_LEFT = "┌" + TOP_MIDDLE = "┬" + TOP_RIGHT = "┐" + HORIZONTAL = "─" + VERTICAL = "│" + MIDDLE_LEFT = "├" + MIDDLE_MIDDLE = "┼" + MIDDLE_RIGHT = "┤" + BOTTOM_LEFT = "└" + BOTTOM_MIDDLE = "┴" + BOTTOM_RIGHT = "┘" + CELL_PADDING = " " + COLUMN_SEPARATOR = " | " + snippet = ( """ # >>> ersilia >>> diff --git a/ersilia/hub/content/catalog.py b/ersilia/hub/content/catalog.py index 01ca201b3..3f6b2dc53 100644 --- a/ersilia/hub/content/catalog.py +++ b/ersilia/hub/content/catalog.py @@ -11,6 +11,7 @@ from ...utils.identifiers.model import ModelIdentifier from ...auth.auth import Auth from ...default import GITHUB_ORG, BENTOML_PATH, MODEL_SOURCE_FILE +from ...default import TableConstants from ... import logger try: @@ -42,6 +43,122 @@ def as_json(self): R = self.as_list_of_dicts() return json.dumps(R, indent=4) + def generate_separator_line(self, left, middle, right, horizontal, widths): + """ + Generates a separator line for the table based on the given borders and column widths. + + The line starts with a 'left' border, followed by repeated 'horizontal' + sections for each column's width, joined by 'middle' separators, and ends + with a 'right' border. + + Args: + left (str): The character to use for the left border of the line. + middle (str): The character to use between columns (as separators). + right (str): The character to use for the right border of the line. + horizontal (str): The character used to draw the horizontal border. + widths (list[int]): A list of column widths to determine how much + horizontal space each column takes. + + Returns: + str: The formatted separator line as a string. + """ + return left + middle.join(horizontal * (width + 2) for width in widths) + right + + def as_table(self): + """ + Returns the catalog data in table format. The method calculates the + column widths dynamically by determining the maximum width of each column, + based on the data and column headers. + + A row format string is then constructed using the column widths, + specifying that each cell is left-aligned and padded with a column seperator sperating the rows. + + The method starts by constructing the top border using the + 'generate_separator_line' helper. It then adds the headers, + formatted to fit the column widths, followed by a separator line + also created by the helper function. + + A 'for' loop iterates over the data rows, adding each row to the + table with borders and padding. After each row, a separator line is inserted. + Finally, the bottom border is added using the helper function, completing the table. + + """ + column_widths = [ + max( + len(str(item)) if item is not None else 0 + for item in [col] + [row[i] for row in self.data] + ) + for i, col in enumerate(self.columns) + ] + + table_constants = TableConstants + + row_format = table_constants.COLUMN_SEPARATOR.join( + f"{{:<{width}}}" for width in column_widths + ) + + table = ( + self.generate_separator_line( + table_constants.TOP_LEFT, + table_constants.TOP_MIDDLE, + table_constants.TOP_RIGHT, + table_constants.HORIZONTAL, + column_widths, + ) + + "\n" + ) + table += ( + table_constants.VERTICAL + + table_constants.CELL_PADDING + + row_format.format(*self.columns) + + table_constants.CELL_PADDING + + table_constants.VERTICAL + + "\n" + ) + table += ( + self.generate_separator_line( + table_constants.MIDDLE_LEFT, + table_constants.MIDDLE_MIDDLE, + table_constants.MIDDLE_RIGHT, + table_constants.HORIZONTAL, + column_widths, + ) + + "\n" + ) + + for index, row in enumerate(self.data): + row = [str(item) if item is not None else "" for item in row] + table += ( + table_constants.VERTICAL + + table_constants.CELL_PADDING + + row_format.format(*row) + + table_constants.CELL_PADDING + + table_constants.VERTICAL + + "\n" + ) + + if index < len(self.data) - 1: + table += ( + self.generate_separator_line( + table_constants.MIDDLE_LEFT, + table_constants.MIDDLE_MIDDLE, + table_constants.MIDDLE_RIGHT, + table_constants.HORIZONTAL, + column_widths, + ) + + "\n" + ) + + table += self.generate_separator_line( + table_constants.BOTTOM_LEFT, + table_constants.BOTTOM_MIDDLE, + table_constants.BOTTOM_RIGHT, + table_constants.HORIZONTAL, + column_widths, + ) + + return table + def write(self, file_name): with open(file_name, "w") as f: if file_name.endswith(".csv"): @@ -73,7 +190,7 @@ def _is_eos(self, s): if not self.mi.is_test(s): return True return False - + def _get_item(self, card, item): if "card" in card: card = card["card"] @@ -100,7 +217,7 @@ def _get_input(self, card): def _get_output(self, card): return self._get_item(card, "output")[0] - + def _get_model_source(self, model_id): model_source_file = os.path.join(self._model_path(model_id), MODEL_SOURCE_FILE) if os.path.exists(model_source_file): @@ -108,7 +225,7 @@ def _get_model_source(self, model_id): return f.read().rstrip() else: return None - + def _get_service_class(self, card): if "service_class" in card: return card["service_class"] @@ -207,7 +324,18 @@ def local(self): output = self._get_output(card) model_source = self._get_model_source(model_id) service_class = self._get_service_class(card) - R += [[model_id, slug, title, status, inputs, output, model_source, service_class]] + R += [ + [ + model_id, + slug, + title, + status, + inputs, + output, + model_source, + service_class, + ] + ] columns = [ "Identifier", "Slug", From a9de77c42ce3b426bcda930d6bf08d4b8ae821b9 Mon Sep 17 00:00:00 2001 From: Farida Momoh <95109808+KimFarida@users.noreply.github.com> Date: Sun, 13 Oct 2024 09:15:28 +0100 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=88=20Task:=20Refactor=20ersilia?= =?UTF-8?q?=20clear=20command=20#1266=20(#1301)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added Docker image cleanup method to SimpleDocker class * Rename Clearer Class -> Uninstaller| Added Docker Image CleanUp * Feat: Remove Ersillia installation * Refactor: Changed clear command to uninstall * Feat: Removed Docker Images/ Extra Dependecies * Corrected uninstall_cmd docstring | Cmd help * Changed Print statements to logger.debug() * Added log statement to cleanup_ersilia_images * Renamed cl variable to ui * Initialized Logger instance for Simple Docker Class --------- Co-authored-by: Dhanshree Arora --- ersilia/cli/cmd.py | 6 +-- ersilia/cli/commands/clear.py | 15 ------ ersilia/cli/commands/uninstall.py | 15 ++++++ ersilia/cli/create_cli.py | 2 +- ersilia/utils/clear.py | 29 ------------ ersilia/utils/docker.py | 34 ++++++++++++-- ersilia/utils/uninstall.py | 76 +++++++++++++++++++++++++++++++ 7 files changed, 124 insertions(+), 53 deletions(-) delete mode 100644 ersilia/cli/commands/clear.py create mode 100644 ersilia/cli/commands/uninstall.py delete mode 100644 ersilia/utils/clear.py create mode 100644 ersilia/utils/uninstall.py diff --git a/ersilia/cli/cmd.py b/ersilia/cli/cmd.py index 7d0b1be2a..9b137be11 100644 --- a/ersilia/cli/cmd.py +++ b/ersilia/cli/cmd.py @@ -14,9 +14,9 @@ def catalog(self): m = importlib.import_module("ersilia.cli.commands.catalog") m.catalog_cmd() - def clear(self): - m = importlib.import_module("ersilia.cli.commands.clear") - m.clear_cmd() + def uninstall(self): + m = importlib.import_module("ersilia.cli.commands.uninstall") + m.uninstall_cmd() def close(self): m = importlib.import_module("ersilia.cli.commands.close") diff --git a/ersilia/cli/commands/clear.py b/ersilia/cli/commands/clear.py deleted file mode 100644 index 26ad9fbc7..000000000 --- a/ersilia/cli/commands/clear.py +++ /dev/null @@ -1,15 +0,0 @@ -from . import ersilia_cli -from ...utils.clear import Clearer - - -def clear_cmd(): - """Clears all contents related to Ersilia available in the local computer""" - - # Example usage: ersilia setup - @ersilia_cli.command( - short_help="Clear ersilia", - help="Clears all contents related to Ersilia available in the local computer.", - ) - def clear(): - cl = Clearer() - cl.clear() diff --git a/ersilia/cli/commands/uninstall.py b/ersilia/cli/commands/uninstall.py new file mode 100644 index 000000000..55a5fbd5b --- /dev/null +++ b/ersilia/cli/commands/uninstall.py @@ -0,0 +1,15 @@ +from . import ersilia_cli +from ...utils.uninstall import Uninstaller + + +def uninstall_cmd(): + """Uninstalls all Ersilia artifacts present locally on the user's system""" + + # Example usage: ersilia setup + @ersilia_cli.command( + short_help="Uninstall ersilia", + help="Uninstalls all Ersilia artifacts present locally on the user's system.", + ) + def uninstall(): + ui = Uninstaller() + ui.uninstall() diff --git a/ersilia/cli/create_cli.py b/ersilia/cli/create_cli.py index 0dc7e42c1..8fc4d3afa 100644 --- a/ersilia/cli/create_cli.py +++ b/ersilia/cli/create_cli.py @@ -10,7 +10,7 @@ def create_ersilia_cli(): cmd.auth() cmd.catalog() - cmd.clear() + cmd.uninstall() cmd.close() cmd.delete() cmd.example() diff --git a/ersilia/utils/clear.py b/ersilia/utils/clear.py deleted file mode 100644 index a82fa2280..000000000 --- a/ersilia/utils/clear.py +++ /dev/null @@ -1,29 +0,0 @@ -import os -import shutil - -from .conda import SimpleConda -from ..default import EOS, BENTOML_PATH - - -class Clearer(object): - def __init__(self): - pass - - def _directories(self): - shutil.rmtree(EOS) - shutil.rmtree(BENTOML_PATH) - - def _conda(self): - sc = SimpleConda() - for env in sc._env_list(): - if env.startswith("#"): - continue - if not env.startswith("eos"): - continue - env = env.split(" ")[0] - if len(env.split("-")[0]) == 7: - sc.delete(env) - - def clear(self): - self._conda() - self._directories() diff --git a/ersilia/utils/docker.py b/ersilia/utils/docker.py index 2ec5561a5..448df52dd 100644 --- a/ersilia/utils/docker.py +++ b/ersilia/utils/docker.py @@ -60,6 +60,7 @@ def is_udocker_installed(): class SimpleDocker(object): def __init__(self, use_udocker=None): self.identifier = LongIdentifier() + self.logger = logger if use_udocker is None: self._with_udocker = self._use_udocker() else: @@ -303,24 +304,47 @@ def container_peak(self, model_id): if peak_memory is not None: return peak_memory else: - logger.debug( + self.logger.debug( f"Could not compute container peak memory for model {model_id}" ) return else: - logger.debug(f"No container found for model {model_id}") + self.logger.debug(f"No container found for model {model_id}") return except docker.errors.NotFound as e: - print(f"Container {container.name} not found: {e}") + self.logger.debug(f"Container {container.name} not found: {e}") return None except docker.errors.APIError as e: - print(f"Docker API error: {e}") + logger.debug(f"Docker API error: {e}") return None except Exception as e: - print(f"An error occurred: {e}") + self.logger.debug(f"An error occurred: {e}") return None + + def cleanup_ersilia_images(self): + """Remove all Ersilia-related Docker images""" + if self._with_udocker: + self.logger.warning("Docker cleanup not supported with udocker") + return + try: + images_dict = self.images() + + if not images_dict: + logger.info("No Docker images found") + return + + for image_name, image_id in images_dict.items(): + if DOCKERHUB_ORG in image_name: + try: + logger.info(f"Removing Docker image: {image_name}") + self.delete(*self._splitter(image_name)) + except Exception as e: + logger.error(f"Failed to remove Docker image {image_name}: {e}") + + except Exception as e: + self.logger.error(f"Failed to cleanup Docker images: {e}") class SimpleDockerfileParser(DockerfileParser): def __init__(self, path): diff --git a/ersilia/utils/uninstall.py b/ersilia/utils/uninstall.py new file mode 100644 index 000000000..ddd6ef5f1 --- /dev/null +++ b/ersilia/utils/uninstall.py @@ -0,0 +1,76 @@ +import os +import shutil +import subprocess + +from .conda import SimpleConda +from .docker import SimpleDocker +from ..default import EOS, BENTOML_PATH +from .logging import logger + + +class Uninstaller(object): + def __init__(self): + self.docker_cleaner = SimpleDocker() + + def _uninstall_ersilia_package(self): + """Uninstall the Ersilia package if installed via pip.""" + try: + logger.info("Uninstalling Ersilia package...") + subprocess.run(["pip", "uninstall", "-y", "ersilia"], check=True) + logger.info("Ersilia package uninstalled successfully.") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to uninstall Ersilia package: {e}") + + def _directories(self): + """Remove additional directories.""" + dirs_to_remove = [EOS, BENTOML_PATH] + for dir in dirs_to_remove: + if os.path.exists(dir): + try: + logger.info(f"Removing directory: {dir}...") + shutil.rmtree(dir) + logger.info(f"Directory {dir} removed successfully.") + except Exception as e: + logger.error(f"Failed to remove directory {dir}: {e}") + + def _conda(self): + sc = SimpleConda() + + for env in sc._env_list(): + if env.startswith("#"): + continue + if not env.startswith("eos"): + continue + env = env.split(" ")[0] + if len(env.split("-")[0]) == 7: + try: + logger.info(f"Removing conda environment: {env}") + sc.delete(env) + except Exception as e: + logger.error(f"Failed to remove conda environment {env}: {e}") + + + env_name = "ersilia" + + try: + logger.info(f"Removing Conda environment: {env_name}...") + sc.delete(env_name) + logger.info(f"Conda environment {env_name} removed successfully.") + except Exception as e: + logger.error(f"Failed to remove Conda environment {env_name}: {e}") + + def uninstall(self): + """Main uninstallation method""" + + try: + logger.info("Starting Ersillia uninstallation...") + + self.docker_cleaner.cleanup_ersilia_images() + self._uninstall_ersilia_package() + self._conda() + self._directories() + + logger.info("Ersilia uninstallation completed") + except Exception as e: + logger.error(f"Uninstallation failed: {e}") + raise From 3374d048f91c73382b781a641dc507407fa3a8e3 Mon Sep 17 00:00:00 2001 From: musasizivictoria <141638023+musasizivictoria@users.noreply.github.com> Date: Sun, 13 Oct 2024 11:27:58 +0300 Subject: [PATCH 4/4] Fix: Update CompoundIdentifier to handle invalid SMILES inputs (#1295) * Update CompoundIdentifier to handle invalid SMILES inputs * utilize encode process * update encode * simplify and refactor * test env * address review * address review * address review * address review --------- Co-authored-by: Dhanshree Arora --- ersilia/default.py | 1 + ersilia/io/types/compound.py | 6 ++---- ersilia/utils/identifiers/compound.py | 29 ++++++++++++++------------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/ersilia/default.py b/ersilia/default.py index 1a3fafd0e..512be39b4 100644 --- a/ersilia/default.py +++ b/ersilia/default.py @@ -30,6 +30,7 @@ DEFAULT_API_NAME = "run" PACKMODE_FILE = "pack_mode.txt" CARD_FILE = "card.json" +UNPROCESSABLE_INPUT="UNPROCESSABLE_INPUT" DOTENV_FILE = ".env" API_SCHEMA_FILE = "api_schema.json" MODEL_SIZE_FILE = "size.json" diff --git a/ersilia/io/types/compound.py b/ersilia/io/types/compound.py index 5eb01d179..6e0e52fb4 100644 --- a/ersilia/io/types/compound.py +++ b/ersilia/io/types/compound.py @@ -1,7 +1,6 @@ import os import csv import random -import importlib from ...utils.identifiers.arbitrary import ArbitraryIdentifier from ...setup.requirements.compound import ( @@ -12,6 +11,7 @@ from ..shape import InputShapeSingle, InputShapeList, InputShapePairOfLists from .examples import compound as test_examples from . import EXAMPLES_FOLDER +from ...utils.identifiers.compound import CompoundIdentifier EXAMPLES = "compound.tsv" @@ -23,9 +23,7 @@ def __init__(self, input_shape): self.input_shape = input_shape self.example_file = os.path.join(EXAMPLES_FOLDER, EXAMPLES) self.setup() - self.identifier = importlib.import_module( - "ersilia.utils.identifiers.compound" - ).CompoundIdentifier() + self.identifier = CompoundIdentifier() self.arbitrary_identifier = ArbitraryIdentifier() if type(self.input_shape) is InputShapeSingle: self.logger.debug( diff --git a/ersilia/utils/identifiers/compound.py b/ersilia/utils/identifiers/compound.py index 8834906dc..25f4f69d7 100644 --- a/ersilia/utils/identifiers/compound.py +++ b/ersilia/utils/identifiers/compound.py @@ -14,6 +14,7 @@ except: Chem = None +from ...default import UNPROCESSABLE_INPUT class CompoundIdentifier(object): def __init__(self, local=True): @@ -67,13 +68,13 @@ def _is_inchikey(text): return True def guess_type(self, text): - if text is None: - return self.default_type + if not isinstance(text, str) or not text.strip() or text == UNPROCESSABLE_INPUT: + return UNPROCESSABLE_INPUT if self._is_inchikey(text): return "inchikey" if self._is_smiles(text): return "smiles" - return "name" + return UNPROCESSABLE_INPUT def unichem_resolver(self, inchikey): if Chem is None or unichem is None: @@ -111,6 +112,8 @@ def _pubchem_smiles_to_inchikey(smiles): @staticmethod def chemical_identifier_resolver(identifier): """Returns SMILES string of a given identifier, using NCI tool""" + if not identifier or not isinstance(identifier, str): + return UNPROCESSABLE_INPUT identifier = urllib.parse.quote(identifier) url = "https://cactus.nci.nih.gov/chemical/structure/{0}/smiles".format( identifier @@ -122,25 +125,23 @@ def chemical_identifier_resolver(identifier): def encode(self, smiles): """Get InChIKey of compound based on SMILES string""" + if not isinstance(smiles, str) or not smiles.strip() or smiles == UNPROCESSABLE_INPUT: + return UNPROCESSABLE_INPUT + if self.Chem is None: - inchikey = self._pubchem_smiles_to_inchikey(smiles) - if inchikey is None: - inchikey = self._nci_smiles_to_inchikey(smiles) + inchikey = self._pubchem_smiles_to_inchikey(smiles) or self._nci_smiles_to_inchikey(smiles) else: try: mol = self.Chem.MolFromSmiles(smiles) if mol is None: - raise Exception( - "The SMILES string: %s is not valid or could not be converted to an InChIKey" - % smiles - ) + return UNPROCESSABLE_INPUT inchi = self.Chem.rdinchi.MolToInchi(mol)[0] if inchi is None: - raise Exception("Could not obtain InChI") + return UNPROCESSABLE_INPUT inchikey = self.Chem.rdinchi.InchiToInchiKey(inchi) except: inchikey = None - return inchikey + + return inchikey if inchikey else UNPROCESSABLE_INPUT - -Identifier = CompoundIdentifier +Identifier = CompoundIdentifier \ No newline at end of file